diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b801c94c8..7989e1403 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5773,6 +5773,8 @@ }, "card": { "internet": "Internet", + "up": "Up", + "down": "Down", "latency": "{value} ms", "packetLoss": "{value} % loss", "measuredAt": "Measured at {time}", diff --git a/packages/widgets/src/unifi-os/component.tsx b/packages/widgets/src/unifi-os/component.tsx index a152043c7..41cec69d3 100644 --- a/packages/widgets/src/unifi-os/component.tsx +++ b/packages/widgets/src/unifi-os/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; import { IconCircleFilled } from "@tabler/icons-react"; import dayjs from "dayjs"; @@ -11,6 +11,9 @@ import { useIntegrationConnected } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { ThroughputChart } from "./throughput-chart"; +import type { ThroughputPoint } from "./throughput-history"; +import { appendThroughputPoint, canRenderThroughputChart } from "./throughput-history"; dayjs.extend(relativeTime); @@ -50,12 +53,33 @@ const formatUptime = (seconds: number): string => { export default function UnifiOsWidget({ integrationIds, options }: WidgetComponentProps<"unifiOs">) { const t = useI18n(); - const { data: results = [] } = clientApi.widget.unifiOs.summary.useQuery({ integrationIds }); + const { data: results = [], dataUpdatedAt } = clientApi.widget.unifiOs.summary.useQuery({ integrationIds }); const summary = useMemo(() => results[0]?.summary ?? null, [results]); const updatedAt = results[0]?.updatedAt ?? NEVER_UPDATED; const isLocalFresh = useIntegrationConnected(updatedAt, { timeout: LOCAL_STALE_AFTER_MS }); + /* L'API ne fournit que l'instantané et Homarr ne stocke aucun historique : + on l'accumule ici, comme le fait le widget systemResources. La série + repart donc vide à chaque rechargement de page — c'est assumé. */ + const [throughputHistory, setThroughputHistory] = useState([]); + + const previousIntegrationIds = useRef(integrationIds); + useEffect(() => { + if (previousIntegrationIds.current === integrationIds) return; + previousIntegrationIds.current = integrationIds; + setThroughputHistory([]); + }, [integrationIds]); + + const lastRecordedAt = useRef(dataUpdatedAt); + useEffect(() => { + if (dataUpdatedAt === lastRecordedAt.current) return; + lastRecordedAt.current = dataUpdatedAt; + + const throughput = results[0]?.summary.wan.throughput ?? null; + setThroughputHistory((previous) => appendThroughputPoint(previous, throughput)); + }, [dataUpdatedAt, results]); + if (summary === null) { return ( @@ -66,6 +90,8 @@ export default function UnifiOsWidget({ integrationIds, options }: WidgetCompone ); } + const showThroughputChart = canRenderThroughputChart(throughputHistory); + const wanColor = summary.wan.state === "online" ? "green" : summary.wan.state === "offline" ? "red" : "gray"; const isp = summary.wan.isp; @@ -103,13 +129,30 @@ export default function UnifiOsWidget({ integrationIds, options }: WidgetCompone {t("widget.unifiOs.card.internet")} - - {summary.wan.throughput === null - ? EMPTY_VALUE - : `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`} - + {/* Sous le seuil de points, ou sans mesure courante, on montre les + chiffres seuls : un graphe tracé sur un point unique dessine une + ligne plate, qui se lit comme un réseau au repos alors qu'on n'a + simplement rien encore mesuré. */} + {!showThroughputChart && ( + + {summary.wan.throughput === null + ? EMPTY_VALUE + : `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`} + + )} + {showThroughputChart && summary.wan.throughput !== null && ( + + )} + {/* Bloc ISP masqué quand il n'y a pas de donnée cloud (§8) : sans clé Site Manager, cloud injoignable, ou aucun site correspondant au MAC du gateway. Une absence que l'intégration a positivement établie ne diff --git a/packages/widgets/src/unifi-os/throughput-chart.tsx b/packages/widgets/src/unifi-os/throughput-chart.tsx new file mode 100644 index 000000000..8d4cea766 --- /dev/null +++ b/packages/widgets/src/unifi-os/throughput-chart.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { AreaChart } from "@mantine/charts"; +import { Group, Paper, Stack, Text } from "@mantine/core"; + +import type { ThroughputPoint } from "./throughput-history"; + +/** + * Series colours. + * + * Not copied from the `systemResources` network chart, which pairs `orange.5` + * with `yellow.5`: that pair scores a normal-vision ΔE of 12.1, below the + * legibility floor of 15, so even readers with full colour vision struggle to + * tell the two lines apart. This pair was validated instead, and passes every + * check — lightness band, chroma, colour-vision-deficiency separation, + * normal-vision separation and surface contrast — against BOTH the light and + * the dark chart surface, so one pair serves both themes. + */ +const DOWN_COLOR = "blue.7"; +const UP_COLOR = "orange.8"; + +const CHART_HEIGHT = 44; + +/** + * A dot carries the series identity so the figure beside it can stay in muted + * ink: colour is never the only thing distinguishing up from down — the arrow + * glyph and the label do it too. + */ +const SeriesKey = ({ color, label }: { color: string; label: string }) => ( + + + ● + + + {label} + + +); + +/** + * Defined at module level rather than inline in the `content` prop: recharts + * clones this element and injects `payload`, so it keeps a stable identity + * across renders. The repo's other chart tooltips pass a closure instead and + * each carries a `no-unstable-nested-components` warning for it. + */ +const ThroughputTooltip = ({ + payload, + formatRate, + upName, + downName, +}: { + payload?: { name?: string | number; value?: string | number }[]; + formatRate: (bitsPerSecond: number) => string; + upName: string; + downName: string; +}) => { + if (!payload || payload.length === 0) return null; + + return ( + + + {payload.map((item) => ( + + {item.name === "up" ? `↑ ${upName}` : `↓ ${downName}`} {formatRate(Number(item.value))} + + ))} + + + ); +}; + +export interface ThroughputChartProps { + history: ThroughputPoint[]; + /** Rendered beside the up marker, already formatted. */ + upLabel: string; + /** Rendered beside the down marker, already formatted. */ + downLabel: string; + /** Formats a raw bits-per-second value for the tooltip. */ + formatRate: (bitsPerSecond: number) => string; + upName: string; + downName: string; +} + +export const ThroughputChart = ({ + history, + upLabel, + downLabel, + formatRate, + upName, + downName, +}: ThroughputChartProps) => ( + + + + + + + , + }} + /> + +); diff --git a/packages/widgets/src/unifi-os/throughput-history.spec.ts b/packages/widgets/src/unifi-os/throughput-history.spec.ts new file mode 100644 index 000000000..568609bcd --- /dev/null +++ b/packages/widgets/src/unifi-os/throughput-history.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; + +import type { ThroughputPoint } from "./throughput-history"; +import { + appendThroughputPoint, + canRenderThroughputChart, + MAX_THROUGHPUT_POINTS, + MIN_POINTS_FOR_CHART, +} from "./throughput-history"; + +const point = (index: number, up: number, down: number): ThroughputPoint => ({ index, up, down }); + +describe("appendThroughputPoint", () => { + test("records a reading with an index continuing the series", () => { + const history = appendThroughputPoint([point(0, 1, 2)], { txBps: 30, rxBps: 40 }); + + expect(history).toEqual([point(0, 1, 2), point(1, 30, 40)]); + }); + + test("starts the series at index 0 when there is no history yet", () => { + expect(appendThroughputPoint([], { txBps: 5, rxBps: 6 })).toEqual([point(0, 5, 6)]); + }); + + /** + * A failed reading is not a measurement of zero. Appending 0 would draw a + * plunge to the baseline that never happened — the chart would report an + * outage the network did not have. + */ + test("skips a null reading instead of recording it as zero throughput", () => { + const existing = [point(0, 10, 20)]; + + expect(appendThroughputPoint(existing, null)).toEqual(existing); + }); + + test("keeps only the most recent points once the cap is reached", () => { + const full = Array.from({ length: MAX_THROUGHPUT_POINTS }, (_, index) => point(index, index, index)); + + const history = appendThroughputPoint(full, { txBps: 99, rxBps: 98 }); + + expect(history).toHaveLength(MAX_THROUGHPUT_POINTS); + expect(history.at(0)).toEqual(point(1, 1, 1)); + expect(history.at(-1)).toMatchObject({ up: 99, down: 98 }); + }); + + test("does not mutate the history it is given", () => { + const existing = [point(0, 10, 20)]; + + appendThroughputPoint(existing, { txBps: 1, rxBps: 2 }); + + expect(existing).toEqual([point(0, 10, 20)]); + }); +}); + +describe("canRenderThroughputChart", () => { + /** + * One point drawn as a line is a flat line, which reads as "a network at + * rest" when the truth is "we do not know yet". Below the threshold the + * widget shows the numbers alone. + */ + test("refuses to draw a chart from a single point", () => { + expect(canRenderThroughputChart([])).toBe(false); + expect(canRenderThroughputChart([point(0, 1, 2)])).toBe(false); + }); + + test("draws once there are at least two points to join", () => { + const enough = Array.from({ length: MIN_POINTS_FOR_CHART }, (_, index) => point(index, 1, 2)); + + expect(canRenderThroughputChart(enough)).toBe(true); + }); +}); diff --git a/packages/widgets/src/unifi-os/throughput-history.ts b/packages/widgets/src/unifi-os/throughput-history.ts new file mode 100644 index 000000000..98ff381a8 --- /dev/null +++ b/packages/widgets/src/unifi-os/throughput-history.ts @@ -0,0 +1,48 @@ +/** + * Rolling WAN throughput history, kept client-side. + * + * The UniFi OS integration reports the gateway's uplink rate at the instant it + * is polled — there is no history endpoint, and Homarr stores none. So the + * widget accumulates points in React state while the page is open, the same way + * the `systemResources` widget does. The consequence is deliberate and worth + * knowing: the series restarts empty on every page load. + */ + +export interface ThroughputPoint { + index: number; + /** Uplink, bits per second. Measured against a real console: the API's `Bps` fields are bits. */ + up: number; + /** Downlink, bits per second. */ + down: number; +} + +/** At the widget's 60 s refetch interval this holds roughly an hour of history. */ +export const MAX_THROUGHPUT_POINTS = 60; + +/** Two points are the fewest that can be joined into a line. */ +export const MIN_POINTS_FOR_CHART = 2; + +/** + * Appends one reading, oldest points falling off the front once the cap is hit. + * + * A `null` reading — the statistics call failed, or no gateway was identified — + * is skipped rather than recorded as zero. Zero is a measurement, and drawing + * one would show a plunge to the baseline that never happened. + */ +export const appendThroughputPoint = ( + history: ThroughputPoint[], + throughput: { txBps: number; rxBps: number } | null, +): ThroughputPoint[] => { + if (throughput === null) return history; + + const nextIndex = (history.at(-1)?.index ?? -1) + 1; + + return [...history, { index: nextIndex, up: throughput.txBps, down: throughput.rxBps }].slice(-MAX_THROUGHPUT_POINTS); +}; + +/** + * A single point renders as a flat line, which reads as a quiet network when + * the truth is that nothing has been measured yet. Below the threshold the + * widget shows the current figures on their own. + */ +export const canRenderThroughputChart = (history: ThroughputPoint[]): boolean => history.length >= MIN_POINTS_FOR_CHART;