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:
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>
|
||||
|
||||
Reference in new issue
Block a user