All checks were successful
React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
7.4 KiB
TypeScript
246 lines
7.4 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { ApiError } from "../../api/client";
|
|
import type { Machine } from "../../api/types";
|
|
import { useAuth } from "../../auth/AuthContext";
|
|
import { PageHeader } from "../../components/layout/PageHeader";
|
|
import {
|
|
InfoIcon,
|
|
KeyIcon,
|
|
RefreshIcon,
|
|
SearchIcon,
|
|
TrashIcon,
|
|
} from "../../components/layout/icons";
|
|
import { Badge } from "../../components/ui/Badge";
|
|
import { Button } from "../../components/ui/Button";
|
|
import { Input } from "../../components/ui/Input";
|
|
import { Panel } from "../../components/ui/Panel";
|
|
import { EmptyState, ErrorState } from "../../components/ui/States";
|
|
import { StatusDot } from "../../components/ui/StatusDot";
|
|
import { machineTone } from "../../components/ui/status";
|
|
import { Table, type Column } from "../../components/ui/Table";
|
|
import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
|
import "./machines.css";
|
|
import { DeleteMachineDialog } from "./DeleteMachineDialog";
|
|
import { MachineDetailDrawer } from "./MachineDetailDrawer";
|
|
import { MachineKeysModal } from "./MachineKeysModal";
|
|
import { useMachines } from "./hooks";
|
|
|
|
export function MachinesPage() {
|
|
const { isAdmin } = useAuth();
|
|
const machinesQuery = useMachines();
|
|
const [filter, setFilter] = useState("");
|
|
|
|
const [detailFor, setDetailFor] = useState<Machine | null>(null);
|
|
const [keysFor, setKeysFor] = useState<Machine | null>(null);
|
|
const [deleteFor, setDeleteFor] = useState<Machine | null>(null);
|
|
|
|
const { data } = machinesQuery;
|
|
const machines = useMemo(() => data ?? [], [data]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = filter.trim().toLowerCase();
|
|
if (!q) return machines;
|
|
return machines.filter(
|
|
(m) =>
|
|
m.hostname.toLowerCase().includes(q) ||
|
|
m.agent_id.toLowerCase().includes(q),
|
|
);
|
|
}, [machines, filter]);
|
|
|
|
const onlineCount = useMemo(
|
|
() => machines.filter((m) => m.status === "online").length,
|
|
[machines],
|
|
);
|
|
|
|
const columns: Column<Machine>[] = [
|
|
{
|
|
key: "status",
|
|
header: "",
|
|
cellClass: "dt__status",
|
|
render: (m) => (
|
|
<StatusDot tone={machineTone(m.status)} label={m.status} />
|
|
),
|
|
},
|
|
{
|
|
key: "hostname",
|
|
header: "Hostname",
|
|
render: (m) => <span className="dt__strong">{m.hostname}</span>,
|
|
},
|
|
{
|
|
key: "os",
|
|
header: "OS",
|
|
render: (m) => (
|
|
<span className="dt__muted">{m.os_version ?? "Unknown"}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "mode",
|
|
header: "Mode",
|
|
render: (m) =>
|
|
m.is_persistent ? (
|
|
<Badge tone="accent">Persistent</Badge>
|
|
) : (
|
|
<Badge tone="neutral">Attended</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: "last_seen",
|
|
header: "Last seen",
|
|
render: (m) => (
|
|
<span className="dt__mono" title={absoluteTime(m.last_seen)}>
|
|
{relativeTime(m.last_seen)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "agent_id",
|
|
header: "Agent ID",
|
|
render: (m) => (
|
|
<span className="dt__mono" title={m.agent_id}>
|
|
{m.agent_id}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: "",
|
|
cellClass: "dt__actions",
|
|
render: (m) => (
|
|
<span
|
|
className="dt__rowactions"
|
|
// Row actions shouldn't trigger the row's open-detail click.
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDetailFor(m)}
|
|
aria-label={`View detail for ${m.hostname}`}
|
|
>
|
|
<InfoIcon width={14} height={14} />
|
|
Detail
|
|
</Button>
|
|
{isAdmin && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setKeysFor(m)}
|
|
aria-label={`Manage keys for ${m.hostname}`}
|
|
>
|
|
<KeyIcon width={14} height={14} />
|
|
Keys
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setDeleteFor(m)}
|
|
aria-label={`Remove ${m.hostname}`}
|
|
>
|
|
<TrashIcon width={14} height={14} />
|
|
Delete
|
|
</Button>
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="page">
|
|
<PageHeader
|
|
title="Machines"
|
|
subtitle="Registered agents across all managed endpoints."
|
|
actions={
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => void machinesQuery.refetch()}
|
|
loading={machinesQuery.isFetching}
|
|
>
|
|
<RefreshIcon width={15} height={15} />
|
|
Refresh
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Panel flush>
|
|
<div style={{ padding: "14px 16px 0" }}>
|
|
<div className="toolbar">
|
|
<div className="searchbox">
|
|
<span className="searchbox__icon">
|
|
<SearchIcon width={15} height={15} />
|
|
</span>
|
|
<Input
|
|
placeholder="Filter by hostname or agent ID"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
aria-label="Filter machines"
|
|
/>
|
|
</div>
|
|
<div className="toolbar__count">
|
|
<span className="mono">{onlineCount}</span> online ·{" "}
|
|
<span className="mono">{machines.length}</span> total
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{machinesQuery.isLoading ? (
|
|
<>
|
|
<span className="visually-hidden" role="status">
|
|
Loading machines
|
|
</span>
|
|
<TableSkeleton
|
|
headers={["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]}
|
|
/>
|
|
</>
|
|
) : machinesQuery.isError ? (
|
|
<ErrorState
|
|
title="Could not load machines"
|
|
message={
|
|
machinesQuery.error instanceof ApiError
|
|
? machinesQuery.error.message
|
|
: "The GuruConnect server did not respond. Check the relay status, then retry."
|
|
}
|
|
action={
|
|
<Button variant="primary" onClick={() => void machinesQuery.refetch()}>
|
|
Retry
|
|
</Button>
|
|
}
|
|
/>
|
|
) : filtered.length === 0 ? (
|
|
filter ? (
|
|
<EmptyState
|
|
title="No matching machines"
|
|
message={`Nothing matches "${filter}". Clear the filter to see every registered machine.`}
|
|
action={
|
|
<Button variant="ghost" onClick={() => setFilter("")}>
|
|
Clear filter
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
title="No machines registered yet"
|
|
message="Agents appear here the moment they enroll with the relay. Install the GuruConnect agent on an endpoint to get started."
|
|
/>
|
|
)
|
|
) : (
|
|
<Table
|
|
columns={columns}
|
|
rows={filtered}
|
|
rowKey={(m) => m.id}
|
|
onRowClick={setDetailFor}
|
|
rowLabel={(m) => m.hostname}
|
|
/>
|
|
)}
|
|
</Panel>
|
|
|
|
<MachineDetailDrawer machine={detailFor} onClose={() => setDetailFor(null)} />
|
|
{isAdmin && (
|
|
<MachineKeysModal machine={keysFor} onClose={() => setKeysFor(null)} />
|
|
)}
|
|
<DeleteMachineDialog machine={deleteFor} onClose={() => setDeleteFor(null)} />
|
|
</div>
|
|
);
|
|
}
|