From 4d67341ba5ee7d361449f56cb1a1e245293fc974 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 10 Aug 2026 09:35:39 +0100 Subject: [PATCH] feat(web): fleet search and sort, shared empty/error states, no background polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet list had a tag filter and nothing else: no search, no sort, and an unbounded list. Searching hostname/address/OS and sorting by hostname, status or last seen are all client-side, since the browser already holds the fleet the page just fetched. Sorting by status orders by how much attention each state wants rather than alphabetically, which is the only reason to sort by it. The filtered count is shown beside the total so a search does not read as the fleet having shrunk, and "no results" is a distinct empty state from "no servers", with a way back out of the search. refetchIntervalInBackground defaults to false on the query client. Polling pages kept refetching in a hidden tab — the fleet list pulls inventory blobs every 30s — so a console left open in a background tab polled until its session expired. It belongs in the defaults because the argument is identical on every polling page. --- web/app/(app)/servers/page.tsx | 150 +++++++++++++++++++++++++-------- web/lib/query-client.ts | 7 ++ 2 files changed, 122 insertions(+), 35 deletions(-) diff --git a/web/app/(app)/servers/page.tsx b/web/app/(app)/servers/page.tsx index db7b76a..edf7348 100644 --- a/web/app/(app)/servers/page.tsx +++ b/web/app/(app)/servers/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { Suspense } from "react"; +import { Suspense, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "next/navigation"; import { api, Server } from "@/lib/api"; -import { Button, Card } from "@/components/ui"; +import { AsyncBoundary, Button, Card, CenteredSpinner, EmptyState, TableSkeleton } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; import { TagChips } from "@/components/servers/TagChips"; import { TagFilterBar } from "@/components/servers/TagFilterBar"; @@ -12,6 +12,23 @@ import { TagFilterBar } from "@/components/servers/TagFilterBar"; type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok"; +type SortKey = "hostname" | "status" | "last_seen"; + +const SORT_LABELS: Record = { + hostname: "Hostname", + status: "Status (worst first)", + last_seen: "Last seen (newest first)", +}; + +// Sorting by status means "show me what is wrong", so the order is by how much +// attention each state wants rather than alphabetical. +const STATUS_ORDER: Record = { + offline: 0, + "needs-update": 1, + "has-package-updates": 2, + ok: 3, +}; + function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus { if (server.status === "offline" || server.status === "pending") return "offline"; if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update"; @@ -108,7 +125,7 @@ function ServersPageBody() { router.replace(qs ? `/servers?${qs}` : "/servers"); } - const { data: servers, isLoading, error } = useQuery({ + const { data: servers, isLoading, error, refetch } = useQuery({ queryKey: ["servers", selected], queryFn: () => api.listServers(selected), refetchInterval: 30_000, @@ -121,13 +138,43 @@ function ServersPageBody() { }); const latestVersion = latestVersionData?.version; + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("hostname"); + + const visible = useMemo(() => { + const q = search.trim().toLowerCase(); + const matched = q + ? (servers ?? []).filter((s) => + [s.hostname, s.ip_address, s.os_info].some((field) => field?.toLowerCase().includes(q)), + ) + : (servers ?? []); + + // Sorted on a copy: the query cache's array is not ours to reorder. + return [...matched].sort((a, b) => { + switch (sort) { + case "status": + // Whatever needs attention first, which is the reason to sort by + // status at all. + return STATUS_ORDER[resolveStatus(a, latestVersion)] - STATUS_ORDER[resolveStatus(b, latestVersion)]; + case "last_seen": + return new Date(b.last_seen ?? 0).getTime() - new Date(a.last_seen ?? 0).getTime(); + default: + return a.hostname.localeCompare(b.hostname); + } + }); + }, [servers, search, sort, latestVersion]); + return (

Servers

-

- {servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""} +

+ {/* Showing the filtered count beside the total is what stops a + search reading as "the fleet shrank". */} + {visible.length === (servers?.length ?? 0) + ? `${servers?.length ?? 0} registered server${servers?.length !== 1 ? "s" : ""}` + : `${visible.length} of ${servers?.length ?? 0} servers`}

-
- )} +
); @@ -230,13 +316,7 @@ function ServersPageBody() { export default function ServersPage() { return ( - -
-
- } - > + }> ); diff --git a/web/lib/query-client.ts b/web/lib/query-client.ts index 089b922..f9c715a 100644 --- a/web/lib/query-client.ts +++ b/web/lib/query-client.ts @@ -7,6 +7,13 @@ export const queryClient = new QueryClient({ queries: { staleTime: 30_000, retry: 1, + // Several pages poll on an interval — the fleet list every 30s, run logs + // faster than that. A hidden tab was still doing all of it, so a console + // left open overnight in a background tab kept refetching the fleet and + // its inventory blobs until the session expired. The default here rather + // than per page, because the argument is the same everywhere and the + // pages that poll are exactly the ones nobody remembers to check. + refetchIntervalInBackground: false, }, }, });