Files
wakey/ui/src/pages/DevicesPage.tsx
T

534 lines
18 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
import { toast } from "sonner";
import {
fetchFleetDevices,
fetchFleetDevice,
fetchKnownDevices,
fetchKnownDevice,
refreshFleetDevices,
wakeFleetDevice,
type Agent,
type FleetDevice,
type KnownDevice,
} from "@/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
FilterSelect,
MobileLabel,
PresenceBadge,
} from "@/pages/fleet/FleetComponents";
import { FleetDeviceDetailsDialog } from "@/pages/fleet/FleetDeviceDetailsDialog";
import {
agentLabel,
formatSeen,
knownFilters,
presenceFilters,
presenceRank,
summarize,
type KnownFilter,
type SortDir,
type SortKey,
} from "@/pages/fleet/utils";
type Props = {
agents: Agent[];
selectedAgentId: string;
onSelectAgent: (agentId: string) => void;
onAfterWake?: () => Promise<void>;
onRefresh?: () => Promise<void>;
};
export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
const [devices, setDevices] = useState<FleetDevice[]>([]);
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
const [query, setQuery] = useState("");
const [presence, setPresence] = useState("all");
const [known, setKnown] = useState<KnownFilter>("all");
const [agentId, setAgentId] = useState("all");
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({
key: "last_seen",
dir: "desc",
});
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [wakeBusyKey, setWakeBusyKey] = useState("");
const [details, setDetails] = useState<FleetDevice | null>(null);
const loadSeqRef = useRef(0);
const detailsKeyRef = useRef<string | null>(null);
const detailsSeqRef = useRef(0);
function selectDetails(device: FleetDevice | null) {
// Closing or selecting another row invalidates in-flight detail refreshes.
detailsSeqRef.current += 1;
detailsKeyRef.current = device?.device_key ?? null;
setDetails(device);
}
async function loadFleet() {
// Latest request wins: filter changes and debounced searches overlap, and
// an older response must never overwrite fresher state.
const seq = ++loadSeqRef.current;
setLoading(true);
try {
const [nextDevices, nextKnownDevices] = await Promise.all([
fetchFleetDevices({
query,
presence,
known,
agentId: agentId === "all" ? "" : agentId,
visibility: "all",
limit: 500,
}),
fetchKnownDevices(),
]);
if (seq !== loadSeqRef.current) return;
setDevices(nextDevices);
setKnownDevices(nextKnownDevices);
} catch (err) {
if (seq !== loadSeqRef.current) return;
toast.error("Failed to load fleet", { description: String(err) });
} finally {
if (seq === loadSeqRef.current) setLoading(false);
}
}
async function refreshView(
originKey: string | null = detailsKeyRef.current,
knownDeviceId?: string,
) {
if (!originKey || originKey !== detailsKeyRef.current) {
await loadFleet();
return;
}
// Mutations supply an explicit identity; never infer it from a shared IP.
// Details use their own read, so list filters and limits cannot close them.
const key = knownDeviceId ? `known:${knownDeviceId}` : originKey;
detailsKeyRef.current = key;
const seq = ++detailsSeqRef.current;
try {
const [device] = await Promise.all([fetchFleetDevice(key), loadFleet()]);
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
return;
const saved = device.known_device
? await fetchKnownDevice(device.known_device.device_id)
: null;
if (seq === detailsSeqRef.current && detailsKeyRef.current === key) {
// The dialog's full identity must stay current even if the list refresh
// failed. Dropdown options may come from the list, not mutation controls.
if (saved) {
setKnownDevices((current) =>
current.some((item) => item.device_id === saved.device_id)
? current.map((item) =>
item.device_id === saved.device_id ? saved : item,
)
: [...current, saved],
);
}
setDetails(device);
}
} catch (err) {
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
return;
// A missing/failed identity read must not leave stale mutation controls.
selectDetails(null);
toast.error("Failed to refresh device details", {
description: String(err),
});
}
}
async function refreshAllConnected() {
setRefreshing(true);
try {
const result = await refreshFleetDevices();
const failed = result.agents.filter((agent) => agent.status !== "ok");
const msg = failed.length
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
toast.success(msg);
await refreshView();
} catch (err) {
toast.error("Fleet refresh failed", { description: String(err) });
} finally {
setRefreshing(false);
}
}
async function wakeDevice(device: FleetDevice, routeId?: string | null) {
if (!device.recommended_route && !routeId) return;
setWakeBusyKey(device.device_key);
try {
const result = await wakeFleetDevice({
deviceKey: device.device_key,
routeId,
});
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
toast.success(msg);
if (onAfterWake) await onAfterWake();
await refreshView();
} catch (err) {
toast.error("Wake failed", { description: String(err) });
} finally {
setWakeBusyKey("");
}
}
async function copyValue(label: string, value: string) {
const text = value.trim();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
toast.success(`Copied ${label}`);
} catch {
const area = document.createElement("textarea");
area.value = text;
area.style.cssText = "position:fixed;opacity:0";
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
toast.success(`Copied ${label}`);
}
}
useEffect(() => {
void loadFleet();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presence, known, agentId]);
// The filter effect above already loads on mount; only react to query edits.
const mountedRef = useRef(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
const timer = window.setTimeout(() => void loadFleet(), 220);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const sortedDevices = useMemo(() => {
const rows = [...devices];
rows.sort((a, b) => {
// Pinned always float to top
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const dir = sort.dir === "asc" ? 1 : -1;
if (sort.key === "presence") {
return dir * (presenceRank(a.presence) - presenceRank(b.presence));
}
if (sort.key === "last_seen") {
return dir * ((a.last_seen_unix ?? 0) - (b.last_seen_unix ?? 0));
}
if (sort.key === "agents")
return dir * (a.agents.length - b.agents.length);
if (sort.key === "ip") {
return dir * (a.ips[0] ?? "").localeCompare(b.ips[0] ?? "");
}
if (sort.key === "mac") {
return dir * (a.macs[0] ?? "").localeCompare(b.macs[0] ?? "");
}
return dir * a.display_name.localeCompare(b.display_name);
});
return rows;
}, [devices, sort]);
const connectedCount = agents.filter((agent) => agent.connected).length;
return (
<section className="grid gap-3">
<Card>
<CardHeader className="gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Devices</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{devices.length} fleet rows from agent snapshots and known
devices
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
void loadFleet();
if (onRefresh) void onRefresh();
}}
disabled={loading}
>
<RefreshCw className="size-4" aria-hidden />
{loading ? "Refreshing" : "Refresh view"}
</Button>
<Button
size="sm"
onClick={() => void refreshAllConnected()}
disabled={refreshing || connectedCount === 0}
>
<RefreshCw className="size-4" aria-hidden />
{refreshing
? "Collecting"
: `Collect fleet (${connectedCount})`}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
<label
htmlFor="fleet-search"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Search</span>
<div className="relative">
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
id="fleet-search"
className="pl-8"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="name, ip, mac, agent"
/>
</div>
</label>
<FilterSelect
label="Presence"
value={presence}
values={presenceFilters}
onChange={setPresence}
/>
<FilterSelect
label="Identity"
value={known}
values={knownFilters}
labels={{ known: "saved", unknown: "unsaved" }}
onChange={(value) => setKnown(value as KnownFilter)}
/>
<label
htmlFor="fleet-agent"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Agent</span>
<Select
value={agentId}
onValueChange={(value) => setAgentId(value ?? "all")}
>
<SelectTrigger>
<span>
{agentId === "all"
? "all agents"
: agentLabel(
agentId,
agents.find((agent) => agent.agent_id === agentId)
?.nickname,
)}
</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value="all">all agents</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.agent_id} value={agent.agent_id}>
{agentLabel(agent.agent_id, agent.nickname)}
{agent.connected ? " · connected" : " · offline"}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<FilterSelect
label="Sort"
value={`${sort.key}:${sort.dir}`}
values={[
"last_seen:desc",
"name:asc",
"presence:desc",
"agents:desc",
"ip:asc",
"mac:asc",
]}
onChange={(value) => {
const [key, dir] = value.split(":") as [SortKey, SortDir];
setSort({ key, dir });
}}
/>
</div>
{/* Skeleton loading */}
{loading && !devices.length ? (
<div className="grid gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
) : (
<div className="grid gap-2">
<div className="hidden grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm font-medium xl:grid">
<span>Device</span>
<span>Presence</span>
<span>IPs</span>
<span>MACs</span>
<span>Agents</span>
<span>Last seen</span>
<span className="text-right">Actions</span>
</div>
{sortedDevices.map((device) => (
<FleetDeviceRow
key={device.device_key}
device={device}
busy={wakeBusyKey === device.device_key}
onWake={() => void wakeDevice(device)}
onDetails={() => selectDetails(device)}
onCopy={(label, value) => void copyValue(label, value)}
/>
))}
{!sortedDevices.length && (
<div className="flex flex-col items-center justify-center rounded-md border bg-card py-12 text-center">
<Inbox className="size-10 text-muted-foreground/30" />
<p className="mt-3 text-sm text-muted-foreground">
No devices match the current filters
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
<FleetDeviceDetailsDialog
key={details?.device_key ?? "__none"}
device={details}
knownDevices={knownDevices}
open={Boolean(details)}
busy={wakeBusyKey === details?.device_key}
onOpenChange={(open) => {
if (!open) selectDetails(null);
}}
onWake={(routeId) => {
if (details) void wakeDevice(details, routeId);
}}
onCopy={(label, value) => void copyValue(label, value)}
onChanged={(knownDeviceId) =>
refreshView(details?.device_key ?? null, knownDeviceId)
}
/>
</section>
);
}
function FleetDeviceRow({
device,
busy,
onWake,
onDetails,
onCopy,
}: {
device: FleetDevice;
busy: boolean;
onWake: () => void;
onDetails: () => void;
onCopy: (label: string, value: string) => void;
}) {
const presenceBorder =
device.presence === "online"
? "border-l-emerald-500/70"
: device.presence === "likely_online"
? "border-l-sky-500/60"
: device.presence === "offline"
? "border-l-muted-foreground/20"
: "";
return (
<div
className={`grid gap-3 rounded-md border border-l-[3px] ${presenceBorder} bg-card px-3 py-3 text-sm xl:grid-cols-[minmax(10rem,1.3fr)_8rem_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(10rem,1fr)_8rem_8rem] xl:items-center xl:gap-2`}
>
<button className="min-w-0 text-left" type="button" onClick={onDetails}>
<div className="truncate font-medium">{device.display_name}</div>
<div className="mt-1 flex flex-wrap gap-1">
{device.known_device ? (
<Badge variant="secondary">saved</Badge>
) : (
<Badge variant="outline">unsaved</Badge>
)}
{device.pinned && <Badge variant="outline">pinned</Badge>}
{device.sources.map((source) => (
<Badge key={source} variant="outline">
{source}
</Badge>
))}
</div>
</button>
<span>
<PresenceBadge presence={device.presence} />
</span>
<span className="min-w-0 text-muted-foreground xl:truncate">
<MobileLabel label="IPs" />
{summarize(device.ips)}
</span>
<span className="min-w-0 text-muted-foreground xl:truncate">
<MobileLabel label="MACs" />
{summarize(device.macs)}
</span>
<span className="min-w-0 text-muted-foreground xl:truncate">
<MobileLabel label="Agents" />
{summarize(
device.agents.map((agent) =>
agentLabel(agent.agent_id, agent.nickname),
),
)}
</span>
<span className="text-muted-foreground">
<MobileLabel label="Last seen" />
{formatSeen(device.last_seen_unix)}
</span>
<div className="flex justify-end gap-2">
<Button
size="sm"
onClick={onWake}
disabled={busy || !device.recommended_route}
>
<Zap className="size-4" aria-hidden />
{busy ? "Waking" : "Wake"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex size-9 items-center justify-center rounded-md border bg-background">
<MoreHorizontal className="size-4" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={onDetails}>Details</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onCopy("IP", device.ips[0] ?? "")}
data-disabled={!device.ips.length}
>
Copy first IP
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onCopy("MAC", device.macs[0] ?? "")}
data-disabled={!device.macs.length}
>
Copy first MAC
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}