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:
106
dashboard/src/features/auth/LoginPage.tsx
Normal file
106
dashboard/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
dashboard/src/features/auth/login.css
Normal file
91
dashboard/src/features/auth/login.css
Normal 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;
|
||||
}
|
||||
126
dashboard/src/features/machines/DeleteMachineDialog.tsx
Normal file
126
dashboard/src/features/machines/DeleteMachineDialog.tsx
Normal 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);
|
||||
}
|
||||
72
dashboard/src/features/machines/KeyRevealModal.tsx
Normal file
72
dashboard/src/features/machines/KeyRevealModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
dashboard/src/features/machines/MachineDetailDrawer.tsx
Normal file
153
dashboard/src/features/machines/MachineDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
dashboard/src/features/machines/MachineKeysModal.tsx
Normal file
197
dashboard/src/features/machines/MachineKeysModal.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
78
dashboard/src/features/machines/hooks.ts
Normal file
78
dashboard/src/features/machines/hooks.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
131
dashboard/src/features/machines/machines.css
Normal file
131
dashboard/src/features/machines/machines.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user