feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s

React + Vite + TypeScript SPA: scaffold, operations-terminal design
system, Bearer-token auth, and the Machines view.

- Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan),
  Hanken Grotesk + JetBrains Mono, status-color language
  (online/offline/granted/pending/denied/not_required), motion with
  prefers-reduced-motion honored.
- Auth: token in sessionStorage via ref (never React state), protected
  routes, 401 session teardown, admin-gated per-agent-key UI.
- Machines view: data table (sticky header, keyboard-activated rows,
  skeleton loading, actionable empty/error states), non-blocking detail
  drawer, delete confirm, admin key management with copy-once reveal.
- UI primitives: Modal (focus trap + inert + portal + dialogStack),
  Drawer, Table, Badge/StatusDot, toast, states.
- Typed API client normalizing the two error-envelope shapes.

Passed Code Review (no blockers), impeccable critique-and-polish, and
local gates (tsc/lint/build green). Dev-only Vite proxy to :3002.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 12:51:11 -07:00
parent f9bdecbfdb
commit 43a9432b81
66 changed files with 7777 additions and 995 deletions

View File

@@ -1,162 +0,0 @@
/**
* Minimal protobuf encoder/decoder for GuruConnect messages
*
* For MVP, we use a simplified binary format. In production,
* this would use a proper protobuf library like protobufjs.
*/
import type { MouseEvent, KeyEvent, MouseEventType, KeyEventType, VideoFrame, RawFrame } from '../types/protocol';
// Message type identifiers (matching proto field numbers)
const MSG_VIDEO_FRAME = 10;
const MSG_MOUSE_EVENT = 20;
const MSG_KEY_EVENT = 21;
/**
* Encode a mouse event to binary format
*/
export function encodeMouseEvent(event: MouseEvent): Uint8Array {
const buffer = new ArrayBuffer(32);
const view = new DataView(buffer);
// Message type
view.setUint8(0, MSG_MOUSE_EVENT);
// Event type
view.setUint8(1, event.eventType);
// Coordinates (scaled to 16-bit for efficiency)
view.setInt16(2, event.x, true);
view.setInt16(4, event.y, true);
// Buttons bitmask
let buttons = 0;
if (event.buttons.left) buttons |= 1;
if (event.buttons.right) buttons |= 2;
if (event.buttons.middle) buttons |= 4;
if (event.buttons.x1) buttons |= 8;
if (event.buttons.x2) buttons |= 16;
view.setUint8(6, buttons);
// Wheel deltas
view.setInt16(7, event.wheelDeltaX, true);
view.setInt16(9, event.wheelDeltaY, true);
return new Uint8Array(buffer, 0, 11);
}
/**
* Encode a key event to binary format
*/
export function encodeKeyEvent(event: KeyEvent): Uint8Array {
const buffer = new ArrayBuffer(32);
const view = new DataView(buffer);
// Message type
view.setUint8(0, MSG_KEY_EVENT);
// Key down/up
view.setUint8(1, event.down ? 1 : 0);
// Key type
view.setUint8(2, event.keyType);
// Virtual key code
view.setUint16(3, event.vkCode, true);
// Scan code
view.setUint16(5, event.scanCode, true);
// Modifiers bitmask
let mods = 0;
if (event.modifiers.ctrl) mods |= 1;
if (event.modifiers.alt) mods |= 2;
if (event.modifiers.shift) mods |= 4;
if (event.modifiers.meta) mods |= 8;
if (event.modifiers.capsLock) mods |= 16;
if (event.modifiers.numLock) mods |= 32;
view.setUint8(7, mods);
// Unicode character (if present)
if (event.unicode && event.unicode.length > 0) {
const charCode = event.unicode.charCodeAt(0);
view.setUint16(8, charCode, true);
return new Uint8Array(buffer, 0, 10);
}
return new Uint8Array(buffer, 0, 8);
}
/**
* Decode a video frame from binary format
*/
export function decodeVideoFrame(data: Uint8Array): VideoFrame | null {
if (data.length < 2) return null;
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const msgType = view.getUint8(0);
if (msgType !== MSG_VIDEO_FRAME) return null;
const encoding = view.getUint8(1);
const displayId = view.getUint8(2);
const sequence = view.getUint32(3, true);
const timestamp = Number(view.getBigInt64(7, true));
// Frame dimensions
const width = view.getUint16(15, true);
const height = view.getUint16(17, true);
// Compressed flag
const compressed = view.getUint8(19) === 1;
// Is keyframe
const isKeyframe = view.getUint8(20) === 1;
// Frame data starts at offset 21
const frameData = data.slice(21);
const encodingStr = ['raw', 'vp9', 'h264', 'h265'][encoding] as 'raw' | 'vp9' | 'h264' | 'h265';
if (encodingStr === 'raw') {
return {
timestamp,
displayId,
sequence,
encoding: 'raw',
raw: {
width,
height,
data: frameData,
compressed,
dirtyRects: [], // TODO: Parse dirty rects
isKeyframe,
},
};
}
return {
timestamp,
displayId,
sequence,
encoding: encodingStr,
encoded: {
data: frameData,
keyframe: isKeyframe,
pts: timestamp,
dts: timestamp,
},
};
}
/**
* Simple zstd decompression placeholder
* In production, use a proper zstd library like fzstd
*/
export async function decompressZstd(data: Uint8Array): Promise<Uint8Array> {
// For MVP, assume uncompressed frames or use fzstd library
// This is a placeholder - actual implementation would use:
// import { decompress } from 'fzstd';
// return decompress(data);
return data;
}

60
dashboard/src/lib/time.ts Normal file
View File

@@ -0,0 +1,60 @@
// Time formatting helpers. Relative for at-a-glance scanning, absolute (mono)
// for the precise value on hover.
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
/**
* Human relative time, e.g. "3 minutes ago" / "in 2 days". Returns "—" for
* missing input and "just now" for sub-10-second deltas.
*/
export function relativeTime(iso: string | null | undefined): string {
if (!iso) return "—";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "—";
const deltaSec = Math.round((then - Date.now()) / 1000);
const abs = Math.abs(deltaSec);
if (abs < 10) return "just now";
for (const [unit, secs] of UNITS) {
if (abs >= secs || unit === "second") {
return rtf.format(Math.round(deltaSec / secs), unit);
}
}
return "just now";
}
/** Absolute local timestamp for tooltips / mono display. */
export function absoluteTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
/** Format a duration in seconds as a compact "1h 04m" / "47s" string. */
export function formatDuration(secs: number | null | undefined): string {
if (secs == null || secs < 0) return "—";
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`;
}

View File

@@ -0,0 +1,48 @@
import { useCallback, useRef, useState } from "react";
/**
* Copy-to-clipboard with a transient "copied" flag. Falls back to a hidden
* textarea + execCommand for non-secure contexts where the async Clipboard API
* is unavailable.
*/
export function useClipboard(resetMs = 1800): {
copied: boolean;
copy: (text: string) => Promise<boolean>;
} {
const [copied, setCopied] = useState(false);
const timer = useRef<number | undefined>(undefined);
const copy = useCallback(
async (text: string): Promise<boolean> => {
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
ok = document.execCommand("copy");
document.body.removeChild(ta);
}
} catch {
ok = false;
}
if (ok) {
setCopied(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), resetMs);
}
return ok;
},
[resetMs],
);
return { copied, copy };
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
/**
* Lightweight relay-connection liveness probe for the topbar indicator.
*
* Pass 1 has no viewer WebSocket yet, so "live" here means the GC server is
* reachable. It polls the unauthenticated `/health` route every 15s. This is a
* deliberately cheap signal — the real relay/WS liveness lands with the viewer
* in a later pass.
*/
export function useRelayStatus(): { live: boolean; checking: boolean } {
const { data, isLoading, isError } = useQuery({
queryKey: ["health"],
queryFn: async ({ signal }) => {
const res = await fetch(`${BASE_URL}/health`, { signal });
if (!res.ok) throw new Error(`health ${res.status}`);
return true;
},
refetchInterval: 15_000,
refetchOnWindowFocus: true,
retry: 1,
staleTime: 10_000,
});
return { live: data === true && !isError, checking: isLoading };
}