ui: split app.tsx into modules; switch mono font to JetBrains Mono

This commit is contained in:
2026-08-01 02:50:32 +02:00
parent 9d10298d97
commit 031ca5eb0b
12 changed files with 1014 additions and 992 deletions
+2 -2
View File
@@ -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=="],
+1 -1
View File
@@ -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"
}
}
+6 -981
View File
@@ -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<string, { min?: number; max?: number }>
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 <span class={`dot ${status}`} title={status} />
}
function Chip({
label,
state,
value,
}: {
label: string
state?: ChipState
value?: string
}) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
const ref = useRef<HTMLSpanElement>(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 (
<>
<span
ref={ref}
class={`chip ${state ?? 'unknown'}`}
title={hasValue ? undefined : `${label}: ${state}`}
onMouseEnter={show}
onMouseLeave={hide}
>
{label}
{!hasValue && <span class="chip-dot" />}
</span>
{hasValue && pos && (
<span
class="chip-pop"
style={{ left: `${pos.x}px`, top: `${pos.y}px` }}
>
{value}
</span>
)}
</>
)
}
function CheckState({ state }: { state: ChipState }) {
return (
<span class="check-state">
<span class={`check-dot ${state}`} />
{state}
</span>
)
}
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 (
<div
class="machine"
data-status={m.status}
onClick={() => onOpen(m)}
role="button"
tabIndex={0}
>
<div class="machine-head">
<Dot status={m.status} />
<span class="machine-name">{m.name}</span>
{interactive && (
<button
class="refresh-btn"
title="Refresh now"
onClick={(e) => {
e.stopPropagation()
onRefresh(m)
}}
>
<iconify-icon icon="mdi:refresh" width="15" height="15" />
</button>
)}
</div>
<div class="machine-ip">{m.ip || '—'}</div>
<div class="chips">
<Chip label="ping" state={m.icmp} />
<Chip label="tcp" state={m.tcp} />
{m.sshConfigured && <Chip label="ssh" state={sshState} />}
</div>
<div class="popover" onClick={(e) => e.stopPropagation()}>
<div class="popover-head">
<div class="popover-meta">
<span class="popover-meta-item">
<span class="popover-meta-key">ip</span>
<span class="popover-meta-val">{m.ip || 'unknown'}</span>
</span>
<span class="popover-meta-item">
<span class="popover-meta-key">last ping</span>
<span class="popover-meta-val">{formatTime(m.lastPing)}</span>
</span>
</div>
<span class="popover-status">
<Dot status={m.status} />
<span class={`badge ${m.status}`}>{m.status}</span>
</span>
</div>
{scriptChips.length > 0 && (
<table class="popover-table">
<tbody>
{scriptChips.map((c) => (
<tr key={c.name}>
<th>{c.name}</th>
<td>
{c.status === 'unknown' ? 'unknown' : c.info || c.status}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
function Grid({
machines,
onOpen,
interactive,
onRefresh,
}: {
machines: Machine[]
onOpen: (m: Machine) => void
interactive: boolean
onRefresh: (m: Machine) => void
}) {
return (
<div class="grid">
{machines.map((m) => (
<MachineCard
key={m.id}
m={m}
onOpen={onOpen}
interactive={interactive}
onRefresh={onRefresh}
/>
))}
</div>
)
}
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<HTMLDivElement>(null)
const tipRef = useRef<HTMLDivElement>(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 (
<div class="line-plot-wrap">
<div
ref={boxRef}
class="line-plot-box"
onMouseMove={(e) => {
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)}
>
<svg
viewBox={`0 0 ${width} ${height}`}
class="line-plot"
preserveAspectRatio="none"
>
<path
d={path}
fill="none"
stroke={color}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<circle cx={x(0)} cy={y(first)} r="3" fill={color} />
<circle cx={x(values.length - 1)} cy={y(last)} r="3" fill={color} />
</svg>
{hover && (
<div
class="plot-hover-line"
style={{ left: `${(x(hover.i) / width) * 100}%` }}
/>
)}
{hover && (
<div
class="plot-hover-dot"
style={{
left: `${(x(hover.i) / width) * 100}%`,
top: `${(y(values[hover.i]!) / height) * 100}%`,
}}
/>
)}
<div class="plot-tip" ref={tipRef} style={{ visibility: hover ? 'visible' : 'hidden' }}>
{hover
? `${formatTickShort(times[hover.i]!)} · ${formatMetric({ name: '', value: values[hover.i]!, unit: unit ?? '', updated: null })}`
: ''}
</div>
</div>
<div class="plot-marks">
{marks.map((mk, i) => (
<span key={i} class="plot-mark" style={{ left: `${mk.left}%`, transform: mk.transform }}>
{mk.label}
</span>
))}
</div>
</div>
)
}
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<HTMLDivElement>(null)
const tipRef = useRef<HTMLDivElement>(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 (
<div class="line-plot-wrap">
<div
ref={boxRef}
class="line-plot-box"
onMouseMove={(e) => {
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)}
>
<svg
viewBox={`0 0 ${width} ${height}`}
class="line-plot"
preserveAspectRatio="none"
>
{paths.map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke={color}
stroke-opacity={opacity}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
))}
</svg>
{hover && (
<div class="plot-hover-line" style={{ left: `${(xForT(hover.sample.ts) / width) * 100}%` }} />
)}
{hover && (
<div
class="plot-hover-dot"
style={{
left: `${(xForT(hover.sample.ts) / width) * 100}%`,
top: `${(y(hover.sample.value) / height) * 100}%`,
}}
/>
)}
<div class="plot-tip" ref={tipRef} style={{ visibility: hover ? 'visible' : 'hidden' }}>
{hover
? `${formatTickShort(hover.sample.ts)} · ${hover.machine}: ${formatMetric({ name: hover.sample.name, value: hover.sample.value, unit: hover.sample.unit, updated: null })}`
: ''}
</div>
</div>
<div class="plot-marks">
{marks.map((mk, i) => (
<span key={i} class="plot-mark" style={{ left: `${mk.left}%`, transform: mk.transform }}>
{mk.label}
</span>
))}
</div>
</div>
)
}
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<string, { min?: number; max?: number }>
sharedMetricWindow?: boolean
}) {
const [hist, setHist] = useState<HistoryEntry[] | null>(null)
const [metricEntries, setMetricEntries] = useState<MetricEntry[] | null>(null)
const ticksRef = useRef<HTMLDivElement>(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<string, MetricEntry[]>()
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 (
<div class="modal-overlay" onClick={onClose}>
<div class="modal" onClick={(e) => e.stopPropagation()}>
<div class="modal-head">
<Dot status={m.status} />
<h2>{m.name}</h2>
<button class="modal-close" onClick={onClose} aria-label="close">
×
</button>
</div>
<div class="modal-grid">
<div class="modal-cell">
<span class="modal-cell-label">host</span>
<span class="modal-cell-value">{m.host}</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">status</span>
<span class="modal-cell-value">
<span class={`badge ${m.status}`}>{m.status}</span>
</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">ping</span>
<span class="modal-cell-value">
<CheckState state={m.icmp} />
</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">tcp</span>
<span class="modal-cell-value">
<CheckState state={m.tcp} />
</span>
</div>
{m.sshConfigured && (
<div class="modal-cell">
<span class="modal-cell-label">ssh</span>
<span class="modal-cell-value">
<CheckState state={!m.ssh ? 'unknown' : m.ssh.ok ? 'ok' : 'fail'} />
</span>
</div>
)}
{scriptChips.map((c) => (
<div class="modal-cell" key={c.name}>
<span class="modal-cell-label">{c.name}</span>
<span class="modal-cell-value">
<span class="check-state">
<span class={`check-dot ${scriptChipState(c.status)}`} />
{c.status === 'unknown' ? 'unknown' : c.info || c.status}
</span>
</span>
</div>
))}
{(m.metrics ?? []).map((mt) => (
<div class="modal-cell" key={mt.name}>
<span class="modal-cell-label" title={mt.updated ? `updated ${formatTime(mt.updated)}` : undefined}>
{mt.name}
</span>
<span class="modal-cell-value metric-value">
{formatMetric(mt)}
</span>
</div>
))}
<div class="modal-cell">
<span class="modal-cell-label">current ip</span>
<span class="modal-cell-value mono">{m.ip || 'unknown'}</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">ip history</span>
<span class="modal-cell-value mono">
{(() => {
const seen = new Set<string>()
const last: string[] = []
if (m.ips) {
for (let i = m.ips.length - 1; i >= 0 && seen.size < 5; i--) {
const ip = m.ips[i]
if (ip && !seen.has(ip)) {
seen.add(ip)
last.unshift(ip)
}
}
}
return last.length ? last.join(' · ') : 'none recorded'
})()}
</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">last ping</span>
<span class="modal-cell-value mono">{formatTime(m.lastPing)}</span>
</div>
{m.ssh && (
<>
<div class="modal-cell">
<span class="modal-cell-label">script run</span>
<span class="modal-cell-value mono">
{formatTime(m.ssh.lastRun)}
{m.ssh.exitCode !== null && ` · exit ${m.ssh.exitCode}`}
</span>
</div>
<div class="modal-cell full">
<span class="modal-cell-label">script output</span>
<pre class="script-out tall">{scriptText}</pre>
</div>
</>
)}
</div>
<div class="hist">
<div class="hist-head">
<strong>tick history</strong>
{hist === null && <span class="muted">loading…</span>}
{hist && hist.length === 0 && <span class="muted">no history yet</span>}
{hist && hist.length > 0 && (
<span class="hist-counts">
<span><span class="dot up" /> {counts.up}</span>
<span><span class="dot degraded" /> {counts.degraded}</span>
<span><span class="dot down" /> {counts.down}</span>
</span>
)}
</div>
{hist && hist.length > 0 && (
<div class="ticks" ref={ticksRef}>
{ticks.map((e, i) => {
const step = Math.max(1, Math.round(ticks.length / 4))
const mark =
i === 0 ||
i === ticks.length - 1 ||
i % step === 0
return (
<span key={i} class="tick-cell">
<span
class={`tick ${e.status}`}
title={`${formatTick(e.ts)} · ${e.status}${e.ip ? ` · ${e.ip}` : ''}`}
/>
{mark && (
<span class="tick-mark">{formatTickShort(e.ts)}</span>
)}
</span>
)
})}
</div>
)}
{hist && hist.length > 0 && (
<div class="incidents">
<div class="incidents-title">incidents</div>
{groupIncidents(ticks)
.slice(-30)
.reverse()
.map((inc, i) => (
<div key={i} class="incident-row">
<span class={`badge ${inc.status}`}>{inc.status}</span>
<span>
{formatTickShort(inc.start)}
{inc.count > 1
? ` → ${formatTickShort(inc.end)}`
: ''}
</span>
<span class="muted">
{inc.count > 1 ? `${inc.count} ticks` : ''}
</span>
</div>
))}
</div>
)}
{metricPlots && metricPlots.length > 0 && (
<div class="metric-plots">
<div class="incidents-title">metrics</div>
{metricPlots.map((p) => (
<div key={p.name} class="metric-plot">
<div class="metric-plot-head">
<span
class="metric-dot"
style={{ background: hashColor(p.name) }}
/>
<strong>{p.name}</strong>
<span
class={`metric-latest${p.yMin !== undefined && p.values[p.values.length - 1]! < p.yMin || p.yMax !== undefined && p.values[p.values.length - 1]! > p.yMax ? ' out-of-range' : ''}`}
>
{formatMetric({ name: p.name, value: p.values[p.values.length - 1]!, unit: p.unit, updated: null })}
</span>
</div>
<LinePlot
values={p.values}
times={p.times}
color={hashColor(p.name)}
unit={p.unit}
yMin={p.yMin}
yMax={p.yMax}
xMin={p.xMin}
xMax={p.xMax}
/>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
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<StatusPayload | null>(null)
+116
View File
@@ -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 (
<div
class="machine"
data-status={m.status}
onClick={() => onOpen(m)}
role="button"
tabIndex={0}
>
<div class="machine-head">
<Dot status={m.status} />
<span class="machine-name">{m.name}</span>
{interactive && (
<button
class="refresh-btn"
title="Refresh now"
onClick={(e) => {
e.stopPropagation()
onRefresh(m)
}}
>
<iconify-icon icon="mdi:refresh" width="15" height="15" />
</button>
)}
</div>
<div class="machine-ip">{m.ip || '—'}</div>
<div class="chips">
<Chip label="ping" state={m.icmp} />
<Chip label="tcp" state={m.tcp} />
{m.sshConfigured && <Chip label="ssh" state={sshState} />}
</div>
<div class="popover" onClick={(e) => e.stopPropagation()}>
<div class="popover-head">
<div class="popover-meta">
<span class="popover-meta-item">
<span class="popover-meta-key">ip</span>
<span class="popover-meta-val">{m.ip || 'unknown'}</span>
</span>
<span class="popover-meta-item">
<span class="popover-meta-key">last ping</span>
<span class="popover-meta-val">{formatTime(m.lastPing)}</span>
</span>
</div>
<span class="popover-status">
<Dot status={m.status} />
<span class={`badge ${m.status}`}>{m.status}</span>
</span>
</div>
{scriptChips.length > 0 && (
<table class="popover-table">
<tbody>
{scriptChips.map((c) => (
<tr key={c.name}>
<th>{c.name}</th>
<td>
{c.status === 'unknown' ? 'unknown' : c.info || c.status}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
export function Grid({
machines,
onOpen,
interactive,
onRefresh,
}: {
machines: Machine[]
onOpen: (m: Machine) => void
interactive: boolean
onRefresh: (m: Machine) => void
}) {
return (
<div class="grid">
{machines.map((m) => (
<MachineCard
key={m.id}
m={m}
onOpen={onOpen}
interactive={interactive}
onRefresh={onRefresh}
/>
))}
</div>
)
}
+327
View File
@@ -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<string, { min?: number; max?: number }>
sharedMetricWindow?: boolean
}) {
const [hist, setHist] = useState<HistoryEntry[] | null>(null)
const [metricEntries, setMetricEntries] = useState<MetricEntry[] | null>(null)
const ticksRef = useRef<HTMLDivElement>(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<string, MetricEntry[]>()
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 (
<div class="modal-overlay" onClick={onClose}>
<div class="modal" onClick={(e) => e.stopPropagation()}>
<div class="modal-head">
<Dot status={m.status} />
<h2>{m.name}</h2>
<button class="modal-close" onClick={onClose} aria-label="close">
×
</button>
</div>
<div class="modal-grid">
<div class="modal-cell">
<span class="modal-cell-label">host</span>
<span class="modal-cell-value">{m.host}</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">status</span>
<span class="modal-cell-value">
<span class={`badge ${m.status}`}>{m.status}</span>
</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">ping</span>
<span class="modal-cell-value">
<CheckState state={m.icmp} />
</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">tcp</span>
<span class="modal-cell-value">
<CheckState state={m.tcp} />
</span>
</div>
{m.sshConfigured && (
<div class="modal-cell">
<span class="modal-cell-label">ssh</span>
<span class="modal-cell-value">
<CheckState state={!m.ssh ? 'unknown' : m.ssh.ok ? 'ok' : 'fail'} />
</span>
</div>
)}
{scriptChips.map((c) => (
<div class="modal-cell" key={c.name}>
<span class="modal-cell-label">{c.name}</span>
<span class="modal-cell-value">
<span class="check-state">
<span class={`check-dot ${scriptChipState(c.status)}`} />
{c.status === 'unknown' ? 'unknown' : c.info || c.status}
</span>
</span>
</div>
))}
{(m.metrics ?? []).map((mt) => (
<div class="modal-cell" key={mt.name}>
<span class="modal-cell-label" title={mt.updated ? `updated ${formatTime(mt.updated)}` : undefined}>
{mt.name}
</span>
<span class="modal-cell-value metric-value">
{formatMetric(mt)}
</span>
</div>
))}
<div class="modal-cell">
<span class="modal-cell-label">current ip</span>
<span class="modal-cell-value mono">{m.ip || 'unknown'}</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">ip history</span>
<span class="modal-cell-value mono">
{(() => {
const seen = new Set<string>()
const last: string[] = []
if (m.ips) {
for (let i = m.ips.length - 1; i >= 0 && seen.size < 5; i--) {
const ip = m.ips[i]
if (ip && !seen.has(ip)) {
seen.add(ip)
last.unshift(ip)
}
}
}
return last.length ? last.join(' · ') : 'none recorded'
})()}
</span>
</div>
<div class="modal-cell">
<span class="modal-cell-label">last ping</span>
<span class="modal-cell-value mono">{formatTime(m.lastPing)}</span>
</div>
{m.ssh && (
<>
<div class="modal-cell">
<span class="modal-cell-label">script run</span>
<span class="modal-cell-value mono">
{formatTime(m.ssh.lastRun)}
{m.ssh.exitCode !== null && ` · exit ${m.ssh.exitCode}`}
</span>
</div>
<div class="modal-cell full">
<span class="modal-cell-label">script output</span>
<pre class="script-out tall">{scriptText}</pre>
</div>
</>
)}
</div>
<div class="hist">
<div class="hist-head">
<strong>tick history</strong>
{hist === null && <span class="muted">loading…</span>}
{hist && hist.length === 0 && <span class="muted">no history yet</span>}
{hist && hist.length > 0 && (
<span class="hist-counts">
<span><span class="dot up" /> {counts.up}</span>
<span><span class="dot degraded" /> {counts.degraded}</span>
<span><span class="dot down" /> {counts.down}</span>
</span>
)}
</div>
{hist && hist.length > 0 && (
<div class="ticks" ref={ticksRef}>
{ticks.map((e, i) => {
const step = Math.max(1, Math.round(ticks.length / 4))
const mark =
i === 0 ||
i === ticks.length - 1 ||
i % step === 0
return (
<span key={i} class="tick-cell">
<span
class={`tick ${e.status}`}
title={`${formatTick(e.ts)} · ${e.status}${e.ip ? ` · ${e.ip}` : ''}`}
/>
{mark && (
<span class="tick-mark">{formatTickShort(e.ts)}</span>
)}
</span>
)
})}
</div>
)}
{hist && hist.length > 0 && (
<div class="incidents">
<div class="incidents-title">incidents</div>
{groupIncidents(ticks)
.slice(-30)
.reverse()
.map((inc, i) => (
<div key={i} class="incident-row">
<span class={`badge ${inc.status}`}>{inc.status}</span>
<span>
{formatTickShort(inc.start)}
{inc.count > 1
? ` → ${formatTickShort(inc.end)}`
: ''}
</span>
<span class="muted">
{inc.count > 1 ? `${inc.count} ticks` : ''}
</span>
</div>
))}
</div>
)}
{metricPlots && metricPlots.length > 0 && (
<div class="metric-plots">
<div class="incidents-title">metrics</div>
{metricPlots.map((p) => (
<div key={p.name} class="metric-plot">
<div class="metric-plot-head">
<span
class="metric-dot"
style={{ background: hashColor(p.name) }}
/>
<strong>{p.name}</strong>
<span
class={`metric-latest${p.yMin !== undefined && p.values[p.values.length - 1]! < p.yMin || p.yMax !== undefined && p.values[p.values.length - 1]! > p.yMax ? ' out-of-range' : ''}`}
>
{formatMetric({ name: p.name, value: p.values[p.values.length - 1]!, unit: p.unit, updated: null })}
</span>
</div>
<LinePlot
values={p.values}
times={p.times}
color={hashColor(p.name)}
unit={p.unit}
yMin={p.yMin}
yMax={p.yMax}
xMin={p.xMin}
xMax={p.xMax}
/>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
+300
View File
@@ -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<HTMLDivElement>(null)
const tipRef = useRef<HTMLDivElement>(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 (
<div class="line-plot-wrap">
<div
ref={boxRef}
class="line-plot-box"
onMouseMove={(e) => {
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)}
>
<svg
viewBox={`0 0 ${width} ${height}`}
class="line-plot"
preserveAspectRatio="none"
>
<path
d={path}
fill="none"
stroke={color}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<circle cx={x(0)} cy={y(first)} r="3" fill={color} />
<circle cx={x(values.length - 1)} cy={y(last)} r="3" fill={color} />
</svg>
{hover && (
<div
class="plot-hover-line"
style={{ left: `${(x(hover.i) / width) * 100}%` }}
/>
)}
{hover && (
<div
class="plot-hover-dot"
style={{
left: `${(x(hover.i) / width) * 100}%`,
top: `${(y(values[hover.i]!) / height) * 100}%`,
}}
/>
)}
<div class="plot-tip" ref={tipRef} style={{ visibility: hover ? 'visible' : 'hidden' }}>
{hover
? `${formatTickShort(times[hover.i]!)} · ${formatMetric({ name: '', value: values[hover.i]!, unit: unit ?? '', updated: null })}`
: ''}
</div>
</div>
<div class="plot-marks">
{marks.map((mk, i) => (
<span key={i} class="plot-mark" style={{ left: `${mk.left}%`, transform: mk.transform }}>
{mk.label}
</span>
))}
</div>
</div>
)
}
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<HTMLDivElement>(null)
const tipRef = useRef<HTMLDivElement>(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 (
<div class="line-plot-wrap">
<div
ref={boxRef}
class="line-plot-box"
onMouseMove={(e) => {
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)}
>
<svg
viewBox={`0 0 ${width} ${height}`}
class="line-plot"
preserveAspectRatio="none"
>
{paths.map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke={color}
stroke-opacity={opacity}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
))}
</svg>
{hover && (
<div class="plot-hover-line" style={{ left: `${(xForT(hover.sample.ts) / width) * 100}%` }} />
)}
{hover && (
<div
class="plot-hover-dot"
style={{
left: `${(xForT(hover.sample.ts) / width) * 100}%`,
top: `${(y(hover.sample.value) / height) * 100}%`,
}}
/>
)}
<div class="plot-tip" ref={tipRef} style={{ visibility: hover ? 'visible' : 'hidden' }}>
{hover
? `${formatTickShort(hover.sample.ts)} · ${hover.machine}: ${formatMetric({ name: hover.sample.name, value: hover.sample.value, unit: hover.sample.unit, updated: null })}`
: ''}
</div>
</div>
<div class="plot-marks">
{marks.map((mk, i) => (
<span key={i} class="plot-mark" style={{ left: `${mk.left}%`, transform: mk.transform }}>
{mk.label}
</span>
))}
</div>
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useRef, useState } from 'preact/hooks'
import type { ChipState, Status } from '../types'
export function Dot({ status }: { status: Status }) {
return <span class={`dot ${status}`} title={status} />
}
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<HTMLSpanElement>(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 (
<>
<span
ref={ref}
class={`chip ${state ?? 'unknown'}`}
title={hasValue ? undefined : `${label}: ${state}`}
onMouseEnter={show}
onMouseLeave={hide}
>
{label}
{!hasValue && <span class="chip-dot" />}
</span>
{hasValue && pos && (
<span
class="chip-pop"
style={{ left: `${pos.x}px`, top: `${pos.y}px` }}
>
{value}
</span>
)}
</>
)
}
export function CheckState({ state }: { state: ChipState }) {
return (
<span class="check-state">
<span class={`check-dot ${state}`} />
{state}
</span>
)
}
+54
View File
@@ -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%)`
}
+2 -2
View File
@@ -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(<App />, document.getElementById('app')!)
+40
View File
@@ -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
}
+7 -6
View File
@@ -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;
}
+101
View File
@@ -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<string, { min?: number; max?: number }>
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
}