From 031ca5eb0b3a15de4284994349f4bed8e04b6dfc Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Sat, 1 Aug 2026 02:50:32 +0200 Subject: [PATCH] ui: split app.tsx into modules; switch mono font to JetBrains Mono --- bun.lock | 4 +- package.json | 2 +- src/app.tsx | 987 +-------------------------------- src/components/Grid.tsx | 116 ++++ src/components/StatusModal.tsx | 327 +++++++++++ src/components/plots.tsx | 300 ++++++++++ src/components/ui.tsx | 58 ++ src/format.ts | 54 ++ src/main.tsx | 4 +- src/parse.ts | 40 ++ src/styles.css | 13 +- src/types.ts | 101 ++++ 12 files changed, 1014 insertions(+), 992 deletions(-) create mode 100644 src/components/Grid.tsx create mode 100644 src/components/StatusModal.tsx create mode 100644 src/components/plots.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/format.ts create mode 100644 src/parse.ts create mode 100644 src/types.ts diff --git a/bun.lock b/bun.lock index 34b8831..66b9ae1 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "statuspage", "dependencies": { "@fontsource/inria-sans": "^5.3.0", - "@fontsource/iosevka-etoile": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "preact": "^10.29.7", }, "devDependencies": { @@ -70,7 +70,7 @@ "@fontsource/inria-sans": ["@fontsource/inria-sans@5.3.0", "", {}, "sha512-rTqLkvlaJ8ys39iH4iyC8ltntGwFXu6Q8A5C5PpOEi4xk8zjddEoS1RYCjJxUv1WVKDL3SOjdh8dfrx3Pf7fvA=="], - "@fontsource/iosevka-etoile": ["@fontsource/iosevka-etoile@5.3.0", "", {}, "sha512-fuUgKPpwQxNghd3tak28lfwAOLd7wI7A9Bttx+BuKCqV9IBYA469dMQAVAnDnzxBC5xItASAJvEfMlvn51PfWQ=="], + "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.3.0", "", {}, "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], diff --git a/package.json b/package.json index b4a1ce8..b43be31 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@fontsource/inria-sans": "^5.3.0", - "@fontsource/iosevka-etoile": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "preact": "^10.29.7" } } diff --git a/src/app.tsx b/src/app.tsx index 14812fa..1f1f8b0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,984 +1,9 @@ -import { useEffect, useRef, useState } from 'preact/hooks' - -type Status = 'up' | 'degraded' | 'down' | 'unknown' -type Check = 'ok' | 'fail' | 'na' -type ChipState = 'ok' | 'fail' | 'na' | 'off' | 'unknown' - -interface SshResult { - ok: boolean - result: string - exitCode: number | null - lastRun: string | null - error: string | null -} - -interface ScriptChip { - name: string - status: string - info: string -} - -interface MetricStatus { - name: string - value: number - unit: string - updated: string | null -} - -interface Machine { - id: string - name: string - host: string - ip: string - ips: string[] - status: Status - icmp: Check - tcp: Check - lastPing: string | null - sshConfigured: boolean - ssh: SshResult | null - metrics: MetricStatus[] -} - -interface Group { - name: string - machines: Machine[] -} - -interface Stats { - pingInterval: string - pingTimeout: string - tcpPort: number - sshInterval: string - sshEnabled: boolean - dbSize: number - dbRows: number - dbSizeMonth: number -} - -interface StatusPayload { - title: string - interactive: boolean - sharedMetricWindow?: boolean - stats: Stats - metricRanges?: Record - groups: Group[] - machines: Machine[] -} - -function formatBytes(n: number): string { - if (n < 1024) return `${n} B` - const units = ['KB', 'MB', 'GB', 'TB'] - let v = n - let i = -1 - do { - v /= 1024 - i++ - } while (v >= 1024 && i < units.length - 1) - return `${v.toFixed(1)} ${units[i]}` -} - -function formatTime(ts: string | null): string { - if (!ts) return 'never' - const d = new Date(ts) - return isNaN(d.getTime()) ? ts : d.toLocaleString() -} - -function formatMetric(m: MetricStatus): string { - const v = Number.isInteger(m.value) - ? String(m.value) - : m.value.toFixed(1) - return m.unit ? `${v} ${m.unit}` : v -} - -function groupIcon(name: string): string { - const n = name.toLowerCase() - if (n.includes('home')) return 'mdi:home' - if (n.includes('lab')) return 'mdi:flask' - if (n.includes('gpu') || n.includes('compute') || n.includes('render')) return 'mdi:desktop-tower' - if (n.includes('server') || n.includes('data') || n.includes('prod')) return 'mdi:server-network' - if (n.includes('class') || n.includes('aula') || n.includes('room') || n.includes('student')) return 'mdi:school' - return 'mdi:folder-outline' -} - -function Dot({ status }: { status: Status }) { - return -} - -function Chip({ - label, - state, - value, -}: { - label: string - state?: ChipState - value?: string -}) { - const [pos, setPos] = useState<{ x: number; y: number } | null>(null) - const ref = useRef(null) - const hasValue = value !== undefined - const show = () => { - const el = ref.current - if (!el) return - const r = el.getBoundingClientRect() - setPos({ x: r.left + r.width / 2, y: r.top }) - } - const hide = () => setPos(null) - return ( - <> - - {label} - {!hasValue && } - - {hasValue && pos && ( - - {value} - - )} - - ) -} - -function CheckState({ state }: { state: ChipState }) { - return ( - - - {state} - - ) -} - -function parseScriptChips(out: string): ScriptChip[] { - const chips: ScriptChip[] = [] - for (const line of out.split('\n')) { - if (line.includes('---')) break - const i = line.indexOf(':') - if (i <= 0) continue - const name = line.slice(0, i).trim() - const rest = line.slice(i + 1) - const j = rest.indexOf(':') - if (j < 0) continue - const status = rest.slice(0, j).trim() - const info = rest.slice(j + 1).trim() - if (!name || !status || status === 'metric') continue - chips.push({ name, status, info }) - } - return chips -} - -function scriptChipState(status: string): ChipState { - if (status === 'on') return 'ok' - if (status === 'off') return 'off' - if (status === 'down') return 'fail' - return 'unknown' -} - -function MachineCard({ - m, - onOpen, - interactive, - onRefresh, -}: { - m: Machine - onOpen: (m: Machine) => void - interactive: boolean - onRefresh: (m: Machine) => void -}) { - const sshState: ChipState = !m.sshConfigured - ? 'off' - : !m.ssh - ? 'unknown' - : m.ssh.ok - ? 'ok' - : 'fail' - const scriptChips = m.ssh && !m.ssh.error ? parseScriptChips(m.ssh.result) : [] - return ( -
onOpen(m)} - role="button" - tabIndex={0} - > -
- - {m.name} - {interactive && ( - - )} -
-
{m.ip || '—'}
-
- - - {m.sshConfigured && } -
- -
e.stopPropagation()}> -
-
- - ip - {m.ip || 'unknown'} - - - last ping - {formatTime(m.lastPing)} - -
- - - {m.status} - -
- {scriptChips.length > 0 && ( - - - {scriptChips.map((c) => ( - - - - - ))} - -
{c.name} - {c.status === 'unknown' ? 'unknown' : c.info || c.status} -
- )} -
-
- ) -} - -function Grid({ - machines, - onOpen, - interactive, - onRefresh, -}: { - machines: Machine[] - onOpen: (m: Machine) => void - interactive: boolean - onRefresh: (m: Machine) => void -}) { - return ( -
- {machines.map((m) => ( - - ))} -
- ) -} - -interface HistoryEntry { - ts: number - status: Status - ip: string -} - -interface MetricEntry { - ts: number - name: string - value: number - unit: string -} - -interface MetricSeries { - machine: string - samples: MetricEntry[] -} - -interface MetricAggregate { - name: string - unit: string - series: MetricSeries[] -} - -interface MetricAggregatePayload { - window: [number, number] - metrics: MetricAggregate[] -} - -function hashColor(s: string): string { - let h = 5381 - for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0 - return `hsl(${h % 360} 62% 42%)` -} - -function formatTick(ts: number): string { - const d = new Date(ts * 1000) - return isNaN(d.getTime()) ? String(ts) : d.toLocaleString() -} - -function LinePlot({ - values, - times, - color, - unit, - yMin, - yMax, - xMin, - xMax, - width = 400, - height = 90, - pad = 10, -}: { - values: number[] - times: number[] - color: string - unit?: string - yMin?: number - yMax?: number - xMin?: number - xMax?: number - width?: number - height?: number - pad?: number -}) { - if (values.length < 2 || times.length !== values.length) return null - const first = values[0]! - const last = values[values.length - 1]! - const dataMin = Math.min(...values) - const dataMax = Math.max(...values) - const min = yMin !== undefined && yMin < dataMin ? yMin : dataMin - const max = yMax !== undefined && yMax > dataMax ? yMax : dataMax - const span = max - min || 1 - const t0 = xMin ?? times[0]! - const t1 = xMax ?? times[times.length - 1]! - const tspan = t1 - t0 || 1 - const xForT = (t: number) => - pad + ((t - t0) / tspan) * (width - pad * 2) - const x = (i: number) => xForT(times[i]!) - const y = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2) - const path = values - .map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`) - .join(' ') - - const plotLeft = (pad / width) * 100 - const plotRight = 100 - plotLeft - const marks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ - left: plotLeft + f * (plotRight - plotLeft), - label: formatTickShort(t0 + f * tspan), - transform: f === 0 ? 'translateX(0)' : f === 1 ? 'translateX(-100%)' : 'translateX(-50%)', - })) - - const [hover, setHover] = useState<{ i: number; x: number; y: number } | null>(null) - const boxRef = useRef(null) - const tipRef = useRef(null) - - useEffect(() => { - const tip = tipRef.current - const box = boxRef.current - if (!hover || !tip || !box) return - const br = box.getBoundingClientRect() - const w = tip.offsetWidth - const minX = br.left + w / 2 + 4 - const maxX = br.right - w / 2 - 4 - tip.style.left = `${Math.min(Math.max(hover.x, minX), maxX)}px` - tip.style.top = `${hover.y}px` - }, [hover]) - - return ( -
-
{ - const r = e.currentTarget.getBoundingClientRect() - const vbx = ((e.clientX - r.left) / r.width) * width - const targetT = - t0 + ((vbx - pad) / (width - pad * 2)) * tspan - let idx = 0 - let best = Infinity - for (let i = 0; i < values.length; i++) { - const d = Math.abs(times[i]! - targetT) - if (d < best) { - best = d - idx = i - } - } - setHover({ - i: idx, - x: r.left + (x(idx) / width) * r.width, - y: r.top + (y(values[idx]!) / height) * r.height, - }) - }} - onMouseLeave={() => setHover(null)} - > - - - - - - {hover && ( -
- )} - {hover && ( -
- )} -
- {hover - ? `${formatTickShort(times[hover.i]!)} · ${formatMetric({ name: '', value: values[hover.i]!, unit: unit ?? '', updated: null })}` - : ''} -
-
-
- {marks.map((mk, i) => ( - - {mk.label} - - ))} -
-
- ) -} - -function AggregatePlot({ - series, - color, - xMin, - xMax, - yMin, - yMax, - width = 400, - height = 90, - pad = 10, -}: { - series: MetricSeries[] - color: string - xMin?: number - xMax?: number - yMin?: number - yMax?: number - width?: number - height?: number - pad?: number -}) { - const samples = series.flatMap((s) => s.samples) - if (series.length === 0 || samples.length === 0) return null - const dataMin = Math.min(...samples.map((e) => e.value)) - const dataMax = Math.max(...samples.map((e) => e.value)) - const min = yMin !== undefined && yMin < dataMin ? yMin : dataMin - const max = yMax !== undefined && yMax > dataMax ? yMax : dataMax - const span = max - min || 1 - const t0 = xMin ?? Math.min(...samples.map((e) => e.ts)) - const t1 = xMax ?? Math.max(...samples.map((e) => e.ts)) - const tspan = t1 - t0 || 1 - - const xForT = (t: number) => pad + ((t - t0) / tspan) * (width - pad * 2) - const y = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2) - - const paths = series - .map((s) => { - if (s.samples.length < 2) return null - return s.samples - .map((e, i) => `${i === 0 ? 'M' : 'L'}${xForT(e.ts).toFixed(1)} ${y(e.value).toFixed(1)}`) - .join(' ') - }) - .filter((p): p is string => p !== null) - const lineCount = Math.max(1, paths.length) - const opacity = 1 / lineCount - - const [hover, setHover] = useState<{ - sample: MetricEntry - machine: string - x: number - y: number - } | null>(null) - const boxRef = useRef(null) - const tipRef = useRef(null) - - useEffect(() => { - const tip = tipRef.current - const box = boxRef.current - if (!hover || !tip || !box) return - const br = box.getBoundingClientRect() - const w = tip.offsetWidth - const minX = br.left + w / 2 + 4 - const maxX = br.right - w / 2 - 4 - tip.style.left = `${Math.min(Math.max(hover.x, minX), maxX)}px` - tip.style.top = `${hover.y}px` - }, [hover]) - - const marks = [0, 0.25, 0.5, 0.75, 1].map((f) => { - const plotLeft = (pad / width) * 100 - const plotRight = 100 - plotLeft - return { - left: plotLeft + f * (plotRight - plotLeft), - label: formatTickShort(t0 + f * tspan), - transform: f === 0 ? 'translateX(0)' : f === 1 ? 'translateX(-100%)' : 'translateX(-50%)', - } - }) - - return ( -
-
{ - const r = e.currentTarget.getBoundingClientRect() - const vbx = ((e.clientX - r.left) / r.width) * width - const targetT = t0 + ((vbx - pad) / (width - pad * 2)) * tspan - let best: { sample: MetricEntry; machine: string } | null = null - let bd = Infinity - for (const s of series) { - for (const e0 of s.samples) { - const d = Math.abs(e0.ts - targetT) - if (d < bd) { - bd = d - best = { sample: e0, machine: s.machine } - } - } - } - if (!best) return - setHover({ - sample: best.sample, - machine: best.machine, - x: r.left + (xForT(best.sample.ts) / width) * r.width, - y: r.top + (y(best.sample.value) / height) * r.height, - }) - }} - onMouseLeave={() => setHover(null)} - > - - {paths.map((d, i) => ( - - ))} - - {hover && ( -
- )} - {hover && ( -
- )} -
- {hover - ? `${formatTickShort(hover.sample.ts)} · ${hover.machine}: ${formatMetric({ name: hover.sample.name, value: hover.sample.value, unit: hover.sample.unit, updated: null })}` - : ''} -
-
-
- {marks.map((mk, i) => ( - - {mk.label} - - ))} -
-
- ) -} - -function formatTickShort(ts: number): string { - const d = new Date(ts * 1000) - if (isNaN(d.getTime())) return String(ts) - const p = (n: number) => String(n).padStart(2, '0') - return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}` -} - -interface Incident { - status: Status - start: number - end: number - count: number -} - -function groupIncidents(entries: HistoryEntry[]): Incident[] { - const out: Incident[] = [] - for (const e of entries) { - const last = out[out.length - 1] - if (last && last.status === e.status) { - last.end = e.ts - last.count++ - } else { - out.push({ status: e.status, start: e.ts, end: e.ts, count: 1 }) - } - } - return out -} - -function StatusModal({ - m, - onClose, - ranges, - sharedMetricWindow, -}: { - m: Machine - onClose: () => void - ranges?: Record - sharedMetricWindow?: boolean -}) { - const [hist, setHist] = useState(null) - const [metricEntries, setMetricEntries] = useState(null) - const ticksRef = useRef(null) - - useEffect(() => { - const el = ticksRef.current - if (el) el.scrollLeft = el.scrollWidth - }, [hist]) - - const scriptChips = m.ssh && !m.ssh.error ? parseScriptChips(m.ssh.result) : [] - const scriptText = (() => { - if (!m.ssh) return '' - const out = m.ssh.error || m.ssh.result || '(no output)' - const sep = out.indexOf('---') - return sep === -1 ? out : out.slice(sep + 3).trim() - })() - - useEffect(() => { - let cancelled = false - setHist(null) - fetch(`/api/history/${encodeURIComponent(m.id)}?limit=500`) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) - .then((entries: HistoryEntry[]) => { - if (!cancelled) setHist(entries) - }) - .catch((e) => { - if (!cancelled) setHist([]) - console.error('history fetch failed:', e) - }) - return () => { - cancelled = true - } - }, [m.id]) - - const metricNames = (m.metrics ?? []).map((mt) => mt.name) - - useEffect(() => { - let cancelled = false - setMetricEntries(null) - const nowS = Math.floor(Date.now() / 1000) - const min = nowS - 86400 - const shared = sharedMetricWindow ? '&shared=1' : '' - Promise.all( - metricNames.map((name) => - fetch( - `/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}&max_points=100${shared}` - ).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) - ) - ) - .then((lists: MetricEntry[][]) => { - if (cancelled) return - const all: MetricEntry[] = [] - for (const l of lists) all.push(...l) - setMetricEntries(all) - }) - .catch((e) => { - if (!cancelled) setMetricEntries([]) - console.error('metrics fetch failed:', e) - }) - return () => { - cancelled = true - } - }, [m.id, metricNames.join(','), sharedMetricWindow]) - - const metricPlots = (() => { - if (!metricEntries) return null - const byName = new Map() - for (const e of metricEntries) { - if (!byName.has(e.name)) byName.set(e.name, []) - byName.get(e.name)!.push(e) - } - const winMax = Math.floor(Date.now() / 1000) - let winMin = winMax - 86400 - if (sharedMetricWindow && metricEntries.length > 0) { - const earliest = Math.min(...metricEntries.map((e) => e.ts)) - winMin = Math.max(winMin, earliest) - } - const plots: { - name: string - unit: string - values: number[] - times: number[] - yMin?: number - yMax?: number - xMin?: number - xMax?: number - }[] = [] - for (const [name, entries] of byName) { - const r = ranges?.[name] - plots.push({ - name, - unit: entries[0]?.unit ?? '', - values: entries.map((e) => e.value), - times: entries.map((e) => e.ts), - yMin: r?.min, - yMax: r?.max, - xMin: sharedMetricWindow ? winMin : undefined, - xMax: sharedMetricWindow ? winMax : undefined, - }) - } - plots.sort((a, b) => a.name.localeCompare(b.name)) - return plots - })() - - const ticks = hist ? [...hist].reverse() : [] - const counts = { up: 0, degraded: 0, down: 0, unknown: 0 } - hist?.forEach((e) => { - if (counts[e.status] !== undefined) counts[e.status]++ - }) - - return ( - - ) -} +import { useEffect, useState } from 'preact/hooks' +import type { Machine, MetricAggregatePayload, StatusPayload } from './types' +import { formatBytes, groupIcon, hashColor } from './format' +import { Grid } from './components/Grid' +import { AggregatePlot } from './components/plots' +import { StatusModal } from './components/StatusModal' export function App() { const [data, setData] = useState(null) diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx new file mode 100644 index 0000000..e8eccb1 --- /dev/null +++ b/src/components/Grid.tsx @@ -0,0 +1,116 @@ +import type { Machine } from '../types' +import { formatTime } from '../format' +import { parseScriptChips } from '../parse' +import { Chip, Dot } from './ui' + +export function MachineCard({ + m, + onOpen, + interactive, + onRefresh, +}: { + m: Machine + onOpen: (m: Machine) => void + interactive: boolean + onRefresh: (m: Machine) => void +}) { + const sshState = !m.sshConfigured + ? 'off' + : !m.ssh + ? 'unknown' + : m.ssh.ok + ? 'ok' + : 'fail' + const scriptChips = m.ssh && !m.ssh.error ? parseScriptChips(m.ssh.result) : [] + return ( +
onOpen(m)} + role="button" + tabIndex={0} + > +
+ + {m.name} + {interactive && ( + + )} +
+
{m.ip || '—'}
+
+ + + {m.sshConfigured && } +
+ +
e.stopPropagation()}> +
+
+ + ip + {m.ip || 'unknown'} + + + last ping + {formatTime(m.lastPing)} + +
+ + + {m.status} + +
+ {scriptChips.length > 0 && ( + + + {scriptChips.map((c) => ( + + + + + ))} + +
{c.name} + {c.status === 'unknown' ? 'unknown' : c.info || c.status} +
+ )} +
+
+ ) +} + +export function Grid({ + machines, + onOpen, + interactive, + onRefresh, +}: { + machines: Machine[] + onOpen: (m: Machine) => void + interactive: boolean + onRefresh: (m: Machine) => void +}) { + return ( +
+ {machines.map((m) => ( + + ))} +
+ ) +} diff --git a/src/components/StatusModal.tsx b/src/components/StatusModal.tsx new file mode 100644 index 0000000..05889a3 --- /dev/null +++ b/src/components/StatusModal.tsx @@ -0,0 +1,327 @@ +import { useEffect, useRef, useState } from 'preact/hooks' +import type { HistoryEntry, Machine, MetricEntry } from '../types' +import { formatMetric, formatTick, formatTickShort, formatTime, hashColor } from '../format' +import { groupIncidents, parseScriptChips, scriptChipState } from '../parse' +import { CheckState, Dot } from './ui' +import { LinePlot } from './plots' + +export function StatusModal({ + m, + onClose, + ranges, + sharedMetricWindow, +}: { + m: Machine + onClose: () => void + ranges?: Record + sharedMetricWindow?: boolean +}) { + const [hist, setHist] = useState(null) + const [metricEntries, setMetricEntries] = useState(null) + const ticksRef = useRef(null) + + useEffect(() => { + const el = ticksRef.current + if (el) el.scrollLeft = el.scrollWidth + }, [hist]) + + const scriptChips = m.ssh && !m.ssh.error ? parseScriptChips(m.ssh.result) : [] + const scriptText = (() => { + if (!m.ssh) return '' + const out = m.ssh.error || m.ssh.result || '(no output)' + const sep = out.indexOf('---') + return sep === -1 ? out : out.slice(sep + 3).trim() + })() + + useEffect(() => { + let cancelled = false + setHist(null) + fetch(`/api/history/${encodeURIComponent(m.id)}?limit=500`) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((entries: HistoryEntry[]) => { + if (!cancelled) setHist(entries) + }) + .catch((e) => { + if (!cancelled) setHist([]) + console.error('history fetch failed:', e) + }) + return () => { + cancelled = true + } + }, [m.id]) + + const metricNames = (m.metrics ?? []).map((mt) => mt.name) + + useEffect(() => { + let cancelled = false + setMetricEntries(null) + const nowS = Math.floor(Date.now() / 1000) + const min = nowS - 86400 + const shared = sharedMetricWindow ? '&shared=1' : '' + Promise.all( + metricNames.map((name) => + fetch( + `/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}&max_points=100${shared}` + ).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + ) + ) + .then((lists: MetricEntry[][]) => { + if (cancelled) return + const all: MetricEntry[] = [] + for (const l of lists) all.push(...l) + setMetricEntries(all) + }) + .catch((e) => { + if (!cancelled) setMetricEntries([]) + console.error('metrics fetch failed:', e) + }) + return () => { + cancelled = true + } + }, [m.id, metricNames.join(','), sharedMetricWindow]) + + const metricPlots = (() => { + if (!metricEntries) return null + const byName = new Map() + for (const e of metricEntries) { + if (!byName.has(e.name)) byName.set(e.name, []) + byName.get(e.name)!.push(e) + } + const winMax = Math.floor(Date.now() / 1000) + let winMin = winMax - 86400 + if (sharedMetricWindow && metricEntries.length > 0) { + const earliest = Math.min(...metricEntries.map((e) => e.ts)) + winMin = Math.max(winMin, earliest) + } + const plots: { + name: string + unit: string + values: number[] + times: number[] + yMin?: number + yMax?: number + xMin?: number + xMax?: number + }[] = [] + for (const [name, entries] of byName) { + const r = ranges?.[name] + plots.push({ + name, + unit: entries[0]?.unit ?? '', + values: entries.map((e) => e.value), + times: entries.map((e) => e.ts), + yMin: r?.min, + yMax: r?.max, + xMin: sharedMetricWindow ? winMin : undefined, + xMax: sharedMetricWindow ? winMax : undefined, + }) + } + plots.sort((a, b) => a.name.localeCompare(b.name)) + return plots + })() + + const ticks = hist ? [...hist].reverse() : [] + const counts = { up: 0, degraded: 0, down: 0, unknown: 0 } + hist?.forEach((e) => { + if (counts[e.status] !== undefined) counts[e.status]++ + }) + + return ( + + ) +} diff --git a/src/components/plots.tsx b/src/components/plots.tsx new file mode 100644 index 0000000..13538a3 --- /dev/null +++ b/src/components/plots.tsx @@ -0,0 +1,300 @@ +import { useEffect, useRef, useState } from 'preact/hooks' +import type { MetricEntry, MetricSeries } from '../types' +import { formatMetric, formatTickShort } from '../format' + +export function LinePlot({ + values, + times, + color, + unit, + yMin, + yMax, + xMin, + xMax, + width = 400, + height = 90, + pad = 10, +}: { + values: number[] + times: number[] + color: string + unit?: string + yMin?: number + yMax?: number + xMin?: number + xMax?: number + width?: number + height?: number + pad?: number +}) { + if (values.length < 2 || times.length !== values.length) return null + const first = values[0]! + const last = values[values.length - 1]! + const dataMin = Math.min(...values) + const dataMax = Math.max(...values) + const min = yMin !== undefined && yMin < dataMin ? yMin : dataMin + const max = yMax !== undefined && yMax > dataMax ? yMax : dataMax + const span = max - min || 1 + const t0 = xMin ?? times[0]! + const t1 = xMax ?? times[times.length - 1]! + const tspan = t1 - t0 || 1 + const xForT = (t: number) => + pad + ((t - t0) / tspan) * (width - pad * 2) + const x = (i: number) => xForT(times[i]!) + const y = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2) + const path = values + .map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`) + .join(' ') + + const plotLeft = (pad / width) * 100 + const plotRight = 100 - plotLeft + const marks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ + left: plotLeft + f * (plotRight - plotLeft), + label: formatTickShort(t0 + f * tspan), + transform: f === 0 ? 'translateX(0)' : f === 1 ? 'translateX(-100%)' : 'translateX(-50%)', + })) + + const [hover, setHover] = useState<{ i: number; x: number; y: number } | null>(null) + const boxRef = useRef(null) + const tipRef = useRef(null) + + useEffect(() => { + const tip = tipRef.current + const box = boxRef.current + if (!hover || !tip || !box) return + const br = box.getBoundingClientRect() + const w = tip.offsetWidth + const minX = br.left + w / 2 + 4 + const maxX = br.right - w / 2 - 4 + tip.style.left = `${Math.min(Math.max(hover.x, minX), maxX)}px` + tip.style.top = `${hover.y}px` + }, [hover]) + + return ( +
+
{ + const r = e.currentTarget.getBoundingClientRect() + const vbx = ((e.clientX - r.left) / r.width) * width + const targetT = + t0 + ((vbx - pad) / (width - pad * 2)) * tspan + let idx = 0 + let best = Infinity + for (let i = 0; i < values.length; i++) { + const d = Math.abs(times[i]! - targetT) + if (d < best) { + best = d + idx = i + } + } + setHover({ + i: idx, + x: r.left + (x(idx) / width) * r.width, + y: r.top + (y(values[idx]!) / height) * r.height, + }) + }} + onMouseLeave={() => setHover(null)} + > + + + + + + {hover && ( +
+ )} + {hover && ( +
+ )} +
+ {hover + ? `${formatTickShort(times[hover.i]!)} · ${formatMetric({ name: '', value: values[hover.i]!, unit: unit ?? '', updated: null })}` + : ''} +
+
+
+ {marks.map((mk, i) => ( + + {mk.label} + + ))} +
+
+ ) +} + +export function AggregatePlot({ + series, + color, + xMin, + xMax, + yMin, + yMax, + width = 400, + height = 90, + pad = 10, +}: { + series: MetricSeries[] + color: string + xMin?: number + xMax?: number + yMin?: number + yMax?: number + width?: number + height?: number + pad?: number +}) { + const samples = series.flatMap((s) => s.samples) + if (series.length === 0 || samples.length === 0) return null + const dataMin = Math.min(...samples.map((e) => e.value)) + const dataMax = Math.max(...samples.map((e) => e.value)) + const min = yMin !== undefined && yMin < dataMin ? yMin : dataMin + const max = yMax !== undefined && yMax > dataMax ? yMax : dataMax + const span = max - min || 1 + const t0 = xMin ?? Math.min(...samples.map((e) => e.ts)) + const t1 = xMax ?? Math.max(...samples.map((e) => e.ts)) + const tspan = t1 - t0 || 1 + + const xForT = (t: number) => pad + ((t - t0) / tspan) * (width - pad * 2) + const y = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2) + + const paths = series + .map((s) => { + if (s.samples.length < 2) return null + return s.samples + .map((e, i) => `${i === 0 ? 'M' : 'L'}${xForT(e.ts).toFixed(1)} ${y(e.value).toFixed(1)}`) + .join(' ') + }) + .filter((p): p is string => p !== null) + const lineCount = Math.max(1, paths.length) + const opacity = 1 / lineCount + + const [hover, setHover] = useState<{ + sample: MetricEntry + machine: string + x: number + y: number + } | null>(null) + const boxRef = useRef(null) + const tipRef = useRef(null) + + useEffect(() => { + const tip = tipRef.current + const box = boxRef.current + if (!hover || !tip || !box) return + const br = box.getBoundingClientRect() + const w = tip.offsetWidth + const minX = br.left + w / 2 + 4 + const maxX = br.right - w / 2 - 4 + tip.style.left = `${Math.min(Math.max(hover.x, minX), maxX)}px` + tip.style.top = `${hover.y}px` + }, [hover]) + + const marks = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const plotLeft = (pad / width) * 100 + const plotRight = 100 - plotLeft + return { + left: plotLeft + f * (plotRight - plotLeft), + label: formatTickShort(t0 + f * tspan), + transform: f === 0 ? 'translateX(0)' : f === 1 ? 'translateX(-100%)' : 'translateX(-50%)', + } + }) + + return ( +
+
{ + const r = e.currentTarget.getBoundingClientRect() + const vbx = ((e.clientX - r.left) / r.width) * width + const targetT = t0 + ((vbx - pad) / (width - pad * 2)) * tspan + let best: { sample: MetricEntry; machine: string } | null = null + let bd = Infinity + for (const s of series) { + for (const e0 of s.samples) { + const d = Math.abs(e0.ts - targetT) + if (d < bd) { + bd = d + best = { sample: e0, machine: s.machine } + } + } + } + if (!best) return + setHover({ + sample: best.sample, + machine: best.machine, + x: r.left + (xForT(best.sample.ts) / width) * r.width, + y: r.top + (y(best.sample.value) / height) * r.height, + }) + }} + onMouseLeave={() => setHover(null)} + > + + {paths.map((d, i) => ( + + ))} + + {hover && ( +
+ )} + {hover && ( +
+ )} +
+ {hover + ? `${formatTickShort(hover.sample.ts)} · ${hover.machine}: ${formatMetric({ name: hover.sample.name, value: hover.sample.value, unit: hover.sample.unit, updated: null })}` + : ''} +
+
+
+ {marks.map((mk, i) => ( + + {mk.label} + + ))} +
+
+ ) +} diff --git a/src/components/ui.tsx b/src/components/ui.tsx new file mode 100644 index 0000000..4d4d162 --- /dev/null +++ b/src/components/ui.tsx @@ -0,0 +1,58 @@ +import { useRef, useState } from 'preact/hooks' +import type { ChipState, Status } from '../types' + +export function Dot({ status }: { status: Status }) { + return +} + +export function Chip({ + label, + state, + value, +}: { + label: string + state?: ChipState + value?: string +}) { + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const ref = useRef(null) + const hasValue = value !== undefined + const show = () => { + const el = ref.current + if (!el) return + const r = el.getBoundingClientRect() + setPos({ x: r.left + r.width / 2, y: r.top }) + } + const hide = () => setPos(null) + return ( + <> + + {label} + {!hasValue && } + + {hasValue && pos && ( + + {value} + + )} + + ) +} + +export function CheckState({ state }: { state: ChipState }) { + return ( + + + {state} + + ) +} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..5b98cf4 --- /dev/null +++ b/src/format.ts @@ -0,0 +1,54 @@ +import type { MetricStatus } from './types' + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let v = n + let i = -1 + do { + v /= 1024 + i++ + } while (v >= 1024 && i < units.length - 1) + return `${v.toFixed(1)} ${units[i]}` +} + +export function formatTime(ts: string | null): string { + if (!ts) return 'never' + const d = new Date(ts) + return isNaN(d.getTime()) ? ts : d.toLocaleString() +} + +export function formatMetric(m: MetricStatus): string { + const v = Number.isInteger(m.value) + ? String(m.value) + : m.value.toFixed(1) + return m.unit ? `${v} ${m.unit}` : v +} + +export function formatTick(ts: number): string { + const d = new Date(ts * 1000) + return isNaN(d.getTime()) ? String(ts) : d.toLocaleString() +} + +export function formatTickShort(ts: number): string { + const d = new Date(ts * 1000) + if (isNaN(d.getTime())) return String(ts) + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}` +} + +export function groupIcon(name: string): string { + const n = name.toLowerCase() + if (n.includes('home')) return 'mdi:home' + if (n.includes('lab')) return 'mdi:flask' + if (n.includes('gpu') || n.includes('compute') || n.includes('render')) return 'mdi:desktop-tower' + if (n.includes('server') || n.includes('data') || n.includes('prod')) return 'mdi:server-network' + if (n.includes('class') || n.includes('aula') || n.includes('room') || n.includes('student')) return 'mdi:school' + return 'mdi:folder-outline' +} + +export function hashColor(s: string): string { + let h = 5381 + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0 + return `hsl(${h % 360} 62% 42%)` +} diff --git a/src/main.tsx b/src/main.tsx index 39da5ba..874b630 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,8 @@ import { App } from './app' import '@fontsource/inria-sans/400.css' import '@fontsource/inria-sans/700.css' import '@fontsource/inria-sans/400-italic.css' -import '@fontsource/iosevka-etoile/400.css' -import '@fontsource/iosevka-etoile/600.css' +import '@fontsource/jetbrains-mono/400.css' +import '@fontsource/jetbrains-mono/600.css' import './styles.css' render(, document.getElementById('app')!) diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..c33dc3e --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,40 @@ +import type { ChipState, HistoryEntry, Incident, ScriptChip } from './types' + +export function parseScriptChips(out: string): ScriptChip[] { + const chips: ScriptChip[] = [] + for (const line of out.split('\n')) { + if (line.includes('---')) break + const i = line.indexOf(':') + if (i <= 0) continue + const name = line.slice(0, i).trim() + const rest = line.slice(i + 1) + const j = rest.indexOf(':') + if (j < 0) continue + const status = rest.slice(0, j).trim() + const info = rest.slice(j + 1).trim() + if (!name || !status || status === 'metric') continue + chips.push({ name, status, info }) + } + return chips +} + +export function scriptChipState(status: string): ChipState { + if (status === 'on') return 'ok' + if (status === 'off') return 'off' + if (status === 'down') return 'fail' + return 'unknown' +} + +export function groupIncidents(entries: HistoryEntry[]): Incident[] { + const out: Incident[] = [] + for (const e of entries) { + const last = out[out.length - 1] + if (last && last.status === e.status) { + last.end = e.ts + last.count++ + } else { + out.push({ status: e.status, start: e.ts, end: e.ts, count: 1 }) + } + } + return out +} diff --git a/src/styles.css b/src/styles.css index ebb84f7..02f61d1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8,8 +8,7 @@ --radius: 0.5rem; --green: #1aad4b; --yellow: #e5b400; - --mono: "Iosevka Etoile", ui-monospace, "Cascadia Code", "JetBrains Mono", - Menlo, monospace; + --mono: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, monospace; } * { @@ -647,7 +646,7 @@ body { .modal-cell-value { font-weight: 700; - font-size: 1rem; + font-size: 0.9rem; word-break: break-word; } @@ -798,10 +797,11 @@ body { .check-state { display: inline-flex; - align-items: center; + align-items: flex-start; gap: 0.375rem; - font-weight: 700; - font-size: 0.72rem; + font-family: var(--mono); + font-weight: 600; + font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; } @@ -810,6 +810,7 @@ body { flex: none; width: 0.5rem; height: 0.5rem; + margin-top: 0.325rem; border-radius: 50%; border: 1.5px solid #111; } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8e51b16 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,101 @@ +export type Status = 'up' | 'degraded' | 'down' | 'unknown' +export type Check = 'ok' | 'fail' | 'na' +export type ChipState = 'ok' | 'fail' | 'na' | 'off' | 'unknown' + +export interface SshResult { + ok: boolean + result: string + exitCode: number | null + lastRun: string | null + error: string | null +} + +export interface ScriptChip { + name: string + status: string + info: string +} + +export interface MetricStatus { + name: string + value: number + unit: string + updated: string | null +} + +export interface Machine { + id: string + name: string + host: string + ip: string + ips: string[] + status: Status + icmp: Check + tcp: Check + lastPing: string | null + sshConfigured: boolean + ssh: SshResult | null + metrics: MetricStatus[] +} + +export interface Group { + name: string + machines: Machine[] +} + +export interface Stats { + pingInterval: string + pingTimeout: string + tcpPort: number + sshInterval: string + sshEnabled: boolean + dbSize: number + dbRows: number + dbSizeMonth: number +} + +export interface StatusPayload { + title: string + interactive: boolean + sharedMetricWindow?: boolean + stats: Stats + metricRanges?: Record + groups: Group[] + machines: Machine[] +} + +export interface HistoryEntry { + ts: number + status: Status + ip: string +} + +export interface MetricEntry { + ts: number + name: string + value: number + unit: string +} + +export interface MetricSeries { + machine: string + samples: MetricEntry[] +} + +export interface MetricAggregate { + name: string + unit: string + series: MetricSeries[] +} + +export interface MetricAggregatePayload { + window: [number, number] + metrics: MetricAggregate[] +} + +export interface Incident { + status: Status + start: number + end: number + count: number +}