From 6ecb937eb66e46a1c407a059b07556e213f74de6 Mon Sep 17 00:00:00 2001
From: Mike Swanson
Date: Sat, 30 May 2026 13:12:04 -0700
Subject: [PATCH] feat(dashboard): GuruConnect v2 Sessions view (pass 2)
Active-sessions table with consent-state badges, viewer-token Join,
and disconnect, built on the v2 session API and existing UI primitives.
- Sessions table: machine, mode (managed/attended), consent badge
(granted/pending+pulse/denied/not_required), viewers, started,
duration, status. Sticky header, skeleton load, empty/error states.
- Join action mints a session-scoped viewer token
(POST /api/sessions/:id/viewer-token) and reveals it with the
/ws/viewer relay URL and copy buttons. The static viewer.html is
intentionally not targeted: it sends the raw login JWT, which the v2
viewer plane rejects. In-dashboard web viewer ships in a later pass.
- Authz split mirrors the server mint gate: admin or control permission
gets Control; view permission gets View only; neither hides the action.
Server remains authoritative; the minted token carries the signed
access claim.
- Disconnect via confirm dialog (DELETE /api/sessions/:id), invalidates
the sessions query. List polls every 8s so consent transitions surface.
Passed Code Review (no blockers) and local gates (tsc/lint/build green).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
dashboard/src/App.tsx | 4 +-
dashboard/src/api/sessions.ts | 36 +++
dashboard/src/api/types.ts | 63 ++++
dashboard/src/components/layout/Sidebar.tsx | 2 +-
dashboard/src/components/layout/icons.tsx | 16 +
dashboard/src/components/ui/status.ts | 20 ++
.../features/sessions/EndSessionDialog.tsx | 65 ++++
.../features/sessions/JoinSessionModal.tsx | 214 ++++++++++++
.../src/features/sessions/SessionsPage.tsx | 306 ++++++++++++++++++
dashboard/src/features/sessions/hooks.ts | 46 +++
dashboard/src/features/sessions/sessions.css | 102 ++++++
11 files changed, 872 insertions(+), 2 deletions(-)
create mode 100644 dashboard/src/api/sessions.ts
create mode 100644 dashboard/src/features/sessions/EndSessionDialog.tsx
create mode 100644 dashboard/src/features/sessions/JoinSessionModal.tsx
create mode 100644 dashboard/src/features/sessions/SessionsPage.tsx
create mode 100644 dashboard/src/features/sessions/hooks.ts
create mode 100644 dashboard/src/features/sessions/sessions.css
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 9113598..fe83402 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -6,6 +6,7 @@ import { AppShell } from "./components/layout/AppShell";
import { ToastProvider } from "./components/ui/toast";
import { LoginPage } from "./features/auth/LoginPage";
import { MachinesPage } from "./features/machines/MachinesPage";
+import { SessionsPage } from "./features/sessions/SessionsPage";
const queryClient = new QueryClient({
defaultOptions: {
@@ -27,7 +28,8 @@ export function App() {
}>
}>
} />
- {/* Sessions / Codes / Users land in later passes. */}
+ } />
+ {/* Codes / Users land in later passes. */}
} />
diff --git a/dashboard/src/api/sessions.ts b/dashboard/src/api/sessions.ts
new file mode 100644
index 0000000..b986750
--- /dev/null
+++ b/dashboard/src/api/sessions.ts
@@ -0,0 +1,36 @@
+import { http } from "./client";
+import type { Session, ViewerTokenResponse } from "./types";
+
+/**
+ * GET /api/sessions — all live sessions known to the relay's in-memory session
+ * manager (active + offline-persistent). Requires an authenticated dashboard
+ * JWT; any authenticated user may list.
+ */
+export function listSessions(signal?: AbortSignal): Promise {
+ return http.get("/api/sessions", signal);
+}
+
+/**
+ * POST /api/sessions/:id/viewer-token — mint a short-lived, session-scoped
+ * viewer token. The server decides the access mode from the caller's
+ * permissions: admin or `control` permission gets a `control` token, otherwise
+ * a `view_only` token. A caller with neither `control` nor `view` gets 403.
+ * The access mode is stamped into the signed token; this response only echoes
+ * it. (See server/src/api/sessions.rs::mint_viewer_token.)
+ */
+export function mintViewerToken(
+ sessionId: string,
+): Promise {
+ return http.post(
+ `/api/sessions/${encodeURIComponent(sessionId)}/viewer-token`,
+ );
+}
+
+/**
+ * DELETE /api/sessions/:id — disconnect/end a live session. The relay sends a
+ * Disconnect to the agent. Returns 200 on success, 404 if the session is not
+ * found. Requires an authenticated dashboard JWT (not admin-gated server-side).
+ */
+export function endSession(sessionId: string): Promise {
+ return http.del(`/api/sessions/${encodeURIComponent(sessionId)}`);
+}
diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts
index f5ac52c..004b677 100644
--- a/dashboard/src/api/types.ts
+++ b/dashboard/src/api/types.ts
@@ -94,6 +94,69 @@ export interface DeleteMachineResponse {
history: MachineHistory | null;
}
+// ---------------------------------------------------------------------------
+// Sessions (live relay state)
+// ---------------------------------------------------------------------------
+
+/**
+ * Attended-consent state. Mirrors `connect_sessions.consent_state` and
+ * `session::ConsentState::as_db_str`. Managed/persistent sessions are
+ * `not_required`; attended (support-code) sessions move
+ * `pending` -> `granted` | `denied`. A viewer may only join `not_required` or
+ * `granted` (the relay refuses the others).
+ */
+export type ConsentState =
+ | "not_required"
+ | "pending"
+ | "granted"
+ | "denied";
+
+/** A technician/viewer currently watching a session. Mirrors `ViewerInfoApi`. */
+export interface SessionViewer {
+ id: string;
+ name: string;
+ connected_at: string; // RFC3339
+}
+
+/**
+ * Live session as returned by GET /api/sessions. Field names mirror
+ * `api::SessionInfo` (server/src/api/mod.rs) exactly. This is in-memory relay
+ * state, not the historical `connect_sessions` row (that is `SessionRecord`).
+ */
+export interface Session {
+ id: string;
+ agent_id: string;
+ agent_name: string;
+ started_at: string; // RFC3339
+ viewer_count: number;
+ viewers: SessionViewer[];
+ is_streaming: boolean;
+ is_online: boolean;
+ is_persistent: boolean;
+ last_heartbeat: string; // RFC3339
+ os_version: string | null;
+ is_elevated: boolean;
+ uptime_secs: number;
+ display_count: number;
+ agent_version: string | null;
+ consent_state: ConsentState | string;
+}
+
+/** Access mode the relay grants a minted viewer token. */
+export type ViewerAccess = "control" | "view_only";
+
+/**
+ * Response from POST /api/sessions/:id/viewer-token. Mirrors
+ * `api::sessions::ViewerTokenResponse`. The signed token carries the
+ * authoritative access claim; `access` here is the echoed mode.
+ */
+export interface ViewerTokenResponse {
+ token: string;
+ session_id: string;
+ expires_in_secs: number;
+ access: ViewerAccess | string;
+}
+
// ---------------------------------------------------------------------------
// Per-agent keys (admin plane)
// ---------------------------------------------------------------------------
diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx
index 04c481d..8dca59a 100644
--- a/dashboard/src/components/layout/Sidebar.tsx
+++ b/dashboard/src/components/layout/Sidebar.tsx
@@ -17,7 +17,7 @@ interface NavItem {
const NAV: NavItem[] = [
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
- { to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: false },
+ { to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: true },
{ to: "/codes", label: "Codes", Icon: CodesIcon, enabled: false },
{ to: "/users", label: "Users", Icon: UsersIcon, enabled: false },
];
diff --git a/dashboard/src/components/layout/icons.tsx b/dashboard/src/components/layout/icons.tsx
index a2ccf19..d3cde36 100644
--- a/dashboard/src/components/layout/icons.tsx
+++ b/dashboard/src/components/layout/icons.tsx
@@ -111,3 +111,19 @@ export function CopyIcon(props: IconProps) {
);
}
+
+export function JoinIcon(props: IconProps) {
+ return (
+
+ );
+}
+
+export function StopIcon(props: IconProps) {
+ return (
+
+ );
+}
diff --git a/dashboard/src/components/ui/status.ts b/dashboard/src/components/ui/status.ts
index 9b74e09..fd8a535 100644
--- a/dashboard/src/components/ui/status.ts
+++ b/dashboard/src/components/ui/status.ts
@@ -26,3 +26,23 @@ export function consentTone(state: string): StatusTone {
return "neutral";
}
}
+
+/**
+ * Human label for an attended-consent state. Kept here next to `consentTone`
+ * so the color and the words for a given state never drift apart. `pending` is
+ * phrased as the active wait it represents (a tech is blocked on it).
+ */
+export function consentLabel(state: string): string {
+ switch (state) {
+ case "granted":
+ return "Granted";
+ case "pending":
+ return "Awaiting consent";
+ case "denied":
+ return "Denied";
+ case "not_required":
+ return "Not required";
+ default:
+ return state;
+ }
+}
diff --git a/dashboard/src/features/sessions/EndSessionDialog.tsx b/dashboard/src/features/sessions/EndSessionDialog.tsx
new file mode 100644
index 0000000..48dd283
--- /dev/null
+++ b/dashboard/src/features/sessions/EndSessionDialog.tsx
@@ -0,0 +1,65 @@
+import { ApiError } from "../../api/client";
+import type { Session } from "../../api/types";
+import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
+import { useToast } from "../../components/ui/toast-context";
+import { useEndSession } from "./hooks";
+
+interface EndSessionDialogProps {
+ /** The session to end, or null when the dialog is closed. */
+ session: Session | null;
+ onClose: () => void;
+}
+
+/**
+ * Confirm + end a live session. The relay sends a Disconnect to the agent and
+ * tears down the viewer stream; for an attended session this drops the support
+ * connection entirely. We confirm first because it interrupts whoever is
+ * connected, then invalidate the list so the row updates.
+ */
+export function EndSessionDialog({ session, onClose }: EndSessionDialogProps) {
+ const toast = useToast();
+ const endSession = useEndSession();
+ const open = session != null;
+
+ function onConfirm() {
+ if (!session) return;
+ endSession.mutate(session.id, {
+ onSuccess: () => {
+ toast.success(
+ "Session ended",
+ `Disconnected ${session.agent_name}.`,
+ );
+ onClose();
+ },
+ onError: (err) => {
+ toast.error(
+ "Could not end session",
+ err instanceof ApiError ? err.message : "The relay did not respond.",
+ );
+ },
+ });
+ }
+
+ return (
+
+ This disconnects{" "}
+ {session.agent_name} and drops any technician
+ currently viewing it. {session.is_persistent
+ ? "The managed agent stays enrolled and can reconnect."
+ : "The support session ends and cannot be resumed."}
+
+ ) : null
+ }
+ />
+ );
+}
diff --git a/dashboard/src/features/sessions/JoinSessionModal.tsx b/dashboard/src/features/sessions/JoinSessionModal.tsx
new file mode 100644
index 0000000..8df125e
--- /dev/null
+++ b/dashboard/src/features/sessions/JoinSessionModal.tsx
@@ -0,0 +1,214 @@
+import { useEffect, useMemo, useState } from "react";
+import { ApiError } from "../../api/client";
+import type { Session, ViewerTokenResponse } from "../../api/types";
+import { Badge } from "../../components/ui/Badge";
+import { Button } from "../../components/ui/Button";
+import { Modal } from "../../components/ui/Modal";
+import { Spinner } from "../../components/ui/Spinner";
+import { ErrorState } from "../../components/ui/States";
+import { CopyIcon } from "../../components/layout/icons";
+import { useClipboard } from "../../lib/useClipboard";
+import { useMintViewerToken } from "./hooks";
+import "./sessions.css";
+
+interface JoinSessionModalProps {
+ /** The session to join, or null when the modal is closed. */
+ session: Session | null;
+ /** Whether the caller may request a control token (admin or `control`). */
+ canControl: boolean;
+ onClose: () => void;
+}
+
+/**
+ * Build the exact `/ws/viewer` URL the relay expects for a minted token. The
+ * relay reads `session_id`, `viewer_name`, and `token` query params
+ * (server/src/relay/mod.rs::ViewerParams) and binds the token to this session.
+ * Same-origin in production; the dashboard is served behind the same host as
+ * the relay.
+ */
+function buildViewerUrl(token: ViewerTokenResponse, viewerName: string): string {
+ const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const qs = new URLSearchParams({
+ session_id: token.session_id,
+ viewer_name: viewerName,
+ token: token.token,
+ });
+ return `${wsProto}//${window.location.host}/ws/viewer?${qs.toString()}`;
+}
+
+/**
+ * Join action. The in-dashboard web viewer is not built yet (a later pass), and
+ * the existing static `viewer.html` is incompatible with v2: it reads the token
+ * from localStorage and sends the raw login JWT, which the v2 relay rejects on
+ * the viewer plane (it requires a session-scoped viewer token). So instead of
+ * opening a viewer that would be refused, we mint the token here and reveal it
+ * with the exact relay URL and copy buttons. This is the honest, working bridge
+ * until the web viewer ships.
+ *
+ * The access mode (Control vs View) is decided SERVER-SIDE from the caller's
+ * permissions; we only request and reflect it. `canControl` gates the label and
+ * intent so we never present a control affordance the server would downgrade.
+ */
+export function JoinSessionModal({
+ session,
+ canControl,
+ onClose,
+}: JoinSessionModalProps) {
+ const open = session != null;
+ const mint = useMintViewerToken();
+ const tokenCopy = useClipboard();
+ const urlCopy = useClipboard();
+ const [result, setResult] = useState(null);
+
+ const viewerName = canControl ? "Operator" : "Observer";
+
+ // Mint once when the modal opens for a session; reset on close. We mint via an
+ // effect (not on the click) so the modal can own the loading/error/success
+ // states cleanly and a re-open always re-mints a fresh short-lived token.
+ useEffect(() => {
+ if (!session) {
+ setResult(null);
+ mint.reset();
+ return;
+ }
+ let cancelled = false;
+ mint
+ .mutateAsync(session.id)
+ .then((res) => {
+ if (!cancelled) setResult(res);
+ })
+ .catch(() => {
+ // Error surfaced via mint.isError below; nothing to do here.
+ });
+ return () => {
+ cancelled = true;
+ };
+ // Mint is keyed to the session id only; the mutation object is stable.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [session?.id]);
+
+ const viewerUrl = useMemo(
+ () => (result ? buildViewerUrl(result, viewerName) : ""),
+ [result, viewerName],
+ );
+
+ if (!open) return null;
+
+ const grantedControl = result?.access === "control";
+ const ttlMins = result ? Math.round(result.expires_in_secs / 60) : 0;
+
+ return (
+ Done}
+ >
+
+ A short-lived viewer token for{" "}
+ {session.agent_name} scoped to this
+ session only. The in-dashboard web viewer ships in a later pass; until
+ then, use this token with the relay URL below to connect a viewer.
+
+ Token expires in about {ttlMins} minute{ttlMins === 1 ? "" : "s"} and
+ works only for this session. It carries the access mode in its signed
+ claims, so it cannot be upgraded to control after the fact.
+