feat(dashboard): GuruConnect v2 operator console (pass 1)
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

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:
2026-05-30 12:51:11 -07:00
parent f9bdecbfdb
commit 43a9432b81
66 changed files with 7777 additions and 995 deletions

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
import { Button } from "../../components/ui/Button";
import { Field, Input } from "../../components/ui/Input";
import "./login.css";
interface LocationState {
from?: { pathname: string };
}
export function LoginPage() {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Already authenticated — bounce to the app.
if (user) return <Navigate to="/machines" replace />;
const from = (location.state as LocationState | null)?.from?.pathname ?? "/machines";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(
err.status === 401
? "Invalid username or password."
: err.message,
);
} else {
setError("Could not sign in. Please try again.");
}
} finally {
setSubmitting(false);
}
}
return (
<div className="login">
<div className="login__scanlines" aria-hidden="true" />
<form className="login__card" onSubmit={handleSubmit}>
<div className="login__brand">
<span className="login__logo" aria-hidden="true">
GC
</span>
<div>
<div className="login__title">GuruConnect</div>
<div className="login__sub">Operator Console</div>
</div>
</div>
<Field label="Username" htmlFor="username">
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
required
/>
</Field>
<Field label="Password" htmlFor="password">
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
{error && (
<div className="login__error" role="alert">
{error}
</div>
)}
<Button
type="submit"
variant="primary"
block
loading={submitting}
disabled={!username || !password}
>
Sign in
</Button>
<div className="login__foot mono">GuruConnect · Operator Console</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,91 @@
.login {
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(
1100px 520px at 50% -10%,
oklch(78% 0.13 184 / 0.08),
transparent 60%
),
var(--bg);
overflow: hidden;
}
/* Faint console scanlines for control-room texture. */
.login__scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background-image: repeating-linear-gradient(
to bottom,
oklch(93% 0.008 var(--brand-hue) / 0.016) 0px,
oklch(93% 0.008 var(--brand-hue) / 0.016) 1px,
transparent 1px,
transparent 3px
);
mask-image: radial-gradient(70% 60% at 50% 40%, black, transparent);
}
.login__card {
position: relative;
z-index: 1;
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 28px 26px 22px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-2);
}
.login__brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.login__logo {
width: 40px;
height: 40px;
border-radius: 9px;
background: linear-gradient(135deg, var(--accent), var(--accent-press));
display: grid;
place-items: center;
color: var(--accent-ink);
font-weight: 800;
font-size: 17px;
}
.login__title {
font-size: 19px;
font-weight: 700;
letter-spacing: -0.01em;
}
.login__sub {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
}
.login__error {
font-size: 13px;
color: var(--bad);
background: var(--bad-soft);
border: 1px solid var(--bad-line);
border-radius: var(--radius-sm);
padding: 9px 12px;
}
.login__foot {
text-align: center;
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import type { Machine } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { Modal } from "../../components/ui/Modal";
import { useToast } from "../../components/ui/toast-context";
import { useDeleteMachine } from "./hooks";
interface DeleteMachineDialogProps {
machine: Machine | null;
onClose: () => void;
}
/**
* Destructive machine removal with two options:
* - uninstall: also command the agent to uninstall (only meaningful online)
* - export: return full history in the delete response before removal
*/
export function DeleteMachineDialog({ machine, onClose }: DeleteMachineDialogProps) {
const toast = useToast();
const del = useDeleteMachine();
const [uninstall, setUninstall] = useState(false);
const [exportHistory, setExportHistory] = useState(false);
// Reset options each time a new machine is targeted.
useEffect(() => {
if (machine) {
setUninstall(false);
setExportHistory(false);
}
}, [machine]);
function handleConfirm() {
if (!machine) return;
del.mutate(
{ agentId: machine.agent_id, params: { uninstall, export: exportHistory } },
{
onSuccess: (res) => {
if (exportHistory && res.history) {
downloadHistory(machine.hostname, res.history);
}
toast.success(
"Machine deleted",
res.uninstall_sent
? "Uninstall command sent to the agent."
: undefined,
);
onClose();
},
onError: (err) => {
toast.error(
"Could not delete machine",
err instanceof ApiError
? err.message
: "The server did not respond. The machine was not deleted.",
);
},
},
);
}
return (
<Modal
open={machine != null}
title="Delete machine"
onClose={del.isPending ? () => {} : onClose}
dismissable={!del.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={del.isPending}>
Keep machine
</Button>
<Button variant="danger" onClick={handleConfirm} loading={del.isPending}>
Delete machine
</Button>
</>
}
>
<p style={{ marginTop: 0 }}>
Permanently delete{" "}
<span className="mono">{machine?.hostname}</span> from GuruConnect,
including its registration and full history. This cannot be undone.
</p>
<label className="optline">
<input
type="checkbox"
checked={uninstall}
onChange={(e) => setUninstall(e.target.checked)}
/>
<span>
Also uninstall the agent
{machine && machine.status !== "online" && (
<em className="optline__note">
{" "}
(offline now; queued until the agent next checks in)
</em>
)}
</span>
</label>
<label className="optline">
<input
type="checkbox"
checked={exportHistory}
onChange={(e) => setExportHistory(e.target.checked)}
/>
<span>Export full history (download JSON) before removal</span>
</label>
</Modal>
);
}
function downloadHistory(hostname: string, history: unknown) {
const blob = new Blob([JSON.stringify(history, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${hostname}-history-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,72 @@
import { Button } from "../../components/ui/Button";
import { Modal } from "../../components/ui/Modal";
import { CopyIcon } from "../../components/layout/icons";
import { useClipboard } from "../../lib/useClipboard";
interface KeyRevealModalProps {
/** The plaintext `cak_...` key, or null when closed. */
plaintextKey: string | null;
onClose: () => void;
}
/**
* Copy-once key reveal. The server returns the plaintext key exactly once on
* creation; this is the only place it is ever shown. The user is warned and
* given a copy button. Closing dismisses it for good.
*/
export function KeyRevealModal({ plaintextKey, onClose }: KeyRevealModalProps) {
const { copied, copy } = useClipboard();
const open = plaintextKey != null;
return (
<Modal
open={open}
title="Agent key created"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Done</Button>}
>
<div className="keyreveal__warn" role="alert">
<svg
className="keyreveal__warnicon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10.3 3.7 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z" />
<path d="M12 9v4M12 17h.01" />
</svg>
<div>
<strong>Copy this key now. You will not see it again.</strong>
<span>
The key is shown only at creation and cannot be recovered. If you
lose it, revoke it and create a new one.
</span>
</div>
</div>
<div className="keyreveal__value">
<code className="keyreveal__key">{plaintextKey}</code>
<Button
variant="ghost"
size="sm"
onClick={() => plaintextKey && void copy(plaintextKey)}
aria-label={copied ? "Key copied to clipboard" : "Copy key to clipboard"}
>
<CopyIcon width={14} height={14} />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="keyreveal__hint">
Use this key to enroll the agent as a persistent, individually revocable
identity.
</p>
</Modal>
);
}

View File

@@ -0,0 +1,153 @@
import { ApiError } from "../../api/client";
import type { Machine } from "../../api/types";
import { Badge } from "../../components/ui/Badge";
import { Drawer } from "../../components/ui/Drawer";
import { Spinner } from "../../components/ui/Spinner";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { machineTone } from "../../components/ui/status";
import { StatusDot } from "../../components/ui/StatusDot";
import { absoluteTime, formatDuration, relativeTime } from "../../lib/time";
import { useMachineHistory } from "./hooks";
interface MachineDetailDrawerProps {
machine: Machine | null;
onClose: () => void;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<div className="mdetail__k">{label}</div>
<div className="mdetail__v">{children}</div>
</>
);
}
/**
* Read and inspect surface for a single machine: facts plus session and event
* history. A side drawer (not a modal): inspecting a machine is a lightweight,
* non-blocking read, and the list stays visible behind it for context.
*/
export function MachineDetailDrawer({ machine, onClose }: MachineDetailDrawerProps) {
const history = useMachineHistory(machine?.agent_id ?? null);
return (
<Drawer
open={machine != null}
ariaLabel={`Machine detail: ${machine?.hostname ?? ""}`}
title={
<>
{machine && (
<StatusDot tone={machineTone(machine.status)} label={machine.status} />
)}
<span className="mono">{machine?.hostname}</span>
</>
}
subtitle={machine ? `Agent ${machine.agent_id}` : undefined}
onClose={onClose}
>
{machine && (
<div className="mdetail__grid">
<Row label="Status">
<Badge tone={machineTone(machine.status)} dot>
{machine.status}
</Badge>
</Row>
<Row label="OS version">{machine.os_version ?? "Unknown"}</Row>
<Row label="Connection">
{machine.is_persistent ? (
<Badge tone="accent">Persistent</Badge>
) : (
<Badge tone="neutral">Attended</Badge>
)}{" "}
{machine.is_elevated && <Badge tone="ok">Elevated</Badge>}
</Row>
<Row label="First seen">
<span className="mono" title={absoluteTime(machine.first_seen)}>
{relativeTime(machine.first_seen)}
</span>
</Row>
<Row label="Last seen">
<span className="mono" title={absoluteTime(machine.last_seen)}>
{relativeTime(machine.last_seen)}
</span>
</Row>
</div>
)}
<div className="mdetail__section">
<h3>Session history</h3>
{history.isLoading ? (
<Spinner label="Loading history" />
) : history.isError ? (
<ErrorState
title="Could not load history"
message={
history.error instanceof ApiError
? history.error.message
: "The server did not respond. Try reopening this panel."
}
/>
) : !history.data || history.data.sessions.length === 0 ? (
<EmptyState
title="No sessions yet"
message="Support and managed sessions for this machine will be listed here."
/>
) : (
<table className="minitable">
<thead>
<tr>
<th>Started</th>
<th>Duration</th>
<th>Type</th>
<th>Code</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{history.data.sessions.map((s) => (
<tr key={s.id}>
<td className="mono" title={absoluteTime(s.started_at)}>
{relativeTime(s.started_at)}
</td>
<td className="mono">{formatDuration(s.duration_secs)}</td>
<td>{s.is_support_session ? "Support" : "Managed"}</td>
<td className="mono">{s.support_code ?? "None"}</td>
<td>{s.status}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{history.data && history.data.events.length > 0 && (
<div className="mdetail__section">
<h3>Recent events</h3>
<table className="minitable">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Viewer</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{history.data.events.slice(0, 25).map((e) => (
<tr key={e.id}>
<td className="mono" title={absoluteTime(e.timestamp)}>
{relativeTime(e.timestamp)}
</td>
<td>{e.event_type}</td>
<td>{e.viewer_name ?? "None"}</td>
<td className="mono">{e.ip_address ?? "None"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,197 @@
import { useState } from "react";
import { ApiError } from "../../api/client";
import type { KeyMetadata, Machine } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { Modal } from "../../components/ui/Modal";
import { Badge } from "../../components/ui/Badge";
import { Spinner } from "../../components/ui/Spinner";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { useToast } from "../../components/ui/toast-context";
import { absoluteTime, relativeTime } from "../../lib/time";
import {
useCreateMachineKey,
useMachineKeys,
useRevokeMachineKey,
} from "./hooks";
import { KeyRevealModal } from "./KeyRevealModal";
interface MachineKeysModalProps {
machine: Machine | null;
onClose: () => void;
}
function keyState(k: KeyMetadata): { tone: "ok" | "neutral"; label: string } {
return k.revoked_at
? { tone: "neutral", label: "Revoked" }
: { tone: "ok", label: "Active" };
}
/**
* Admin-only per-agent key management. Lists key metadata (never the secret),
* mints new keys (revealed once via KeyRevealModal), and revokes existing keys.
*/
export function MachineKeysModal({ machine, onClose }: MachineKeysModalProps) {
const toast = useToast();
const agentId = machine?.agent_id ?? "";
const keysQuery = useMachineKeys(machine?.agent_id ?? null, machine != null);
const createKey = useCreateMachineKey(agentId);
const revokeKey = useRevokeMachineKey(agentId);
const [revealKey, setRevealKey] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<KeyMetadata | null>(null);
function handleCreate() {
createKey.mutate(undefined, {
onSuccess: (created) => {
setRevealKey(created.key);
toast.success("Key created", "Copy it now — it is shown only once.");
},
onError: (err) => {
toast.error(
"Could not create key",
err instanceof ApiError ? err.message : "Unexpected error.",
);
},
});
}
function handleRevoke() {
if (!pendingRevoke) return;
const id = pendingRevoke.id;
revokeKey.mutate(id, {
onSuccess: () => {
toast.success("Key revoked");
setPendingRevoke(null);
},
onError: (err) => {
toast.error(
"Could not revoke key",
err instanceof ApiError ? err.message : "Unexpected error.",
);
setPendingRevoke(null);
},
});
}
const keys = keysQuery.data ?? [];
return (
<>
<Modal
open={machine != null && revealKey == null}
title={
<>
Agent keys ·{" "}
<span className="mono" style={{ fontWeight: 500 }}>
{machine?.hostname}
</span>
</>
}
ariaLabel={`Agent keys for ${machine?.hostname ?? "machine"}`}
onClose={onClose}
wide
footer={
<>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<Button
variant="primary"
onClick={handleCreate}
loading={createKey.isPending}
>
Create key
</Button>
</>
}
>
{keysQuery.isLoading ? (
<div style={{ padding: "24px 0", display: "grid", placeItems: "center" }}>
<Spinner label="Loading keys" />
</div>
) : keysQuery.isError ? (
<ErrorState
title="Failed to load keys"
message={
keysQuery.error instanceof ApiError
? keysQuery.error.message
: "Unexpected error."
}
/>
) : keys.length === 0 ? (
<EmptyState
title="No keys issued"
message="This machine has no per-agent keys. Create one to enroll it as a persistent identity."
/>
) : (
<table className="minitable">
<thead>
<tr>
<th>State</th>
<th>Key ID</th>
<th>Created</th>
<th>Last used</th>
<th />
</tr>
</thead>
<tbody>
{keys.map((k) => {
const s = keyState(k);
return (
<tr key={k.id} className={k.revoked_at ? "key--revoked" : undefined}>
<td>
<Badge tone={s.tone} dot>
{s.label}
</Badge>
</td>
<td className="mono" title={k.id}>
{k.id}
</td>
<td className="mono" title={absoluteTime(k.created_at)}>
{relativeTime(k.created_at)}
</td>
<td className="mono" title={absoluteTime(k.last_used_at)}>
{k.last_used_at ? relativeTime(k.last_used_at) : "never"}
</td>
<td style={{ textAlign: "right" }}>
{!k.revoked_at && (
<Button
variant="danger"
size="sm"
onClick={() => setPendingRevoke(k)}
>
Revoke
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Modal>
<KeyRevealModal plaintextKey={revealKey} onClose={() => setRevealKey(null)} />
<ConfirmDialog
open={pendingRevoke != null}
title="Revoke agent key"
danger
busy={revokeKey.isPending}
confirmLabel="Revoke key"
body={
<span>
Revoking this key immediately blocks any agent authenticating with
it. This cannot be undone. Key{" "}
<code className="mono">{pendingRevoke?.id}</code>.
</span>
}
onConfirm={handleRevoke}
onCancel={() => setPendingRevoke(null)}
/>
</>
);
}

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

View File

@@ -0,0 +1,78 @@
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import * as machinesApi from "../../api/machines";
import type { DeleteMachineParams } from "../../api/types";
const MACHINES_KEY = ["machines"] as const;
/** List all machines. Polls so online/offline status stays fresh. */
export function useMachines() {
return useQuery({
queryKey: MACHINES_KEY,
queryFn: ({ signal }) => machinesApi.listMachines(signal),
refetchInterval: 20_000,
staleTime: 10_000,
});
}
/** Machine history (sessions + events) for the detail drawer. Lazy via `enabled`. */
export function useMachineHistory(agentId: string | null) {
return useQuery({
queryKey: ["machine-history", agentId],
queryFn: ({ signal }) => machinesApi.getMachineHistory(agentId!, signal),
enabled: agentId != null,
staleTime: 30_000,
});
}
/** Delete a machine, then invalidate the list so it disappears. */
export function useDeleteMachine() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
agentId,
params,
}: {
agentId: string;
params: DeleteMachineParams;
}) => machinesApi.deleteMachine(agentId, params),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: MACHINES_KEY });
},
});
}
// --- Admin: per-agent keys --------------------------------------------------
export function useMachineKeys(agentId: string | null, enabled: boolean) {
return useQuery({
queryKey: ["machine-keys", agentId],
queryFn: () => machinesApi.listMachineKeys(agentId!),
enabled: enabled && agentId != null,
staleTime: 15_000,
});
}
export function useCreateMachineKey(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => machinesApi.createMachineKey(agentId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["machine-keys", agentId] });
},
});
}
export function useRevokeMachineKey(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (keyId: string) =>
machinesApi.revokeMachineKey(agentId, keyId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["machine-keys", agentId] });
},
});
}

View File

@@ -0,0 +1,131 @@
/* ===================================================== Machine detail body */
.mdetail__grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 16px;
align-items: baseline;
}
.mdetail__k {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.mdetail__v {
color: var(--text);
font-size: 13px;
word-break: break-word;
}
.mdetail__section {
margin-top: 22px;
}
.mdetail__section h3 {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
margin: 0 0 10px;
}
/* Inline mini table inside the detail (sessions / events / keys). */
.minitable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.minitable th {
text-align: left;
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: 700;
padding: 0 10px 6px 0;
}
.minitable td {
padding: 6px 10px 6px 0;
border-top: 1px solid var(--border);
color: var(--text);
vertical-align: top;
}
.minitable .mono {
color: var(--text-muted);
}
/* ============================================================ Key reveal === */
.keyreveal__warn {
display: flex;
gap: 11px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--warn-soft);
border: 1px solid var(--warn-soft);
color: var(--text);
font-size: 13px;
margin-bottom: 14px;
}
.keyreveal__warnicon {
flex: 0 0 auto;
margin-top: 1px;
color: var(--warn);
}
.keyreveal__warn strong {
display: block;
margin-bottom: 3px;
}
.keyreveal__warn span {
color: var(--text-muted);
}
.keyreveal__value {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--panel-2);
border: 1px solid var(--border-strong);
}
.keyreveal__key {
flex: 1;
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent);
word-break: break-all;
user-select: all;
}
.keyreveal__hint {
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
}
/* ====================================================== Delete options === */
.optline {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.optline input[type="checkbox"] {
margin-top: 2px;
width: 15px;
height: 15px;
accent-color: var(--accent);
flex: 0 0 auto;
}
.optline__note {
color: var(--warn);
font-style: normal;
}
/* Revoked rows read dimmer. */
.key--revoked td {
color: var(--text-faint);
}
.key--revoked .mono {
color: var(--text-faint);
}