From 992f09b97a1d49640264daf7e2ea5d1d7b512451 Mon Sep 17 00:00:00 2001 From: jleung Date: Fri, 23 Oct 2026 14:38:16 +0000 Subject: [PATCH] Overview tab: use generated overview types, drop dead DeviceHeader/OverviewSummary Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP --- web/src/api/client.ts | 3 + web/src/api/overviewApi.ts | 44 +--- web/src/lib/metricModel.test.ts | 31 +-- web/src/lib/metricModel.ts | 48 +--- web/src/lib/overviewModel.test.ts | 231 +++++++----------- web/src/lib/overviewModel.ts | 151 +++++------- web/src/pages/device/DeviceHeader.tsx | 127 ---------- .../pages/device/metrics/OverviewSummary.tsx | 46 ---- .../device/overview/AvailabilityPanel.tsx | 59 ++--- web/src/pages/device/overview/BarRow.tsx | 72 ++---- .../pages/device/overview/CompactPanel.tsx | 72 ++---- web/src/pages/device/overview/EventsPanel.tsx | 49 ++-- web/src/pages/device/overview/OverviewTab.tsx | 113 +++------ web/src/pages/device/overview/Panel.tsx | 42 +--- web/src/pages/device/overview/PingPanel.tsx | 41 +--- web/src/pages/device/overview/PortsPanel.tsx | 67 ++--- .../pages/device/overview/ResourcePanel.tsx | 73 ++---- web/src/pages/device/overview/SeriesGraph.tsx | 65 ++--- web/src/pages/device/overview/SystemPanel.tsx | 35 ++- 19 files changed, 371 insertions(+), 998 deletions(-) delete mode 100644 web/src/pages/device/DeviceHeader.tsx delete mode 100644 web/src/pages/device/metrics/OverviewSummary.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 957605e..f23bea4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -133,3 +133,6 @@ export type MetricDisplay = MetricGroup['display'] export type PrinterList = S['PrinterList'] export type PrinterRow = S['PrinterRow'] export type PrinterSupply = S['PrinterSupply'] +const _overviewCall = () => client.GET('/api/v1/devices/{slug}/overview', { params: { path: { slug: '' } } }) +export type DeviceOverview = NonNullable>['data']> +export type OverviewPoint = DeviceOverview['rtt'][number] diff --git a/web/src/api/overviewApi.ts b/web/src/api/overviewApi.ts index 0420d31..d9efac4 100644 --- a/web/src/api/overviewApi.ts +++ b/web/src/api/overviewApi.ts @@ -1,43 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; -import { toApiError } from "./client"; -import type { RangeKey } from "@/lib/range"; +import { useQuery } from '@tanstack/react-query' +import type { RangeKey } from '@/lib/range' +import { client, unwrap } from './client' -// TEMPORARY: hand-written until the generated schema carries GET /api/v1/devices/{slug}/overview. -export type OverviewPoint = (number | null)[]; -export interface OverviewEvent { - at: string | number; - kind: "alert" | "audit"; - severity?: string; - summary: string; -} -export interface DeviceOverview { - range: string; - step_seconds: number; - traffic: { - in: OverviewPoint[]; - out: OverviewPoint[]; - unreliable_excluded: number; - }; - ports: { total: number; up: number; down: number; admin_down: number }; - availability: Record<"24h" | "7d" | "30d" | "1y", number | null>; - rtt: OverviewPoint[]; - events: OverviewEvent[]; - partial?: boolean; -} +export type OvRangeKey = Extract /** Overview aggregates of a device (summed traffic, port counts, availability, RTT, recent events). */ -export function useOverview(slug: string, range: RangeKey) { +export function useOverview(slug: string, range: OvRangeKey) { return useQuery({ - queryKey: ["device-overview", slug, range], - queryFn: async ({ signal }): Promise => { - const res = await fetch( - `/api/v1/devices/${encodeURIComponent(slug)}/overview?range=${range}`, - { signal, credentials: "same-origin" }, - ); - if (!res.ok) throw toApiError(undefined, res); - return (await res.json()) as DeviceOverview; - }, + queryKey: ['device-overview', slug, range], + queryFn: async ({ signal }) => unwrap(await client.GET('/api/v1/devices/{slug}/overview', { params: { path: { slug }, query: { range } }, signal })), staleTime: 30_000, refetchInterval: 60_000, - }); + }) } diff --git a/web/src/lib/metricModel.test.ts b/web/src/lib/metricModel.test.ts index 7fe48e0..3b8433b 100644 --- a/web/src/lib/metricModel.test.ts +++ b/web/src/lib/metricModel.test.ts @@ -1,36 +1,9 @@ import { describe, expect, it } from 'vitest' -import type { MetricCategory, MetricComponent, MetricDisplay, MetricGroup } from '@/api/client' -import { pickSummary, radioCards, splitSsid, ssidRows } from './metricModel' +import type { MetricComponent, MetricDisplay, MetricGroup } from '@/api/client' +import { radioCards, splitSsid, ssidRows } from './metricModel' const comp = (component: string, value: number | null, max: number | null = null): MetricComponent => ({ component, value, max, percent: value != null && max ? (100 * value) / max : undefined, text: null, state: null, ts: '' }) const group = (metric: string, label: string, unit: string, display: MetricDisplay, components: MetricComponent[]): MetricGroup => ({ metric, label, unit, display, components }) -const health = (groups: MetricGroup[]): MetricCategory => ({ name: 'health', label: 'Health', groups }) - -describe('pickSummary', () => { - const cats = [ - health([ - group('hrProcessorLoad', 'CPU load', 'percent', 'gauge', [comp('CPU 0', 20), comp('CPU 1', 60)]), - group('memUsed', 'Memory', 'bytes', 'percent', [comp('Physical', 8, 16), comp('Swap', 1, 2)]), - group('hrStorageUsed', 'Storage', 'bytes', 'percent', [comp('/', 10, 100), comp('/var', 90, 100)]), - group('fanState', 'Fans', 'enum', 'state', [comp('Fan 1', 1)]), - ]), - ] - it('picks CPU, memory and storage', () => { - expect(pickSummary(cats).map((s) => s.key)).toEqual(['cpu', 'memory', 'storage']) - }) - it('uses the first component for CPU and memory, the fullest for storage', () => { - const [cpu, mem, sto] = pickSummary(cats) - expect(cpu?.component.component).toBe('CPU 0') - expect(mem?.component.component).toBe('Physical') - expect(mem?.percent).toBe(50) - expect(sto?.component.component).toBe('/var') - expect(sto?.percent).toBe(90) - }) - it('ignores state groups and other categories; a device without metrics has no summary', () => { - expect(pickSummary([{ name: 'power', label: 'Power', groups: cats[0]?.groups ?? [] }])).toEqual([]) - expect(pickSummary([])).toEqual([]) - }) -}) describe('wireless model', () => { const groups = [ diff --git a/web/src/lib/metricModel.ts b/web/src/lib/metricModel.ts index 07aa384..25a8c4b 100644 --- a/web/src/lib/metricModel.ts +++ b/web/src/lib/metricModel.ts @@ -1,48 +1,6 @@ -// Pure view-models for the device tabs: the Overview summary and the Wireless table (unit-tested in metricModel.test.ts). -import type { MetricCategory, MetricComponent, MetricGroup } from '@/api/client' -import { percentOf, wifiRole } from '@/lib/metricFormat' - -export interface SummaryItem { - key: string - label: string - group: MetricGroup - component: MetricComponent - /** 0-100 fill, or null when the value is not a fraction */ - percent: number | null -} - -const SLOTS: { key: string; label: string; re: RegExp; fullest: boolean }[] = [ - { key: 'cpu', label: 'CPU', re: /cpu|processor/i, fullest: false }, - { key: 'memory', label: 'Memory', re: /mem|ram/i, fullest: false }, - { key: 'storage', label: 'Storage', re: /stor|disk|filesystem|flash/i, fullest: true }, -] - -/** The first component with a reading, or the fullest one by percent for things that fill up (storage). */ -function pickComponent(g: MetricGroup, fullestOne: boolean): { c: MetricComponent; pct: number | null } | null { - let best: { c: MetricComponent; pct: number | null } | null = null - for (const c of g.components) { - if (c.value == null) continue - const pct = percentOf(c, g.unit) - if (!best || (fullestOne && (pct ?? -1) > (best.pct ?? -1))) best = { c, pct } - } - return best -} - -/** CPU / memory / storage for the Overview: first matching group per slot from the system and health categories. */ -export function pickSummary(cats: readonly MetricCategory[]): SummaryItem[] { - const groups = cats.filter((c) => c.name === 'system' || c.name === 'health').flatMap((c) => c.groups) - const out: SummaryItem[] = [] - for (const slot of SLOTS) { - for (const g of groups) { - if (g.display === 'state' || g.display === 'text' || !slot.re.test(`${g.metric} ${g.label}`)) continue - const f = pickComponent(g, slot.fullest) - if (!f || out.some((o) => o.group === g)) continue - out.push({ key: slot.key, label: slot.label, group: g, component: f.c, percent: f.pct }) - break - } - } - return out -} +// Pure view-models for the device tabs: the Wireless table (unit-tested in metricModel.test.ts). +import type { MetricComponent, MetricGroup } from '@/api/client' +import { wifiRole } from '@/lib/metricFormat' export interface SsidRow { key: string diff --git a/web/src/lib/overviewModel.test.ts b/web/src/lib/overviewModel.test.ts index ee86521..6ff72e2 100644 --- a/web/src/lib/overviewModel.test.ts +++ b/web/src/lib/overviewModel.test.ts @@ -1,174 +1,109 @@ -import { describe, expect, it } from "vitest"; -import type { - MetricCategory, - MetricComponent, - MetricDisplay, - MetricGroup, -} from "@/api/client"; -import { - aggregateSpec, - availabilityTone, - formatAvailability, - pickPanels, - recentEvents, -} from "./overviewModel"; +import { describe, expect, it } from 'vitest' +import type { MetricCategory, MetricComponent, MetricDisplay, MetricGroup } from '@/api/client' +import { aggregateSpec, availabilityTone, formatAvailability, pickPanels, recentEvents } from './overviewModel' -const comp = ( - component: string, - value: number | null, - max: number | null = null, - extra: Partial = {}, -): MetricComponent => ({ +const comp = (component: string, value: number | null, max: number | null = null, extra: Partial = {}): MetricComponent => ({ component, value, max, percent: value != null && max ? (100 * value) / max : undefined, text: null, state: null, - ts: "", + ts: '', ...extra, -}); -const group = ( - metric: string, - label: string, - unit: string, - display: MetricDisplay, - components: MetricComponent[], -): MetricGroup => ({ metric, label, unit, display, components }); -const cat = ( - name: MetricCategory["name"], - groups: MetricGroup[], -): MetricCategory => ({ +}) +const group = (metric: string, label: string, unit: string, display: MetricDisplay, components: MetricComponent[]): MetricGroup => ({ metric, label, unit, display, components }) +const cat = (name: MetricCategory['name'], groups: MetricGroup[]): MetricCategory => ({ name, label: name, groups, -}); +}) -describe("pickPanels", () => { +describe('pickPanels', () => { const cats = [ - cat("health", [ - group("hrProcessorLoad", "CPU load", "percent", "gauge", [ - comp("CPU 0", 20), - comp("CPU 1", 60), - ]), - group("memUsed", "Memory used", "bytes", "percent", [ - comp("Physical", 8, 16), - ]), - group("memTotal", "Memory total", "bytes", "gauge", [ - comp("Physical", 16), - ]), - group("hrStorageUsed", "Storage used", "bytes", "percent", [ - comp("/", 10, 100), - comp("/var", 90, 100), - ]), - group("loadAvg", "Load average", "count", "gauge", [comp("1m", 0.5)]), + cat('health', [ + group('hrProcessorLoad', 'CPU load', 'percent', 'gauge', [comp('CPU 0', 20), comp('CPU 1', 60)]), + group('memUsed', 'Memory used', 'bytes', 'percent', [comp('Physical', 8, 16)]), + group('memTotal', 'Memory total', 'bytes', 'gauge', [comp('Physical', 16)]), + group('hrStorageUsed', 'Storage used', 'bytes', 'percent', [comp('/', 10, 100), comp('/var', 90, 100)]), + group('loadAvg', 'Load average', 'count', 'gauge', [comp('1m', 0.5)]), ]), - cat("environment", [ - group("temp", "Temperature", "celsius", "gauge", [comp("Board", 41)]), - group("fan", "Fans", "enum", "state", [ - comp("Fan 1", 1, null, { state: "ok" }), - ]), - ]), - cat("printer", [ - group("markerSuppliesLevel", "Supply level", "percent", "percent", [ - comp("Black", 80), - comp("Cyan", 5), - ]), - ]), - ]; - const p = pickPanels(cats); - it("splits groups into panels", () => { - expect(p.cpu.map((r) => r.name)).toEqual(["CPU 0", "CPU 1"]); - expect(p.storage.map((r) => r.name)).toEqual(["/", "/var"]); - expect(p.sensors.map((r) => r.name)).toEqual(["Board", "Fan 1"]); - }); - it("keeps only memory rows that are a fraction, and sorts supplies lowest first", () => { - expect(p.memory.map((r) => r.name)).toEqual(["Physical"]); - expect(p.memory[0]?.pct).toBe(50); - expect(p.supplies.map((r) => r.name)).toEqual(["Cyan", "Black"]); - }); - it("hides panels the device has no data for", () => { - const only = pickPanels([ - cat("health", [ - group("memUsed", "Memory used", "bytes", "percent", [comp("", 1, 4)]), - ]), - ]); - expect(only.cpu).toEqual([]); - expect(only.storage).toEqual([]); - expect(only.supplies).toEqual([]); - expect(only.memory).toHaveLength(1); - expect(only.memory[0]?.name).toBe("Memory used"); + cat('environment', [group('temp', 'Temperature', 'celsius', 'gauge', [comp('Board', 41)]), group('fan', 'Fans', 'enum', 'state', [comp('Fan 1', 1, null, { state: 'ok' })])]), + cat('printer', [group('markerSuppliesLevel', 'Supply level', 'percent', 'percent', [comp('Black', 80), comp('Cyan', 5)])]), + ] + const p = pickPanels(cats) + it('splits groups into panels', () => { + expect(p.cpu.map((r) => r.name)).toEqual(['CPU 0', 'CPU 1']) + expect(p.storage.map((r) => r.name)).toEqual(['/', '/var']) + expect(p.sensors.map((r) => r.name)).toEqual(['Board', 'Fan 1']) + }) + it('keeps only memory rows that are a fraction, and sorts supplies lowest first', () => { + expect(p.memory.map((r) => r.name)).toEqual(['Physical']) + expect(p.memory[0]?.pct).toBe(50) + expect(p.supplies.map((r) => r.name)).toEqual(['Cyan', 'Black']) + }) + it('hides panels the device has no data for', () => { + const only = pickPanels([cat('health', [group('memUsed', 'Memory used', 'bytes', 'percent', [comp('', 1, 4)])])]) + expect(only.cpu).toEqual([]) + expect(only.storage).toEqual([]) + expect(only.supplies).toEqual([]) + expect(only.memory).toHaveLength(1) + expect(only.memory[0]?.name).toBe('Memory used') expect(pickPanels([])).toEqual({ cpu: [], memory: [], storage: [], supplies: [], sensors: [], - }); - }); -}); + }) + }) +}) -describe("aggregateSpec", () => { - it("aggregates several components of a group; percent averages, bytes sum", () => { - const cpu = pickPanels([ - cat("health", [ - group("hrProcessorLoad", "CPU load", "percent", "gauge", [ - comp("a", 1), - comp("b", 2), - ]), - ]), - ]).cpu; +describe('aggregateSpec', () => { + it('aggregates several components of a group; percent averages, bytes sum', () => { + const cpu = pickPanels([cat('health', [group('hrProcessorLoad', 'CPU load', 'percent', 'gauge', [comp('a', 1), comp('b', 2)])])]).cpu expect(aggregateSpec(cpu)).toMatchObject({ - metric: "hrProcessorLoad", - component: "", - aggregate: "avg", - }); - const mem = pickPanels([ - cat("health", [ - group("memUsed", "Memory", "bytes", "percent", [ - comp("a", 1, 4), - comp("b", 2, 4), - ]), - ]), - ]).memory; - expect(aggregateSpec(mem)?.aggregate).toBe("sum"); - }); - it("uses the component itself when there is one, and nothing when empty", () => { - const cpu = pickPanels([ - cat("health", [group("cpu", "CPU", "percent", "gauge", [comp("", 7)])]), - ]).cpu; + metric: 'hrProcessorLoad', + component: '', + aggregate: 'avg', + }) + const mem = pickPanels([cat('health', [group('memUsed', 'Memory', 'bytes', 'percent', [comp('a', 1, 4), comp('b', 2, 4)])])]).memory + expect(aggregateSpec(mem)?.aggregate).toBe('sum') + }) + it('uses the component itself when there is one, and nothing when empty', () => { + const cpu = pickPanels([cat('health', [group('cpu', 'CPU', 'percent', 'gauge', [comp('', 7)])])]).cpu expect(aggregateSpec(cpu)).toEqual({ - metric: "cpu", - component: "", - label: "CPU", - }); - expect(aggregateSpec([])).toBeNull(); - }); -}); + metric: 'cpu', + component: '', + label: 'CPU', + }) + expect(aggregateSpec([])).toBeNull() + }) +}) -describe("availability", () => { - it("colours by threshold", () => { - expect(availabilityTone(100)).toBe("ok"); - expect(availabilityTone(99.95)).toBe("ok"); - expect(availabilityTone(99.5)).toBe("warn"); - expect(availabilityTone(98.9)).toBe("crit"); - expect(availabilityTone(null)).toBe("none"); - }); - it("formats", () => { - expect(formatAvailability(100)).toBe("100%"); - expect(formatAvailability(99.123)).toBe("99.12%"); - expect(formatAvailability(87.46)).toBe("87.5%"); - expect(formatAvailability(null)).toBe("—"); - }); -}); +describe('availability', () => { + it('colours by threshold', () => { + expect(availabilityTone(100)).toBe('ok') + expect(availabilityTone(99.95)).toBe('ok') + expect(availabilityTone(99.5)).toBe('warn') + expect(availabilityTone(98.9)).toBe('crit') + expect(availabilityTone(null)).toBe('none') + }) + it('formats', () => { + expect(formatAvailability(100)).toBe('100%') + expect(formatAvailability(99.123)).toBe('99.12%') + expect(formatAvailability(87.46)).toBe('87.5%') + expect(formatAvailability(null)).toBe('—') + }) +}) -describe("recentEvents", () => { - it("sorts newest first and caps", () => { +describe('recentEvents', () => { + it('sorts newest first and caps', () => { const ev = [ - { at: "2026-01-01T00:00:00Z", kind: "alert" as const, summary: "a" }, - { at: "2026-01-03T00:00:00Z", kind: "audit" as const, summary: "b" }, - { at: "2026-01-02T00:00:00Z", kind: "alert" as const, summary: "c" }, - ]; - expect(recentEvents(ev, 2).map((e) => e.summary)).toEqual(["b", "c"]); - }); -}); + { at: '2026-01-01T00:00:00Z', kind: 'alert' as const, summary: 'a' }, + { at: '2026-01-03T00:00:00Z', kind: 'audit' as const, summary: 'b' }, + { at: '2026-01-02T00:00:00Z', kind: 'alert' as const, summary: 'c' }, + ] + expect(recentEvents(ev, 2).map((e) => e.summary)).toEqual(['b', 'c']) + }) +}) diff --git a/web/src/lib/overviewModel.ts b/web/src/lib/overviewModel.ts index b3f7a67..642858d 100644 --- a/web/src/lib/overviewModel.ts +++ b/web/src/lib/overviewModel.ts @@ -1,35 +1,31 @@ // Pure view-models for the device Overview dashboard (unit-tested in overviewModel.test.ts). -import type { - MetricCategory, - MetricComponent, - MetricGroup, -} from "@/api/client"; -import type { Aggregate } from "@/api/metricsApi"; -import { hasReading, percentOf } from "@/lib/metricFormat"; +import type { MetricCategory, MetricComponent, MetricGroup } from '@/api/client' +import type { Aggregate } from '@/api/metricsApi' +import { hasReading, percentOf } from '@/lib/metricFormat' export interface MetricRow { - group: MetricGroup; - comp: MetricComponent; + group: MetricGroup + comp: MetricComponent /** 0-100 fill, or null when the reading is not a fraction */ - pct: number | null; + pct: number | null /** row label: the component, or the group label for a device-wide scalar */ - name: string; + name: string } export interface OverviewPanels { - cpu: MetricRow[]; - memory: MetricRow[]; - storage: MetricRow[]; - supplies: MetricRow[]; - sensors: MetricRow[]; + cpu: MetricRow[] + memory: MetricRow[] + storage: MetricRow[] + supplies: MetricRow[] + sensors: MetricRow[] } -const CPU = /cpu|processor/i; -const MEM = /mem|ram/i; -const STORAGE = /stor|disk|filesystem|flash/i; -const NOT_LOAD = /temp|average|freq|count/i; +const CPU = /cpu|processor/i +const MEM = /mem|ram/i +const STORAGE = /stor|disk|filesystem|flash/i +const NOT_LOAD = /temp|average|freq|count/i -type Panel = keyof OverviewPanels; +type Panel = keyof OverviewPanels function rowsOf(g: MetricGroup): MetricRow[] { return g.components.filter(hasReading).map((c) => ({ @@ -37,27 +33,25 @@ function rowsOf(g: MetricGroup): MetricRow[] { comp: c, pct: percentOf(c, g.unit), name: c.component || g.label, - })); + })) } /** Which panel a metric group feeds, or null. Storage is tested before memory ("Storage used" would match "mem"-less anyway, but flash/ram names can overlap). */ export function panelOf(catName: string, g: MetricGroup): Panel | null { - const text = `${g.metric} ${g.label}`; - if (catName === "printer") - return /supplies.*level/i.test(g.metric) ? "supplies" : null; - if (catName === "environment") return "sensors"; - if (catName !== "health" && catName !== "system") return null; - if (g.display === "text") return null; - if (g.display === "state") - return /fan|power|psu|temp/i.test(text) ? "sensors" : null; - if (STORAGE.test(text)) return "storage"; - if (MEM.test(text)) return "memory"; - if (CPU.test(text) && !NOT_LOAD.test(text)) return "cpu"; - return null; + const text = `${g.metric} ${g.label}` + if (catName === 'printer') return /supplies.*level/i.test(g.metric) ? 'supplies' : null + if (catName === 'environment') return 'sensors' + if (catName !== 'health' && catName !== 'system') return null + if (g.display === 'text') return null + if (g.display === 'state') return /fan|power|psu|temp/i.test(text) ? 'sensors' : null + if (STORAGE.test(text)) return 'storage' + if (MEM.test(text)) return 'memory' + if (CPU.test(text) && !NOT_LOAD.test(text)) return 'cpu' + return null } /** Rows that can be drawn as a bar: storage and memory need a fraction, so rows without one (a "total" or "free" gauge) drop out. */ -const needsFraction = (p: Panel) => p === "storage" || p === "memory"; +const needsFraction = (p: Panel) => p === 'storage' || p === 'memory' /** Split a device's metric categories into the Overview's panels; a panel with no rows is empty and the view hides it. */ export function pickPanels(cats: readonly MetricCategory[]): OverviewPanels { @@ -67,27 +61,23 @@ export function pickPanels(cats: readonly MetricCategory[]): OverviewPanels { storage: [], supplies: [], sensors: [], - }; + } for (const cat of cats) { for (const g of cat.groups) { - const p = panelOf(cat.name, g); - if (!p) continue; - out[p].push( - ...rowsOf(g).filter((r) => !needsFraction(p) || r.pct != null), - ); + const p = panelOf(cat.name, g) + if (!p) continue + out[p].push(...rowsOf(g).filter((r) => !needsFraction(p) || r.pct != null)) } } - out.supplies.sort( - (a, b) => (a.pct ?? 101) - (b.pct ?? 101) || a.name.localeCompare(b.name), - ); - return out; + out.supplies.sort((a, b) => (a.pct ?? 101) - (b.pct ?? 101) || a.name.localeCompare(b.name)) + return out } export interface GraphSpec { - metric: string; - component: string; - aggregate?: Aggregate; - label: string; + metric: string + component: string + aggregate?: Aggregate + label: string } /** @@ -95,65 +85,54 @@ export interface GraphSpec { * (percent averages, bytes and counts add up), or the single component itself. */ export function aggregateSpec(rows: readonly MetricRow[]): GraphSpec | null { - const first = rows[0]; - if (!first) return null; - const g = first.group; - const n = rows.filter((r) => r.group === g).length; + const first = rows[0] + if (!first) return null + const g = first.group + const n = rows.filter((r) => r.group === g).length if (n <= 1) return { metric: g.metric, component: first.comp.component, label: first.name, - }; - const aggregate: Aggregate = - g.unit === "percent" - ? "avg" - : g.unit === "bytes" || g.unit === "count" - ? "sum" - : "max"; - return { metric: g.metric, component: "", aggregate, label: g.label }; + } + const aggregate: Aggregate = g.unit === 'percent' ? 'avg' : g.unit === 'bytes' || g.unit === 'count' ? 'sum' : 'max' + return { metric: g.metric, component: '', aggregate, label: g.label } } -export type AvailTone = "ok" | "warn" | "crit" | "none"; +export type AvailTone = 'ok' | 'warn' | 'crit' | 'none' /** Availability colour: >= 99.9 healthy, >= 99 degraded, below that bad; null (no history) is neutral. */ export function availabilityTone(pct: number | null | undefined): AvailTone { - if (pct == null || !Number.isFinite(pct)) return "none"; - if (pct >= 99.9) return "ok"; - if (pct >= 99) return "warn"; - return "crit"; + if (pct == null || !Number.isFinite(pct)) return 'none' + if (pct >= 99.9) return 'ok' + if (pct >= 99) return 'warn' + return 'crit' } export function formatAvailability(pct: number | null | undefined): string { - if (pct == null || !Number.isFinite(pct)) return "—"; - return pct >= 100 ? "100%" : `${pct.toFixed(pct >= 99 ? 2 : 1)}%`; + if (pct == null || !Number.isFinite(pct)) return '—' + return pct >= 100 ? '100%' : `${pct.toFixed(pct >= 99 ? 2 : 1)}%` } export interface EventLike { - at: string | number; - kind: "alert" | "audit"; - summary: string; + at: string | number + kind: 'alert' | 'audit' + summary: string } /** Event time in unix seconds (the API sends RFC 3339; a bare number is taken as seconds). */ -export const eventSeconds = (at: string | number): number => - typeof at === "number" ? at : Date.parse(at) / 1000; +export const eventSeconds = (at: string | number): number => (typeof at === 'number' ? at : Date.parse(at) / 1000) /** Newest first, capped. */ -export function recentEvents( - events: readonly T[], - limit = 10, -): T[] { - return [...events] - .sort((a, b) => eventSeconds(b.at) - eventSeconds(a.at)) - .slice(0, limit); +export function recentEvents(events: readonly T[], limit = 10): T[] { + return [...events].sort((a, b) => eventSeconds(b.at) - eventSeconds(a.at)).slice(0, limit) } /** The tab a panel's title links to. */ export const TAB_OF: Record = { - cpu: "health", - memory: "health", - storage: "health", - supplies: "printer", - sensors: "environment", -}; + cpu: 'health', + memory: 'health', + storage: 'health', + supplies: 'printer', + sensors: 'environment', +} diff --git a/web/src/pages/device/DeviceHeader.tsx b/web/src/pages/device/DeviceHeader.tsx deleted file mode 100644 index 4b8ee58..0000000 --- a/web/src/pages/device/DeviceHeader.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import type { ReactNode } from 'react' -import { Link } from '@tanstack/react-router' -import type { DeviceView } from '@/api/client' -import { PollBadge, StatusDot } from '@/components/Status' -import { WarningBadge } from '@/components/Warnings' -import { Badge } from '@/components/ui/badge' -import { Card } from '@/components/ui/card' -import { formatAgo, formatDuration } from '@/lib/format' -import { cn } from '@/lib/utils' - -function Field({ label, children, wide }: { label: string; children: ReactNode; wide?: boolean }) { - return ( -
-
{label}
-
{children}
-
- ) -} - -const None = () => none - -function Parents({ d }: { d: DeviceView }) { - if (!d.parents.length) return - return ( - - {d.parents.map((p) => ( - - {p.name} - - ))} - - ) -} - -function Groups({ d }: { d: DeviceView }) { - if (!d.groups.length) return - return ( - - {d.groups.map((g) => ( - - {g.name} - - ))} - - ) -} - -function Identity({ d }: { d: DeviceView }) { - return ( - <> - - {d.mgmt_ip || '—'} - {d.addresses.length > 1 && +{d.addresses.length - 1}} - - - {d.profile} - {d.profile_pinned && ( - - pinned - - )} - {d.snmp_version && · SNMP {d.snmp_version}} - - {d.profile === 'generic' && d.profile_match?.generic_reason && ( -

- generic: {d.profile_match.generic_reason}. An admin can choose a profile with Edit. -

- )} - - {d.site || '—'} / {d.role || '—'} - - - {d.kind} - {!d.enabled && ( - - disabled - - )} - - - {d.sys_descr || '—'} - - - {d.sys_object_id || '—'} - {d.hw_serial && · {d.hw_serial}} - - - ) -} - -export function DeviceHeader({ d }: { d: DeviceView }) { - const now = Date.now() - return ( - -
- - - - - - - - {formatDuration(d.uptime_seconds)} - - - {formatAgo(d.last_seen, now)} - - - - - - - - - {d.warnings.length > 0 && ( - - - {d.warnings.map((w) => ( - i.unreliable).length : undefined} /> - ))} - - - )} -
-
- ) -} diff --git a/web/src/pages/device/metrics/OverviewSummary.tsx b/web/src/pages/device/metrics/OverviewSummary.tsx deleted file mode 100644 index 34e1b80..0000000 --- a/web/src/pages/device/metrics/OverviewSummary.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Cpu, HardDrive, MemoryStick } from 'lucide-react' -import type { MetricCategory } from '@/api/client' -import { Card, CardHeader, CardTitle } from '@/components/ui/card' -import { formatMetricValue } from '@/lib/metricFormat' -import { pickSummary, type SummaryItem } from '@/lib/metricModel' -import { Bar } from './Renderers' - -const ICON = { cpu: Cpu, memory: MemoryStick, storage: HardDrive } as const - -function Tile({ s }: { s: SummaryItem }) { - const Icon = ICON[s.key as keyof typeof ICON] - const { group: g, component: c } = s - const hasMax = c.max != null && c.max > 0 - return ( -
-
- - {s.label} - {c.component && g.components.length > 1 && · {c.component}} -
-
- {s.percent != null && !hasMax ? `${s.percent.toFixed(s.percent >= 10 ? 0 : 1)}%` : formatMetricValue(g.unit, c.value)} - {hasMax && of {formatMetricValue(g.unit, c.max)}} -
- {s.percent != null && } -
- ) -} - -/** Compact CPU / memory / storage summary for the Overview tab, from whichever health metrics match. */ -export function OverviewSummary({ cats }: { cats: readonly MetricCategory[] }) { - const items = pickSummary(cats) - if (!items.length) return null - return ( - - - Resources - -
- {items.map((s) => ( - - ))} -
-
- ) -} diff --git a/web/src/pages/device/overview/AvailabilityPanel.tsx b/web/src/pages/device/overview/AvailabilityPanel.tsx index 7a65d02..78631a5 100644 --- a/web/src/pages/device/overview/AvailabilityPanel.tsx +++ b/web/src/pages/device/overview/AvailabilityPanel.tsx @@ -1,58 +1,37 @@ -import type { DeviceOverview } from "@/api/overviewApi"; -import { - availabilityTone, - formatAvailability, - type AvailTone, -} from "@/lib/overviewModel"; -import { Panel, Skeleton } from "./Panel"; +import type { DeviceOverview } from '@/api/client' +import { availabilityTone, formatAvailability, type AvailTone } from '@/lib/overviewModel' +import { Panel, Skeleton } from './Panel' const TONE: Record = { - ok: "text-up", - warn: "text-warn", - crit: "text-down", - none: "text-muted-foreground", -}; -const PERIODS = ["24h", "7d", "30d", "1y"] as const; + ok: 'text-up', + warn: 'text-warn', + crit: 'text-down', + none: 'text-muted-foreground', +} +const PERIODS = ['24h', '7d', '30d', '1y'] as const -export function AvailabilityPanel({ - slug, - ov, - loading, -}: { - slug: string; - ov: DeviceOverview | undefined; - loading: boolean; -}) { +export function AvailabilityPanel({ slug, ov, loading }: { slug: string; ov: DeviceOverview | undefined; loading: boolean }) { if (loading) return ( - ); - if (!ov) return null; + ) + if (!ov) return null return (
{PERIODS.map((p) => { - const v = ov.availability[p]; - const tone = availabilityTone(v); + const v = ov.availability[p] + const tone = availabilityTone(v) return ( -
-
- {formatAvailability(v)} -
-
- {p} -
+
+
{formatAvailability(v)}
+
{p}
- ); + ) })}
- ); + ) } diff --git a/web/src/pages/device/overview/BarRow.tsx b/web/src/pages/device/overview/BarRow.tsx index d450c4b..ed68622 100644 --- a/web/src/pages/device/overview/BarRow.tsx +++ b/web/src/pages/device/overview/BarRow.tsx @@ -1,69 +1,29 @@ -import type { ReactNode } from "react"; -import { Link } from "@tanstack/react-router"; -import { - fillTone, - formatMetricValue, - higherIsBetter, -} from "@/lib/metricFormat"; -import type { MetricRow } from "@/lib/overviewModel"; -import { Bar } from "../metrics/Renderers"; +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { fillTone, formatMetricValue, higherIsBetter } from '@/lib/metricFormat' +import type { MetricRow } from '@/lib/overviewModel' +import { Bar } from '../metrics/Renderers' /** Value text of a bar row: "used / size (pct%)" when the row has a capacity, else the formatted value. */ export function rowValue(r: MetricRow): string { - const { group: g, comp: c, pct } = r; - if (c.max != null && c.max > 0) - return `${formatMetricValue(g.unit, c.value)} / ${formatMetricValue(g.unit, c.max)}${pct != null ? ` (${pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%)` : ""}`; - return c.text ?? formatMetricValue(g.unit, c.value); + const { group: g, comp: c, pct } = r + if (c.max != null && c.max > 0) return `${formatMetricValue(g.unit, c.value)} / ${formatMetricValue(g.unit, c.max)}${pct != null ? ` (${pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%)` : ''}` + return c.text ?? formatMetricValue(g.unit, c.value) } /** One label + bar + value line; the row links to the tab holding the full metric (and its graph). */ -export function BarRow({ - slug, - tab, - r, - extra, - compact, -}: { - slug: string; - tab: string; - r: MetricRow; - extra?: ReactNode; - compact?: boolean; -}) { - const tone = - r.pct != null - ? fillTone(r.pct, higherIsBetter(`${r.group.label} ${r.name}`)) - : undefined; +export function BarRow({ slug, tab, r, extra, compact }: { slug: string; tab: string; r: MetricRow; extra?: ReactNode; compact?: boolean }) { + const tone = r.pct != null ? fillTone(r.pct, higherIsBetter(`${r.group.label} ${r.name}`)) : undefined return ( -
  • - - +
  • + + {r.name} - {r.pct != null ? ( - - ) : ( - - )} + {r.pct != null ? : } {extra} - - {rowValue(r)} - + {rowValue(r)}
  • - ); + ) } diff --git a/web/src/pages/device/overview/CompactPanel.tsx b/web/src/pages/device/overview/CompactPanel.tsx index 0d35d73..61fc67d 100644 --- a/web/src/pages/device/overview/CompactPanel.tsx +++ b/web/src/pages/device/overview/CompactPanel.tsx @@ -1,84 +1,46 @@ -import { StateBadge } from "../metrics/Renderers"; -import { BarRow } from "./BarRow"; -import { Panel } from "./Panel"; -import { TAB_OF, type MetricRow } from "@/lib/overviewModel"; -import { formatMetricValue } from "@/lib/metricFormat"; -import { Link } from "@tanstack/react-router"; +import { StateBadge } from '../metrics/Renderers' +import { BarRow } from './BarRow' +import { Panel } from './Panel' +import { TAB_OF, type MetricRow } from '@/lib/overviewModel' +import { formatMetricValue } from '@/lib/metricFormat' +import { Link } from '@tanstack/react-router' /** Printer supplies: compact level bars, lowest first. */ -export function SuppliesPanel({ - slug, - rows, -}: { - slug: string; - rows: MetricRow[]; -}) { +export function SuppliesPanel({ slug, rows }: { slug: string; rows: MetricRow[] }) { return ( - +
      {rows.map((r) => ( - + ))}
    - ); + ) } function SensorRow({ slug, r }: { slug: string; r: MetricRow }) { - const isState = r.group.display === "state"; + const isState = r.group.display === 'state' return (
  • - + {r.name} - {isState ? ( - - ) : ( - - {r.comp.text ?? formatMetricValue(r.group.unit, r.comp.value)} - - )} + {isState ? : {r.comp.text ?? formatMetricValue(r.group.unit, r.comp.value)}}
  • - ); + ) } /** Temperatures, fans, PSUs and other state sensors as compact rows. */ -export function SensorsPanel({ - slug, - rows, -}: { - slug: string; - rows: MetricRow[]; -}) { +export function SensorsPanel({ slug, rows }: { slug: string; rows: MetricRow[] }) { return (
      {rows.map((r) => ( - + ))}
    - ); + ) } diff --git a/web/src/pages/device/overview/EventsPanel.tsx b/web/src/pages/device/overview/EventsPanel.tsx index 8033a7f..310c8c9 100644 --- a/web/src/pages/device/overview/EventsPanel.tsx +++ b/web/src/pages/device/overview/EventsPanel.tsx @@ -1,26 +1,18 @@ -import { Bell, ScrollText } from "lucide-react"; -import type { DeviceOverview } from "@/api/overviewApi"; -import { formatAgo } from "@/lib/format"; -import { eventSeconds, recentEvents } from "@/lib/overviewModel"; -import { Note, Panel, Skeleton } from "./Panel"; +import { Bell, ScrollText } from 'lucide-react' +import type { DeviceOverview } from '@/api/client' +import { formatAgo } from '@/lib/format' +import { eventSeconds, recentEvents } from '@/lib/overviewModel' +import { Note, Panel, Skeleton } from './Panel' -export function EventsPanel({ - slug, - ov, - loading, -}: { - slug: string; - ov: DeviceOverview | undefined; - loading: boolean; -}) { +export function EventsPanel({ slug, ov, loading }: { slug: string; ov: DeviceOverview | undefined; loading: boolean }) { if (loading) return ( - ); - if (!ov) return null; - const events = recentEvents(ov.events); + ) + if (!ov) return null + const events = recentEvents(ov.events) return ( {events.length === 0 ? ( @@ -28,30 +20,19 @@ export function EventsPanel({ ) : (
      {events.map((e, i) => { - const Icon = e.kind === "alert" ? Bell : ScrollText; + const Icon = e.kind === 'alert' ? Bell : ScrollText return ( -
    • - +
    • + {e.summary} -
    • - ); + ) })}
    )}
    - ); + ) } diff --git a/web/src/pages/device/overview/OverviewTab.tsx b/web/src/pages/device/overview/OverviewTab.tsx index 569d656..e68653a 100644 --- a/web/src/pages/device/overview/OverviewTab.tsx +++ b/web/src/pages/device/overview/OverviewTab.tsx @@ -1,107 +1,58 @@ -import { useState } from "react"; -import type { DeviceView } from "@/api/client"; -import { useDeviceMetrics } from "@/api/metricsApi"; -import { useOverview } from "@/api/overviewApi"; -import { pickPanels, type OverviewPanels } from "@/lib/overviewModel"; -import type { RangeKey } from "@/lib/range"; -import { DeviceAlerts } from "../DeviceAlerts"; -import { DeviceExtras } from "../DeviceExtras"; -import { AvailabilityPanel } from "./AvailabilityPanel"; -import { SensorsPanel, SuppliesPanel } from "./CompactPanel"; -import { EventsPanel } from "./EventsPanel"; -import { PingPanel } from "./PingPanel"; -import { PortsPanel } from "./PortsPanel"; -import { OvRange, ResourcePanel } from "./ResourcePanel"; -import { SystemPanel } from "./SystemPanel"; +import { useState } from 'react' +import type { DeviceView } from '@/api/client' +import { useDeviceMetrics } from '@/api/metricsApi' +import { useOverview, type OvRangeKey } from '@/api/overviewApi' +import { pickPanels, type OverviewPanels } from '@/lib/overviewModel' +import type { RangeKey } from '@/lib/range' +import { DeviceAlerts } from '../DeviceAlerts' +import { DeviceExtras } from '../DeviceExtras' +import { AvailabilityPanel } from './AvailabilityPanel' +import { SensorsPanel, SuppliesPanel } from './CompactPanel' +import { EventsPanel } from './EventsPanel' +import { PingPanel } from './PingPanel' +import { PortsPanel } from './PortsPanel' +import { OvRange, ResourcePanel } from './ResourcePanel' +import { SystemPanel } from './SystemPanel' -function RightColumn({ - slug, - p, - ov, - range, -}: { - slug: string; - p: OverviewPanels; - ov: ReturnType; - range: RangeKey; -}) { +function RightColumn({ slug, p, ov, range }: { slug: string; p: OverviewPanels; ov: ReturnType; range: RangeKey }) { return (
    - {p.cpu.length > 0 && ( - - )} - {p.memory.length > 0 && ( - - )} - {p.storage.length > 0 && ( - - )} + {p.cpu.length > 0 && } + {p.memory.length > 0 && } + {p.storage.length > 0 && } {p.supplies.length > 0 && } {p.sensors.length > 0 && }
    - ); + ) } /** LibreNMS-style Overview: a two-column dashboard of panels that hide when the device has no such data. */ export function OverviewTab({ d }: { d: DeviceView }) { - const [range, setRange] = useState("24h"); - const ov = useOverview(d.slug, range); - const metrics = useDeviceMetrics(d.slug); - const p = pickPanels(metrics.data?.categories ?? []); - const slug = d.slug; + const [range, setRange] = useState('24h') + const ov = useOverview(d.slug, range) + const metrics = useDeviceMetrics(d.slug) + const p = pickPanels(metrics.data?.categories ?? []) + const slug = d.slug return (
    - - Graphs show the last {range}. - + Graphs show the last {range}.
    - {ov.data?.partial && ( -

    - Some history is still being collected; graphs may have gaps. -

    + {!!ov.data?.partial?.length && ( +

    Could not read {ov.data.partial.join(' and ')} history just now; those graphs may be empty.

    )} {ov.error && ( -

    - Could not load the overview graphs; the metric panels below are - unaffected. +

    + Could not load the overview graphs; the metric panels below are unaffected.

    )}
    - +
    @@ -109,5 +60,5 @@ export function OverviewTab({ d }: { d: DeviceView }) {
    - ); + ) } diff --git a/web/src/pages/device/overview/Panel.tsx b/web/src/pages/device/overview/Panel.tsx index 8e297b2..784a0e0 100644 --- a/web/src/pages/device/overview/Panel.tsx +++ b/web/src/pages/device/overview/Panel.tsx @@ -1,34 +1,15 @@ -import type { ReactNode } from "react"; -import { Link } from "@tanstack/react-router"; -import { Card, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { Card, CardHeader, CardTitle } from '@/components/ui/card' /** Overview panel shell: the title links to the tab with the full detail. */ -export function Panel({ - id, - title, - tab, - slug, - actions, - children, -}: { - id: string; - title: string; - tab?: string; - slug: string; - actions?: ReactNode; - children: ReactNode; -}) { +export function Panel({ id, title, tab, slug, actions, children }: { id: string; title: string; tab?: string; slug: string; actions?: ReactNode; children: ReactNode }) { return ( {tab ? ( - + {title} ) : ( @@ -39,18 +20,13 @@ export function Panel({ {children} - ); + ) } export function Note({ children }: { children: ReactNode }) { - return

    {children}

    ; + return

    {children}

    } -export function Skeleton({ h = "h-32" }: { h?: string }) { - return ( -
    - ); +export function Skeleton({ h = 'h-32' }: { h?: string }) { + return
    } diff --git a/web/src/pages/device/overview/PingPanel.tsx b/web/src/pages/device/overview/PingPanel.tsx index bdf7e4d..e0a5110 100644 --- a/web/src/pages/device/overview/PingPanel.tsx +++ b/web/src/pages/device/overview/PingPanel.tsx @@ -1,39 +1,24 @@ -import type { DeviceOverview } from "@/api/overviewApi"; -import type { ValueFormat } from "@/components/graphTypes"; -import type { RangeKey } from "@/lib/range"; -import { Panel } from "./Panel"; -import { SeriesGraph } from "./SeriesGraph"; +import type { DeviceOverview } from '@/api/client' +import type { ValueFormat } from '@/components/graphTypes' +import type { RangeKey } from '@/lib/range' +import { Panel } from './Panel' +import { SeriesGraph } from './SeriesGraph' -const ms = (s: number) => - s * 1000 >= 100 ? (s * 1000).toFixed(0) : (s * 1000).toFixed(1); +const ms = (s: number) => (s * 1000 >= 100 ? (s * 1000).toFixed(0) : (s * 1000).toFixed(1)) const RTT: ValueFormat = { - name: "RTT", - value: (v) => (v == null ? "—" : `${ms(v)} ms`), + name: 'RTT', + value: (v) => (v == null ? '—' : `${ms(v)} ms`), axis: (v) => `${ms(v)} ms`, -}; +} /** ICMP round-trip time; hidden when the device has no RTT history. */ -export function PingPanel({ - slug, - ov, - range, -}: { - slug: string; - ov: DeviceOverview | undefined; - range: RangeKey; -}) { - if (!ov || ov.rtt.every((p) => p[1] == null)) return null; +export function PingPanel({ slug, ov, range }: { slug: string; ov: DeviceOverview | undefined; range: RangeKey }) { + if (!ov || ov.rtt.every((p) => p[1] == null)) return null return (
    - +
    - ); + ) } diff --git a/web/src/pages/device/overview/PortsPanel.tsx b/web/src/pages/device/overview/PortsPanel.tsx index f61dc73..f3ed6f8 100644 --- a/web/src/pages/device/overview/PortsPanel.tsx +++ b/web/src/pages/device/overview/PortsPanel.tsx @@ -1,74 +1,51 @@ -import type { DeviceOverview } from "@/api/overviewApi"; -import type { RangeKey } from "@/lib/range"; -import { formatInt } from "@/lib/format"; -import { Note, Panel, Skeleton } from "./Panel"; -import { SeriesGraph } from "./SeriesGraph"; +import type { DeviceOverview } from '@/api/client' +import type { RangeKey } from '@/lib/range' +import { formatInt } from '@/lib/format' +import { Note, Panel, Skeleton } from './Panel' +import { SeriesGraph } from './SeriesGraph' -function Counts({ p }: { p: DeviceOverview["ports"] }) { +function Counts({ p }: { p: DeviceOverview['ports'] }) { const cell = (label: string, n: number, cls: string) => (
    {formatInt(n)}
    -
    - {label} -
    +
    {label}
    - ); + ) return ( -
    - {cell("Ports", p.total, "")} - {cell("Up", p.up, "text-up")} - {cell("Down", p.down, p.down > 0 ? "text-down" : "")} - {cell("Admin down", p.admin_down, "text-muted-foreground")} +
    + {cell('Ports', p.total, '')} + {cell('Up', p.up, 'text-up')} + {cell('Down', p.down, p.down > 0 ? 'text-down' : '')} + {cell('Admin down', p.admin_down, 'text-muted-foreground')}
    - ); + ) } -export function PortsPanel({ - slug, - ov, - loading, - range, -}: { - slug: string; - ov: DeviceOverview | undefined; - loading: boolean; - range: RangeKey; -}) { +export function PortsPanel({ slug, ov, loading, range }: { slug: string; ov: DeviceOverview | undefined; loading: boolean; range: RangeKey }) { if (loading) return ( - ); - if (!ov || ov.ports.total === 0) return null; - const tr = ov.traffic; - const hasTraffic = tr.in.length > 0 || tr.out.length > 0; + ) + if (!ov || ov.ports.total === 0) return null + const tr = ov.traffic + const hasTraffic = tr.in.length > 0 || tr.out.length > 0 return ( {hasTraffic ? (
    - +
    ) : ( No traffic history yet. )} {tr.unreliable_excluded > 0 && ( - {tr.unreliable_excluded} port{tr.unreliable_excluded === 1 ? "" : "s"}{" "} - with unreliable counters {tr.unreliable_excluded === 1 ? "is" : "are"}{" "} - left out of the total. + {tr.unreliable_excluded} port{tr.unreliable_excluded === 1 ? '' : 's'} with unreliable counters {tr.unreliable_excluded === 1 ? 'is' : 'are'} left out of the total. )}
    - ); + ) } diff --git a/web/src/pages/device/overview/ResourcePanel.tsx b/web/src/pages/device/overview/ResourcePanel.tsx index 862e926..6bd3fd1 100644 --- a/web/src/pages/device/overview/ResourcePanel.tsx +++ b/web/src/pages/device/overview/ResourcePanel.tsx @@ -1,57 +1,38 @@ -import { RangePicker } from "@/components/common"; -import { aggregateSpec, TAB_OF, type MetricRow } from "@/lib/overviewModel"; -import type { RangeKey } from "@/lib/range"; -import { MetricGraph } from "../metrics/MetricGraph"; -import { Spark } from "../metrics/Renderers"; -import { BarRow } from "./BarRow"; -import { Panel } from "./Panel"; +import { RangePicker } from '@/components/common' +import { aggregateSpec, TAB_OF, type MetricRow } from '@/lib/overviewModel' +import type { OvRangeKey } from '@/api/overviewApi' +import type { RangeKey } from '@/lib/range' +import { MetricGraph } from '../metrics/MetricGraph' +import { Spark } from '../metrics/Renderers' +import { BarRow } from './BarRow' +import { Panel } from './Panel' -export const OV_RANGES: readonly RangeKey[] = ["24h", "7d", "30d"]; +export const OV_RANGES: readonly OvRangeKey[] = ['24h', '7d', '30d'] +const isOv = (r: RangeKey): r is OvRangeKey => (OV_RANGES as readonly string[]).includes(r) -export function OvRange({ - value, - onChange, -}: { - value: RangeKey; - onChange: (r: RangeKey) => void; -}) { - return ; +export function OvRange({ value, onChange }: { value: OvRangeKey; onChange: (r: OvRangeKey) => void }) { + return isOv(r) && onChange(r)} keys={OV_RANGES} /> } interface Props { - id: "cpu" | "memory" | "storage"; - title: string; - slug: string; - rows: MetricRow[]; - range: RangeKey; - graph?: boolean; - spark?: boolean; + id: 'cpu' | 'memory' | 'storage' + title: string + slug: string + rows: MetricRow[] + range: RangeKey + graph?: boolean + spark?: boolean } /** Processors / Memory / Storage: an aggregate graph over the selected range, then one bar row per component. */ -export function ResourcePanel({ - id, - title, - slug, - rows, - range, - graph = true, - spark, -}: Props) { - const spec = graph ? aggregateSpec(rows) : null; - const tab = TAB_OF[id]; +export function ResourcePanel({ id, title, slug, rows, range, graph = true, spark }: Props) { + const spec = graph ? aggregateSpec(rows) : null + const tab = TAB_OF[id] return ( {spec && (
    - +
    )}
      @@ -64,11 +45,7 @@ export function ResourcePanel({ extra={ spark ? (
      - +
      ) : undefined } @@ -76,5 +53,5 @@ export function ResourcePanel({ ))}
    - ); + ) } diff --git a/web/src/pages/device/overview/SeriesGraph.tsx b/web/src/pages/device/overview/SeriesGraph.tsx index b3ab277..c1bbd8f 100644 --- a/web/src/pages/device/overview/SeriesGraph.tsx +++ b/web/src/pages/device/overview/SeriesGraph.tsx @@ -1,32 +1,28 @@ -import { useMemo } from "react"; -import type { GraphResponse } from "@/api/client"; -import type { OverviewPoint } from "@/api/overviewApi"; -import type { ValueFormat } from "@/components/graphTypes"; -import { TrafficGraph } from "@/components/TrafficGraph"; -import { RANGE_SECONDS, windowFor, type RangeKey } from "@/lib/range"; +import { useMemo } from 'react' +import type { GraphResponse } from '@/api/client' +import type { OverviewPoint } from '@/api/client' +import type { ValueFormat } from '@/components/graphTypes' +import { TrafficGraph } from '@/components/TrafficGraph' +import { RANGE_SECONDS, windowFor, type RangeKey } from '@/lib/range' const IFACE = { - id: "overview", - ifname: "overview", - ifalias: "", + id: 'overview', + ifname: 'overview', + ifalias: '', speed_bps: 0, - device: { slug: "", name: "" }, -}; + device: { slug: '', name: '' }, +} /** Wrap overview series as a TrafficGraph response; `out` empty plus a `format` gives the single-series mode. */ -export function graphOf( - step: number, - inS: readonly OverviewPoint[], - outS: readonly OverviewPoint[], -): GraphResponse { +export function graphOf(step: number, inS: readonly OverviewPoint[], outS: readonly OverviewPoint[]): GraphResponse { return { iface: IFACE, step_seconds: step, - tier: "raw", + tier: 'raw', partial: [], unreliable: false, series: { in: [...inS], out: [...outS] }, - }; + } } export function SeriesGraph({ @@ -38,28 +34,15 @@ export function SeriesGraph({ height = 160, loading, }: { - step: number; - inS: readonly OverviewPoint[]; - outS?: readonly OverviewPoint[]; - range: RangeKey; - format?: ValueFormat; - height?: number; - loading?: boolean; + step: number + inS: readonly OverviewPoint[] + outS?: readonly OverviewPoint[] + range: RangeKey + format?: ValueFormat + height?: number + loading?: boolean }) { - const data = useMemo(() => graphOf(step, inS, outS), [step, inS, outS]); - const window = useMemo( - () => windowFor(RANGE_SECONDS[range], Date.now()), - [range], - ); - return ( - - ); + const data = useMemo(() => graphOf(step, inS, outS), [step, inS, outS]) + const window = useMemo(() => windowFor(RANGE_SECONDS[range], Date.now()), [range]) + return } diff --git a/web/src/pages/device/overview/SystemPanel.tsx b/web/src/pages/device/overview/SystemPanel.tsx index 0a6713e..cce17b0 100644 --- a/web/src/pages/device/overview/SystemPanel.tsx +++ b/web/src/pages/device/overview/SystemPanel.tsx @@ -1,9 +1,9 @@ -import type { ReactNode } from "react"; -import { Link } from "@tanstack/react-router"; -import type { DeviceView } from "@/api/client"; -import { StatusDot } from "@/components/Status"; -import { formatDuration } from "@/lib/format"; -import { Panel } from "./Panel"; +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import type { DeviceView } from '@/api/client' +import { StatusDot } from '@/components/Status' +import { formatDuration } from '@/lib/format' +import { Panel } from './Panel' function Row({ label, children }: { label: string; children: ReactNode }) { return ( @@ -11,7 +11,7 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
    {label}
    {children}
    - ); + ) } export function SystemPanel({ d }: { d: DeviceView }) { @@ -21,34 +21,29 @@ export function SystemPanel({ d }: { d: DeviceView }) { - {d.sys_descr || "—"} + {d.sys_descr || '—'} {d.profile} - {d.hw_serial || "—"} + {d.hw_serial || '—'} {formatDuration(d.uptime_seconds)} - {d.mgmt_ip || "—"} + {d.mgmt_ip || '—'} - {d.site || "—"} / {d.role || "—"} + {d.site || '—'} / {d.role || '—'} {d.parents.length === 0 - ? "none" + ? 'none' : d.parents.map((p, i) => ( - {i > 0 && ", "} - + {i > 0 && ', '} + {p.name} @@ -56,5 +51,5 @@ export function SystemPanel({ d }: { d: DeviceView }) { - ); + ) }