Plot Sysinfo (#1054)

Expands the cpuplot with memory plots and individual cpu plots. Also
improves the styling and handling of multiple plots.
This commit is contained in:
Sylvie Crowe 2024-10-17 15:19:13 -07:00 committed by GitHub
parent fae06600cc
commit a5999aa02a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 571 additions and 326 deletions

View File

@ -4,6 +4,7 @@
import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes"; import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes";
import { PlotView } from "@/app/view/plotview/plotview"; import { PlotView } from "@/app/view/plotview/plotview";
import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview"; import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
import { ErrorBoundary } from "@/element/errorboundary"; import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems"; import { CenteredDiv } from "@/element/quickelems";
import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
@ -16,7 +17,6 @@ import {
import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos"; import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos";
import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil";
import { isBlank } from "@/util/util"; import { isBlank } from "@/util/util";
import { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel } from "@/view/cpuplot/cpuplot";
import { HelpView, HelpViewModel, makeHelpViewModel } from "@/view/helpview/helpview"; import { HelpView, HelpViewModel, makeHelpViewModel } from "@/view/helpview/helpview";
import { QuickTipsView, QuickTipsViewModel } from "@/view/quicktipsview/quicktipsview"; import { QuickTipsView, QuickTipsViewModel } from "@/view/quicktipsview/quicktipsview";
import { TermViewModel, TerminalView, makeTerminalModel } from "@/view/term/term"; import { TermViewModel, TerminalView, makeTerminalModel } from "@/view/term/term";
@ -47,8 +47,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel)
if (blockView === "waveai") { if (blockView === "waveai") {
return makeWaveAiViewModel(blockId); return makeWaveAiViewModel(blockId);
} }
if (blockView === "cpuplot") { if (blockView === "cpuplot" || blockView == "sysinfo") {
return makeCpuPlotViewModel(blockId); // "cpuplot" is for backwards compatibility with already-opened widgets
return makeSysinfoViewModel(blockId, blockView);
} }
if (blockView === "help") { if (blockView === "help") {
return makeHelpViewModel(blockId, nodeModel); return makeHelpViewModel(blockId, nodeModel);
@ -89,8 +90,9 @@ function getViewElem(
if (blockView === "waveai") { if (blockView === "waveai") {
return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />; return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />;
} }
if (blockView === "cpuplot") { if (blockView === "cpuplot" || blockView === "sysinfo") {
return <CpuPlotView key={blockId} blockId={blockId} model={viewModel as CpuPlotViewModel} />; // "cpuplot" is for backwards compatibility with already opened widgets
return <SysinfoView key={blockId} blockId={blockId} model={viewModel as SysinfoViewModel} />;
} }
if (blockView == "help") { if (blockView == "help") {
return <HelpView key={blockId} model={viewModel as HelpViewModel} />; return <HelpView key={blockId} model={viewModel as HelpViewModel} />;

View File

@ -106,6 +106,9 @@
--conn-icon-color-7: #dbde52; --conn-icon-color-7: #dbde52;
--conn-icon-color-8: #58c142; --conn-icon-color-8: #58c142;
--sysinfo-cpu-color: #58c142;
--sysinfo-mem-color: #53b4ea;
--bulb-color: rgb(255, 221, 51); --bulb-color: rgb(255, 221, 51);
// term colors (16 + 6) form the base terminal theme // term colors (16 + 6) form the base terminal theme

View File

@ -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%;
}

View File

@ -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 };

View 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);
}
}
}
}
}
}
}

View 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 };

View File

@ -332,6 +332,15 @@ declare global {
command: string; command: string;
msgFn: (msg: RpcMessage) => void; msgFn: (msg: RpcMessage) => void;
}; };
type TimeSeriesMeta = {
name?: string;
color?: string;
label?: string;
maxy?: string | number;
miny?: string | number;
decimalPlaces?: number;
};
} }
export {}; export {};

View File

@ -306,6 +306,7 @@ declare global {
"graph:*"?: boolean; "graph:*"?: boolean;
"graph:numpoints"?: number; "graph:numpoints"?: number;
"graph:metrics"?: string[]; "graph:metrics"?: string[];
"sysinfo:type"?: string;
"bg:*"?: boolean; "bg:*"?: boolean;
bg?: string; bg?: string;
"bg:opacity"?: number; "bg:opacity"?: number;

View File

@ -64,6 +64,8 @@ const (
MetaKey_GraphNumPoints = "graph:numpoints" MetaKey_GraphNumPoints = "graph:numpoints"
MetaKey_GraphMetrics = "graph:metrics" MetaKey_GraphMetrics = "graph:metrics"
MetaKey_SysinfoType = "sysinfo:type"
MetaKey_BgClear = "bg:*" MetaKey_BgClear = "bg:*"
MetaKey_Bg = "bg" MetaKey_Bg = "bg"
MetaKey_BgOpacity = "bg:opacity" MetaKey_BgOpacity = "bg:opacity"

View File

@ -64,6 +64,8 @@ type MetaTSType struct {
GraphNumPoints int `json:"graph:numpoints,omitempty"` GraphNumPoints int `json:"graph:numpoints,omitempty"`
GraphMetrics []string `json:"graph:metrics,omitempty"` GraphMetrics []string `json:"graph:metrics,omitempty"`
SysinfoType string `json:"sysinfo:type,omitempty"`
// for tabs // for tabs
BgClear bool `json:"bg:*,omitempty"` BgClear bool `json:"bg:*,omitempty"`
Bg string `json:"bg,omitempty"` Bg string `json:"bg,omitempty"`

View File

@ -41,13 +41,13 @@
} }
} }
}, },
"defwidget@cpuplot": { "defwidget@sysinfo": {
"display:order": 5, "display:order": 5,
"icon": "chart-line", "icon": "chart-line",
"label": "cpu", "label": "sysinfo",
"blockdef": { "blockdef": {
"meta": { "meta": {
"view": "cpuplot" "view": "sysinfo"
} }
} }
} }

View File

@ -38,7 +38,7 @@ func GetStarterLayout() PortableLayout {
}, Focused: true}, }, Focused: true},
{IndexArr: []int{1}, BlockDef: &waveobj.BlockDef{ {IndexArr: []int{1}, BlockDef: &waveobj.BlockDef{
Meta: waveobj.MetaMapType{ Meta: waveobj.MetaMapType{
waveobj.MetaKey_View: "cpuplot", waveobj.MetaKey_View: "sysinfo",
}, },
}}, }},
{IndexArr: []int{1, 1}, BlockDef: &waveobj.BlockDef{ {IndexArr: []int{1, 1}, BlockDef: &waveobj.BlockDef{

View File

@ -16,6 +16,8 @@ import (
"github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wshutil"
) )
const BYTES_PER_GB = 1073741824
func getCpuData(values map[string]float64) { func getCpuData(values map[string]float64) {
percentArr, err := cpu.Percent(0, false) percentArr, err := cpu.Percent(0, false)
if err != nil { if err != nil {
@ -38,10 +40,10 @@ func getMemData(values map[string]float64) {
if err != nil { if err != nil {
return return
} }
values["mem:total"] = float64(memData.Total) values["mem:total"] = float64(memData.Total) / BYTES_PER_GB
values["mem:available"] = float64(memData.Available) values["mem:available"] = float64(memData.Available) / BYTES_PER_GB
values["mem:used"] = float64(memData.Used) values["mem:used"] = float64(memData.Used) / BYTES_PER_GB
values["mem:free"] = float64(memData.Free) values["mem:free"] = float64(memData.Free) / BYTES_PER_GB
} }
func generateSingleServerData(client *wshutil.WshRpc, connName string) { func generateSingleServerData(client *wshutil.WshRpc, connName string) {

View File

@ -83,7 +83,7 @@ func MakePlotData(ctx context.Context, blockId string) error {
return err return err
} }
viewName := block.Meta.GetString(waveobj.MetaKey_View, "") viewName := block.Meta.GetString(waveobj.MetaKey_View, "")
if viewName != "cpuplot" { if viewName != "cpuplot" && viewName != "sysinfo" {
return fmt.Errorf("invalid view type: %s", viewName) return fmt.Errorf("invalid view type: %s", viewName)
} }
return filestore.WFS.MakeFile(ctx, blockId, "cpuplotdata", nil, filestore.FileOptsType{}) 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 return err
} }
viewName := block.Meta.GetString(waveobj.MetaKey_View, "") viewName := block.Meta.GetString(waveobj.MetaKey_View, "")
if viewName != "cpuplot" { if viewName != "cpuplot" && viewName != "sysinfo" {
return fmt.Errorf("invalid view type: %s", viewName) return fmt.Errorf("invalid view type: %s", viewName)
} }
// todo: interpret the data being passed // todo: interpret the data being passed