feat(widgets): 5s refresh, download first, chart from the first reading
Trois demandes utilisateur, plus une adaptation rendue nécessaire par la première. - Ordre inversé : descendant avant montant. - Rafraîchissement porté de 60 s à 5 s. Il fallait le faire sur les DEUX couches locales : l'intervalle client seul aurait relu la même entrée de cache serveur douze fois. Le cache cloud reste à 5 min, ses métriques n'évoluant que par tranches de 5 min. - Le graphe est rendu dès la première mesure : vide plutôt qu'absent, ce qui évite un saut de mise en page et n'affirme rien de faux. Adaptation : la console ne rafraîchit ses statistiques que toutes les 30 s (mesuré — `lastHeartbeatAt` avance par pas de 30 s exacts, valeurs identiques entre deux battements). Interroger toutes les 5 s renvoie donc six fois la même mesure. Sans filtrage, le graphe deviendrait un escalier et 60 points ne couvriraient plus que 5 minutes. Une lecture identique à la précédente n'est donc pas enregistrée. Seuil de péremption locale recalibré de 150 s à 30 s : l'ancien était dimensionné pour 60 s + 60 s et ne signalait plus rien d'utile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a623b7434
commit
950a925fd6
7 files changed
+63
-59
No files matched your search
@@ -8,7 +8,13 @@ export const unifiOsRequestHandler = createIntegrationRequestHandler<UnifiOsSumm
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getUnifiOsSummaryAsync();
|
||||
},
|
||||
cacheTtlMs: 60 * 1000,
|
||||
cacheTtlMs: 5 * 1000,
|
||||
// Aligné sur l'intervalle du widget : un cache plus long qu'elle rendrait le
|
||||
// rafraîchissement rapide inopérant, la requête relisant la même entrée.
|
||||
// La console ne rafraîchit ses statistiques que toutes les 30 s (mesuré), donc
|
||||
// interroger plus vite gagne en réactivité, pas en résolution.
|
||||
// Le cache cloud reste à 5 min dans unifi-sm-api.ts : ses métriques n'évoluent
|
||||
// que par tranches de 5 min et accusent ~30 min de retard.
|
||||
// Une console momentanément injoignable doit afficher la dernière valeur connue
|
||||
// plutôt qu'une tuile d'erreur ; le widget signale la péremption.
|
||||
fallbackToStaleOnError: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "llamacpp"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "unifiOs"]], intervalSeconds: 5 },
|
||||
{
|
||||
queryKey: [["widget", "weather", "atLocation"]],
|
||||
intervalSeconds: 600,
|
||||
@@ -42,7 +43,6 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "unifiOs"]], intervalSeconds: 60 },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -13,20 +13,21 @@ 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";
|
||||
import { appendThroughputPoint } from "./throughput-history";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const EMPTY_VALUE = "—";
|
||||
|
||||
/**
|
||||
* Le request-handler met les compteurs locaux en cache 60 s et le widget se
|
||||
* rafraîchit toutes les 60 s : une donnée de moins de 2 min 30 est normale.
|
||||
* Au-delà, la console n'a pas répondu et `fallbackToStaleOnError` sert la
|
||||
* dernière valeur connue — ce que l'utilisateur doit voir, sans quoi la source
|
||||
* périmée paraîtrait plus fraîche que le bloc cloud, qui affiche son âge.
|
||||
* Le request-handler met les compteurs locaux en cache 5 s et le widget se
|
||||
* rafraîchit toutes les 5 s : en régime normal la donnée a moins de 10 s.
|
||||
* 30 s laissent six cycles de marge avant de crier au loup ; au-delà, la console
|
||||
* n'a pas répondu et `fallbackToStaleOnError` sert la dernière valeur connue —
|
||||
* ce que l'utilisateur doit voir, sans quoi la source périmée paraîtrait plus
|
||||
* fraîche que le bloc cloud, qui affiche son âge.
|
||||
*/
|
||||
const LOCAL_STALE_AFTER_MS = 150_000;
|
||||
const LOCAL_STALE_AFTER_MS = 30_000;
|
||||
|
||||
/** Identité stable : évite de relancer l'effet du hook à chaque rendu. */
|
||||
const NEVER_UPDATED = new Date(0);
|
||||
@@ -90,8 +91,6 @@ 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;
|
||||
|
||||
@@ -129,20 +128,17 @@ export default function UnifiOsWidget({ integrationIds, options }: WidgetCompone
|
||||
{t("widget.unifiOs.card.internet")}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* 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 && (
|
||||
<Text size="sm" ff="monospace">
|
||||
{summary.wan.throughput === null
|
||||
? EMPTY_VALUE
|
||||
: `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`}
|
||||
{EMPTY_VALUE}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{showThroughputChart && summary.wan.throughput !== null && (
|
||||
{/* Le graphe est rendu dès la première mesure : avec un seul point il
|
||||
reste vide, ce qui évite un saut de mise en page au second — et une
|
||||
aire vide n'affirme rien, contrairement à une ligne plate. */}
|
||||
{summary.wan.throughput !== null && (
|
||||
<ThroughputChart
|
||||
history={throughputHistory}
|
||||
upLabel={formatBitsPerSecond(summary.wan.throughput.txBps)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("unifiOs", {
|
||||
icon: IconRouter,
|
||||
refetchInterval: 60,
|
||||
refetchInterval: 5,
|
||||
supportedIntegrations: ["unifiOs"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -91,8 +91,8 @@ export const ThroughputChart = ({
|
||||
}: ThroughputChartProps) => (
|
||||
<Stack gap={2}>
|
||||
<Group gap="sm" wrap="nowrap" justify="flex-end">
|
||||
<SeriesKey color={UP_COLOR} label={`↑ ${upLabel}`} />
|
||||
<SeriesKey color={DOWN_COLOR} label={`↓ ${downLabel}`} />
|
||||
<SeriesKey color={UP_COLOR} label={`↑ ${upLabel}`} />
|
||||
</Group>
|
||||
|
||||
<AreaChart
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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";
|
||||
import { appendThroughputPoint, MAX_THROUGHPUT_POINTS } from "./throughput-history";
|
||||
|
||||
const point = (index: number, up: number, down: number): ThroughputPoint => ({ index, up, down });
|
||||
|
||||
@@ -32,6 +27,31 @@ describe("appendThroughputPoint", () => {
|
||||
expect(appendThroughputPoint(existing, null)).toEqual(existing);
|
||||
});
|
||||
|
||||
/**
|
||||
* The gateway refreshes its statistics every 30 s (measured: `lastHeartbeatAt`
|
||||
* advances in exact 30 s steps). Polling faster returns the same numbers
|
||||
* repeatedly. Recording them would draw a staircase and burn the history
|
||||
* window on duplicates rather than on time.
|
||||
*/
|
||||
test("ignores a reading identical to the last recorded one", () => {
|
||||
const existing = [point(0, 10, 20)];
|
||||
|
||||
expect(appendThroughputPoint(existing, { txBps: 10, rxBps: 20 })).toEqual(existing);
|
||||
});
|
||||
|
||||
test("records a reading that differs in either direction", () => {
|
||||
const existing = [point(0, 10, 20)];
|
||||
|
||||
expect(appendThroughputPoint(existing, { txBps: 11, rxBps: 20 })).toEqual([...existing, point(1, 11, 20)]);
|
||||
expect(appendThroughputPoint(existing, { txBps: 10, rxBps: 21 })).toEqual([...existing, point(1, 10, 21)]);
|
||||
});
|
||||
|
||||
test("compares only against the most recent point, so a value may recur later", () => {
|
||||
const history = appendThroughputPoint([point(0, 10, 20), point(1, 30, 40)], { txBps: 10, rxBps: 20 });
|
||||
|
||||
expect(history).toEqual([point(0, 10, 20), point(1, 30, 40), point(2, 10, 20)]);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -50,21 +70,3 @@ describe("appendThroughputPoint", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,15 +19,19 @@ export interface ThroughputPoint {
|
||||
/** 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.
|
||||
* Two readings are refused:
|
||||
*
|
||||
* - `null` — the statistics call failed, or no gateway was identified. Skipped
|
||||
* rather than recorded as zero: zero is a measurement, and drawing one would
|
||||
* show a plunge to the baseline that never happened.
|
||||
* - a repeat of the previous point. The gateway refreshes its statistics every
|
||||
* 30 s (measured: `lastHeartbeatAt` advances in exact 30 s steps), while the
|
||||
* widget polls faster than that, so the same numbers come back several times
|
||||
* in a row. Recording them would draw a staircase and spend the history
|
||||
* window on duplicates instead of on elapsed time.
|
||||
*/
|
||||
export const appendThroughputPoint = (
|
||||
history: ThroughputPoint[],
|
||||
@@ -35,14 +39,10 @@ export const appendThroughputPoint = (
|
||||
): ThroughputPoint[] => {
|
||||
if (throughput === null) return history;
|
||||
|
||||
const last = history.at(-1);
|
||||
if (last !== undefined && last.up === throughput.txBps && last.down === throughput.rxBps) 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