mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
merge main to dev 0.9
This commit is contained in:
commit
0590ba2509
156
.github/workflows/testdriver.yml
vendored
Normal file
156
.github/workflows/testdriver.yml
vendored
Normal file
@ -0,0 +1,156 @@
|
||||
name: TestDriver.ai
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: 0 21 * * *
|
||||
workflow_dispatch: null
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.22"
|
||||
NODE_VERSION: "20"
|
||||
|
||||
permissions:
|
||||
contents: read # To allow the action to read repository contents
|
||||
pull-requests: write # To allow the action to create/update pull request comments
|
||||
|
||||
jobs:
|
||||
build_and_upload:
|
||||
name: Test Onboarding
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# General build dependencies
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{env.NODE_VERSION}}
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
corepack enable
|
||||
yarn install
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v2
|
||||
with:
|
||||
version: 3.x
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
run: task package
|
||||
env:
|
||||
USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false # disable codesign
|
||||
shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell
|
||||
|
||||
# Upload .exe as an artifact
|
||||
- name: Upload .exe artifact
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-exe
|
||||
path: make/*.exe
|
||||
|
||||
- uses: testdriverai/action@main
|
||||
id: testdriver
|
||||
env:
|
||||
FORCE_COLOR: "3"
|
||||
with:
|
||||
key: ${{ secrets.DASHCAM_API }}
|
||||
prerun: |
|
||||
$headers = @{
|
||||
Authorization = "token ${{ secrets.GITHUB_TOKEN }}"
|
||||
}
|
||||
|
||||
$downloadFolder = "./download"
|
||||
$artifactFileName = "waveterm.exe"
|
||||
$artifactFilePath = "$downloadFolder/$artifactFileName"
|
||||
|
||||
Write-Host "Starting the artifact download process..."
|
||||
|
||||
# Create the download directory if it doesn't exist
|
||||
if (-not (Test-Path -Path $downloadFolder)) {
|
||||
Write-Host "Creating download folder..."
|
||||
mkdir $downloadFolder
|
||||
} else {
|
||||
Write-Host "Download folder already exists."
|
||||
}
|
||||
|
||||
# Fetch the artifact upload URL
|
||||
Write-Host "Fetching the artifact upload URL..."
|
||||
$artifactUrl = (Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" -Headers $headers).artifacts[0].archive_download_url
|
||||
|
||||
if ($artifactUrl) {
|
||||
Write-Host "Artifact URL successfully fetched: $artifactUrl"
|
||||
} else {
|
||||
Write-Error "Failed to fetch the artifact URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Download the artifact (zipped file)
|
||||
Write-Host "Starting artifact download..."
|
||||
$artifactZipPath = "$env:TEMP\artifact.zip"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $artifactUrl `
|
||||
-Headers $headers `
|
||||
-OutFile $artifactZipPath `
|
||||
-MaximumRedirection 5
|
||||
|
||||
Write-Host "Artifact downloaded successfully to $artifactZipPath"
|
||||
} catch {
|
||||
Write-Error "Error downloading artifact: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Unzip the artifact
|
||||
$artifactUnzipPath = "$env:TEMP\artifact"
|
||||
Write-Host "Unzipping the artifact to $artifactUnzipPath..."
|
||||
try {
|
||||
Expand-Archive -Path $artifactZipPath -DestinationPath $artifactUnzipPath -Force
|
||||
Write-Host "Artifact unzipped successfully to $artifactUnzipPath"
|
||||
} catch {
|
||||
Write-Error "Failed to unzip the artifact: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find the installer or app executable
|
||||
$artifactInstallerPath = Get-ChildItem -Path $artifactUnzipPath -Filter *.exe -Recurse | Select-Object -First 1
|
||||
|
||||
if ($artifactInstallerPath) {
|
||||
Write-Host "Executable file found: $($artifactInstallerPath.FullName)"
|
||||
} else {
|
||||
Write-Error "Executable file not found. Exiting."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run the installer and log the result
|
||||
Write-Host "Running the installer: $($artifactInstallerPath.FullName)..."
|
||||
try {
|
||||
Start-Process -FilePath $artifactInstallerPath.FullName -Wait
|
||||
Write-Host "Installer ran successfully."
|
||||
} catch {
|
||||
Write-Error "Failed to run the installer: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Optional: If the app executable is different from the installer, find and launch it
|
||||
$wavePath = Join-Path $env:USERPROFILE "AppData\Local\Programs\waveterm\Wave.exe"
|
||||
|
||||
Write-Host "Launching the application: $($wavePath)"
|
||||
Start-Process -FilePath $wavePath
|
||||
Write-Host "Application launched."
|
||||
|
||||
prompt: |
|
||||
1. /run testdriver/onboarding.yml
|
||||
2. /generate desktop 20
|
BIN
assets/appicon-windows.png
Normal file
BIN
assets/appicon-windows.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
20
assets/appicon-windows.svg
Normal file
20
assets/appicon-windows.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<svg width="1024" height="727" viewBox="0 0 1024 727" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M328.87 332.526C280.49 332.526 253.744 364.034 239.191 434.533L20.5 403.025C47.2464 172.231 136.925 60.3787 314.317 60.3787C445.688 60.3787 574.307 160.022 637.24 160.022C686.012 160.022 712.365 126.151 726.918 58.0156L945.609 89.5233C921.223 320.711 829.184 432.563 651.793 432.563C518.454 432.17 394.556 332.526 328.87 332.526Z"
|
||||
fill="url(#paint0_linear_1814_3217)" />
|
||||
<path
|
||||
d="M390.87 558.061C342.49 558.061 315.744 589.569 301.191 660.067L82.5 628.559C109.246 397.765 198.925 285.519 376.317 285.519C507.295 285.519 636.307 385.162 699.239 385.162C748.012 385.162 774.365 351.292 788.918 283.156L1007.61 314.664C983.223 545.852 891.184 657.704 713.793 657.704C580.454 657.704 456.556 558.061 390.87 558.061Z"
|
||||
fill="url(#paint1_linear_1814_3217)" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1814_3217" x1="20.5503" y1="246.309" x2="945.797" y2="246.309"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.1418" stop-color="#1F4D22" />
|
||||
<stop offset="0.8656" stop-color="#418D31" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1814_3217" x1="82.5673" y1="471.774" x2="1007.81" y2="471.774"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.2223" stop-color="#418D31" />
|
||||
<stop offset="0.7733" stop-color="#58C142" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
Before Width: | Height: | Size: 84 KiB |
BIN
build/icon.ico
BIN
build/icon.ico
Binary file not shown.
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 295 KiB |
@ -3,6 +3,8 @@ const pkg = require("./package.json");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH;
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
* @see https://www.electron.build/configuration/configuration
|
||||
@ -47,7 +49,6 @@ const config = {
|
||||
arch: ["universal", "arm64", "x64"],
|
||||
},
|
||||
],
|
||||
icon: "build/icons.icns",
|
||||
category: "public.app-category.developer-tools",
|
||||
minimumSystemVersion: "10.15.0",
|
||||
mergeASARs: true,
|
||||
@ -57,7 +58,6 @@ const config = {
|
||||
artifactName: "${name}-${platform}-${arch}-${version}.${ext}",
|
||||
category: "TerminalEmulator",
|
||||
executableName: pkg.name,
|
||||
icon: "build/icons.icns",
|
||||
target: ["zip", "deb", "rpm", "AppImage", "pacman"],
|
||||
synopsis: pkg.description,
|
||||
description: null,
|
||||
@ -73,12 +73,13 @@ const config = {
|
||||
afterInstall: "build/deb-postinstall.tpl",
|
||||
},
|
||||
win: {
|
||||
icon: "build/icons.icns",
|
||||
publisherName: "Command Line Inc",
|
||||
target: ["nsis", "msi", "zip"],
|
||||
signtoolOptions: windowsShouldSign && {
|
||||
signingHashAlgorithms: ["sha256"],
|
||||
publisherName: "Command Line Inc",
|
||||
certificateSubjectName: "Command Line Inc",
|
||||
certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,
|
||||
signingHashAlgorithms: ["sha256"],
|
||||
},
|
||||
},
|
||||
appImage: {
|
||||
license: "LICENSE",
|
||||
|
@ -17,7 +17,10 @@ export async function initDocsite() {
|
||||
console.log("Embedded docsite is running, using embedded version for help view");
|
||||
docsiteUrl = docsiteEmbeddedUrl;
|
||||
} else {
|
||||
console.log("Embedded docsite is not running, using web version for help view", response);
|
||||
console.log(
|
||||
"Embedded docsite is not running, using web version for help view",
|
||||
"status: " + response?.status
|
||||
);
|
||||
docsiteUrl = docsiteWebUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -657,7 +657,7 @@ async function relaunchBrowserWindows(): Promise<void> {
|
||||
}
|
||||
for (const win of wins) {
|
||||
await win.waveReadyPromise;
|
||||
console.log("show", win.waveWindowId);
|
||||
console.log("show window", win.waveWindowId);
|
||||
win.show();
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes";
|
||||
import { PlotView } from "@/app/view/plotview/plotview";
|
||||
import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
|
||||
import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
|
||||
import { ErrorBoundary } from "@/element/errorboundary";
|
||||
import { CenteredDiv } from "@/element/quickelems";
|
||||
import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
|
||||
@ -16,7 +17,6 @@ import {
|
||||
import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos";
|
||||
import { focusedBlockId, getElemAsStr } from "@/util/focusutil";
|
||||
import { isBlank } from "@/util/util";
|
||||
import { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel } from "@/view/cpuplot/cpuplot";
|
||||
import { HelpView, HelpViewModel, makeHelpViewModel } from "@/view/helpview/helpview";
|
||||
import { QuickTipsView, QuickTipsViewModel } from "@/view/quicktipsview/quicktipsview";
|
||||
import { TermViewModel, TerminalView, makeTerminalModel } from "@/view/term/term";
|
||||
@ -47,8 +47,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel)
|
||||
if (blockView === "waveai") {
|
||||
return makeWaveAiViewModel(blockId);
|
||||
}
|
||||
if (blockView === "cpuplot") {
|
||||
return makeCpuPlotViewModel(blockId);
|
||||
if (blockView === "cpuplot" || blockView == "sysinfo") {
|
||||
// "cpuplot" is for backwards compatibility with already-opened widgets
|
||||
return makeSysinfoViewModel(blockId, blockView);
|
||||
}
|
||||
if (blockView === "help") {
|
||||
return makeHelpViewModel(blockId, nodeModel);
|
||||
@ -89,8 +90,9 @@ function getViewElem(
|
||||
if (blockView === "waveai") {
|
||||
return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />;
|
||||
}
|
||||
if (blockView === "cpuplot") {
|
||||
return <CpuPlotView key={blockId} blockId={blockId} model={viewModel as CpuPlotViewModel} />;
|
||||
if (blockView === "cpuplot" || blockView === "sysinfo") {
|
||||
// "cpuplot" is for backwards compatibility with already opened widgets
|
||||
return <SysinfoView key={blockId} blockId={blockId} model={viewModel as SysinfoViewModel} />;
|
||||
}
|
||||
if (blockView == "help") {
|
||||
return <HelpView key={blockId} model={viewModel as HelpViewModel} />;
|
||||
|
@ -12,6 +12,7 @@ const dlog = debug("wave:ws");
|
||||
const WarnWebSocketSendSize = 1024 * 1024; // 1MB
|
||||
const MaxWebSocketSendSize = 5 * 1024 * 1024; // 5MB
|
||||
const reconnectHandlers: (() => void)[] = [];
|
||||
const StableConnTime = 2000;
|
||||
|
||||
function addWSReconnectHandler(handler: () => void) {
|
||||
reconnectHandlers.push(handler);
|
||||
@ -45,6 +46,7 @@ class WSControl {
|
||||
lastReconnectTime: number = 0;
|
||||
eoOpts: ElectronOverrideOpts;
|
||||
noReconnect: boolean = false;
|
||||
onOpenTimeoutId: NodeJS.Timeout = null;
|
||||
|
||||
constructor(
|
||||
baseHostPort: string,
|
||||
@ -80,9 +82,15 @@ class WSControl {
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.wsConn.onopen = this.onopen.bind(this);
|
||||
this.wsConn.onmessage = this.onmessage.bind(this);
|
||||
this.wsConn.onclose = this.onclose.bind(this);
|
||||
this.wsConn.onopen = (e: Event) => {
|
||||
this.onopen(e);
|
||||
};
|
||||
this.wsConn.onmessage = (e: MessageEvent) => {
|
||||
this.onmessage(e);
|
||||
};
|
||||
this.wsConn.onclose = (e: CloseEvent) => {
|
||||
this.onclose(e);
|
||||
};
|
||||
// turns out onerror is not necessary (onclose always follows onerror)
|
||||
// this.wsConn.onerror = this.onerror;
|
||||
}
|
||||
@ -118,8 +126,11 @@ class WSControl {
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
onclose(event: any) {
|
||||
onclose(event: CloseEvent) {
|
||||
// console.log("close", event);
|
||||
if (this.onOpenTimeoutId) {
|
||||
clearTimeout(this.onOpenTimeoutId);
|
||||
}
|
||||
if (event.wasClean) {
|
||||
dlog("connection closed");
|
||||
} else {
|
||||
@ -132,15 +143,18 @@ class WSControl {
|
||||
}
|
||||
}
|
||||
|
||||
onopen() {
|
||||
onopen(e: Event) {
|
||||
dlog("connection open");
|
||||
this.open = true;
|
||||
this.opening = false;
|
||||
this.onOpenTimeoutId = setTimeout(() => {
|
||||
this.reconnectTimes = 0;
|
||||
dlog("clear reconnect times");
|
||||
}, StableConnTime);
|
||||
for (let handler of reconnectHandlers) {
|
||||
handler();
|
||||
}
|
||||
this.runMsgQueue();
|
||||
// reconnectTimes is reset in onmessage:hello
|
||||
}
|
||||
|
||||
runMsgQueue() {
|
||||
@ -157,7 +171,7 @@ class WSControl {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onmessage(event: any) {
|
||||
onmessage(event: MessageEvent) {
|
||||
let eventData = null;
|
||||
if (event.data != null) {
|
||||
eventData = JSON.parse(event.data);
|
||||
@ -173,10 +187,6 @@ class WSControl {
|
||||
// nothing
|
||||
return;
|
||||
}
|
||||
if (eventData.type == "hello") {
|
||||
this.reconnectTimes = 0;
|
||||
return;
|
||||
}
|
||||
if (this.messageCallback) {
|
||||
try {
|
||||
this.messageCallback(eventData);
|
||||
|
@ -106,6 +106,9 @@
|
||||
--conn-icon-color-7: #dbde52;
|
||||
--conn-icon-color-8: #58c142;
|
||||
|
||||
--sysinfo-cpu-color: #58c142;
|
||||
--sysinfo-mem-color: #53b4ea;
|
||||
|
||||
--bulb-color: rgb(255, 221, 51);
|
||||
|
||||
// term colors (16 + 6) form the base terminal theme
|
||||
|
@ -1,9 +0,0 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.plot-view {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
@ -1,302 +0,0 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getConnStatusAtom, globalStore, WOS } from "@/store/global";
|
||||
import * as util from "@/util/util";
|
||||
import * as Plot from "@observablehq/plot";
|
||||
import dayjs from "dayjs";
|
||||
import * as htl from "htl";
|
||||
import * as jotai from "jotai";
|
||||
import * as React from "react";
|
||||
|
||||
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import "./cpuplot.less";
|
||||
|
||||
const DefaultNumPoints = 120;
|
||||
|
||||
type DataItem = {
|
||||
ts: number;
|
||||
[k: string]: number;
|
||||
};
|
||||
|
||||
const SysInfoMetricNames = {
|
||||
cpu: "CPU %",
|
||||
"mem:total": "Memory Total",
|
||||
"mem:used": "Memory Used",
|
||||
"mem:free": "Memory Free",
|
||||
"mem:available": "Memory Available",
|
||||
};
|
||||
for (let i = 0; i < 32; i++) {
|
||||
SysInfoMetricNames[`cpu:${i}`] = `CPU[${i}] %`;
|
||||
}
|
||||
|
||||
function convertWaveEventToDataItem(event: WaveEvent): DataItem {
|
||||
const eventData: TimeSeriesData = event.data;
|
||||
if (eventData == null || eventData.ts == null || eventData.values == null) {
|
||||
return null;
|
||||
}
|
||||
const dataItem = { ts: eventData.ts };
|
||||
for (const key in eventData.values) {
|
||||
dataItem[key] = eventData.values[key];
|
||||
}
|
||||
return dataItem;
|
||||
}
|
||||
|
||||
class CpuPlotViewModel {
|
||||
viewType: string;
|
||||
blockAtom: jotai.Atom<Block>;
|
||||
termMode: jotai.Atom<string>;
|
||||
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
|
||||
blockId: string;
|
||||
viewIcon: jotai.Atom<string>;
|
||||
viewText: jotai.Atom<string>;
|
||||
viewName: jotai.Atom<string>;
|
||||
dataAtom: jotai.PrimitiveAtom<Array<DataItem>>;
|
||||
addDataAtom: jotai.WritableAtom<unknown, [DataItem[]], void>;
|
||||
incrementCount: jotai.WritableAtom<unknown, [], Promise<void>>;
|
||||
loadingAtom: jotai.PrimitiveAtom<boolean>;
|
||||
numPoints: jotai.Atom<number>;
|
||||
metrics: jotai.Atom<string[]>;
|
||||
connection: jotai.Atom<string>;
|
||||
manageConnection: jotai.Atom<boolean>;
|
||||
connStatus: jotai.Atom<ConnStatus>;
|
||||
|
||||
constructor(blockId: string) {
|
||||
this.viewType = "cpuplot";
|
||||
this.blockId = blockId;
|
||||
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
|
||||
this.addDataAtom = jotai.atom(null, (get, set, points) => {
|
||||
const targetLen = get(this.numPoints) + 1;
|
||||
let data = get(this.dataAtom);
|
||||
try {
|
||||
if (data.length > targetLen) {
|
||||
data = data.slice(data.length - targetLen);
|
||||
}
|
||||
if (data.length < targetLen) {
|
||||
const defaultData = this.getDefaultData();
|
||||
data = [...defaultData.slice(defaultData.length - targetLen + data.length), ...data];
|
||||
}
|
||||
const newData = [...data.slice(points.length), ...points];
|
||||
set(this.dataAtom, newData);
|
||||
} catch (e) {
|
||||
console.log("Error adding data to cpuplot", e);
|
||||
}
|
||||
});
|
||||
this.manageConnection = jotai.atom(true);
|
||||
this.loadingAtom = jotai.atom(true);
|
||||
this.numPoints = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const metaNumPoints = blockData?.meta?.["graph:numpoints"];
|
||||
if (metaNumPoints == null || metaNumPoints <= 0) {
|
||||
return DefaultNumPoints;
|
||||
}
|
||||
return metaNumPoints;
|
||||
});
|
||||
this.metrics = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const metrics = blockData?.meta?.["graph:metrics"];
|
||||
if (metrics == null || !Array.isArray(metrics)) {
|
||||
return ["cpu"];
|
||||
}
|
||||
return metrics;
|
||||
});
|
||||
this.viewIcon = jotai.atom((get) => {
|
||||
return "chart-line"; // should not be hardcoded
|
||||
});
|
||||
this.viewName = jotai.atom((get) => {
|
||||
return "CPU %"; // should not be hardcoded
|
||||
});
|
||||
this.incrementCount = jotai.atom(null, async (get, set) => {
|
||||
const meta = get(this.blockAtom).meta;
|
||||
const count = meta.count ?? 0;
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { count: count + 1 },
|
||||
});
|
||||
});
|
||||
this.connection = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const connValue = blockData?.meta?.connection;
|
||||
if (util.isBlank(connValue)) {
|
||||
return "local";
|
||||
}
|
||||
return connValue;
|
||||
});
|
||||
this.dataAtom = jotai.atom(this.getDefaultData());
|
||||
this.loadInitialData();
|
||||
this.connStatus = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const connName = blockData?.meta?.connection;
|
||||
const connAtom = getConnStatusAtom(connName);
|
||||
return get(connAtom);
|
||||
});
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
globalStore.set(this.loadingAtom, true);
|
||||
try {
|
||||
const numPoints = globalStore.get(this.numPoints);
|
||||
const connName = globalStore.get(this.connection);
|
||||
const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, {
|
||||
event: "sysinfo",
|
||||
scope: connName,
|
||||
maxitems: numPoints,
|
||||
});
|
||||
if (initialData == null) {
|
||||
return;
|
||||
}
|
||||
const newData = this.getDefaultData();
|
||||
const initialDataItems: DataItem[] = initialData.map(convertWaveEventToDataItem);
|
||||
// splice the initial data into the default data (replacing the newest points)
|
||||
newData.splice(newData.length - initialDataItems.length, initialDataItems.length, ...initialDataItems);
|
||||
globalStore.set(this.addDataAtom, newData);
|
||||
} catch (e) {
|
||||
console.log("Error loading initial data for cpuplot", e);
|
||||
} finally {
|
||||
globalStore.set(this.loadingAtom, false);
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultData(): DataItem[] {
|
||||
// set it back one to avoid backwards line being possible
|
||||
const numPoints = globalStore.get(this.numPoints);
|
||||
const currentTime = Date.now() - 1000;
|
||||
const points: DataItem[] = [];
|
||||
for (let i = numPoints; i > -1; i--) {
|
||||
points.push({ ts: currentTime - i * 1000 });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
function makeCpuPlotViewModel(blockId: string): CpuPlotViewModel {
|
||||
const cpuPlotViewModel = new CpuPlotViewModel(blockId);
|
||||
return cpuPlotViewModel;
|
||||
}
|
||||
|
||||
const plotColors = ["#58C142", "#FFC107", "#FF5722", "#2196F3", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"];
|
||||
|
||||
type CpuPlotViewProps = {
|
||||
blockId: string;
|
||||
model: CpuPlotViewModel;
|
||||
};
|
||||
|
||||
function CpuPlotView({ model, blockId }: CpuPlotViewProps) {
|
||||
const connName = jotai.useAtomValue(model.connection);
|
||||
const lastConnName = React.useRef(connName);
|
||||
const connStatus = jotai.useAtomValue(model.connStatus);
|
||||
const addPlotData = jotai.useSetAtom(model.addDataAtom);
|
||||
const loading = jotai.useAtomValue(model.loadingAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (connStatus?.status != "connected") {
|
||||
return;
|
||||
}
|
||||
if (lastConnName.current !== connName) {
|
||||
lastConnName.current = connName;
|
||||
model.loadInitialData();
|
||||
}
|
||||
}, [connStatus.status, connName]);
|
||||
React.useEffect(() => {
|
||||
const unsubFn = waveEventSubscribe({
|
||||
eventType: "sysinfo",
|
||||
scope: connName,
|
||||
handler: (event) => {
|
||||
const loading = globalStore.get(model.loadingAtom);
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
const dataItem = convertWaveEventToDataItem(event);
|
||||
addPlotData([dataItem]);
|
||||
},
|
||||
});
|
||||
console.log("subscribe to sysinfo", connName);
|
||||
return () => {
|
||||
unsubFn();
|
||||
};
|
||||
}, [connName]);
|
||||
if (connStatus?.status != "connected") {
|
||||
return null;
|
||||
}
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
return <CpuPlotViewInner key={connStatus?.connection ?? "local"} blockId={blockId} model={model} />;
|
||||
}
|
||||
|
||||
const CpuPlotViewInner = React.memo(({ model }: CpuPlotViewProps) => {
|
||||
const containerRef = React.useRef<HTMLInputElement>();
|
||||
const plotData = jotai.useAtomValue(model.dataAtom);
|
||||
const domRect = useDimensionsWithExistingRef(containerRef, 30);
|
||||
const parentHeight = domRect?.height ?? 0;
|
||||
const parentWidth = domRect?.width ?? 0;
|
||||
const yvals = jotai.useAtomValue(model.metrics);
|
||||
|
||||
React.useEffect(() => {
|
||||
const marks: Plot.Markish[] = [];
|
||||
marks.push(
|
||||
() => htl.svg`<defs>
|
||||
<linearGradient id="gradient" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stop-color="#58C142" stop-opacity="0.7" />
|
||||
<stop offset="100%" stop-color="#58C142" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>`
|
||||
);
|
||||
if (yvals.length == 0) {
|
||||
// nothing
|
||||
} else if (yvals.length == 1) {
|
||||
marks.push(
|
||||
Plot.lineY(plotData, {
|
||||
stroke: plotColors[0],
|
||||
strokeWidth: 2,
|
||||
x: "ts",
|
||||
y: yvals[0],
|
||||
})
|
||||
);
|
||||
marks.push(
|
||||
Plot.areaY(plotData, {
|
||||
fill: "url(#gradient)",
|
||||
x: "ts",
|
||||
y: yvals[0],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
let idx = 0;
|
||||
for (const yval of yvals) {
|
||||
marks.push(
|
||||
Plot.lineY(plotData, {
|
||||
stroke: plotColors[idx % plotColors.length],
|
||||
strokeWidth: 1,
|
||||
x: "ts",
|
||||
y: yval,
|
||||
})
|
||||
);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
const plot = Plot.plot({
|
||||
x: { grid: true, label: "time", tickFormat: (d) => `${dayjs.unix(d / 1000).format("HH:mm:ss")}` },
|
||||
y: { label: "%", domain: [0, 100] },
|
||||
width: parentWidth,
|
||||
height: parentHeight,
|
||||
marks: marks,
|
||||
});
|
||||
|
||||
if (plot !== undefined) {
|
||||
containerRef.current.append(plot);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (plot !== undefined) {
|
||||
plot.remove();
|
||||
}
|
||||
};
|
||||
}, [plotData, parentHeight, parentWidth]);
|
||||
|
||||
return <div className="plot-view" ref={containerRef} />;
|
||||
});
|
||||
|
||||
export { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel };
|
@ -6,7 +6,7 @@ import { WebView, WebViewModel } from "@/app/view/webview/webview";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback } from "react";
|
||||
import "./helpview.less";
|
||||
|
||||
class HelpViewModel extends WebViewModel {
|
||||
@ -48,21 +48,35 @@ function makeHelpViewModel(blockId: string, nodeModel: NodeModel) {
|
||||
return new HelpViewModel(blockId, nodeModel);
|
||||
}
|
||||
|
||||
const baseUrlRegex = /http[s]?:\/\/([^:\/])+(:\d+)?/;
|
||||
|
||||
function HelpView({ model }: { model: HelpViewModel }) {
|
||||
const homepageUrl = useAtomValue(model.homepageUrl);
|
||||
useEffect(
|
||||
() =>
|
||||
|
||||
// Effect to update the docsite base url when the app restarts, since the webserver port is dynamic
|
||||
const onFailLoad = useCallback(
|
||||
(url: string) =>
|
||||
fireAndForget(async () => {
|
||||
const curDocsiteUrl = getApi().getDocsiteUrl();
|
||||
if (curDocsiteUrl !== homepageUrl) {
|
||||
await model.setHomepageUrl(curDocsiteUrl, "block");
|
||||
const newDocsiteUrl = getApi().getDocsiteUrl();
|
||||
|
||||
// Correct the homepage URL, if necessary
|
||||
if (newDocsiteUrl !== homepageUrl) {
|
||||
await model.setHomepageUrl(newDocsiteUrl, "block");
|
||||
}
|
||||
|
||||
// Correct the base URL of the current page, if necessary
|
||||
const newBaseUrl = baseUrlRegex.exec(newDocsiteUrl)?.[0];
|
||||
const curBaseUrl = baseUrlRegex.exec(url)?.[0];
|
||||
console.log("fix-docsite-url", url, newDocsiteUrl, homepageUrl, curBaseUrl, newBaseUrl);
|
||||
if (curBaseUrl && newBaseUrl && curBaseUrl !== newBaseUrl) {
|
||||
model.loadUrl(url.replace(curBaseUrl, newBaseUrl), "fix-fail-load");
|
||||
}
|
||||
}),
|
||||
[]
|
||||
[homepageUrl]
|
||||
);
|
||||
return (
|
||||
<div className="help-view">
|
||||
<WebView blockId={model.blockId} model={model} />
|
||||
<WebView blockId={model.blockId} model={model} onFailLoad={onFailLoad} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
33
frontend/app/view/sysinfo/sysinfo.less
Normal file
33
frontend/app/view/sysinfo/sysinfo.less
Normal file
@ -0,0 +1,33 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.scrollable {
|
||||
flex-flow: column nowrap;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 0;
|
||||
overflow-y: auto;
|
||||
.sysinfo-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
|
||||
&.two-columns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.sysinfo-plot-content {
|
||||
min-height: 100px;
|
||||
svg {
|
||||
[aria-label="tip"] {
|
||||
g {
|
||||
path {
|
||||
color: var(--border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
502
frontend/app/view/sysinfo/sysinfo.tsx
Normal file
502
frontend/app/view/sysinfo/sysinfo.tsx
Normal file
@ -0,0 +1,502 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getConnStatusAtom, globalStore, WOS } from "@/store/global";
|
||||
import * as util from "@/util/util";
|
||||
import * as Plot from "@observablehq/plot";
|
||||
import clsx from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
import * as htl from "htl";
|
||||
import * as jotai from "jotai";
|
||||
import * as React from "react";
|
||||
|
||||
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { atoms } from "@/store/global";
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
import "./sysinfo.less";
|
||||
|
||||
const DefaultNumPoints = 120;
|
||||
|
||||
type DataItem = {
|
||||
ts: number;
|
||||
[k: string]: number;
|
||||
};
|
||||
|
||||
function defaultCpuMeta(name: string): TimeSeriesMeta {
|
||||
return {
|
||||
name: name,
|
||||
label: "%",
|
||||
miny: 0,
|
||||
maxy: 100,
|
||||
color: "var(--sysinfo-cpu-color)",
|
||||
decimalPlaces: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMemMeta(name: string, maxY: string): TimeSeriesMeta {
|
||||
return {
|
||||
name: name,
|
||||
label: "GB",
|
||||
miny: 0,
|
||||
maxy: maxY,
|
||||
color: "var(--sysinfo-mem-color)",
|
||||
decimalPlaces: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const PlotTypes: Object = {
|
||||
CPU: function (dataItem: DataItem): Array<string> {
|
||||
return ["cpu"];
|
||||
},
|
||||
Mem: function (dataItem: DataItem): Array<string> {
|
||||
return ["mem:used"];
|
||||
},
|
||||
"CPU + Mem": function (dataItem: DataItem): Array<string> {
|
||||
return ["cpu", "mem:used"];
|
||||
},
|
||||
"All CPU": function (dataItem: DataItem): Array<string> {
|
||||
return Object.keys(dataItem)
|
||||
.filter((item) => item.startsWith("cpu") && item != "cpu")
|
||||
.sort((a, b) => {
|
||||
const valA = parseInt(a.replace("cpu:", ""));
|
||||
const valB = parseInt(b.replace("cpu:", ""));
|
||||
return valA - valB;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const DefaultPlotMeta = {
|
||||
cpu: defaultCpuMeta("CPU %"),
|
||||
"mem:total": defaultMemMeta("Memory Total", "mem:total"),
|
||||
"mem:used": defaultMemMeta("Memory Used", "mem:total"),
|
||||
"mem:free": defaultMemMeta("Memory Free", "mem:total"),
|
||||
"mem:available": defaultMemMeta("Memory Available", "mem:total"),
|
||||
};
|
||||
for (let i = 0; i < 32; i++) {
|
||||
DefaultPlotMeta[`cpu:${i}`] = defaultCpuMeta(`Core ${i}`);
|
||||
}
|
||||
|
||||
function convertWaveEventToDataItem(event: WaveEvent): DataItem {
|
||||
const eventData: TimeSeriesData = event.data;
|
||||
if (eventData == null || eventData.ts == null || eventData.values == null) {
|
||||
return null;
|
||||
}
|
||||
const dataItem = { ts: eventData.ts };
|
||||
for (const key in eventData.values) {
|
||||
dataItem[key] = eventData.values[key];
|
||||
}
|
||||
return dataItem;
|
||||
}
|
||||
|
||||
class SysinfoViewModel {
|
||||
viewType: string;
|
||||
blockAtom: jotai.Atom<Block>;
|
||||
termMode: jotai.Atom<string>;
|
||||
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
|
||||
blockId: string;
|
||||
viewIcon: jotai.Atom<string>;
|
||||
viewText: jotai.Atom<string>;
|
||||
viewName: jotai.Atom<string>;
|
||||
dataAtom: jotai.PrimitiveAtom<Array<DataItem>>;
|
||||
addDataAtom: jotai.WritableAtom<unknown, [DataItem[]], void>;
|
||||
incrementCount: jotai.WritableAtom<unknown, [], Promise<void>>;
|
||||
loadingAtom: jotai.PrimitiveAtom<boolean>;
|
||||
numPoints: jotai.Atom<number>;
|
||||
metrics: jotai.Atom<string[]>;
|
||||
connection: jotai.Atom<string>;
|
||||
manageConnection: jotai.Atom<boolean>;
|
||||
connStatus: jotai.Atom<ConnStatus>;
|
||||
plotMetaAtom: jotai.PrimitiveAtom<Map<string, TimeSeriesMeta>>;
|
||||
endIconButtons: jotai.Atom<IconButtonDecl[]>;
|
||||
plotTypeSelectedAtom: jotai.Atom<string>;
|
||||
|
||||
constructor(blockId: string, viewType: string) {
|
||||
this.viewType = viewType;
|
||||
this.blockId = blockId;
|
||||
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
|
||||
this.addDataAtom = jotai.atom(null, (get, set, points) => {
|
||||
const targetLen = get(this.numPoints) + 1;
|
||||
let data = get(this.dataAtom);
|
||||
try {
|
||||
if (data.length > targetLen) {
|
||||
data = data.slice(data.length - targetLen);
|
||||
}
|
||||
if (data.length < targetLen) {
|
||||
const defaultData = this.getDefaultData();
|
||||
data = [...defaultData.slice(defaultData.length - targetLen + data.length), ...data];
|
||||
}
|
||||
const newData = [...data.slice(points.length), ...points];
|
||||
set(this.dataAtom, newData);
|
||||
} catch (e) {
|
||||
console.log("Error adding data to sysinfo", e);
|
||||
}
|
||||
});
|
||||
this.plotMetaAtom = jotai.atom(new Map(Object.entries(DefaultPlotMeta)));
|
||||
this.manageConnection = jotai.atom(true);
|
||||
this.loadingAtom = jotai.atom(true);
|
||||
this.numPoints = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const metaNumPoints = blockData?.meta?.["graph:numpoints"];
|
||||
if (metaNumPoints == null || metaNumPoints <= 0) {
|
||||
return DefaultNumPoints;
|
||||
}
|
||||
return metaNumPoints;
|
||||
});
|
||||
this.metrics = jotai.atom((get) => {
|
||||
let plotType = get(this.plotTypeSelectedAtom);
|
||||
const plotData = get(this.dataAtom);
|
||||
try {
|
||||
const metrics = PlotTypes[plotType](plotData[plotData.length - 1]);
|
||||
if (metrics == null || !Array.isArray(metrics)) {
|
||||
return ["cpu"];
|
||||
}
|
||||
return metrics;
|
||||
} catch (e) {
|
||||
return ["cpu"];
|
||||
}
|
||||
});
|
||||
this.plotTypeSelectedAtom = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const plotType = blockData?.meta?.["sysinfo:type"];
|
||||
if (plotType == null || typeof plotType != "string") {
|
||||
return "CPU";
|
||||
}
|
||||
return plotType;
|
||||
});
|
||||
this.viewIcon = jotai.atom((get) => {
|
||||
return "chart-line"; // should not be hardcoded
|
||||
});
|
||||
this.viewName = jotai.atom((get) => {
|
||||
return get(this.plotTypeSelectedAtom);
|
||||
});
|
||||
this.incrementCount = jotai.atom(null, async (get, set) => {
|
||||
const meta = get(this.blockAtom).meta;
|
||||
const count = meta.count ?? 0;
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { count: count + 1 },
|
||||
});
|
||||
});
|
||||
this.connection = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const connValue = blockData?.meta?.connection;
|
||||
if (util.isBlank(connValue)) {
|
||||
return "local";
|
||||
}
|
||||
return connValue;
|
||||
});
|
||||
this.dataAtom = jotai.atom(this.getDefaultData());
|
||||
this.loadInitialData();
|
||||
this.connStatus = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
const connName = blockData?.meta?.connection;
|
||||
const connAtom = getConnStatusAtom(connName);
|
||||
return get(connAtom);
|
||||
});
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
globalStore.set(this.loadingAtom, true);
|
||||
try {
|
||||
const numPoints = globalStore.get(this.numPoints);
|
||||
const connName = globalStore.get(this.connection);
|
||||
const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, {
|
||||
event: "sysinfo",
|
||||
scope: connName,
|
||||
maxitems: numPoints,
|
||||
});
|
||||
if (initialData == null) {
|
||||
return;
|
||||
}
|
||||
const newData = this.getDefaultData();
|
||||
const initialDataItems: DataItem[] = initialData.map(convertWaveEventToDataItem);
|
||||
// splice the initial data into the default data (replacing the newest points)
|
||||
newData.splice(newData.length - initialDataItems.length, initialDataItems.length, ...initialDataItems);
|
||||
globalStore.set(this.addDataAtom, newData);
|
||||
} catch (e) {
|
||||
console.log("Error loading initial data for sysinfo", e);
|
||||
} finally {
|
||||
globalStore.set(this.loadingAtom, false);
|
||||
}
|
||||
}
|
||||
|
||||
getSettingsMenuItems(): ContextMenuItem[] {
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const termThemes = fullConfig?.termthemes ?? {};
|
||||
const termThemeKeys = Object.keys(termThemes);
|
||||
const plotData = globalStore.get(this.dataAtom);
|
||||
|
||||
termThemeKeys.sort((a, b) => {
|
||||
return termThemes[a]["display:order"] - termThemes[b]["display:order"];
|
||||
});
|
||||
const fullMenu: ContextMenuItem[] = [];
|
||||
let submenu: ContextMenuItem[];
|
||||
if (plotData.length == 0) {
|
||||
submenu = [];
|
||||
} else {
|
||||
submenu = Object.keys(PlotTypes).map((plotType) => {
|
||||
const dataTypes = PlotTypes[plotType](plotData[plotData.length - 1]);
|
||||
const currentlySelected = globalStore.get(this.plotTypeSelectedAtom);
|
||||
const menuItem: ContextMenuItem = {
|
||||
label: plotType,
|
||||
type: "radio",
|
||||
checked: currentlySelected == plotType,
|
||||
click: async () => {
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType },
|
||||
});
|
||||
},
|
||||
};
|
||||
return menuItem;
|
||||
});
|
||||
}
|
||||
|
||||
fullMenu.push({
|
||||
label: "Plot Type",
|
||||
submenu: submenu,
|
||||
});
|
||||
fullMenu.push({ type: "separator" });
|
||||
return fullMenu;
|
||||
}
|
||||
|
||||
getDefaultData(): DataItem[] {
|
||||
// set it back one to avoid backwards line being possible
|
||||
const numPoints = globalStore.get(this.numPoints);
|
||||
const currentTime = Date.now() - 1000;
|
||||
const points: DataItem[] = [];
|
||||
for (let i = numPoints; i > -1; i--) {
|
||||
points.push({ ts: currentTime - i * 1000 });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
function makeSysinfoViewModel(blockId: string, viewType: string): SysinfoViewModel {
|
||||
const sysinfoViewModel = new SysinfoViewModel(blockId, viewType);
|
||||
return sysinfoViewModel;
|
||||
}
|
||||
|
||||
const plotColors = ["#58C142", "#FFC107", "#FF5722", "#2196F3", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"];
|
||||
|
||||
type SysinfoViewProps = {
|
||||
blockId: string;
|
||||
model: SysinfoViewModel;
|
||||
};
|
||||
|
||||
function resolveDomainBound(value: number | string, dataItem: DataItem): number | undefined {
|
||||
if (typeof value == "number") {
|
||||
return value;
|
||||
} else if (typeof value == "string") {
|
||||
return dataItem?.[value];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function SysinfoView({ model, blockId }: SysinfoViewProps) {
|
||||
const connName = jotai.useAtomValue(model.connection);
|
||||
const lastConnName = React.useRef(connName);
|
||||
const connStatus = jotai.useAtomValue(model.connStatus);
|
||||
const addPlotData = jotai.useSetAtom(model.addDataAtom);
|
||||
const loading = jotai.useAtomValue(model.loadingAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (connStatus?.status != "connected") {
|
||||
return;
|
||||
}
|
||||
if (lastConnName.current !== connName) {
|
||||
lastConnName.current = connName;
|
||||
model.loadInitialData();
|
||||
}
|
||||
}, [connStatus.status, connName]);
|
||||
React.useEffect(() => {
|
||||
const unsubFn = waveEventSubscribe({
|
||||
eventType: "sysinfo",
|
||||
scope: connName,
|
||||
handler: (event) => {
|
||||
const loading = globalStore.get(model.loadingAtom);
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
const dataItem = convertWaveEventToDataItem(event);
|
||||
addPlotData([dataItem]);
|
||||
},
|
||||
});
|
||||
console.log("subscribe to sysinfo", connName);
|
||||
return () => {
|
||||
unsubFn();
|
||||
};
|
||||
}, [connName]);
|
||||
if (connStatus?.status != "connected") {
|
||||
return null;
|
||||
}
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
return <SysinfoViewInner key={connStatus?.connection ?? "local"} blockId={blockId} model={model} />;
|
||||
}
|
||||
|
||||
type SingleLinePlotProps = {
|
||||
plotData: Array<DataItem>;
|
||||
yval: string;
|
||||
yvalMeta: TimeSeriesMeta;
|
||||
blockId: string;
|
||||
defaultColor: string;
|
||||
title?: boolean;
|
||||
sparkline?: boolean;
|
||||
};
|
||||
|
||||
function SingleLinePlot({
|
||||
plotData,
|
||||
yval,
|
||||
yvalMeta,
|
||||
blockId,
|
||||
defaultColor,
|
||||
title = false,
|
||||
sparkline = false,
|
||||
}: SingleLinePlotProps) {
|
||||
const containerRef = React.useRef<HTMLInputElement>();
|
||||
const domRect = useDimensionsWithExistingRef(containerRef, 300);
|
||||
const plotHeight = domRect?.height ?? 0;
|
||||
const plotWidth = domRect?.width ?? 0;
|
||||
const marks: Plot.Markish[] = [];
|
||||
let decimalPlaces = yvalMeta?.decimalPlaces ?? 0;
|
||||
let color = yvalMeta?.color;
|
||||
if (!color) {
|
||||
color = defaultColor;
|
||||
}
|
||||
marks.push(
|
||||
() => htl.svg`<defs>
|
||||
<linearGradient id="gradient-${blockId}-${yval}" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stop-color="${color}" stop-opacity="0.7" />
|
||||
<stop offset="100%" stop-color="${color}" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>`
|
||||
);
|
||||
|
||||
marks.push(
|
||||
Plot.lineY(plotData, {
|
||||
stroke: color,
|
||||
strokeWidth: 2,
|
||||
x: "ts",
|
||||
y: yval,
|
||||
})
|
||||
);
|
||||
|
||||
// only add the gradient for single items
|
||||
marks.push(
|
||||
Plot.areaY(plotData, {
|
||||
fill: `url(#gradient-${blockId}-${yval})`,
|
||||
x: "ts",
|
||||
y: yval,
|
||||
})
|
||||
);
|
||||
if (title) {
|
||||
marks.push(
|
||||
Plot.text([yvalMeta.name], {
|
||||
frameAnchor: "top-left",
|
||||
dx: 4,
|
||||
fill: "var(--grey-text-color)",
|
||||
})
|
||||
);
|
||||
}
|
||||
const labelY = yvalMeta?.label ?? "?";
|
||||
marks.push(
|
||||
Plot.ruleX(
|
||||
plotData,
|
||||
Plot.pointerX({ x: "ts", py: yval, stroke: "var(--grey-text-color)", strokeWidth: 1, strokeDasharray: 2 })
|
||||
)
|
||||
);
|
||||
marks.push(
|
||||
Plot.ruleY(
|
||||
plotData,
|
||||
Plot.pointerX({ px: "ts", y: yval, stroke: "var(--grey-text-color)", strokeWidth: 1, strokeDasharray: 2 })
|
||||
)
|
||||
);
|
||||
marks.push(
|
||||
Plot.tip(
|
||||
plotData,
|
||||
Plot.pointerX({
|
||||
x: "ts",
|
||||
y: yval,
|
||||
fill: "var(--main-bg-color)",
|
||||
anchor: "middle",
|
||||
dy: -30,
|
||||
title: (d) =>
|
||||
`${dayjs.unix(d.ts / 1000).format("HH:mm:ss")} ${Number(d[yval]).toFixed(decimalPlaces)}${labelY}`,
|
||||
textPadding: 3,
|
||||
})
|
||||
)
|
||||
);
|
||||
marks.push(
|
||||
Plot.dot(
|
||||
plotData,
|
||||
Plot.pointerX({ x: "ts", y: yval, fill: color, r: 3, stroke: "var(--main-text-color)", strokeWidth: 1 })
|
||||
)
|
||||
);
|
||||
let maxY = resolveDomainBound(yvalMeta?.maxy, plotData[plotData.length - 1]) ?? 100;
|
||||
let minY = resolveDomainBound(yvalMeta?.miny, plotData[plotData.length - 1]) ?? 0;
|
||||
const plot = Plot.plot({
|
||||
axis: !sparkline,
|
||||
x: {
|
||||
grid: true,
|
||||
label: "time",
|
||||
tickFormat: (d) => `${dayjs.unix(d / 1000).format("HH:mm:ss")}`,
|
||||
},
|
||||
y: { label: labelY, domain: [minY, maxY] },
|
||||
width: plotWidth,
|
||||
height: plotHeight,
|
||||
marks: marks,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
containerRef.current.append(plot);
|
||||
|
||||
return () => {
|
||||
plot.remove();
|
||||
};
|
||||
}, [plot, plotWidth, plotHeight]);
|
||||
|
||||
return <div ref={containerRef} className="sysinfo-plot-content" />;
|
||||
}
|
||||
|
||||
const SysinfoViewInner = React.memo(({ model }: SysinfoViewProps) => {
|
||||
const plotData = jotai.useAtomValue(model.dataAtom);
|
||||
const yvals = jotai.useAtomValue(model.metrics);
|
||||
const plotMeta = jotai.useAtomValue(model.plotMetaAtom);
|
||||
const osRef = React.useRef<OverlayScrollbarsComponentRef>();
|
||||
let title = false;
|
||||
let cols2 = false;
|
||||
if (yvals.length > 1) {
|
||||
title = true;
|
||||
}
|
||||
if (yvals.length > 2) {
|
||||
cols2 = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayScrollbarsComponent ref={osRef} className="scrollable" options={{ scrollbars: { autoHide: "leave" } }}>
|
||||
<div className={clsx("sysinfo-view", { "two-columns": cols2 })}>
|
||||
{yvals.map((yval, idx) => {
|
||||
return (
|
||||
<SingleLinePlot
|
||||
key={`plot-${model.blockId}-${yval}`}
|
||||
plotData={plotData}
|
||||
yval={yval}
|
||||
yvalMeta={plotMeta.get(yval)}
|
||||
blockId={model.blockId}
|
||||
defaultColor={"var(--accent-color)"}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
);
|
||||
});
|
||||
|
||||
export { makeSysinfoViewModel, SysinfoView, SysinfoViewModel };
|
@ -60,7 +60,6 @@ export class WebViewModel implements ViewModel {
|
||||
this.homepageUrl = atom((get) => {
|
||||
const defaultUrl = get(defaultUrlAtom);
|
||||
const pinnedUrl = get(this.blockAtom).meta.pinnedurl;
|
||||
console.log("homepageUrl", pinnedUrl, defaultUrl);
|
||||
return pinnedUrl ?? defaultUrl;
|
||||
});
|
||||
this.urlWrapperClassName = atom("");
|
||||
@ -468,9 +467,10 @@ function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel {
|
||||
interface WebViewProps {
|
||||
blockId: string;
|
||||
model: WebViewModel;
|
||||
onFailLoad?: (url: string) => void;
|
||||
}
|
||||
|
||||
const WebView = memo(({ model }: WebViewProps) => {
|
||||
const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
|
||||
const blockData = useAtomValue(model.blockAtom);
|
||||
const defaultUrl = useAtomValue(model.homepageUrl);
|
||||
const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch");
|
||||
@ -555,6 +555,10 @@ const WebView = memo(({ model }: WebViewProps) => {
|
||||
console.warn("Suppressed ERR_ABORTED error", e);
|
||||
} else {
|
||||
console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`);
|
||||
if (onFailLoad) {
|
||||
const curUrl = model.webviewRef?.current.getURL();
|
||||
onFailLoad(curUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
const webviewFocus = () => {
|
||||
|
9
frontend/types/custom.d.ts
vendored
9
frontend/types/custom.d.ts
vendored
@ -366,6 +366,15 @@ declare global {
|
||||
initResolve: () => void;
|
||||
waveReadyResolve: () => void;
|
||||
};
|
||||
|
||||
type TimeSeriesMeta = {
|
||||
name?: string;
|
||||
color?: string;
|
||||
label?: string;
|
||||
maxy?: string | number;
|
||||
miny?: string | number;
|
||||
decimalPlaces?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
|
1
frontend/types/gotypes.d.ts
vendored
1
frontend/types/gotypes.d.ts
vendored
@ -322,6 +322,7 @@ declare global {
|
||||
"graph:*"?: boolean;
|
||||
"graph:numpoints"?: number;
|
||||
"graph:metrics"?: string[];
|
||||
"sysinfo:type"?: string;
|
||||
"bg:*"?: boolean;
|
||||
bg?: string;
|
||||
"bg:opacity"?: number;
|
||||
|
@ -7,7 +7,7 @@
|
||||
"productName": "Wave",
|
||||
"description": "Open-Source AI-Native Terminal Built for Seamless Workflows",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.8.12-beta.0",
|
||||
"version": "0.8.12-beta.3",
|
||||
"homepage": "https://waveterm.dev",
|
||||
"build": {
|
||||
"appId": "dev.commandline.waveterm"
|
||||
|
@ -366,7 +366,6 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
bc.ShellProc.Cmd.Write(ic.InputData)
|
||||
}
|
||||
if ic.TermSize != nil {
|
||||
log.Printf("SETTERMSIZE: %dx%d\n", ic.TermSize.Rows, ic.TermSize.Cols)
|
||||
err = setTermSize(ctx, bc.BlockId, *ic.TermSize)
|
||||
if err != nil {
|
||||
log.Printf("error setting pty size: %v\n", err)
|
||||
|
@ -64,6 +64,8 @@ const (
|
||||
MetaKey_GraphNumPoints = "graph:numpoints"
|
||||
MetaKey_GraphMetrics = "graph:metrics"
|
||||
|
||||
MetaKey_SysinfoType = "sysinfo:type"
|
||||
|
||||
MetaKey_BgClear = "bg:*"
|
||||
MetaKey_Bg = "bg"
|
||||
MetaKey_BgOpacity = "bg:opacity"
|
||||
|
@ -64,6 +64,8 @@ type MetaTSType struct {
|
||||
GraphNumPoints int `json:"graph:numpoints,omitempty"`
|
||||
GraphMetrics []string `json:"graph:metrics,omitempty"`
|
||||
|
||||
SysinfoType string `json:"sysinfo:type,omitempty"`
|
||||
|
||||
// for tabs
|
||||
BgClear bool `json:"bg:*,omitempty"`
|
||||
Bg string `json:"bg,omitempty"`
|
||||
|
@ -41,13 +41,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"defwidget@cpuplot": {
|
||||
"defwidget@sysinfo": {
|
||||
"display:order": 5,
|
||||
"icon": "chart-line",
|
||||
"label": "cpu",
|
||||
"label": "sysinfo",
|
||||
"blockdef": {
|
||||
"meta": {
|
||||
"view": "cpuplot"
|
||||
"view": "sysinfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,10 +40,10 @@ func RunWebSocketServer(listener net.Listener) {
|
||||
Handler: gr,
|
||||
}
|
||||
server.SetKeepAlivesEnabled(false)
|
||||
log.Printf("Running websocket server on %s\n", listener.Addr())
|
||||
log.Printf("[websocket] running websocket server on %s\n", listener.Addr())
|
||||
err := server.Serve(listener)
|
||||
if err != nil {
|
||||
log.Printf("[error] trying to run websocket server: %v\n", err)
|
||||
log.Printf("[websocket] error trying to run websocket server: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ func processWSCommand(jmsg map[string]any, outputCh chan any, rpcInputCh chan []
|
||||
r := recover()
|
||||
if r != nil {
|
||||
rtnErr = fmt.Errorf("panic: %v", r)
|
||||
log.Printf("panic in processMessage: %v\n", r)
|
||||
log.Printf("[websocket] panic in processMessage: %v\n", r)
|
||||
debug.PrintStack()
|
||||
}
|
||||
if rtnErr == nil {
|
||||
@ -108,7 +108,7 @@ func processWSCommand(jmsg map[string]any, outputCh chan any, rpcInputCh chan []
|
||||
msgBytes, err := json.Marshal(rpcMsg)
|
||||
if err != nil {
|
||||
// this really should never fail since we just unmarshalled this value
|
||||
log.Printf("error marshalling rpc message: %v\n", err)
|
||||
log.Printf("[websocket] error marshalling rpc message: %v\n", err)
|
||||
return
|
||||
}
|
||||
rpcInputCh <- msgBytes
|
||||
@ -125,7 +125,7 @@ func processWSCommand(jmsg map[string]any, outputCh chan any, rpcInputCh chan []
|
||||
msgBytes, err := json.Marshal(rpcMsg)
|
||||
if err != nil {
|
||||
// this really should never fail since we just unmarshalled this value
|
||||
log.Printf("error marshalling rpc message: %v\n", err)
|
||||
log.Printf("[websocket] error marshalling rpc message: %v\n", err)
|
||||
return
|
||||
}
|
||||
rpcInputCh <- msgBytes
|
||||
@ -152,7 +152,7 @@ func processMessage(jmsg map[string]any, outputCh chan any, rpcInputCh chan []by
|
||||
processWSCommand(jmsg, outputCh, rpcInputCh)
|
||||
}
|
||||
|
||||
func ReadLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, rpcInputCh chan []byte) {
|
||||
func ReadLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, rpcInputCh chan []byte, routeId string) {
|
||||
readWait := wsReadWaitTimeout
|
||||
conn.SetReadLimit(64 * 1024)
|
||||
conn.SetReadDeadline(time.Now().Add(readWait))
|
||||
@ -160,13 +160,13 @@ func ReadLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, rpcInpu
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("ReadPump error: %v\n", err)
|
||||
log.Printf("[websocket] ReadPump error (%s): %v\n", routeId, err)
|
||||
break
|
||||
}
|
||||
jmsg := map[string]any{}
|
||||
err = json.Unmarshal(message, &jmsg)
|
||||
if err != nil {
|
||||
log.Printf("Error unmarshalling json: %v\n", err)
|
||||
log.Printf("[websocket] error unmarshalling json: %v\n", err)
|
||||
break
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(readWait))
|
||||
@ -197,7 +197,7 @@ func WritePing(conn *websocket.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any) {
|
||||
func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, routeId string) {
|
||||
ticker := time.NewTicker(wsInitialPingTime)
|
||||
defer ticker.Stop()
|
||||
initialPing := true
|
||||
@ -211,7 +211,7 @@ func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any) {
|
||||
} else {
|
||||
barr, err = json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("cannot marshal websocket message: %v\n", err)
|
||||
log.Printf("[websocket] cannot marshal websocket message: %v\n", err)
|
||||
// just loop again
|
||||
break
|
||||
}
|
||||
@ -219,14 +219,14 @@ func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any) {
|
||||
err = conn.WriteMessage(websocket.TextMessage, barr)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
log.Printf("WritePump error: %v\n", err)
|
||||
log.Printf("[websocket] WritePump error (%s): %v\n", routeId, err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
err := WritePing(conn)
|
||||
if err != nil {
|
||||
log.Printf("WritePump error: %v\n", err)
|
||||
log.Printf("[websocket] WritePump error (%s): %v\n", routeId, err)
|
||||
return
|
||||
}
|
||||
if initialPing {
|
||||
@ -249,6 +249,7 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err)))
|
||||
log.Printf("[websocket] error validating authkey: %v\n", err)
|
||||
return err
|
||||
}
|
||||
conn, err := WebSocketUpgrader.Upgrade(w, r, nil)
|
||||
@ -257,7 +258,6 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
defer conn.Close()
|
||||
wsConnId := uuid.New().String()
|
||||
log.Printf("New websocket connection: tabid:%s connid:%s\n", tabId, wsConnId)
|
||||
outputCh := make(chan any, 100)
|
||||
closeCh := make(chan any)
|
||||
eventbus.RegisterWSChannel(wsConnId, tabId, outputCh)
|
||||
@ -268,6 +268,7 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
routeId = wshutil.MakeTabRouteId(tabId)
|
||||
}
|
||||
defer eventbus.UnregisterWSChannel(wsConnId)
|
||||
log.Printf("[websocket] new connection: tabid:%s connid:%s routeid:%s\n", tabId, wsConnId, routeId)
|
||||
// we create a wshproxy to handle rpc messages to/from the window
|
||||
wproxy := wshutil.MakeRpcProxy()
|
||||
wshutil.DefaultRouter.RegisterRoute(routeId, wproxy)
|
||||
@ -292,12 +293,12 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
go func() {
|
||||
// read loop
|
||||
defer wg.Done()
|
||||
ReadLoop(conn, outputCh, closeCh, wproxy.FromRemoteCh)
|
||||
ReadLoop(conn, outputCh, closeCh, wproxy.FromRemoteCh, routeId)
|
||||
}()
|
||||
go func() {
|
||||
// write loop
|
||||
defer wg.Done()
|
||||
WriteLoop(conn, outputCh, closeCh)
|
||||
WriteLoop(conn, outputCh, closeCh, routeId)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(wproxy.FromRemoteCh)
|
||||
|
@ -38,7 +38,7 @@ func GetStarterLayout() PortableLayout {
|
||||
}, Focused: true},
|
||||
{IndexArr: []int{1}, BlockDef: &waveobj.BlockDef{
|
||||
Meta: waveobj.MetaMapType{
|
||||
waveobj.MetaKey_View: "cpuplot",
|
||||
waveobj.MetaKey_View: "sysinfo",
|
||||
},
|
||||
}},
|
||||
{IndexArr: []int{1, 1}, BlockDef: &waveobj.BlockDef{
|
||||
|
@ -16,6 +16,8 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
)
|
||||
|
||||
const BYTES_PER_GB = 1073741824
|
||||
|
||||
func getCpuData(values map[string]float64) {
|
||||
percentArr, err := cpu.Percent(0, false)
|
||||
if err != nil {
|
||||
@ -38,10 +40,10 @@ func getMemData(values map[string]float64) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
values["mem:total"] = float64(memData.Total)
|
||||
values["mem:available"] = float64(memData.Available)
|
||||
values["mem:used"] = float64(memData.Used)
|
||||
values["mem:free"] = float64(memData.Free)
|
||||
values["mem:total"] = float64(memData.Total) / BYTES_PER_GB
|
||||
values["mem:available"] = float64(memData.Available) / BYTES_PER_GB
|
||||
values["mem:used"] = float64(memData.Used) / BYTES_PER_GB
|
||||
values["mem:free"] = float64(memData.Free) / BYTES_PER_GB
|
||||
}
|
||||
|
||||
func generateSingleServerData(client *wshutil.WshRpc, connName string) {
|
||||
|
@ -83,7 +83,7 @@ func MakePlotData(ctx context.Context, blockId string) error {
|
||||
return err
|
||||
}
|
||||
viewName := block.Meta.GetString(waveobj.MetaKey_View, "")
|
||||
if viewName != "cpuplot" {
|
||||
if viewName != "cpuplot" && viewName != "sysinfo" {
|
||||
return fmt.Errorf("invalid view type: %s", viewName)
|
||||
}
|
||||
return filestore.WFS.MakeFile(ctx, blockId, "cpuplotdata", nil, filestore.FileOptsType{})
|
||||
@ -95,7 +95,7 @@ func SavePlotData(ctx context.Context, blockId string, history string) error {
|
||||
return err
|
||||
}
|
||||
viewName := block.Meta.GetString(waveobj.MetaKey_View, "")
|
||||
if viewName != "cpuplot" {
|
||||
if viewName != "cpuplot" && viewName != "sysinfo" {
|
||||
return fmt.Errorf("invalid view type: %s", viewName)
|
||||
}
|
||||
// todo: interpret the data being passed
|
||||
@ -422,7 +422,6 @@ func (ws *WshServer) EventPublishCommand(ctx context.Context, data wps.WaveEvent
|
||||
}
|
||||
|
||||
func (ws *WshServer) EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error {
|
||||
// log.Printf("EventSubCommand: %v\n", data)
|
||||
rpcSource := wshutil.GetRpcSourceFromContext(ctx)
|
||||
if rpcSource == "" {
|
||||
return fmt.Errorf("no rpc source set")
|
||||
|
12
testdriver/onboarding.yml
Normal file
12
testdriver/onboarding.yml
Normal file
@ -0,0 +1,12 @@
|
||||
version: 4.0.65
|
||||
steps:
|
||||
- prompt: complete the onboarding of wave terminal
|
||||
commands:
|
||||
- command: hover-text
|
||||
text: Continue
|
||||
description: button to complete onboarding
|
||||
action: click
|
||||
- command: hover-text
|
||||
text: Get Started
|
||||
description: button to complete onboarding
|
||||
action: click
|
Loading…
Reference in New Issue
Block a user