mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
merge main
This commit is contained in:
commit
25f44319d3
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"],
|
||||
certificateSubjectName: "Command Line Inc",
|
||||
certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,
|
||||
signingHashAlgorithms: ["sha256"],
|
||||
signtoolOptions: windowsShouldSign && {
|
||||
signingHashAlgorithms: ["sha256"],
|
||||
publisherName: "Command Line Inc",
|
||||
certificateSubjectName: "Command Line Inc",
|
||||
certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,
|
||||
},
|
||||
},
|
||||
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) {
|
||||
|
@ -471,10 +471,12 @@ function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfi
|
||||
}
|
||||
});
|
||||
win.on(
|
||||
// @ts-expect-error
|
||||
"resize",
|
||||
debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
|
||||
);
|
||||
win.on(
|
||||
// @ts-expect-error
|
||||
"move",
|
||||
debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
|
||||
);
|
||||
@ -1073,7 +1075,7 @@ async function relaunchBrowserWindows(): Promise<void> {
|
||||
}
|
||||
for (const win of wins) {
|
||||
await win.readyPromise;
|
||||
console.log("show", win.waveWindowId);
|
||||
console.log("show window", win.waveWindowId);
|
||||
win.show();
|
||||
}
|
||||
}
|
||||
|
123
frontend/app/app-bg.tsx
Normal file
123
frontend/app/app-bg.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import * as util from "@/util/util";
|
||||
import useResizeObserver from "@react-hook/resize-observer";
|
||||
import { generate as generateCSS, parse as parseCSS, walk as walkCSS } from "css-tree";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CSSProperties, useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import { atoms, getApi, PLATFORM, WOS } from "./store/global";
|
||||
import { useWaveObjectValue } from "./store/wos";
|
||||
|
||||
function encodeFileURL(file: string) {
|
||||
const webEndpoint = getWebServerEndpoint();
|
||||
return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`;
|
||||
}
|
||||
|
||||
function processBackgroundUrls(cssText: string): string {
|
||||
if (util.isBlank(cssText)) {
|
||||
return null;
|
||||
}
|
||||
cssText = cssText.trim();
|
||||
if (cssText.endsWith(";")) {
|
||||
cssText = cssText.slice(0, -1);
|
||||
}
|
||||
const attrRe = /^background(-image)?\s*:\s*/i;
|
||||
cssText = cssText.replace(attrRe, "");
|
||||
const ast = parseCSS("background: " + cssText, {
|
||||
context: "declaration",
|
||||
});
|
||||
let hasUnsafeUrl = false;
|
||||
walkCSS(ast, {
|
||||
visit: "Url",
|
||||
enter(node) {
|
||||
const originalUrl = node.value.trim();
|
||||
if (
|
||||
originalUrl.startsWith("http:") ||
|
||||
originalUrl.startsWith("https:") ||
|
||||
originalUrl.startsWith("data:")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// allow file:/// urls (if they are absolute)
|
||||
if (originalUrl.startsWith("file://")) {
|
||||
const path = originalUrl.slice(7);
|
||||
if (!path.startsWith("/")) {
|
||||
console.log(`Invalid background, contains a non-absolute file URL: ${originalUrl}`);
|
||||
hasUnsafeUrl = true;
|
||||
return;
|
||||
}
|
||||
const newUrl = encodeFileURL(path);
|
||||
node.value = newUrl;
|
||||
return;
|
||||
}
|
||||
// allow absolute paths
|
||||
if (originalUrl.startsWith("/") || originalUrl.startsWith("~/")) {
|
||||
const newUrl = encodeFileURL(originalUrl);
|
||||
node.value = newUrl;
|
||||
return;
|
||||
}
|
||||
hasUnsafeUrl = true;
|
||||
console.log(`Invalid background, contains an unsafe URL scheme: ${originalUrl}`);
|
||||
},
|
||||
});
|
||||
if (hasUnsafeUrl) {
|
||||
return null;
|
||||
}
|
||||
const rtnStyle = generateCSS(ast);
|
||||
if (rtnStyle == null) {
|
||||
return null;
|
||||
}
|
||||
return rtnStyle.replace(/^background:\s*/, "");
|
||||
}
|
||||
|
||||
export function AppBackground() {
|
||||
const bgRef = useRef<HTMLDivElement>(null);
|
||||
const tabId = useAtomValue(atoms.activeTabId);
|
||||
const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
|
||||
const bgAttr = tabData?.meta?.bg;
|
||||
const style: CSSProperties = {};
|
||||
if (!util.isBlank(bgAttr)) {
|
||||
try {
|
||||
const processedBg = processBackgroundUrls(bgAttr);
|
||||
if (!util.isBlank(processedBg)) {
|
||||
const opacity = util.boundNumber(tabData?.meta?.["bg:opacity"], 0, 1) ?? 0.5;
|
||||
style.opacity = opacity;
|
||||
style.background = processedBg;
|
||||
const blendMode = tabData?.meta?.["bg:blendmode"];
|
||||
if (!util.isBlank(blendMode)) {
|
||||
style.backgroundBlendMode = blendMode;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error processing background", e);
|
||||
}
|
||||
}
|
||||
const getAvgColor = useCallback(
|
||||
debounce(30, () => {
|
||||
if (
|
||||
bgRef.current &&
|
||||
PLATFORM !== "darwin" &&
|
||||
bgRef.current &&
|
||||
"windowControlsOverlay" in window.navigator
|
||||
) {
|
||||
const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect();
|
||||
const bgRect = bgRef.current.getBoundingClientRect();
|
||||
if (titlebarRect && bgRect) {
|
||||
const windowControlsLeft = titlebarRect.width - titlebarRect.height;
|
||||
const windowControlsRect: Dimensions = {
|
||||
top: titlebarRect.top,
|
||||
left: windowControlsLeft,
|
||||
height: titlebarRect.height,
|
||||
width: bgRect.width - bgRect.left - windowControlsLeft,
|
||||
};
|
||||
getApi().updateWindowControlsOverlay(windowControlsRect);
|
||||
}
|
||||
}
|
||||
}),
|
||||
[bgRef, style]
|
||||
);
|
||||
useLayoutEffect(getAvgColor, [getAvgColor]);
|
||||
useResizeObserver(bgRef, getAvgColor);
|
||||
|
||||
return <div ref={bgRef} className="app-background" style={style} />;
|
||||
}
|
@ -1,26 +1,22 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useWaveObjectValue } from "@/app/store/wos";
|
||||
import { Workspace } from "@/app/workspace/workspace";
|
||||
import { ContextMenuModel } from "@/store/contextmenu";
|
||||
import { PLATFORM, WOS, atoms, getApi, globalStore, removeFlashError, useSettingsPrefixAtom } from "@/store/global";
|
||||
import { PLATFORM, atoms, createBlock, globalStore, removeFlashError, useSettingsPrefixAtom } from "@/store/global";
|
||||
import { appHandleKeyDown } from "@/store/keymodel";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { getElemAsStr } from "@/util/focusutil";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import * as util from "@/util/util";
|
||||
import useResizeObserver from "@react-hook/resize-observer";
|
||||
import clsx from "clsx";
|
||||
import Color from "color";
|
||||
import * as csstree from "css-tree";
|
||||
import debug from "debug";
|
||||
import * as jotai from "jotai";
|
||||
import { Provider, useAtomValue } from "jotai";
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
import * as React from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import { AppBackground } from "./app-bg";
|
||||
import "./app.less";
|
||||
import { CenteredDiv } from "./element/quickelems";
|
||||
|
||||
@ -28,7 +24,6 @@ const dlog = debug("wave:app");
|
||||
const focusLog = debug("wave:focus");
|
||||
|
||||
const App = () => {
|
||||
let Provider = jotai.Provider;
|
||||
return (
|
||||
<Provider store={globalStore}>
|
||||
<AppInner />
|
||||
@ -36,7 +31,7 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
function isContentEditableBeingEdited() {
|
||||
function isContentEditableBeingEdited(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
return (
|
||||
activeElement &&
|
||||
@ -45,17 +40,17 @@ function isContentEditableBeingEdited() {
|
||||
);
|
||||
}
|
||||
|
||||
function canEnablePaste() {
|
||||
function canEnablePaste(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited();
|
||||
}
|
||||
|
||||
function canEnableCopy() {
|
||||
function canEnableCopy(): boolean {
|
||||
const sel = window.getSelection();
|
||||
return !util.isBlank(sel?.toString());
|
||||
}
|
||||
|
||||
function canEnableCut() {
|
||||
function canEnableCut(): boolean {
|
||||
const sel = window.getSelection();
|
||||
if (document.activeElement?.classList.contains("xterm-helper-textarea")) {
|
||||
return false;
|
||||
@ -63,12 +58,26 @@ function canEnableCut() {
|
||||
return !util.isBlank(sel?.toString()) && canEnablePaste();
|
||||
}
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||
async function getClipboardURL(): Promise<URL> {
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
if (clipboardText == null) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(clipboardText);
|
||||
return url;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
const canPaste = canEnablePaste();
|
||||
const canCopy = canEnableCopy();
|
||||
const canCut = canEnableCut();
|
||||
if (!canPaste && !canCopy && !canCut) {
|
||||
const clipboardURL = await getClipboardURL();
|
||||
if (!canPaste && !canCopy && !canCut && !clipboardURL) {
|
||||
return;
|
||||
}
|
||||
let menu: ContextMenuItem[] = [];
|
||||
@ -81,13 +90,27 @@ function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (canPaste) {
|
||||
menu.push({ label: "Paste", role: "paste" });
|
||||
}
|
||||
if (clipboardURL) {
|
||||
menu.push({ type: "separator" });
|
||||
menu.push({
|
||||
label: "Open Clipboard URL (" + clipboardURL.hostname + ")",
|
||||
click: () => {
|
||||
createBlock({
|
||||
meta: {
|
||||
view: "web",
|
||||
url: clipboardURL.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
ContextMenuModel.showContextMenu(menu, e);
|
||||
}
|
||||
|
||||
function AppSettingsUpdater() {
|
||||
const windowSettingsAtom = useSettingsPrefixAtom("window");
|
||||
const windowSettings = jotai.useAtomValue(windowSettingsAtom);
|
||||
React.useEffect(() => {
|
||||
const windowSettings = useAtomValue(windowSettingsAtom);
|
||||
useEffect(() => {
|
||||
const isTransparentOrBlur =
|
||||
(windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false;
|
||||
const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1);
|
||||
@ -126,7 +149,7 @@ function AppFocusHandler() {
|
||||
return null;
|
||||
|
||||
// for debugging
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
document.addEventListener("focusin", appFocusIn);
|
||||
document.addEventListener("focusout", appFocusOut);
|
||||
document.addEventListener("selectionchange", appSelectionChange);
|
||||
@ -146,105 +169,8 @@ function AppFocusHandler() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function encodeFileURL(file: string) {
|
||||
const webEndpoint = getWebServerEndpoint();
|
||||
return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`;
|
||||
}
|
||||
|
||||
function processBackgroundUrls(cssText: string): string {
|
||||
if (util.isBlank(cssText)) {
|
||||
return null;
|
||||
}
|
||||
cssText = cssText.trim();
|
||||
if (cssText.endsWith(";")) {
|
||||
cssText = cssText.slice(0, -1);
|
||||
}
|
||||
const attrRe = /^background(-image):\s*/;
|
||||
cssText = cssText.replace(attrRe, "");
|
||||
const ast = csstree.parse("background: " + cssText, {
|
||||
context: "declaration",
|
||||
});
|
||||
let hasJSUrl = false;
|
||||
csstree.walk(ast, {
|
||||
visit: "Url",
|
||||
enter(node) {
|
||||
const originalUrl = node.value.trim();
|
||||
if (originalUrl.startsWith("javascript:")) {
|
||||
hasJSUrl = true;
|
||||
return;
|
||||
}
|
||||
if (originalUrl.startsWith("data:")) {
|
||||
return;
|
||||
}
|
||||
const newUrl = encodeFileURL(originalUrl);
|
||||
node.value = newUrl;
|
||||
},
|
||||
});
|
||||
if (hasJSUrl) {
|
||||
console.log("invalid background, contains a 'javascript' protocol url which is not allowed");
|
||||
return null;
|
||||
}
|
||||
const rtnStyle = csstree.generate(ast);
|
||||
if (rtnStyle == null) {
|
||||
return null;
|
||||
}
|
||||
return rtnStyle.replace(/^background:\s*/, "");
|
||||
}
|
||||
|
||||
function AppBackground() {
|
||||
const bgRef = React.useRef<HTMLDivElement>(null);
|
||||
const tabId = jotai.useAtomValue(atoms.activeTabId);
|
||||
const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
|
||||
const bgAttr = tabData?.meta?.bg;
|
||||
const style: React.CSSProperties = {};
|
||||
if (!util.isBlank(bgAttr)) {
|
||||
try {
|
||||
const processedBg = processBackgroundUrls(bgAttr);
|
||||
if (!util.isBlank(processedBg)) {
|
||||
const opacity = util.boundNumber(tabData?.meta?.["bg:opacity"], 0, 1) ?? 0.5;
|
||||
style.opacity = opacity;
|
||||
style.background = processedBg;
|
||||
const blendMode = tabData?.meta?.["bg:blendmode"];
|
||||
if (!util.isBlank(blendMode)) {
|
||||
style.backgroundBlendMode = blendMode;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error processing background", e);
|
||||
}
|
||||
}
|
||||
const getAvgColor = React.useCallback(
|
||||
debounce(30, () => {
|
||||
if (
|
||||
bgRef.current &&
|
||||
PLATFORM !== "darwin" &&
|
||||
bgRef.current &&
|
||||
"windowControlsOverlay" in window.navigator
|
||||
) {
|
||||
const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect();
|
||||
const bgRect = bgRef.current.getBoundingClientRect();
|
||||
if (titlebarRect && bgRect) {
|
||||
const windowControlsLeft = titlebarRect.width - titlebarRect.height;
|
||||
const windowControlsRect: Dimensions = {
|
||||
top: titlebarRect.top,
|
||||
left: windowControlsLeft,
|
||||
height: titlebarRect.height,
|
||||
width: bgRect.width - bgRect.left - windowControlsLeft,
|
||||
};
|
||||
getApi().updateWindowControlsOverlay(windowControlsRect);
|
||||
}
|
||||
}
|
||||
}),
|
||||
[bgRef, style]
|
||||
);
|
||||
React.useLayoutEffect(getAvgColor, [getAvgColor]);
|
||||
useResizeObserver(bgRef, getAvgColor);
|
||||
|
||||
return <div ref={bgRef} className="app-background" style={style} />;
|
||||
}
|
||||
|
||||
const AppKeyHandlers = () => {
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);
|
||||
document.addEventListener("keydown", staticKeyDownHandler);
|
||||
|
||||
@ -256,11 +182,11 @@ const AppKeyHandlers = () => {
|
||||
};
|
||||
|
||||
const FlashError = () => {
|
||||
const flashErrors = jotai.useAtomValue(atoms.flashErrors);
|
||||
const [hoveredId, setHoveredId] = React.useState<string>(null);
|
||||
const [ticker, setTicker] = React.useState<number>(0);
|
||||
const flashErrors = useAtomValue(atoms.flashErrors);
|
||||
const [hoveredId, setHoveredId] = useState<string>(null);
|
||||
const [ticker, setTicker] = useState<number>(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (flashErrors.length == 0 || hoveredId != null) {
|
||||
return;
|
||||
}
|
||||
@ -297,10 +223,10 @@ const FlashError = () => {
|
||||
|
||||
function convertNewlinesToBreaks(text) {
|
||||
return text.split("\n").map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Fragment key={index}>
|
||||
{part}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
@ -328,10 +254,10 @@ const FlashError = () => {
|
||||
};
|
||||
|
||||
const AppInner = () => {
|
||||
const prefersReducedMotion = jotai.useAtomValue(atoms.prefersReducedMotionAtom);
|
||||
const client = jotai.useAtomValue(atoms.client);
|
||||
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
||||
const isFullScreen = jotai.useAtomValue(atoms.isFullScreen);
|
||||
const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom);
|
||||
const client = useAtomValue(atoms.client);
|
||||
const windowData = useAtomValue(atoms.waveWindow);
|
||||
const isFullScreen = useAtomValue(atoms.isFullScreen);
|
||||
|
||||
if (client == null || windowData == null) {
|
||||
return (
|
||||
|
@ -221,15 +221,6 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.block-frame-div-url,
|
||||
.block-frame-div-search {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
input {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block-frame-end-icons {
|
||||
|
@ -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";
|
||||
@ -17,7 +18,6 @@ import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos";
|
||||
import { focusedBlockId, getElemAsStr } from "@/util/focusutil";
|
||||
import { isBlank } from "@/util/util";
|
||||
import { Chat, ChatModel, makeChatModel } from "@/view/chat/chat";
|
||||
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";
|
||||
@ -48,8 +48,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);
|
||||
@ -96,8 +97,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 { WindowRpcClient } 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(WindowRpcClient, {
|
||||
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(WindowRpcClient, {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -534,6 +534,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [refreshVersion, setRefreshVersion] = useAtom(model.refreshVersion);
|
||||
const conn = useAtomValue(model.connection);
|
||||
const blockData = useAtomValue(model.blockAtom);
|
||||
|
||||
useEffect(() => {
|
||||
model.refreshCallback = () => {
|
||||
@ -593,7 +594,12 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
||||
setSearchText((current) => current.slice(0, -1));
|
||||
return true;
|
||||
}
|
||||
if (checkKeyPressed(waveEvent, "Space") && searchText == "" && PLATFORM == "darwin") {
|
||||
if (
|
||||
checkKeyPressed(waveEvent, "Space") &&
|
||||
searchText == "" &&
|
||||
PLATFORM == "darwin" &&
|
||||
!blockData?.meta?.connection
|
||||
) {
|
||||
getApi().onQuicklook(selectedPath);
|
||||
console.log(selectedPath);
|
||||
return true;
|
||||
|
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 { WindowRpcClient } 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(WindowRpcClient, {
|
||||
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(WindowRpcClient, {
|
||||
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(WindowRpcClient, {
|
||||
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 };
|
@ -81,12 +81,12 @@ export class WaveAiModel implements ViewModel {
|
||||
.filter(([k]) => k.startsWith("ai@"))
|
||||
.map(([k, v]) => {
|
||||
const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:"));
|
||||
console.log(aiPresetKeys);
|
||||
v["display:name"] =
|
||||
const newV = { ...v };
|
||||
newV["display:name"] =
|
||||
aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*")
|
||||
? `${v["display:name"] ?? "Default"} (${settings["ai:model"]})`
|
||||
: v["display:name"];
|
||||
return [k, v];
|
||||
? `${newV["display:name"] ?? "Default"} (${settings["ai:model"]})`
|
||||
: newV["display:name"];
|
||||
return [k, newV];
|
||||
})
|
||||
);
|
||||
});
|
||||
@ -109,7 +109,7 @@ export class WaveAiModel implements ViewModel {
|
||||
messages.pop();
|
||||
set(this.messagesAtom, [...messages]);
|
||||
});
|
||||
this.simulateAssistantResponseAtom = atom(null, async (get, set, userMessage: ChatMessageType) => {
|
||||
this.simulateAssistantResponseAtom = atom(null, async (_, set, userMessage: ChatMessageType) => {
|
||||
// unused at the moment. can replace the temp() function in the future
|
||||
const typingMessage: ChatMessageType = {
|
||||
id: crypto.randomUUID(),
|
||||
@ -213,7 +213,7 @@ export class WaveAiModel implements ViewModel {
|
||||
}
|
||||
|
||||
async fetchAiData(): Promise<Array<OpenAIPromptMessageType>> {
|
||||
const { data, fileInfo } = await fetchWaveFile(this.blockId, "aidata");
|
||||
const { data } = await fetchWaveFile(this.blockId, "aidata");
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
@ -318,7 +318,6 @@ export class WaveAiModel implements ViewModel {
|
||||
content: errMsg,
|
||||
};
|
||||
updatedHist.push(errorPrompt);
|
||||
console.log(updatedHist);
|
||||
await BlockService.SaveWaveAiData(blockId, updatedHist);
|
||||
}
|
||||
setLocked(false);
|
||||
|
@ -15,3 +15,16 @@
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.block-frame-div-url {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
input {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.iconbutton {
|
||||
width: fit-content !important;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,8 @@ export class WebViewModel implements ViewModel {
|
||||
urlInputRef: React.RefObject<HTMLInputElement>;
|
||||
nodeModel: NodeModel;
|
||||
endIconButtons?: Atom<IconButtonDecl[]>;
|
||||
mediaPlaying: PrimitiveAtom<boolean>;
|
||||
mediaMuted: PrimitiveAtom<boolean>;
|
||||
|
||||
constructor(blockId: string, nodeModel: NodeModel) {
|
||||
this.nodeModel = nodeModel;
|
||||
@ -58,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("");
|
||||
@ -70,12 +71,18 @@ export class WebViewModel implements ViewModel {
|
||||
this.urlInputRef = React.createRef<HTMLInputElement>();
|
||||
this.webviewRef = React.createRef<WebviewTag>();
|
||||
|
||||
this.mediaPlaying = atom(false);
|
||||
this.mediaMuted = atom(false);
|
||||
|
||||
this.viewText = atom((get) => {
|
||||
let url = get(this.blockAtom)?.meta?.url || get(this.homepageUrl);
|
||||
const homepageUrl = get(this.homepageUrl);
|
||||
const metaUrl = get(this.blockAtom)?.meta?.url;
|
||||
const currUrl = get(this.url);
|
||||
if (currUrl !== undefined) {
|
||||
url = currUrl;
|
||||
}
|
||||
const urlWrapperClassName = get(this.urlWrapperClassName);
|
||||
const refreshIcon = get(this.refreshIcon);
|
||||
const mediaPlaying = get(this.mediaPlaying);
|
||||
const mediaMuted = get(this.mediaMuted);
|
||||
const url = currUrl ?? metaUrl ?? homepageUrl;
|
||||
return [
|
||||
{
|
||||
elemtype: "iconbutton",
|
||||
@ -97,7 +104,7 @@ export class WebViewModel implements ViewModel {
|
||||
},
|
||||
{
|
||||
elemtype: "div",
|
||||
className: clsx("block-frame-div-url", get(this.urlWrapperClassName)),
|
||||
className: clsx("block-frame-div-url", urlWrapperClassName),
|
||||
onMouseOver: this.handleUrlWrapperMouseOver.bind(this),
|
||||
onMouseOut: this.handleUrlWrapperMouseOut.bind(this),
|
||||
children: [
|
||||
@ -111,26 +118,31 @@ export class WebViewModel implements ViewModel {
|
||||
onFocus: this.handleFocus.bind(this),
|
||||
onBlur: this.handleBlur.bind(this),
|
||||
},
|
||||
mediaPlaying && {
|
||||
elemtype: "iconbutton",
|
||||
icon: mediaMuted ? "volume-slash" : "volume",
|
||||
click: this.handleMuteChange.bind(this),
|
||||
},
|
||||
{
|
||||
elemtype: "iconbutton",
|
||||
icon: get(this.refreshIcon),
|
||||
icon: refreshIcon,
|
||||
click: this.handleRefresh.bind(this),
|
||||
},
|
||||
],
|
||||
].filter((v) => v),
|
||||
},
|
||||
] as HeaderElem[];
|
||||
});
|
||||
|
||||
this.endIconButtons = atom((get) => {
|
||||
const url = get(this.url);
|
||||
return [
|
||||
{
|
||||
elemtype: "iconbutton",
|
||||
icon: "arrow-up-right-from-square",
|
||||
title: "Open in External Browser",
|
||||
click: () => {
|
||||
const url = this.getUrl();
|
||||
if (url != null && url != "") {
|
||||
return getApi().openExternal(this.getUrl());
|
||||
return getApi().openExternal(url);
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -180,6 +192,25 @@ export class WebViewModel implements ViewModel {
|
||||
this.loadUrl(globalStore.get(this.homepageUrl), "home");
|
||||
}
|
||||
|
||||
setMediaPlaying(isPlaying: boolean) {
|
||||
console.log("setMediaPlaying", isPlaying);
|
||||
globalStore.set(this.mediaPlaying, isPlaying);
|
||||
}
|
||||
|
||||
handleMuteChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
try {
|
||||
const newMutedVal = !this.webviewRef.current?.isAudioMuted();
|
||||
globalStore.set(this.mediaMuted, newMutedVal);
|
||||
this.webviewRef.current?.setAudioMuted(newMutedVal);
|
||||
} catch (e) {
|
||||
console.error("Failed to change mute value", e);
|
||||
}
|
||||
}
|
||||
|
||||
handleUrlWrapperMouseOver(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
const urlInputFocused = globalStore.get(this.urlInputFocused);
|
||||
if (e.type === "mouseover" && !urlInputFocused) {
|
||||
@ -436,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");
|
||||
@ -523,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 = () => {
|
||||
@ -536,6 +572,12 @@ const WebView = memo(({ model }: WebViewProps) => {
|
||||
setDomReady(true);
|
||||
setBgColor();
|
||||
};
|
||||
const handleMediaPlaying = () => {
|
||||
model.setMediaPlaying(true);
|
||||
};
|
||||
const handleMediaPaused = () => {
|
||||
model.setMediaPlaying(false);
|
||||
};
|
||||
|
||||
webview.addEventListener("did-navigate-in-page", navigateListener);
|
||||
webview.addEventListener("did-navigate", navigateListener);
|
||||
@ -546,6 +588,8 @@ const WebView = memo(({ model }: WebViewProps) => {
|
||||
webview.addEventListener("focus", webviewFocus);
|
||||
webview.addEventListener("blur", webviewBlur);
|
||||
webview.addEventListener("dom-ready", handleDomReady);
|
||||
webview.addEventListener("media-started-playing", handleMediaPlaying);
|
||||
webview.addEventListener("media-paused", handleMediaPaused);
|
||||
|
||||
// Clean up event listeners on component unmount
|
||||
return () => {
|
||||
@ -558,6 +602,8 @@ const WebView = memo(({ model }: WebViewProps) => {
|
||||
webview.removeEventListener("focus", webviewFocus);
|
||||
webview.removeEventListener("blur", webviewBlur);
|
||||
webview.removeEventListener("dom-ready", handleDomReady);
|
||||
webview.removeEventListener("media-started-playing", handleMediaPlaying);
|
||||
webview.removeEventListener("media-paused", handleMediaPaused);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
9
frontend/types/custom.d.ts
vendored
9
frontend/types/custom.d.ts
vendored
@ -333,6 +333,15 @@ declare global {
|
||||
command: string;
|
||||
msgFn: (msg: RpcMessage) => 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
@ -306,6 +306,7 @@ declare global {
|
||||
"graph:*"?: boolean;
|
||||
"graph:numpoints"?: number;
|
||||
"graph:metrics"?: string[];
|
||||
"sysinfo:type"?: string;
|
||||
"bg:*"?: boolean;
|
||||
bg?: string;
|
||||
"bg:opacity"?: number;
|
||||
|
2
go.mod
2
go.mod
@ -16,7 +16,7 @@ require (
|
||||
github.com/kevinburke/ssh_config v1.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/sashabaranov/go-openai v1.31.0
|
||||
github.com/sashabaranov/go-openai v1.32.2
|
||||
github.com/sawka/txwrap v0.2.0
|
||||
github.com/shirou/gopsutil/v4 v4.24.9
|
||||
github.com/skeema/knownhosts v1.3.0
|
||||
|
4
go.sum
4
go.sum
@ -56,8 +56,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sashabaranov/go-openai v1.31.0 h1:rGe77x7zUeCjtS2IS7NCY6Tp4bQviXNMhkQM6hz/UC4=
|
||||
github.com/sashabaranov/go-openai v1.31.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sashabaranov/go-openai v1.32.2 h1:8z9PfYaLPbRzmJIYpwcWu6z3XU8F+RwVMF1QRSeSF2M=
|
||||
github.com/sashabaranov/go-openai v1.32.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94=
|
||||
github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA=
|
||||
github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI=
|
||||
|
38
package.json
38
package.json
@ -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",
|
||||
"homepage": "https://waveterm.dev",
|
||||
"build": {
|
||||
"appId": "dev.commandline.waveterm"
|
||||
@ -30,18 +30,18 @@
|
||||
"@chromatic-com/storybook": "^2.0.2",
|
||||
"@eslint/js": "^9.12.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@storybook/addon-essentials": "^8.3.5",
|
||||
"@storybook/addon-interactions": "^8.3.5",
|
||||
"@storybook/addon-links": "^8.3.5",
|
||||
"@storybook/blocks": "^8.3.5",
|
||||
"@storybook/react": "^8.3.5",
|
||||
"@storybook/react-vite": "^8.3.5",
|
||||
"@storybook/test": "^8.3.5",
|
||||
"@storybook/theming": "^8.3.5",
|
||||
"@storybook/addon-essentials": "^8.3.6",
|
||||
"@storybook/addon-interactions": "^8.3.6",
|
||||
"@storybook/addon-links": "^8.3.6",
|
||||
"@storybook/blocks": "^8.3.6",
|
||||
"@storybook/react": "^8.3.6",
|
||||
"@storybook/react-vite": "^8.3.6",
|
||||
"@storybook/test": "^8.3.6",
|
||||
"@storybook/theming": "^8.3.6",
|
||||
"@types/css-tree": "^2",
|
||||
"@types/debug": "^4",
|
||||
"@types/electron": "^1.6.10",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/node": "^22.7.6",
|
||||
"@types/papaparse": "^5",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^18.3.11",
|
||||
@ -54,8 +54,8 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"@vitest/coverage-istanbul": "^2.1.2",
|
||||
"electron": "^32.1.2",
|
||||
"@vitest/coverage-istanbul": "^2.1.3",
|
||||
"electron": "^32.2.0",
|
||||
"electron-builder": "^25.1.7",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^9.12.0",
|
||||
@ -66,22 +66,22 @@
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"rollup-plugin-flow": "^1.1.1",
|
||||
"semver": "^7.6.3",
|
||||
"storybook": "^8.3.5",
|
||||
"storybook": "^8.3.6",
|
||||
"storybook-dark-mode": "^4.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.6.3",
|
||||
"tslib": "^2.8.0",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"vite": "^5.4.8",
|
||||
"typescript-eslint": "^8.10.0",
|
||||
"vite": "^5.4.9",
|
||||
"vite-plugin-image-optimizer": "^1.1.8",
|
||||
"vite-plugin-static-copy": "^2.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vitest": "^2.1.2"
|
||||
"vitest": "^2.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.24",
|
||||
"@floating-ui/react": "^0.26.25",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@observablehq/plot": "^0.6.16",
|
||||
@ -136,7 +136,7 @@
|
||||
"use-device-pixel-ratio": "^1.1.2",
|
||||
"winston": "^3.15.0",
|
||||
"ws": "^8.18.0",
|
||||
"yaml": "^2.5.1"
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"send@npm:0.18.0": "0.19.0",
|
||||
|
@ -369,7 +369,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -104,15 +104,13 @@
|
||||
"display:name": "Wave Proxy - gpt-4o-mini",
|
||||
"display:order": 0,
|
||||
"ai:*": true,
|
||||
"ai:apitype": "",
|
||||
"ai:baseurl": "",
|
||||
"ai:apitoken": "",
|
||||
"ai:name": "",
|
||||
"ai:orgid": "",
|
||||
"ai:model": "gpt-4o-mini",
|
||||
"ai:maxtokens": 2048,
|
||||
"ai:timeoutms": 60000
|
||||
},
|
||||
"ai@ollama-llama3.1": {
|
||||
"display:name": "ollama - llama3.1",
|
||||
"display:order": 0,
|
||||
"ai:*": true,
|
||||
"ai:baseurl": "http://localhost:11434/v1",
|
||||
"ai:model": "llama3.1:latest"
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,9 @@ const wsInitialPingTime = 1 * time.Second
|
||||
|
||||
const DefaultCommandTimeout = 2 * time.Second
|
||||
|
||||
var GlobalLock = &sync.Mutex{}
|
||||
var RouteToConnMap = map[string]string{} // routeid => connid
|
||||
|
||||
func RunWebSocketServer(listener net.Listener) {
|
||||
gr := mux.NewRouter()
|
||||
gr.HandleFunc("/ws", HandleWs)
|
||||
@ -40,10 +43,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 +84,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 +111,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 +128,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 +155,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 +163,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 +200,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 +214,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 +222,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 {
|
||||
@ -240,6 +243,31 @@ func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any) {
|
||||
}
|
||||
}
|
||||
|
||||
func registerConn(wsConnId string, routeId string, wproxy *wshutil.WshRpcProxy) {
|
||||
GlobalLock.Lock()
|
||||
defer GlobalLock.Unlock()
|
||||
curConnId := RouteToConnMap[routeId]
|
||||
if curConnId != "" {
|
||||
log.Printf("[websocket] warning: replacing existing connection for route %q\n", routeId)
|
||||
wshutil.DefaultRouter.UnregisterRoute(routeId)
|
||||
}
|
||||
RouteToConnMap[routeId] = wsConnId
|
||||
wshutil.DefaultRouter.RegisterRoute(routeId, wproxy)
|
||||
}
|
||||
|
||||
func unregisterConn(wsConnId string, routeId string) {
|
||||
GlobalLock.Lock()
|
||||
defer GlobalLock.Unlock()
|
||||
curConnId := RouteToConnMap[routeId]
|
||||
if curConnId != wsConnId {
|
||||
// only unregister if we are the current connection (otherwise we were already removed)
|
||||
log.Printf("[websocket] warning: trying to unregister connection %q for route %q but it is not the current connection (ignoring)\n", wsConnId, routeId)
|
||||
return
|
||||
}
|
||||
delete(RouteToConnMap, routeId)
|
||||
wshutil.DefaultRouter.UnregisterRoute(routeId)
|
||||
}
|
||||
|
||||
func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
windowId := r.URL.Query().Get("windowid")
|
||||
if windowId == "" {
|
||||
@ -250,6 +278,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)
|
||||
@ -258,25 +287,21 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
defer conn.Close()
|
||||
wsConnId := uuid.New().String()
|
||||
log.Printf("New websocket connection: windowid:%s connid:%s\n", windowId, wsConnId)
|
||||
outputCh := make(chan any, 100)
|
||||
closeCh := make(chan any)
|
||||
eventbus.RegisterWSChannel(wsConnId, windowId, outputCh)
|
||||
var routeId string
|
||||
if windowId == wshutil.ElectronRoute {
|
||||
routeId = wshutil.ElectronRoute
|
||||
} else {
|
||||
routeId = wshutil.MakeWindowRouteId(windowId)
|
||||
}
|
||||
log.Printf("[websocket] new connection: windowid:%s connid:%s routeid:%s\n", windowId, wsConnId, routeId)
|
||||
eventbus.RegisterWSChannel(wsConnId, windowId, outputCh)
|
||||
defer eventbus.UnregisterWSChannel(wsConnId)
|
||||
// we create a wshproxy to handle rpc messages to/from the window
|
||||
wproxy := wshutil.MakeRpcProxy()
|
||||
wshutil.DefaultRouter.RegisterRoute(routeId, wproxy)
|
||||
defer func() {
|
||||
wshutil.DefaultRouter.UnregisterRoute(routeId)
|
||||
close(wproxy.ToRemoteCh)
|
||||
}()
|
||||
// WshServerFactoryFn(rpcInputCh, rpcOutputCh, wshrpc.RpcContext{})
|
||||
wproxy := wshutil.MakeRpcProxy() // we create a wshproxy to handle rpc messages to/from the window
|
||||
defer close(wproxy.ToRemoteCh)
|
||||
registerConn(wsConnId, routeId, wproxy)
|
||||
defer unregisterConn(wsConnId, routeId)
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
@ -293,12 +318,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")
|
||||
|
@ -274,10 +274,14 @@ func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient) {
|
||||
log.Printf("[router] registering wsh route %q\n", routeId)
|
||||
router.Lock.Lock()
|
||||
defer router.Lock.Unlock()
|
||||
alreadyExists := router.RouteMap[routeId] != nil
|
||||
if alreadyExists {
|
||||
log.Printf("[router] warning: route %q already exists (replacing)\n", routeId)
|
||||
}
|
||||
router.RouteMap[routeId] = rpc
|
||||
go func() {
|
||||
// announce
|
||||
if router.GetUpstreamClient() != nil {
|
||||
if !alreadyExists && router.GetUpstreamClient() != nil {
|
||||
announceMsg := RpcMessage{Command: wshrpc.Command_RouteAnnounce, Source: routeId}
|
||||
announceBytes, _ := json.Marshal(announceMsg)
|
||||
router.GetUpstreamClient().SendRpcMessage(announceBytes)
|
||||
|
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