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, }, }, });