feat(dashboard): GuruConnect v2 Sessions view (pass 2)
All checks were successful
All checks were successful
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/machines" element={<MachinesPage />} />
|
||||
{/* Sessions / Codes / Users land in later passes. */}
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
{/* Codes / Users land in later passes. */}
|
||||
<Route path="/" element={<Navigate to="/machines" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
36
dashboard/src/api/sessions.ts
Normal file
36
dashboard/src/api/sessions.ts
Normal file
@@ -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<Session[]> {
|
||||
return http.get<Session[]>("/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<ViewerTokenResponse> {
|
||||
return http.post<ViewerTokenResponse>(
|
||||
`/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<void> {
|
||||
return http.del<void>(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -111,3 +111,19 @@ export function CopyIcon(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function JoinIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StopIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<rect x="5" y="5" width="14" height="14" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
65
dashboard/src/features/sessions/EndSessionDialog.tsx
Normal file
65
dashboard/src/features/sessions/EndSessionDialog.tsx
Normal file
@@ -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 (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
title="End this session?"
|
||||
danger
|
||||
busy={endSession.isPending}
|
||||
confirmLabel="End session"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onClose}
|
||||
body={
|
||||
session ? (
|
||||
<p>
|
||||
This disconnects{" "}
|
||||
<strong>{session.agent_name}</strong> 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."}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
214
dashboard/src/features/sessions/JoinSessionModal.tsx
Normal file
214
dashboard/src/features/sessions/JoinSessionModal.tsx
Normal file
@@ -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<ViewerTokenResponse | null>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
title={canControl ? "Control session" : "View session"}
|
||||
onClose={onClose}
|
||||
wide
|
||||
footer={<Button variant="primary" onClick={onClose}>Done</Button>}
|
||||
>
|
||||
<p className="joinmodal__lede">
|
||||
A short-lived viewer token for{" "}
|
||||
<span className="dt__strong">{session.agent_name}</span> 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.
|
||||
</p>
|
||||
|
||||
{mint.isPending && !result ? (
|
||||
<div className="joinmodal__loading">
|
||||
<Spinner label="Minting viewer token…" />
|
||||
</div>
|
||||
) : mint.isError ? (
|
||||
<ErrorState
|
||||
title="Could not mint a viewer token"
|
||||
message={
|
||||
mint.error instanceof ApiError
|
||||
? mint.error.message
|
||||
: "The relay did not return a token. The session may have just ended."
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (session)
|
||||
void mint.mutateAsync(session.id).then(setResult).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : result ? (
|
||||
<>
|
||||
<div className="joinmodal__accessrow">
|
||||
<span className="joinmodal__accesslabel">Access granted</span>
|
||||
{grantedControl ? (
|
||||
<Badge tone="ok" dot>
|
||||
Full control
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral" dot>
|
||||
View only
|
||||
</Badge>
|
||||
)}
|
||||
{canControl && !grantedControl && (
|
||||
<span className="joinmodal__downgrade">
|
||||
The relay granted view-only for your account.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FieldReveal
|
||||
label="Viewer token"
|
||||
value={result.token}
|
||||
copied={tokenCopy.copied}
|
||||
onCopy={() => void tokenCopy.copy(result.token)}
|
||||
mono
|
||||
/>
|
||||
|
||||
<FieldReveal
|
||||
label="Relay viewer URL"
|
||||
value={viewerUrl}
|
||||
copied={urlCopy.copied}
|
||||
onCopy={() => void urlCopy.copy(viewerUrl)}
|
||||
/>
|
||||
|
||||
<p className="joinmodal__hint">
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="joinmodal__field">
|
||||
<span className="joinmodal__fieldlabel">{label}</span>
|
||||
<div className="joinmodal__fieldvalue">
|
||||
<code className={mono ? "joinmodal__code joinmodal__code--mono" : "joinmodal__code"}>
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
aria-label={copied ? `${label} copied to clipboard` : `Copy ${label.toLowerCase()}`}
|
||||
>
|
||||
<CopyIcon width={14} height={14} />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
dashboard/src/features/sessions/SessionsPage.tsx
Normal file
306
dashboard/src/features/sessions/SessionsPage.tsx
Normal file
@@ -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<Session | null>(null);
|
||||
const [endFor, setEndFor] = useState<Session | null>(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<Session>[] = [
|
||||
{
|
||||
key: "status",
|
||||
header: "",
|
||||
cellClass: "dt__status",
|
||||
render: (s) => (
|
||||
<StatusDot
|
||||
tone={s.is_online ? "ok" : "bad"}
|
||||
label={s.is_online ? "online" : "offline"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "machine",
|
||||
header: "Machine",
|
||||
render: (s) => (
|
||||
<div className="sdt__machine">
|
||||
<span className="dt__strong">{s.agent_name}</span>
|
||||
<span className="dt__mono sdt__agentid" title={s.agent_id}>
|
||||
{s.agent_id}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
header: "Mode",
|
||||
render: (s) =>
|
||||
s.is_persistent ? (
|
||||
<Badge tone="accent">Managed</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Attended</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "consent",
|
||||
header: "Consent",
|
||||
render: (s) => (
|
||||
<Badge tone={consentTone(s.consent_state)} dot>
|
||||
{consentLabel(s.consent_state)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "viewers",
|
||||
header: "Viewers",
|
||||
render: (s) =>
|
||||
s.viewer_count > 0 ? (
|
||||
<span className="sdt__viewers" title={s.viewers.map((v) => v.name).join(", ")}>
|
||||
<span className="dt__strong">{s.viewer_count}</span> watching
|
||||
</span>
|
||||
) : (
|
||||
<span className="dt__muted">None</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "started",
|
||||
header: "Started",
|
||||
render: (s) => (
|
||||
<span className="dt__mono" title={absoluteTime(s.started_at)}>
|
||||
{relativeTime(s.started_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "Duration",
|
||||
render: (s) => (
|
||||
<span className="dt__mono">
|
||||
{s.is_online ? elapsedSince(s.started_at) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
cellClass: "dt__actions",
|
||||
render: (s) => {
|
||||
const blocked = joinBlockedReason(s);
|
||||
return (
|
||||
<span
|
||||
className="dt__rowactions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canView && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setJoinFor(s)}
|
||||
disabled={blocked != null}
|
||||
title={blocked ?? undefined}
|
||||
aria-label={
|
||||
blocked
|
||||
? `${canControl ? "Control" : "View"} ${s.agent_name} (unavailable: ${blocked})`
|
||||
: `${canControl ? "Control" : "View"} ${s.agent_name}`
|
||||
}
|
||||
>
|
||||
<JoinIcon width={14} height={14} />
|
||||
{canControl ? "Control" : "View"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setEndFor(s)}
|
||||
disabled={!s.is_online}
|
||||
title={!s.is_online ? "Agent is offline" : undefined}
|
||||
aria-label={`End session on ${s.agent_name}`}
|
||||
>
|
||||
<StopIcon width={14} height={14} />
|
||||
End
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Sessions"
|
||||
subtitle="Live remote sessions across the relay. Consent transitions update on their own."
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void sessionsQuery.refetch()}
|
||||
loading={sessionsQuery.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 machine or agent ID"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
aria-label="Filter sessions"
|
||||
/>
|
||||
</div>
|
||||
<div className="toolbar__count">
|
||||
<span className="mono">{onlineCount}</span> live ·{" "}
|
||||
<span className="mono">{sessions.length}</span> total
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionsQuery.isLoading ? (
|
||||
<>
|
||||
<span className="visually-hidden" role="status">
|
||||
Loading sessions
|
||||
</span>
|
||||
<TableSkeleton
|
||||
headers={[
|
||||
"",
|
||||
"Machine",
|
||||
"Mode",
|
||||
"Consent",
|
||||
"Viewers",
|
||||
"Started",
|
||||
"Duration",
|
||||
"",
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : sessionsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="Could not load sessions"
|
||||
message={
|
||||
sessionsQuery.error instanceof ApiError
|
||||
? sessionsQuery.error.message
|
||||
: "The GuruConnect relay did not respond. Check the relay status, then retry."
|
||||
}
|
||||
action={
|
||||
<Button variant="primary" onClick={() => void sessionsQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : filtered.length === 0 ? (
|
||||
filter ? (
|
||||
<EmptyState
|
||||
title="No matching sessions"
|
||||
message={`Nothing matches "${filter}". Clear the filter to see every live session.`}
|
||||
action={
|
||||
<Button variant="ghost" onClick={() => setFilter("")}>
|
||||
Clear filter
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No active sessions"
|
||||
message="Sessions appear here when a managed agent connects or a support code is redeemed. Start one from the Machines view or hand a support code to an end user."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={filtered}
|
||||
rowKey={(s) => s.id}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<JoinSessionModal
|
||||
session={joinFor}
|
||||
canControl={canControl}
|
||||
onClose={() => setJoinFor(null)}
|
||||
/>
|
||||
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
dashboard/src/features/sessions/hooks.ts
Normal file
46
dashboard/src/features/sessions/hooks.ts
Normal file
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
102
dashboard/src/features/sessions/sessions.css
Normal file
102
dashboard/src/features/sessions/sessions.css
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user