feat: allow different types of plots

This commit is contained in:
Sylvia Crowe 2024-09-25 15:22:17 -07:00
parent dea651216d
commit bae31cc438
3 changed files with 142 additions and 40 deletions

View File

@ -11,6 +11,7 @@ import * as htl from "htl";
import * as jotai from "jotai";
import * as React from "react";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { waveEventSubscribe } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil";
@ -23,15 +24,33 @@ type DataItem = {
[k: string]: number;
};
const SysInfoMetricNames = {
cpu: "CPU %",
"mem:total": "Memory Total",
"mem:used": "Memory Used",
"mem:free": "Memory Free",
"mem:available": "Memory Available",
function defaultCpuMeta(name: string): TimeSeriesMeta {
return {
name: name,
label: "%",
miny: 0,
maxy: 100,
};
}
function defaultMemMeta(name: string, maxY: string): TimeSeriesMeta {
return {
name: name,
label: "GB",
miny: 0,
maxy: maxY,
};
}
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++) {
SysInfoMetricNames[`cpu:${i}`] = `CPU[${i}] %`;
DefaultPlotMeta[`cpu:${i}`] = defaultCpuMeta(`CPU[${i}] %`);
}
function convertWaveEventToDataItem(event: WaveEvent): DataItem {
@ -64,6 +83,8 @@ class CpuPlotViewModel {
connection: jotai.Atom<string>;
manageConnection: jotai.Atom<boolean>;
connStatus: jotai.Atom<ConnStatus>;
plotMetaAtom: jotai.PrimitiveAtom<Map<string, TimeSeriesMeta>>;
endIconButtons: jotai.Atom<IconButtonDecl[]>;
constructor(blockId: string) {
this.viewType = "cpuplot";
@ -86,6 +107,16 @@ class CpuPlotViewModel {
console.log("Error adding data to cpuplot", e);
}
});
this.plotMetaAtom = jotai.atom(new Map(Object.entries(DefaultPlotMeta)));
this.endIconButtons = jotai.atom((get) => {
return [
{
elemtype: "iconbutton",
icon: "wrench",
click: (e) => this.handleContextMenu(e),
},
];
});
this.manageConnection = jotai.atom(true);
this.loadingAtom = jotai.atom(true);
this.numPoints = jotai.atom((get) => {
@ -108,7 +139,16 @@ class CpuPlotViewModel {
return "chart-line"; // should not be hardcoded
});
this.viewName = jotai.atom((get) => {
return "CPU %"; // should not be hardcoded
const metrics = get(this.metrics);
const meta = get(this.plotMetaAtom);
if (metrics.length == 0) {
return "unknown";
}
const metaSelected = meta.get(metrics[0]);
if (!metaSelected) {
return "unknown";
}
return metaSelected.name;
});
this.incrementCount = jotai.atom(null, async (get, set) => {
const meta = get(this.blockAtom).meta;
@ -161,6 +201,31 @@ class CpuPlotViewModel {
}
}
handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {
e.preventDefault();
e.stopPropagation();
const plotData = globalStore.get(this.dataAtom);
if (plotData.length == 0) {
return;
}
const menu = Object.keys(plotData[plotData.length - 1])
.filter((dataType) => dataType !== "ts")
.map((dataType) => {
const menuItem: ContextMenuItem = {
label: dataType,
click: async () => {
await RpcApi.SetMetaCommand(WindowRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "graph:metrics": [dataType] },
});
},
};
return menuItem;
});
ContextMenuModel.showContextMenu(menu, e);
}
getDefaultData(): DataItem[] {
// set it back one to avoid backwards line being possible
const numPoints = globalStore.get(this.numPoints);
@ -185,6 +250,16 @@ type CpuPlotViewProps = {
model: CpuPlotViewModel;
};
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 CpuPlotView({ model, blockId }: CpuPlotViewProps) {
const connName = jotai.useAtomValue(model.connection);
const lastConnName = React.useRef(connName);
@ -234,52 +309,69 @@ const CpuPlotViewInner = React.memo(({ model }: CpuPlotViewProps) => {
const parentHeight = useHeight(containerRef);
const parentWidth = useWidth(containerRef);
const yvals = jotai.useAtomValue(model.metrics);
const plotMeta = jotai.useAtomValue(model.plotMetaAtom);
React.useEffect(() => {
if (yvals.length == 0) {
// don't bother creating plots if none are selected
return;
}
const singleItem = yvals.length == 1;
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" />
yvals.forEach((yval, idx) => {
// use rotating colors for
// color not configured
// plotting multiple items
let color = plotMeta.get(yval)?.color;
if (!color || !singleItem) {
color = plotColors[idx];
}
marks.push(
() => htl.svg`<defs>
<linearGradient id="gradient-${model.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>`
);
if (yvals.length == 0) {
// nothing
} else if (yvals.length == 1) {
);
marks.push(
Plot.lineY(plotData, {
stroke: plotColors[0],
strokeWidth: 2,
stroke: color,
strokeWidth: singleItem ? 2 : 1,
x: "ts",
y: yvals[0],
y: yval,
})
);
marks.push(
Plot.areaY(plotData, {
fill: "url(#gradient)",
x: "ts",
y: yvals[0],
})
);
} else {
let idx = 0;
for (const yval of yvals) {
// only add the gradient for single items
if (singleItem) {
marks.push(
Plot.lineY(plotData, {
stroke: plotColors[idx % plotColors.length],
strokeWidth: 1,
Plot.areaY(plotData, {
fill: `url(#gradient-${model.blockId}-${yvals[0]})`,
x: "ts",
y: yval,
})
);
idx++;
}
});
// use the largest configured yval.maxYs. if none is found, use 100
const maxYs = yvals.map((yval) => resolveDomainBound(plotMeta.get(yval)?.maxy, plotData[plotData.length - 1]));
let maxY = Math.max(...maxYs.filter(Number.isFinite));
if (!Number.isFinite(maxY)) {
maxY = 100;
}
// use the smalles configured yval.minYs. if none is found, use 0
const minYs = yvals.map((yval) => resolveDomainBound(plotMeta.get(yval)?.miny, plotData[plotData.length - 1]));
let minY = Math.min(...minYs.filter(Number.isFinite));
if (!Number.isFinite(maxY)) {
minY = 0;
}
const labelY = plotMeta.get(yvals[0])?.label ?? "?";
const plot = Plot.plot({
x: { grid: true, label: "time", tickFormat: (d) => `${dayjs.unix(d / 1000).format("HH:mm:ss")}` },
y: { label: "%", domain: [0, 100] },
y: { label: labelY, domain: [minY, maxY] },
width: parentWidth,
height: parentHeight,
marks: marks,
@ -294,7 +386,7 @@ const CpuPlotViewInner = React.memo(({ model }: CpuPlotViewProps) => {
plot.remove();
}
};
}, [plotData, parentHeight, parentWidth]);
}, [plotData, parentHeight, parentWidth, yvals, plotMeta, model.blockId]);
return <div className="plot-view" ref={containerRef} />;
});

View File

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

View File

@ -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) {