feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
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>
This commit is contained in:
245
dashboard/src/features/machines/MachinesPage.tsx
Normal file
245
dashboard/src/features/machines/MachinesPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user