diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 3b5129039..98120959c 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -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 ; } - if (blockView === "cpuplot") { - return ; + if (blockView === "cpuplot" || blockView === "sysinfo") { + // "cpuplot" is for backwards compatibility with already opened widgets + return ; } if (blockView == "help") { return ; diff --git a/frontend/app/theme.less b/frontend/app/theme.less index 4c33e6c3a..1904523a7 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -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 diff --git a/frontend/app/view/cpuplot/cpuplot.less b/frontend/app/view/cpuplot/cpuplot.less deleted file mode 100644 index 4f950dc63..000000000 --- a/frontend/app/view/cpuplot/cpuplot.less +++ /dev/null @@ -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%; -} diff --git a/frontend/app/view/cpuplot/cpuplot.tsx b/frontend/app/view/cpuplot/cpuplot.tsx deleted file mode 100644 index e773f9a3a..000000000 --- a/frontend/app/view/cpuplot/cpuplot.tsx +++ /dev/null @@ -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; - termMode: jotai.Atom; - htmlElemFocusRef: React.RefObject; - blockId: string; - viewIcon: jotai.Atom; - viewText: jotai.Atom; - viewName: jotai.Atom; - dataAtom: jotai.PrimitiveAtom>; - addDataAtom: jotai.WritableAtom; - incrementCount: jotai.WritableAtom>; - loadingAtom: jotai.PrimitiveAtom; - numPoints: jotai.Atom; - metrics: jotai.Atom; - connection: jotai.Atom; - manageConnection: jotai.Atom; - connStatus: jotai.Atom; - - constructor(blockId: string) { - this.viewType = "cpuplot"; - this.blockId = blockId; - this.blockAtom = WOS.getWaveObjectAtom(`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 ; -} - -const CpuPlotViewInner = React.memo(({ model }: CpuPlotViewProps) => { - const containerRef = React.useRef(); - 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` - - - - - ` - ); - 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
; -}); - -export { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel }; diff --git a/frontend/app/view/sysinfo/sysinfo.less b/frontend/app/view/sysinfo/sysinfo.less new file mode 100644 index 000000000..2f36f915f --- /dev/null +++ b/frontend/app/view/sysinfo/sysinfo.less @@ -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); + } + } + } + } + } + } +} diff --git a/frontend/app/view/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx new file mode 100644 index 000000000..6c15cff47 --- /dev/null +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -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 { + return ["cpu"]; + }, + Mem: function (dataItem: DataItem): Array { + return ["mem:used"]; + }, + "CPU + Mem": function (dataItem: DataItem): Array { + return ["cpu", "mem:used"]; + }, + "All CPU": function (dataItem: DataItem): Array { + 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; + termMode: jotai.Atom; + htmlElemFocusRef: React.RefObject; + blockId: string; + viewIcon: jotai.Atom; + viewText: jotai.Atom; + viewName: jotai.Atom; + dataAtom: jotai.PrimitiveAtom>; + addDataAtom: jotai.WritableAtom; + incrementCount: jotai.WritableAtom>; + loadingAtom: jotai.PrimitiveAtom; + numPoints: jotai.Atom; + metrics: jotai.Atom; + connection: jotai.Atom; + manageConnection: jotai.Atom; + connStatus: jotai.Atom; + plotMetaAtom: jotai.PrimitiveAtom>; + endIconButtons: jotai.Atom; + plotTypeSelectedAtom: jotai.Atom; + + constructor(blockId: string, viewType: string) { + this.viewType = viewType; + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`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 ; +} + +type SingleLinePlotProps = { + plotData: Array; + 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(); + 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` + + + + + ` + ); + + 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
; +} + +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(); + let title = false; + let cols2 = false; + if (yvals.length > 1) { + title = true; + } + if (yvals.length > 2) { + cols2 = true; + } + + return ( + +
+ {yvals.map((yval, idx) => { + return ( + + ); + })} +
+
+ ); +}); + +export { makeSysinfoViewModel, SysinfoView, SysinfoViewModel }; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index eee4dae5c..a33feb5fb 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -332,6 +332,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 {}; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a3e6eba1b..fcbe84680 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -306,6 +306,7 @@ declare global { "graph:*"?: boolean; "graph:numpoints"?: number; "graph:metrics"?: string[]; + "sysinfo:type"?: string; "bg:*"?: boolean; bg?: string; "bg:opacity"?: number; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 41fdce4dd..e904c3def 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -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" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2b52e897a..93c1919f0 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -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"` diff --git a/pkg/wconfig/defaultconfig/defaultwidgets.json b/pkg/wconfig/defaultconfig/defaultwidgets.json index 5711e1f7b..b59e5c59a 100644 --- a/pkg/wconfig/defaultconfig/defaultwidgets.json +++ b/pkg/wconfig/defaultconfig/defaultwidgets.json @@ -41,13 +41,13 @@ } } }, - "defwidget@cpuplot": { + "defwidget@sysinfo": { "display:order": 5, "icon": "chart-line", - "label": "cpu", + "label": "sysinfo", "blockdef": { "meta": { - "view": "cpuplot" + "view": "sysinfo" } } } diff --git a/pkg/wlayout/wlayout.go b/pkg/wlayout/wlayout.go index 3f8bfdbd1..96885d17b 100644 --- a/pkg/wlayout/wlayout.go +++ b/pkg/wlayout/wlayout.go @@ -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{ diff --git a/pkg/wshrpc/wshremote/sysinfo.go b/pkg/wshrpc/wshremote/sysinfo.go index eba8290fb..045e5b5d1 100644 --- a/pkg/wshrpc/wshremote/sysinfo.go +++ b/pkg/wshrpc/wshremote/sysinfo.go @@ -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) { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index baed1bd3e..fe093250f 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -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