Files
guru-connect/dashboard/src/features/machines/MachinesPage.tsx
Mike Swanson 43a9432b81
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s
feat(dashboard): GuruConnect v2 operator console (pass 1)
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>
2026-05-30 12:51:11 -07:00

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