feat(widgets): chart WAN throughput instead of static figures
L'API UniFi ne fournit que l'instantané et Homarr ne stocke aucun historique : la série est accumulée côté client, comme le fait déjà le widget systemResources. Elle repart donc vide à chaque rechargement. Deux décisions d'honnêteté, dans la ligne du reste du widget : - une lecture en échec est ignorée, jamais enregistrée comme un zéro, qui dessinerait une chute à la ligne de base n'ayant pas eu lieu ; - sous deux points, on affiche les chiffres seuls : une ligne plate tracée sur un point unique se lit comme un réseau au repos alors qu'on n'a rien mesuré. Couleurs validées par le validateur de palette (bande de luminosité, chroma, séparation daltonisme et vision normale, contraste) en thème clair ET sombre. La paire orange.5/yellow.5 du widget systemResources n'a délibérément pas été reprise : son ΔE en vision normale est de 12,1, sous le plancher de lisibilité de 15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e950ce5e3
commit
0a623b7434
5 files changed
+289
-7
No files matched your search
@@ -5773,6 +5773,8 @@
|
||||
},
|
||||
"card": {
|
||||
"internet": "Internet",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"latency": "{value} ms",
|
||||
"packetLoss": "{value} % loss",
|
||||
"measuredAt": "Measured at {time}",
|
||||
|
||||
@@ -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<ThroughputPoint[]>([]);
|
||||
|
||||
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 (
|
||||
<Box h="100%" p="sm">
|
||||
@@ -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")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" ff="monospace">
|
||||
{summary.wan.throughput === null
|
||||
? EMPTY_VALUE
|
||||
: `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`}
|
||||
</Text>
|
||||
{/* 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 && (
|
||||
<Text size="sm" ff="monospace">
|
||||
{summary.wan.throughput === null
|
||||
? EMPTY_VALUE
|
||||
: `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{showThroughputChart && summary.wan.throughput !== null && (
|
||||
<ThroughputChart
|
||||
history={throughputHistory}
|
||||
upLabel={formatBitsPerSecond(summary.wan.throughput.txBps)}
|
||||
downLabel={formatBitsPerSecond(summary.wan.throughput.rxBps)}
|
||||
formatRate={formatBitsPerSecond}
|
||||
upName={t("widget.unifiOs.card.up")}
|
||||
downName={t("widget.unifiOs.card.down")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -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 }) => (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text c={color} size="xs" lh={1} aria-hidden>
|
||||
●
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" ff="monospace" lh={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Paper px="xs" py={4} shadow="md" withBorder>
|
||||
<Stack gap={2}>
|
||||
{payload.map((item) => (
|
||||
<Text key={String(item.name)} size="xs" c="dimmed" ff="monospace">
|
||||
{item.name === "up" ? `↑ ${upName}` : `↓ ${downName}`} {formatRate(Number(item.value))}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
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) => (
|
||||
<Stack gap={2}>
|
||||
<Group gap="sm" wrap="nowrap" justify="flex-end">
|
||||
<SeriesKey color={UP_COLOR} label={`↑ ${upLabel}`} />
|
||||
<SeriesKey color={DOWN_COLOR} label={`↓ ${downLabel}`} />
|
||||
</Group>
|
||||
|
||||
<AreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={history}
|
||||
dataKey="index"
|
||||
series={[
|
||||
{ name: "down", label: downName, color: DOWN_COLOR },
|
||||
{ name: "up", label: upName, color: UP_COLOR },
|
||||
]}
|
||||
curveType="monotone"
|
||||
strokeWidth={2}
|
||||
withDots={false}
|
||||
withXAxis={false}
|
||||
withYAxis={false}
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
tooltipAnimationDuration={150}
|
||||
tooltipProps={{
|
||||
content: <ThroughputTooltip formatRate={formatRate} upName={upName} downName={downName} />,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user