fix(widgets): make the UniFi site table sortable and fill the tile

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent 5e02ad4e78
commit e32b956385
2 files changed
+107 -4

No files matched your search

@@ -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;
}
@@ -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<UnifiSiteManagerSite>["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<DataTableSortStatus<UnifiSiteManagerSite>>({
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<UnifiSiteHealthState, string> = {
healthy: t("widget.unifiSiteManager.health.healthy"),
degraded: t("widget.unifiSiteManager.health.degraded"),
@@ -160,14 +226,18 @@ export default function UnifiSiteManagerWidget({
return (
<Box h="100%">
<HomarrDataTable
className="unifi-site-manager-table"
isEditMode={isEditMode}
cellPadding={isNarrow ? "2px 6px" : "4px 8px"}
records={sites}
records={sortedSites}
columns={columns}
idAccessor="siteId"
sortStatus={sortStatus}
// En mode édition, le tableau ne doit pas se réordonner sous le curseur.
onSortStatusChange={isEditMode ? undefined : setSortStatus}
// 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}
fetching={isFetching && sortedSites.length === 0}
noRecordsText={t("widget.unifiSiteManager.noSites")}
/>
</Box>