feat(web): fleet search and sort, shared empty/error states, no background polling

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.
This commit is contained in:
2026-08-10 09:35:39 +01:00
parent 1fa9160c59
commit 4d67341ba5
2 changed files with 122 additions and 35 deletions
+115 -35
View File
@@ -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<SortKey, string> = {
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<DotStatus, number> = {
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<SortKey>("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 (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Servers</h1>
<p className="mt-1 text-sm text-text-secondary">
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
<p className="mt-1 text-sm text-text-secondary" aria-live="polite">
{/* 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`}
</p>
</div>
<Button href="/servers/new" variant="primary">
@@ -140,16 +187,67 @@ function ServersPageBody() {
<TagFilterBar value={selected} onChange={setSelected} />
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<label htmlFor="fleet-search" className="sr-only">
Search servers by hostname, address or OS
</label>
<input
id="fleet-search"
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search hostname, address or OS…"
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder-text-secondary/60 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div className="flex items-center gap-2">
<label htmlFor="fleet-sort" className="font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary">
Sort
</label>
<select
id="fleet-sort"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
>
{(Object.keys(SORT_LABELS) as SortKey[]).map((k) => (
<option key={k} value={k}>
{SORT_LABELS[k]}
</option>
))}
</select>
</div>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load servers. Is the backend running?
</div>
) : servers && servers.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={6} />}
isEmpty={visible.length === 0}
empty={
search.trim() ? (
<EmptyState
title="No servers match that search."
description="Clear the search to see the rest of the fleet."
action={{ label: "Clear search", onClick: () => setSearch("") }}
/>
) : (
<EmptyState
title="No servers registered yet."
description="Add one and Vantage gives you an install one-liner to run on it."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7" />
</svg>
}
action={{ label: "Add your first server", href: "/servers/new" }}
/>
)
}
>
<Table>
<Thead>
<Tr>
@@ -163,7 +261,7 @@ function ServersPageBody() {
</Tr>
</Thead>
<Tbody>
{servers.map((server: Server) => (
{visible.map((server: Server) => (
<Tr key={server.server_id}>
<Td label="Hostname">
<span className="font-medium text-text-primary">
@@ -210,19 +308,7 @@ function ServersPageBody() {
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
</div>
<p className="text-text-secondary">No servers registered yet.</p>
<Button href="/servers/new" variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
@@ -230,13 +316,7 @@ function ServersPageBody() {
export default function ServersPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center p-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
}
>
<Suspense fallback={<CenteredSpinner label="Loading fleet" />}>
<ServersPageBody />
</Suspense>
);
+7
View File
@@ -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,
},
},
});