From 3473b567c499cdd2007da1db4abc4479c8af1853 Mon Sep 17 00:00:00 2001 From: C-D-Lewis Date: Wed, 16 Oct 2024 21:51:52 +0100 Subject: [PATCH] update --- dashboard2/src/components/AppArea.ts | 48 ++++--- dashboard2/src/components/AppCard.ts | 2 +- dashboard2/src/components/DeviceMetrics.ts | 154 +++++++++++++++++++++ dashboard2/src/components/SideBar.ts | 11 +- dashboard2/src/constants.ts | 2 + dashboard2/src/theme.ts | 1 + dashboard2/src/types.ts | 24 ++++ dashboard2/src/util.ts | 99 ++++++++++++- 8 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 dashboard2/src/components/DeviceMetrics.ts diff --git a/dashboard2/src/components/AppArea.ts b/dashboard2/src/components/AppArea.ts index 6401d29..4e885d2 100644 --- a/dashboard2/src/components/AppArea.ts +++ b/dashboard2/src/components/AppArea.ts @@ -3,6 +3,7 @@ import { AppState } from '../types'; import StatRow from './StatRow'; import AppLoader from './AppLoader'; import AppCard from './AppCard'; +import DeviceMetrics from './DeviceMetrics'; declare const fabricate: Fabricate; @@ -38,28 +39,33 @@ const AppCardList = () => fabricate('Row') * * @returns {HTMLElement} Fabricate component. */ -const AppArea = () => fabricate('Column') - .setStyles({ width: '100%' }) - .onUpdate(async (el, state) => { - const { selectedDevice } = state; +const AppArea = () => { + /** + * Determine if this device's apps are loaded. + * + * @param {AppState} s - App state. + * @returns {boolean} true if apps are loaded + */ + const areAppsLoaded = (s: AppState) => !!s.selectedDeviceApps?.length; - if (!selectedDevice) { - el.setChildren([NoDeviceLabel()]); - return; - } + return fabricate('Column') + .setStyles({ width: '100%' }) + .onUpdate(async (el, state) => { + const { selectedDevice } = state; - setTimeout(() => el.scrollIntoView({ behavior: 'smooth' }), 200); - el.setChildren([ - StatRow({ device: selectedDevice }), - fabricate.conditional( - (s) => !s.selectedDeviceApps?.length, - AppLoader, - ), - fabricate.conditional( - (s) => !!s.selectedDeviceApps?.length, - () => AppCardList(), - ), - ]); - }, [fabricate.StateKeys.Created, 'selectedDevice']); + if (!selectedDevice) { + el.setChildren([NoDeviceLabel()]); + return; + } + + setTimeout(() => el.scrollIntoView({ behavior: 'smooth' }), 200); + el.setChildren([ + StatRow({ device: selectedDevice }), + fabricate.conditional((s) => !areAppsLoaded(s), AppLoader), + fabricate.conditional(areAppsLoaded, DeviceMetrics), + fabricate.conditional(areAppsLoaded, AppCardList), + ]); + }, [fabricate.StateKeys.Created, 'selectedDevice']); +}; export default AppArea; diff --git a/dashboard2/src/components/AppCard.ts b/dashboard2/src/components/AppCard.ts index a28adc8..3cebd5c 100644 --- a/dashboard2/src/components/AppCard.ts +++ b/dashboard2/src/components/AppCard.ts @@ -91,7 +91,7 @@ const AppCard = ({ app }: { app: DeviceApp }) => { return fabricate('Column') .setStyles(({ palette }) => ({ - margin: '10px', + margin: '15px', backgroundColor: palette.grey3, width: '300px', border: `solid 2px ${palette.grey6}`, diff --git a/dashboard2/src/components/DeviceMetrics.ts b/dashboard2/src/components/DeviceMetrics.ts new file mode 100644 index 0000000..69b2be2 --- /dev/null +++ b/dashboard2/src/components/DeviceMetrics.ts @@ -0,0 +1,154 @@ +import { Fabricate, FabricateComponent } from 'fabricate.js'; +import { + AppState, DataPoint, MetricData, MetricName, +} from '../types'; +import { fetchMetric } from '../util'; +import Theme from '../theme'; + +declare const fabricate: Fabricate; + +/** + * NoDeviceLabel component. + * + * @returns {HTMLElement} Fabricate component. + */ +const NoMetricsLabel = () => fabricate('Text') + .setStyles(({ palette }) => ({ + color: palette.grey5, + cursor: 'default', + height: '100px', + backgroundColor: palette.grey2, + width: '100%', + textAlign: 'center', + alignContent: 'center', + })) + .setNarrowStyles({ + margin: '0px', + }) + .setText('No metrics to show'); + +/** + * MetricGraph component. + * + * @param {object} props - Component props. + * @param {MetricName} props.name - Metric name to fetch and graph. + * @returns {FabricateComponent} MetricGraph component. + */ +const MetricGraph = ({ name } : { name: MetricName }) => { + const dataKey = fabricate.buildKey('metricData', name); + const canvas = fabricate('canvas') as unknown as HTMLCanvasElement; + + /** + * Draw metric data on the canvas. + * + * @param {AppState} state - App state. + */ + const draw = (state: AppState) => { + const { width, height } = canvas; + const { buckets, minValue, maxValue } = state[dataKey] as MetricData; + + const range = maxValue - minValue; + + const ctx = canvas.getContext('2d')!; + + // Background + ctx.fillStyle = Theme.palette.grey2; + ctx.fillRect(0, 0, width, height); + + if (!buckets.length) { + // ctx.font = '24px Arial'; + // ctx.fillStyle = 'white'; + // ctx.fillText('No data', width / 2, height / 2); + return; + } + + // Latest data + ctx.fillStyle = Theme.palette.secondary; + const points = buckets.length > width ? buckets.slice(buckets.length - width) : buckets; + points.forEach((p: DataPoint, i: number) => { + const pHeight = ((p.value - minValue) * height) / range; + const x = i; + const y = height - pHeight; + + ctx.fillRect(x, y, 2, 2); + }); + }; + + return fabricate('div') + .setStyles(({ palette }) => ({ + width: '100%', + height: '100%', + })) + .setChildren([canvas as unknown as FabricateComponent]) + .onUpdate(async (el, state, keys) => { + if (keys.includes(fabricate.StateKeys.Created)) { + fetchMetric(state, name); + return; + } + + if (keys.includes(dataKey)) { + canvas.width = el.offsetWidth - 1; + canvas.height = el.offsetHeight; + + draw(state); + } + }, [fabricate.StateKeys.Created, dataKey]); +}; + +/** + * MetricContainer component. + * + * @param {object} props - Component props. + * @param {MetricName} props.name - Metric name to fetch and graph. + * @returns {FabricateComponent} MetricContainer component. + */ +const MetricContainer = ({ name } : { name: MetricName }) => fabricate('Column') + .setStyles(({ palette }) => ({ + margin: '15px', + width: '30%', + height: '180px', + border: `solid 2px ${palette.grey6}`, + })) + .setNarrowStyles({ + width: '100%', + }) + .setChildren([ + fabricate('Text') + .setStyles(({ fonts, palette }) => ({ + color: 'white', + fontSize: '0.9rem', + fontFamily: fonts.code, + margin: '5px 0px 0px 0px', + padding: '0px 5px', + borderBottom: `solid 2px ${palette.grey6}`, + })) + .setText(name), + MetricGraph({ name }), + ]); + +/** + * DeviceMetrics component. + * + * @returns {HTMLElement} Fabricate component. + */ +const DeviceMetrics = () => fabricate('Row') + .setStyles({ + margin: '15px', + flexWrap: 'wrap', + }) + .onUpdate(async (el, state) => { + const { selectedDevice } = state; + + if (!selectedDevice) { + el.setChildren([NoMetricsLabel()]); + return; + } + + el.setChildren([ + MetricContainer({ name: 'cpu' }), + MetricContainer({ name: 'memoryPerc' }), + MetricContainer({ name: 'tempRaw' }), + ]); + }, [fabricate.StateKeys.Created, 'selectedDevice']); + +export default DeviceMetrics; diff --git a/dashboard2/src/components/SideBar.ts b/dashboard2/src/components/SideBar.ts index 81b95a7..60032e5 100644 --- a/dashboard2/src/components/SideBar.ts +++ b/dashboard2/src/components/SideBar.ts @@ -35,7 +35,9 @@ const GroupLabel = ({ publicIp }: { publicIp: string }) => fabricate('Text') * @returns {HTMLElement} Fabricate component. */ const DeviceRow = ({ device }: { device: Device }) => { - const { deviceType, deviceName, localIp } = device; + const { + deviceType, deviceName, localIp, lastCheckIn, + } = device; const nameView = fabricate('Text') .setStyles({ @@ -60,12 +62,16 @@ const DeviceRow = ({ device }: { device: Device }) => { */ const isSelected = (s: AppState) => s.selectedDevice?.deviceName === deviceName; + const minsAgo = Math.round((Date.now() - lastCheckIn) / (1000 * 60)); + const seenRecently = minsAgo < 12; // Based on default checkin interval of 10m + return fabricate('Row') .setStyles(({ palette }) => ({ backgroundColor: palette.grey3, padding: '4px 0px 4px 8px', cursor: 'pointer', borderBottom: `solid 2px ${palette.grey6}`, + borderLeft: `solid 5px ${seenRecently ? palette.statusOk : palette.grey6}`, })) .setChildren([ fabricate('Image', { src: `assets/images/${ICON_NAMES[deviceType]}.png` }) @@ -105,8 +111,7 @@ const DeviceRow = ({ device }: { device: Device }) => { const SideBar = () => fabricate('Column') .setStyles(({ palette }) => ({ backgroundColor: palette.grey3, - minWidth: '240px', - borderRight: `solid 2px ${palette.grey6}`, + minWidth: '250px', })) .setNarrowStyles({ width: '100vw' }) .onUpdate(async (el, state) => { diff --git a/dashboard2/src/constants.ts b/dashboard2/src/constants.ts index fcd6bfd..0ec48bf 100644 --- a/dashboard2/src/constants.ts +++ b/dashboard2/src/constants.ts @@ -10,6 +10,8 @@ export const CONDUIT_PORT = 5959; export const INITIAL_STATE: AppState = { // App data token: '', + + // Loaded data selectedDeviceApps: [], devices: [], diff --git a/dashboard2/src/theme.ts b/dashboard2/src/theme.ts index a761294..0967a62 100644 --- a/dashboard2/src/theme.ts +++ b/dashboard2/src/theme.ts @@ -2,6 +2,7 @@ const Theme = { palette: { primary: '#c7053d', + secondary: '#ffeb3b', text: 'white', grey1: '#111', grey2: '#222', diff --git a/dashboard2/src/types.ts b/dashboard2/src/types.ts index 31bc811..2f30864 100644 --- a/dashboard2/src/types.ts +++ b/dashboard2/src/types.ts @@ -30,6 +30,28 @@ export type Device = { diskUsage: number; }; +/** Graph data point. */ +export type DataPoint = { + value: number; + timestamp: number; + dateTime: string; +}; + +/** Available graphed metrics */ +export type MetricName = 'cpu' | 'memoryPerc' | 'tempRaw'; + +/** Raw metric point */ +export type MetricPoint = [number, number]; + +/** Metric data */ +export type MetricData = { + buckets: DataPoint[]; + minTime: string; + maxTime: string; + minValue: number; + maxValue: number; +}; + /** App state type */ export type AppState = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -37,6 +59,8 @@ export type AppState = { // App data token: string; + + // Loaded data selectedDeviceApps: DeviceApp[]; devices: Device[]; diff --git a/dashboard2/src/util.ts b/dashboard2/src/util.ts index dc7483c..80c6ba9 100644 --- a/dashboard2/src/util.ts +++ b/dashboard2/src/util.ts @@ -1,5 +1,7 @@ import { Fabricate, FabricateComponent } from 'fabricate.js'; -import { AppState, Device, DeviceApp } from './types'; +import { + AppState, DataPoint, Device, DeviceApp, MetricName, +} from './types'; import { CONDUIT_PORT, FLEET_HOST } from './constants'; import { sendConduitPacket } from './services/conduitService'; @@ -160,3 +162,98 @@ export const fetchDeviceApps = async (state: AppState, device: Device) => { fabricate.update({ selectedDeviceApps: [] }); } }; + +/** + * Shorter representation of a date time. + * + * @param {number} timestamp - Input time. + * @returns {string} Short date time. + */ +export const shortDateTime = (timestamp: string) => { + const [, time] = new Date(timestamp).toISOString().split('T'); + const shortTime = time.split(':').slice(0, 2).join(':'); + return `${shortTime}`; +}; + +/** + * Fetch data for a metric. + * + * @param {AppState} state - App state. + * @param {MetricName} name - Metric name. + */ +export const fetchMetric = async (state: AppState, name: MetricName) => { + const dataKey = fabricate.buildKey('metricData', name); + fabricate.update( + dataKey, + { + buckets: [], + minTime: 0, + maxTime: 0, + minValue: 0, + maxValue: 0, + }, + ); + + const res = await sendConduitPacket( + state, + { + to: 'monitor', + topic: 'getMetricToday', + message: { name }, + }, + ); + const { message: newHistory } = res; + if (res.error) console.log(res); + if (!newHistory) return; + + const type = Array.isArray(newHistory[0][1]) ? 'array' : 'number'; + + const minTime = shortDateTime(newHistory[0][0]); + const maxTime = shortDateTime(newHistory[newHistory.length - 1][0]); + let minValue = 0; + let maxValue = 0; + + if (type === 'number') { + // Aggregate values + minValue = name.includes('Perc') + ? 0 + : newHistory.reduce( + // @ts-expect-error handled with 'type' + (acc: number, [, value]: MetricPoint) => (value < acc ? value : acc), + 9999999, + ); + maxValue = name.includes('Perc') + ? 100 + : newHistory.reduce( + // @ts-expect-error handled with 'type' + (acc: number, [, value]: MetricPoint) => (value > acc ? value : acc), + 0, + ); + } else { + throw new Error('Unexpected metric data type'); + } + + // Average into buckets + const copy = [...newHistory]; + const buckets: DataPoint[] = []; + while (copy.length) { + const points = copy.splice(0, 5); + const avgIndex = Math.floor(points.length / 2); + buckets.push({ + value: points.reduce((acc, [, value]) => acc + value, 0) / points.length, + timestamp: points[avgIndex][0], + dateTime: new Date(points[avgIndex][0]).toISOString(), + }); + } + + fabricate.update( + dataKey, + { + buckets, + minTime, + maxTime, + minValue, + maxValue, + }, + ); +};