// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useHeight } from "@/app/hook/useHeight"; import { useWidth } from "@/app/hook/useWidth"; 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 { 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 parentHeight = useHeight(containerRef); const parentWidth = useWidth(containerRef); 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 };