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. +

+ + {mint.isPending && !result ? ( +
+ +
+ ) : mint.isError ? ( + { + if (session) + void mint.mutateAsync(session.id).then(setResult).catch(() => {}); + }} + > + Retry + + } + /> + ) : result ? ( + <> +
+ Access granted + {grantedControl ? ( + + Full control + + ) : ( + + View only + + )} + {canControl && !grantedControl && ( + + The relay granted view-only for your account. + + )} +
+ + void tokenCopy.copy(result.token)} + mono + /> + + void urlCopy.copy(viewerUrl)} + /> + +

+ 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. +

+ + ) : null} +
+ ); +} + +interface FieldRevealProps { + label: string; + value: string; + copied: boolean; + onCopy: () => void; + mono?: boolean; +} + +/** A labeled read-only value with a copy button (token / URL reveal). */ +function FieldReveal({ label, value, copied, onCopy, mono }: FieldRevealProps) { + return ( +
+ {label} +
+ + {value} + + +
+
+ ); +} diff --git a/dashboard/src/features/sessions/SessionsPage.tsx b/dashboard/src/features/sessions/SessionsPage.tsx new file mode 100644 index 0000000..c35d23f --- /dev/null +++ b/dashboard/src/features/sessions/SessionsPage.tsx @@ -0,0 +1,306 @@ +import { useMemo, useState } from "react"; +import { ApiError } from "../../api/client"; +import type { Session } from "../../api/types"; +import { useAuth } from "../../auth/AuthContext"; +import { PageHeader } from "../../components/layout/PageHeader"; +import { + JoinIcon, + RefreshIcon, + SearchIcon, + StopIcon, +} 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 { consentLabel, consentTone } 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 { EndSessionDialog } from "./EndSessionDialog"; +import { JoinSessionModal } from "./JoinSessionModal"; +import { useSessions } from "./hooks"; +import "./sessions.css"; + +/** Live elapsed time since an ISO start, formatted like the duration helper. */ +function elapsedSince(iso: string): string { + const start = new Date(iso).getTime(); + if (Number.isNaN(start)) return "—"; + const secs = Math.max(0, Math.floor((Date.now() - start) / 1000)); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`; + return `${s}s`; +} + +export function SessionsPage() { + const { isAdmin, hasPermission } = useAuth(); + const sessionsQuery = useSessions(); + const [filter, setFilter] = useState(""); + + const [joinFor, setJoinFor] = useState(null); + const [endFor, setEndFor] = useState(null); + + // The same authz split the server enforces at mint time: admin OR the + // `control` permission yields a control token; otherwise the floor is `view`. + // We mirror it so we never present a control affordance the server downgrades, + // and so a user with neither permission gets no join action at all (the mint + // would 403). + const canControl = isAdmin || hasPermission("control"); + const canView = canControl || hasPermission("view"); + + const { data } = sessionsQuery; + const sessions = useMemo(() => data ?? [], [data]); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return sessions; + return sessions.filter( + (s) => + s.agent_name.toLowerCase().includes(q) || + s.agent_id.toLowerCase().includes(q), + ); + }, [sessions, filter]); + + const onlineCount = useMemo( + () => sessions.filter((s) => s.is_online).length, + [sessions], + ); + + // A viewer may only join sessions the relay would admit: not_required or + // granted. pending/denied/offline cannot be joined, so the join action is + // disabled with a reason rather than offered and rejected. + function joinBlockedReason(s: Session): string | null { + if (!s.is_online) return "Agent is offline"; + if (s.consent_state === "pending") return "Waiting on end-user consent"; + if (s.consent_state === "denied") return "Consent was denied"; + return null; + } + + const columns: Column[] = [ + { + key: "status", + header: "", + cellClass: "dt__status", + render: (s) => ( + + ), + }, + { + key: "machine", + header: "Machine", + render: (s) => ( +
+ {s.agent_name} + + {s.agent_id} + +
+ ), + }, + { + key: "mode", + header: "Mode", + render: (s) => + s.is_persistent ? ( + Managed + ) : ( + Attended + ), + }, + { + key: "consent", + header: "Consent", + render: (s) => ( + + {consentLabel(s.consent_state)} + + ), + }, + { + key: "viewers", + header: "Viewers", + render: (s) => + s.viewer_count > 0 ? ( + v.name).join(", ")}> + {s.viewer_count} watching + + ) : ( + None + ), + }, + { + key: "started", + header: "Started", + render: (s) => ( + + {relativeTime(s.started_at)} + + ), + }, + { + key: "duration", + header: "Duration", + render: (s) => ( + + {s.is_online ? elapsedSince(s.started_at) : "—"} + + ), + }, + { + key: "actions", + header: "", + cellClass: "dt__actions", + render: (s) => { + const blocked = joinBlockedReason(s); + return ( + e.stopPropagation()} + > + {canView && ( + + )} + + + ); + }, + }, + ]; + + return ( +
+ void sessionsQuery.refetch()} + loading={sessionsQuery.isFetching} + > + + Refresh + + } + /> + + +
+
+
+ + + + setFilter(e.target.value)} + aria-label="Filter sessions" + /> +
+
+ {onlineCount} live ·{" "} + {sessions.length} total +
+
+
+ + {sessionsQuery.isLoading ? ( + <> + + Loading sessions + + + + ) : sessionsQuery.isError ? ( + void sessionsQuery.refetch()}> + Retry + + } + /> + ) : filtered.length === 0 ? ( + filter ? ( + setFilter("")}> + Clear filter + + } + /> + ) : ( + + ) + ) : ( + s.id} + /> + )} + + + setJoinFor(null)} + /> + setEndFor(null)} /> + + ); +} diff --git a/dashboard/src/features/sessions/hooks.ts b/dashboard/src/features/sessions/hooks.ts new file mode 100644 index 0000000..c0c4c11 --- /dev/null +++ b/dashboard/src/features/sessions/hooks.ts @@ -0,0 +1,46 @@ +import { + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import * as sessionsApi from "../../api/sessions"; + +const SESSIONS_KEY = ["sessions"] as const; + +/** + * List all live sessions. Polls on a short interval so consent-state + * transitions (pending -> granted/denied) and viewer counts surface while the + * page is open — a tech watching for an attended user to accept needs this to + * move on its own. Slightly tighter than the machines poll because consent is a + * time-sensitive, human-in-the-loop transition. + */ +export function useSessions() { + return useQuery({ + queryKey: SESSIONS_KEY, + queryFn: ({ signal }) => sessionsApi.listSessions(signal), + refetchInterval: 8_000, + staleTime: 4_000, + }); +} + +/** + * Mint a session-scoped viewer token. The server decides the access mode from + * the caller's permissions; the result is shown in the join modal. No cache + * invalidation — minting does not change session state. + */ +export function useMintViewerToken() { + return useMutation({ + mutationFn: (sessionId: string) => sessionsApi.mintViewerToken(sessionId), + }); +} + +/** End a session, then invalidate the list so it drops (or flips offline). */ +export function useEndSession() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (sessionId: string) => sessionsApi.endSession(sessionId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: SESSIONS_KEY }); + }, + }); +} diff --git a/dashboard/src/features/sessions/sessions.css b/dashboard/src/features/sessions/sessions.css new file mode 100644 index 0000000..3362bf1 --- /dev/null +++ b/dashboard/src/features/sessions/sessions.css @@ -0,0 +1,102 @@ +/* ===================================================== Sessions table cells */ + +/* Machine cell: name over a dimmer agent id, same two-line idiom as the + detail drawer's key/value pairing. */ +.sdt__machine { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.sdt__agentid { + font-size: 11px; + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} +.sdt__viewers { + color: var(--text); + font-size: 13px; +} + +/* ========================================================= Join modal ===== */ +.joinmodal__lede { + margin: 0 0 16px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} + +.joinmodal__loading { + display: flex; + justify-content: center; + padding: 28px 0; +} + +/* Access-mode summary row: "Access granted" + the granted-mode badge. */ +.joinmodal__accessrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding: 11px 14px; + margin-bottom: 14px; + border-radius: var(--radius-sm); + background: var(--panel-2); + border: 1px solid var(--border); +} +.joinmodal__accesslabel { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); +} +.joinmodal__downgrade { + flex-basis: 100%; + font-size: 12px; + color: var(--text-faint); +} + +/* Token / URL reveal field: label above a boxed value with a copy button. */ +.joinmodal__field { + margin-bottom: 14px; +} +.joinmodal__fieldlabel { + display: block; + margin-bottom: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-faint); +} +.joinmodal__fieldvalue { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--panel-2); + border: 1px solid var(--border-strong); +} +.joinmodal__code { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--text); + word-break: break-all; + user-select: all; +} +.joinmodal__code--mono { + font-family: var(--font-mono); + color: var(--accent); +} + +.joinmodal__hint { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +}