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:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user