fix(widgets): report every health state in the UniFi site summary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-10-08 00:24:40 +02:00
1 parent 2c0f0bbdd3
commit 145dc17d8d
2 files changed
+60 -33

No files matched your search

@@ -25,7 +25,7 @@ export default function UnifiSiteManagerWidget({
width,
}: WidgetComponentProps<"unifiSiteManager">) {
const t = useI18n();
const { data: results = [] } = clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds });
const { data: results = [], isFetching } = clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds });
const sites = results[0]?.overview.sites ?? [];
const isNarrow = width < NARROW_WIDTH;
@@ -165,6 +165,9 @@ export default function UnifiSiteManagerWidget({
records={sites}
columns={columns}
idAccessor="siteId"
// Sans cela, « aucun site » s'affiche pendant la première requête : une
// affirmation sur le compte, fausse tant qu'on n'a rien reçu.
fetching={isFetching && sites.length === 0}
noRecordsText={t("widget.unifiSiteManager.noSites")}
/>
</Box>
@@ -1,6 +1,6 @@
"use client";
import { Box, Group, Text, Tooltip } from "@mantine/core";
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { UnifiSiteHealthSegment, UnifiSiteHealthState, UnifiSiteHealthSummary } from "@homarr/integrations/types";
@@ -20,6 +20,25 @@ const STATE_COLOR: Record<UnifiSiteHealthState, string> = {
const SEGMENT_HEIGHT = 14;
const NOTABLE_STATES = ["down", "degraded", "unknown"] as const;
/**
* TOUS les états non nuls sont listés, sans plafond sur leur nombre.
* N'en retenir qu'un ferait disparaître les heures des autres, et le lecteur
* en déduirait un reste sain qui serait faux. `unknown` compte au même titre :
* une fenêtre partiellement rapportée n'est pas une fenêtre partiellement
* dégradée, et la taire laisse croire que les heures manquantes étaient mauvaises.
* Pour qui ne distingue pas les trois couleurs de la barre, cette phrase EST la
* donnée — toute omission ici est une omission tout court.
*/
const composeSummary = (summary: UnifiSiteHealthSummary, labels: Record<UnifiSiteHealthState, string>) => {
const notable = NOTABLE_STATES.filter((state) => summary[state] > 0).map(
(state) => `${summary[state]}h ${labels[state]}`,
);
return notable.length > 0 ? notable.join(" · ") : `${summary.healthy}h ${labels.healthy}`;
};
export const HealthBar = ({
segments,
summary,
@@ -30,35 +49,40 @@ export const HealthBar = ({
summary: UnifiSiteHealthSummary;
formatHour: (at: Date) => string;
labels: Record<UnifiSiteHealthState, string>;
}) => (
<Group gap={6} wrap="nowrap">
<Group gap={1} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{segments.map((segment) => (
<Tooltip
key={segment.at.toISOString()}
label={`${formatHour(segment.at)} — ${labels[segment.state]}`}
openDelay={150}
>
<Box
style={{
flex: 1,
minWidth: 2,
height: SEGMENT_HEIGHT,
borderRadius: 1,
background: STATE_COLOR[segment.state],
}}
/>
</Tooltip>
))}
</Group>
}) => {
const summaryText = composeSummary(summary, labels);
{/* Le canal qui porte l'information quand la couleur ne passe pas. */}
<Text size="xs" c="dimmed" ff="monospace" style={{ whiteSpace: "nowrap" }}>
{summary.down > 0
? `${summary.down}h ${labels.down}`
: summary.degraded > 0
? `${summary.degraded}h ${labels.degraded}`
: `${summary.healthy}h ${labels.healthy}`}
</Text>
</Group>
);
return (
<Stack gap={2}>
{/* Les segments n'ont que le survol : sans nom accessible, la barre est
muette au clavier et au toucher. Le nom réutilise le résumé composé
ci-dessus pour que les deux ne puissent pas diverger. */}
<Group gap={1} wrap="nowrap" aria-label={summaryText}>
{segments.map((segment) => (
<Tooltip
key={segment.at.toISOString()}
label={`${formatHour(segment.at)} — ${labels[segment.state]}`}
openDelay={150}
>
<Box
style={{
flex: 1,
minWidth: 2,
height: SEGMENT_HEIGHT,
borderRadius: 1,
background: STATE_COLOR[segment.state],
}}
/>
</Tooltip>
))}
</Group>
{/* Le canal qui porte l'information quand la couleur ne passe pas.
Sous la barre et sans `nowrap` : il dispose de toute la largeur de la
cellule et passe à la ligne plutôt que d'être tronqué. */}
<Text size="xs" c="dimmed" ff="monospace">
{summaryText}
</Text>
</Stack>
);
};