Overview tab: use generated overview types, drop dead DeviceHeader/OverviewSummary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
This commit is contained in:
2026-10-23 14:38:16 +00:00
parent d92c6e1e08
commit 992f09b97a
19 changed files with 371 additions and 998 deletions

View File

@@ -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<Awaited<ReturnType<typeof _overviewCall>>['data']>
export type OverviewPoint = DeviceOverview['rtt'][number]

View File

@@ -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<RangeKey, '24h' | '7d' | '30d'>
/** 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<DeviceOverview> => {
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,
});
})
}

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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> = {},
): MetricComponent => ({
const comp = (component: string, value: number | null, max: number | null = null, extra: Partial<MetricComponent> = {}): 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'])
})
})

View File

@@ -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<T extends EventLike>(
events: readonly T[],
limit = 10,
): T[] {
return [...events]
.sort((a, b) => eventSeconds(b.at) - eventSeconds(a.at))
.slice(0, limit);
export function recentEvents<T extends EventLike>(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<Panel, string> = {
cpu: "health",
memory: "health",
storage: "health",
supplies: "printer",
sensors: "environment",
};
cpu: 'health',
memory: 'health',
storage: 'health',
supplies: 'printer',
sensors: 'environment',
}

View File

@@ -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 (
<div className={cn('min-w-0', wide && 'sm:col-span-2')}>
<div className="mb-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div>{children}</div>
</div>
)
}
const None = () => <span className="text-muted-foreground">none</span>
function Parents({ d }: { d: DeviceView }) {
if (!d.parents.length) return <None />
return (
<span className="flex flex-wrap gap-1.5">
{d.parents.map((p) => (
<Link key={p.slug} to="/devices/$slug" params={{ slug: p.slug }} search={{}} className="text-primary hover:underline">
{p.name}
</Link>
))}
</span>
)
}
function Groups({ d }: { d: DeviceView }) {
if (!d.groups.length) return <None />
return (
<span className="flex flex-wrap gap-1.5">
{d.groups.map((g) => (
<Badge key={g.id} variant="outline" title={g.kind}>
{g.name}
</Badge>
))}
</span>
)
}
function Identity({ d }: { d: DeviceView }) {
return (
<>
<Field label="Mgmt IP">
<span className="mono">{d.mgmt_ip || '—'}</span>
{d.addresses.length > 1 && <span className="mono text-muted-foreground"> +{d.addresses.length - 1}</span>}
</Field>
<Field label="Profile">
<span className="mono">{d.profile}</span>
{d.profile_pinned && (
<Badge variant="outline" className="ml-2" title="An operator chose this profile; discovery keeps it">
pinned
</Badge>
)}
{d.snmp_version && <span className="text-muted-foreground"> · SNMP {d.snmp_version}</span>}
</Field>
{d.profile === 'generic' && d.profile_match?.generic_reason && (
<p className="text-xs text-muted-foreground sm:col-span-2" data-testid="generic-hint">
generic: {d.profile_match.generic_reason}. An admin can choose a profile with Edit.
</p>
)}
<Field label="Site / role">
{d.site || '—'} <span className="text-muted-foreground">/</span> {d.role || '—'}
</Field>
<Field label="Kind">
{d.kind}
{!d.enabled && (
<Badge variant="outline" className="ml-2">
disabled
</Badge>
)}
</Field>
<Field label="System description" wide>
<span className="break-words">{d.sys_descr || '—'}</span>
</Field>
<Field label="sysObjectID / serial">
<span className="mono break-all">{d.sys_object_id || '—'}</span>
{d.hw_serial && <span className="mono text-muted-foreground"> · {d.hw_serial}</span>}
</Field>
</>
)
}
export function DeviceHeader({ d }: { d: DeviceView }) {
const now = Date.now()
return (
<Card data-testid="device-header">
<div className="grid gap-x-8 gap-y-3 p-4 text-xs sm:grid-cols-2 xl:grid-cols-4">
<Field label="Reachability">
<StatusDot status={d.reachability} withLabel />
</Field>
<Field label="Poll state">
<PollBadge state={d.poll_state} />
</Field>
<Field label="Uptime">
<span className="num">{formatDuration(d.uptime_seconds)}</span>
</Field>
<Field label="Last seen">
<span title={d.last_seen ?? ''}>{formatAgo(d.last_seen, now)}</span>
</Field>
<Identity d={d} />
<Field label="Parents">
<Parents d={d} />
</Field>
<Field label="Groups" wide>
<Groups d={d} />
</Field>
{d.warnings.length > 0 && (
<Field label="Warnings" wide>
<span className="flex flex-wrap gap-1.5" data-testid="device-warnings">
{d.warnings.map((w) => (
<WarningBadge key={w} code={w} count={w === 'counter_32bit_fast_link' ? d.interfaces.filter((i) => i.unreliable).length : undefined} />
))}
</span>
</Field>
)}
</div>
</Card>
)
}

View File

@@ -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 (
<div className="space-y-1.5" data-testid="summary-tile" data-key={s.key}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Icon className="size-3.5" aria-hidden />
{s.label}
{c.component && g.components.length > 1 && <span className="mono truncate">· {c.component}</span>}
</div>
<div className="num text-lg font-semibold">
{s.percent != null && !hasMax ? `${s.percent.toFixed(s.percent >= 10 ? 0 : 1)}%` : formatMetricValue(g.unit, c.value)}
{hasMax && <span className="ml-1.5 text-xs font-normal text-muted-foreground">of {formatMetricValue(g.unit, c.max)}</span>}
</div>
{s.percent != null && <Bar pct={s.percent} label={s.label} />}
</div>
)
}
/** 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 (
<Card data-testid="overview-summary">
<CardHeader>
<CardTitle>Resources</CardTitle>
</CardHeader>
<div className="grid gap-x-8 gap-y-4 p-4 sm:grid-cols-3">
{items.map((s) => (
<Tile key={s.key} s={s} />
))}
</div>
</Card>
)
}

View File

@@ -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<AvailTone, string> = {
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 (
<Panel id="availability" title="Availability" slug={slug}>
<Skeleton h="h-12" />
</Panel>
);
if (!ov) return null;
)
if (!ov) return null
return (
<Panel id="availability" title="Availability" slug={slug}>
<div className="grid grid-cols-4 gap-2 px-4 py-3">
{PERIODS.map((p) => {
const v = ov.availability[p];
const tone = availabilityTone(v);
const v = ov.availability[p]
const tone = availabilityTone(v)
return (
<div
key={p}
className="text-center"
data-testid={`avail-${p}`}
data-tone={tone}
>
<div className={`num text-base font-semibold ${TONE[tone]}`}>
{formatAvailability(v)}
</div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
{p}
</div>
<div key={p} className="text-center" data-testid={`avail-${p}`} data-tone={tone}>
<div className={`num text-base font-semibold ${TONE[tone]}`}>{formatAvailability(v)}</div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{p}</div>
</div>
);
)
})}
</div>
</Panel>
);
)
}

View File

@@ -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 (
<li
data-testid="ov-row"
data-metric={r.group.metric}
data-component={r.comp.component}
>
<Link
to="/devices/$slug"
params={{ slug }}
search={{ tab }}
className="flex items-center gap-3 px-4 py-1.5 text-xs hover:bg-accent/40"
>
<span
className={
compact ? "w-32 shrink-0 truncate" : "w-40 shrink-0 truncate"
}
title={r.name}
>
<li data-testid="ov-row" data-metric={r.group.metric} data-component={r.comp.component}>
<Link to="/devices/$slug" params={{ slug }} search={{ tab }} className="flex items-center gap-3 px-4 py-1.5 text-xs hover:bg-accent/40">
<span className={compact ? 'w-32 shrink-0 truncate' : 'w-40 shrink-0 truncate'} title={r.name}>
{r.name}
</span>
{r.pct != null ? (
<Bar pct={r.pct} tone={tone} label={r.name} />
) : (
<span className="flex-1" />
)}
{r.pct != null ? <Bar pct={r.pct} tone={tone} label={r.name} /> : <span className="flex-1" />}
{extra}
<span className="num shrink-0 text-right whitespace-nowrap">
{rowValue(r)}
</span>
<span className="num shrink-0 text-right whitespace-nowrap">{rowValue(r)}</span>
</Link>
</li>
);
)
}

View File

@@ -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 (
<Panel
id="supplies"
title="Printer supplies"
tab={TAB_OF.supplies}
slug={slug}
>
<Panel id="supplies" title="Printer supplies" tab={TAB_OF.supplies} slug={slug}>
<ul className="divide-y divide-border">
{rows.map((r) => (
<BarRow
key={`${r.group.metric}/${r.comp.component}`}
slug={slug}
tab={TAB_OF.supplies}
r={r}
compact
/>
<BarRow key={`${r.group.metric}/${r.comp.component}`} slug={slug} tab={TAB_OF.supplies} r={r} compact />
))}
</ul>
</Panel>
);
)
}
function SensorRow({ slug, r }: { slug: string; r: MetricRow }) {
const isState = r.group.display === "state";
const isState = r.group.display === 'state'
return (
<li data-testid="ov-row" data-metric={r.group.metric}>
<Link
to="/devices/$slug"
params={{ slug }}
search={{ tab: TAB_OF.sensors }}
className="flex items-center justify-between gap-3 px-4 py-1.5 text-xs hover:bg-accent/40"
>
<Link to="/devices/$slug" params={{ slug }} search={{ tab: TAB_OF.sensors }} className="flex items-center justify-between gap-3 px-4 py-1.5 text-xs hover:bg-accent/40">
<span className="truncate" title={r.name}>
{r.name}
</span>
{isState ? (
<StateBadge state={r.comp.state} text={r.comp.text} />
) : (
<span className="num shrink-0">
{r.comp.text ?? formatMetricValue(r.group.unit, r.comp.value)}
</span>
)}
{isState ? <StateBadge state={r.comp.state} text={r.comp.text} /> : <span className="num shrink-0">{r.comp.text ?? formatMetricValue(r.group.unit, r.comp.value)}</span>}
</Link>
</li>
);
)
}
/** 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 (
<Panel id="sensors" title="Sensors" tab={TAB_OF.sensors} slug={slug}>
<ul className="divide-y divide-border">
{rows.map((r) => (
<SensorRow
key={`${r.group.metric}/${r.comp.component}`}
slug={slug}
r={r}
/>
<SensorRow key={`${r.group.metric}/${r.comp.component}`} slug={slug} r={r} />
))}
</ul>
</Panel>
);
)
}

View File

@@ -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 (
<Panel id="events" title="Recent events" slug={slug}>
<Skeleton h="h-24" />
</Panel>
);
if (!ov) return null;
const events = recentEvents(ov.events);
)
if (!ov) return null
const events = recentEvents(ov.events)
return (
<Panel id="events" title="Recent events" slug={slug}>
{events.length === 0 ? (
@@ -28,30 +20,19 @@ export function EventsPanel({
) : (
<ul className="divide-y divide-border text-xs">
{events.map((e, i) => {
const Icon = e.kind === "alert" ? Bell : ScrollText;
const Icon = e.kind === 'alert' ? Bell : ScrollText
return (
<li
key={`${e.at}-${i}`}
className="flex items-start gap-2 px-4 py-1.5"
data-testid="ov-event"
data-kind={e.kind}
>
<Icon
className={`mt-0.5 size-3.5 shrink-0 ${e.severity === "critical" ? "text-down" : "text-muted-foreground"}`}
aria-label={e.kind}
/>
<li key={`${e.at}-${i}`} className="flex items-start gap-2 px-4 py-1.5" data-testid="ov-event" data-kind={e.kind}>
<Icon className={`mt-0.5 size-3.5 shrink-0 ${e.severity === 'critical' ? 'text-down' : 'text-muted-foreground'}`} aria-label={e.kind} />
<span className="min-w-0 flex-1 break-words">{e.summary}</span>
<time
className="shrink-0 text-muted-foreground"
dateTime={new Date(eventSeconds(e.at) * 1000).toISOString()}
>
<time className="shrink-0 text-muted-foreground" dateTime={new Date(eventSeconds(e.at) * 1000).toISOString()}>
{formatAgo(new Date(eventSeconds(e.at) * 1000).toISOString())}
</time>
</li>
);
)
})}
</ul>
)}
</Panel>
);
)
}

View File

@@ -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<typeof useOverview>;
range: RangeKey;
}) {
function RightColumn({ slug, p, ov, range }: { slug: string; p: OverviewPanels; ov: ReturnType<typeof useOverview>; range: RangeKey }) {
return (
<div className="min-w-0 space-y-4">
{p.cpu.length > 0 && (
<ResourcePanel
id="cpu"
title="Processors"
slug={slug}
rows={p.cpu}
range={range}
spark
/>
)}
{p.memory.length > 0 && (
<ResourcePanel
id="memory"
title="Memory"
slug={slug}
rows={p.memory}
range={range}
/>
)}
{p.storage.length > 0 && (
<ResourcePanel
id="storage"
title="Storage"
slug={slug}
rows={p.storage}
range={range}
graph={false}
/>
)}
{p.cpu.length > 0 && <ResourcePanel id="cpu" title="Processors" slug={slug} rows={p.cpu} range={range} spark />}
{p.memory.length > 0 && <ResourcePanel id="memory" title="Memory" slug={slug} rows={p.memory} range={range} />}
{p.storage.length > 0 && <ResourcePanel id="storage" title="Storage" slug={slug} rows={p.storage} range={range} graph={false} />}
{p.supplies.length > 0 && <SuppliesPanel slug={slug} rows={p.supplies} />}
{p.sensors.length > 0 && <SensorsPanel slug={slug} rows={p.sensors} />}
<EventsPanel slug={slug} ov={ov.data} loading={ov.isPending} />
</div>
);
)
}
/** 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<RangeKey>("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<OvRangeKey>('24h')
const ov = useOverview(d.slug, range)
const metrics = useDeviceMetrics(d.slug)
const p = pickPanels(metrics.data?.categories ?? [])
const slug = d.slug
return (
<div className="space-y-4" data-testid="overview-tab">
<DeviceAlerts d={d} />
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Graphs show the last {range}.
</span>
<span className="text-xs text-muted-foreground">Graphs show the last {range}.</span>
<OvRange value={range} onChange={setRange} />
</div>
{ov.data?.partial && (
<p className="rounded-md border border-border bg-muted/40 p-2 text-xs text-muted-foreground">
Some history is still being collected; graphs may have gaps.
</p>
{!!ov.data?.partial?.length && (
<p className="rounded-md border border-border bg-muted/40 p-2 text-xs text-muted-foreground">Could not read {ov.data.partial.join(' and ')} history just now; those graphs may be empty.</p>
)}
{ov.error && (
<p
role="alert"
className="rounded-md border border-border bg-muted/40 p-2 text-xs text-muted-foreground"
>
Could not load the overview graphs; the metric panels below are
unaffected.
<p role="alert" className="rounded-md border border-border bg-muted/40 p-2 text-xs text-muted-foreground">
Could not load the overview graphs; the metric panels below are unaffected.
</p>
)}
<div className="grid items-start gap-4 lg:grid-cols-2">
<div className="min-w-0 space-y-4">
<SystemPanel d={d} />
<PortsPanel
slug={slug}
ov={ov.data}
loading={ov.isPending}
range={range}
/>
<PortsPanel slug={slug} ov={ov.data} loading={ov.isPending} range={range} />
<AvailabilityPanel slug={slug} ov={ov.data} loading={ov.isPending} />
<PingPanel slug={slug} ov={ov.data} range={range} />
</div>
@@ -109,5 +60,5 @@ export function OverviewTab({ d }: { d: DeviceView }) {
</div>
<DeviceExtras d={d} />
</div>
);
)
}

View File

@@ -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 (
<Card data-testid={`ov-${id}`}>
<CardHeader>
<CardTitle>
{tab ? (
<Link
to="/devices/$slug"
params={{ slug }}
search={{ tab }}
className="hover:text-primary hover:underline"
>
<Link to="/devices/$slug" params={{ slug }} search={{ tab }} className="hover:text-primary hover:underline">
{title}
</Link>
) : (
@@ -39,18 +20,13 @@ export function Panel({
</CardHeader>
{children}
</Card>
);
)
}
export function Note({ children }: { children: ReactNode }) {
return <p className="px-4 py-3 text-xs text-muted-foreground">{children}</p>;
return <p className="px-4 py-3 text-xs text-muted-foreground">{children}</p>
}
export function Skeleton({ h = "h-32" }: { h?: string }) {
return (
<div
className={`m-3 animate-pulse rounded-md bg-muted/60 ${h}`}
aria-label="loading"
/>
);
export function Skeleton({ h = 'h-32' }: { h?: string }) {
return <div className={`m-3 animate-pulse rounded-md bg-muted/60 ${h}`} aria-label="loading" />
}

View File

@@ -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 (
<Panel id="ping" title="Ping" slug={slug}>
<div className="p-3">
<SeriesGraph
step={ov.step_seconds}
inS={ov.rtt}
range={range}
format={RTT}
height={130}
/>
<SeriesGraph step={ov.step_seconds} inS={ov.rtt} range={range} format={RTT} height={130} />
</div>
</Panel>
);
)
}

View File

@@ -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) => (
<div className="text-center">
<div className={`num text-lg font-semibold ${cls}`}>{formatInt(n)}</div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
</div>
);
)
return (
<div
className="grid grid-cols-4 gap-2 border-t border-border px-4 py-3"
data-testid="ov-port-counts"
>
{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")}
<div className="grid grid-cols-4 gap-2 border-t border-border px-4 py-3" data-testid="ov-port-counts">
{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')}
</div>
);
)
}
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 (
<Panel id="ports" title="Ports" tab="ports" slug={slug}>
<Skeleton />
</Panel>
);
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 (
<Panel id="ports" title="Ports" tab="ports" slug={slug}>
{hasTraffic ? (
<div className="p-3">
<SeriesGraph
step={ov.step_seconds}
inS={tr.in}
outS={tr.out}
range={range}
height={170}
/>
<SeriesGraph step={ov.step_seconds} inS={tr.in} outS={tr.out} range={range} height={170} />
</div>
) : (
<Note>No traffic history yet.</Note>
)}
{tr.unreliable_excluded > 0 && (
<Note>
{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.
</Note>
)}
<Counts p={ov.ports} />
</Panel>
);
)
}

View File

@@ -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 <RangePicker value={value} onChange={onChange} keys={OV_RANGES} />;
export function OvRange({ value, onChange }: { value: OvRangeKey; onChange: (r: OvRangeKey) => void }) {
return <RangePicker value={value} onChange={(r) => 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 (
<Panel id={id} title={title} tab={tab} slug={slug}>
{spec && (
<div className="p-3">
<MetricGraph
slug={slug}
metric={spec.metric}
component={spec.component}
aggregate={spec.aggregate}
range={range}
height={150}
/>
<MetricGraph slug={slug} metric={spec.metric} component={spec.component} aggregate={spec.aggregate} range={range} height={150} />
</div>
)}
<ul className="divide-y divide-border border-t border-border">
@@ -64,11 +45,7 @@ export function ResourcePanel({
extra={
spark ? (
<div className="w-28 shrink-0">
<Spark
slug={slug}
metric={r.group.metric}
component={r.comp.component}
/>
<Spark slug={slug} metric={r.group.metric} component={r.comp.component} />
</div>
) : undefined
}
@@ -76,5 +53,5 @@ export function ResourcePanel({
))}
</ul>
</Panel>
);
)
}

View File

@@ -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 (
<TrafficGraph
data={data}
window={window}
loading={loading}
variant="compact"
height={height}
rangeLabel={range}
format={format}
/>
);
const data = useMemo(() => graphOf(step, inS, outS), [step, inS, outS])
const window = useMemo(() => windowFor(RANGE_SECONDS[range], Date.now()), [range])
return <TrafficGraph data={data} window={window} loading={loading} variant="compact" height={height} rangeLabel={range} format={format} />
}

View File

@@ -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 }) {
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words">{children}</dd>
</div>
);
)
}
export function SystemPanel({ d }: { d: DeviceView }) {
@@ -21,34 +21,29 @@ export function SystemPanel({ d }: { d: DeviceView }) {
<Row label="Status">
<StatusDot status={d.reachability} withLabel />
</Row>
<Row label="Description">{d.sys_descr || "—"}</Row>
<Row label="Description">{d.sys_descr || '—'}</Row>
<Row label="Profile">
<span className="mono">{d.profile}</span>
</Row>
<Row label="Serial">
<span className="mono">{d.hw_serial || "—"}</span>
<span className="mono">{d.hw_serial || '—'}</span>
</Row>
<Row label="Uptime">
<span className="num">{formatDuration(d.uptime_seconds)}</span>
</Row>
<Row label="Mgmt IP">
<span className="mono">{d.mgmt_ip || "—"}</span>
<span className="mono">{d.mgmt_ip || '—'}</span>
</Row>
<Row label="Site / role">
{d.site || "—"} / {d.role || "—"}
{d.site || '—'} / {d.role || '—'}
</Row>
<Row label="Parents">
{d.parents.length === 0
? "none"
? 'none'
: d.parents.map((p, i) => (
<span key={p.slug}>
{i > 0 && ", "}
<Link
to="/devices/$slug"
params={{ slug: p.slug }}
search={{}}
className="text-primary hover:underline"
>
{i > 0 && ', '}
<Link to="/devices/$slug" params={{ slug: p.slug }} search={{}} className="text-primary hover:underline">
{p.name}
</Link>
</span>
@@ -56,5 +51,5 @@ export function SystemPanel({ d }: { d: DeviceView }) {
</Row>
</dl>
</Panel>
);
)
}