diff --git a/packages/widgets/src/unifi-site-manager/component.css b/packages/widgets/src/unifi-site-manager/component.css new file mode 100644 index 000000000..7ddad3027 --- /dev/null +++ b/packages/widgets/src/unifi-site-manager/component.css @@ -0,0 +1,33 @@ +/* + * Les lignes se partagent la hauteur de la tuile au lieu de laisser du vide + * sous la dernière. Scopé à ce widget : `homarr-data-table.css` est partagé + * avec docker, downloads et les autres, qui ne veulent pas de ce comportement. + */ + +/* + * Relais indispensable. Entre le viewport de la ScrollArea — seul élément de + * la chaîne à avoir une hauteur définie — et le tableau, Mantine insère un div + * de contenu de hauteur automatique. Une hauteur en pourcentage se résout en + * `auto` sous un parent de hauteur automatique : sans cette règle, le + * `height: 100%` ci-dessous n'a strictement aucun effet. + */ +.unifi-site-manager-table .mantine-datatable-scroll-area > [data-scrollarea-viewport] > div { + height: 100%; +} + +/* + * Le navigateur répartit alors la hauteur excédentaire entre les lignes. Quand + * les lignes débordent, le tableau redevient plus haut que le viewport et le + * défilement existant reprend la main : la règle n'écrase jamais le contenu. + */ +.unifi-site-manager-table .mantine-datatable-table { + height: 100%; +} + +/* + * Sur une cellule de tableau, `height` vaut minimum et non taille fixe : + * l'en-tête garde sa hauteur naturelle au lieu de grandir avec les lignes. + */ +.unifi-site-manager-table .mantine-datatable-table thead th { + height: 0; +} diff --git a/packages/widgets/src/unifi-site-manager/component.tsx b/packages/widgets/src/unifi-site-manager/component.tsx index 92aa9376b..04094e955 100644 --- a/packages/widgets/src/unifi-site-manager/component.tsx +++ b/packages/widgets/src/unifi-site-manager/component.tsx @@ -1,7 +1,10 @@ "use client"; +import "./component.css"; + +import { useMemo, useState } from "react"; import { Box, Text } from "@mantine/core"; -import type { DataTableColumn } from "mantine-datatable"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; import { clientApi } from "@homarr/api/client"; import type { UnifiSiteHealthState, UnifiSiteManagerSite } from "@homarr/integrations/types"; @@ -18,6 +21,57 @@ const NARROW_WIDTH = 420; const formatHour = (at: Date) => at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); +/** + * Départage deux sites dont au moins un porte une valeur inconnue. + * + * Renvoie 0 quand les deux le sont — à l'appelant de continuer, s'il a mieux. + */ +const compareUnknownLast = (first: unknown, second: unknown) => { + if (first === null && second === null) return 0; + return first === null ? 1 : -1; +}; + +/** + * Compare deux sites sur la colonne triée. + * + * `multiplier` ne s'applique qu'à la comparaison de deux valeurs CONNUES. + * `latencyMs` et `ispName` sont nullables, et `null` y signifie « pas mesuré » + * ou « pas rapporté » — jamais « zéro milliseconde » ni « chaîne vide ». Un + * inconnu n'est donc pas une valeur extrême et n'a sa place à aucun des deux + * bouts : il part en dernier DANS LES DEUX SENS, hors du multiplicateur. Le + * traiter comme une valeur ferait remonter en tête d'un tri croissant un site + * dont la latence n'a jamais été relevée, comme s'il était le plus rapide. + * + * Les colonnes textuelles passent par `localeCompare` et non par `<` : sinon + * les noms accentués se classeraient en ordre de points de code. + */ +const compareSites = ( + first: UnifiSiteManagerSite, + second: UnifiSiteManagerSite, + accessor: DataTableSortStatus["columnAccessor"], + multiplier: number, +) => { + if (accessor === "latencyMs") { + const { latencyMs: firstLatency } = first; + const { latencyMs: secondLatency } = second; + if (firstLatency === null || secondLatency === null) return compareUnknownLast(firstLatency, secondLatency); + return (firstLatency - secondLatency) * multiplier; + } + + if (accessor === "ispName") { + const { ispName: firstIsp } = first; + const { ispName: secondIsp } = second; + if (firstIsp === null || secondIsp === null) return compareUnknownLast(firstIsp, secondIsp); + return firstIsp.localeCompare(secondIsp) * multiplier; + } + + if (accessor === "deviceCount") return (first.deviceCount - second.deviceCount) * multiplier; + if (accessor === "pendingUpdateCount") return (first.pendingUpdateCount - second.pendingUpdateCount) * multiplier; + + // `name` : la seule autre colonne à déclarer `sortable`. + return first.name.localeCompare(second.name) * multiplier; +}; + export default function UnifiSiteManagerWidget({ integrationIds, options, @@ -27,9 +81,21 @@ export default function UnifiSiteManagerWidget({ const t = useI18n(); const { data: results = [], isFetching } = clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds }); - const sites = results[0]?.overview.sites ?? []; + const sites = useMemo(() => results[0]?.overview.sites ?? [], [results]); const isNarrow = width < NARROW_WIDTH; + const [sortStatus, setSortStatus] = useState>({ + columnAccessor: "name", + direction: "asc", + }); + + const sortedSites = useMemo(() => { + const multiplier = sortStatus.direction === "desc" ? -1 : 1; + // Copie : le tableau appartient au cache de la requête, le trier en place + // le muterait sous les autres lecteurs. + return [...sites].toSorted((first, second) => compareSites(first, second, sortStatus.columnAccessor, multiplier)); + }, [sites, sortStatus]); + const labels: Record = { healthy: t("widget.unifiSiteManager.health.healthy"), degraded: t("widget.unifiSiteManager.health.degraded"), @@ -160,14 +226,18 @@ export default function UnifiSiteManagerWidget({ return (