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:
12
dashboard/.env.example
Normal file
12
dashboard/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# GuruConnect dashboard — environment.
|
||||
# Copy to `.env.local` for local overrides (gitignored via `*.local`).
|
||||
|
||||
# Base URL for the GuruConnect API. Leave UNSET to use same-origin (the
|
||||
# production default — the dashboard is served by the GC server itself).
|
||||
#
|
||||
# In `npm run dev`, leave this unset too: Vite proxies `/api` and `/ws` to the
|
||||
# local GC server (see vite.config.ts), so same-origin requests just work.
|
||||
#
|
||||
# Set it only to point the dashboard at a *different* host (e.g. a remote
|
||||
# server while developing the UI locally):
|
||||
# VITE_API_URL=https://connect.azcomputerguru.com
|
||||
5
dashboard/.gitignore
vendored
Normal file
5
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.vite
|
||||
node_modules/.tmp
|
||||
109
dashboard/README.md
Normal file
109
dashboard/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# GuruConnect Operator Dashboard (v2)
|
||||
|
||||
React + Vite + TypeScript SPA — the operator console for GuruConnect v2. A dark
|
||||
"operations terminal" UI for managing the remote-support fleet.
|
||||
|
||||
> **Pass 1 scope.** This pass ships the scaffold, design system, app shell,
|
||||
> auth, the typed API client, and the **Machines** view. Sessions, Codes, and
|
||||
> Users are nav stubs only (disabled in the sidebar) and arrive in later passes.
|
||||
|
||||
## Stack
|
||||
|
||||
- **React 18** + **React Router 6** (client-side routing)
|
||||
- **Vite 5** (dev server + build)
|
||||
- **TypeScript** (strict)
|
||||
- **@tanstack/react-query** (server-state, polling, cache invalidation)
|
||||
- **@fontsource** — Hanken Grotesk (UI) + JetBrains Mono (technical data)
|
||||
|
||||
No component/icon libraries — primitives and icons are hand-built to keep the
|
||||
console aesthetic and the bundle lean.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite dev server (proxies /api + /ws to the local GC server)
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm run preview # serve the production build locally
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
api/ Typed API client + response interfaces (source of truth: server/src/api/*.rs)
|
||||
client.ts fetch wrapper: base URL, bearer token, dual error-envelope normalization
|
||||
types.ts TS mirrors of the Rust response structs
|
||||
auth.ts login / me / logout
|
||||
machines.ts list / get / history / delete + admin key endpoints
|
||||
stubs.ts sessions / codes / users — scaffolds for later passes
|
||||
auth/ AuthProvider (token in memory + sessionStorage), context, ProtectedRoute
|
||||
components/
|
||||
ui/ Reusable primitives: Button, Badge/StatusDot, Table, Panel,
|
||||
Modal, ConfirmDialog, Input/Field, Spinner, States, Toast
|
||||
layout/ AppShell, Sidebar, Topbar, PageHeader, inline SVG icons
|
||||
features/
|
||||
auth/ LoginPage
|
||||
machines/ MachinesPage + detail / delete / admin-keys modals + hooks
|
||||
lib/ time formatting, clipboard, relay-status probe
|
||||
styles/ tokens.css (design tokens)
|
||||
```
|
||||
|
||||
## Design system — "operations terminal"
|
||||
|
||||
Dark control-room console. Tokens live in `src/styles/tokens.css`; primitive
|
||||
styles in `src/components/ui/*.css`.
|
||||
|
||||
- **Surfaces:** `--bg #0b0f14`, `--panel #141b22`, `--panel-2 #0e1419`
|
||||
- **Accent (signal cyan):** `--accent #22d3bf` — primary actions + live state
|
||||
- **Status language (dot + label, used everywhere):** ok/online `--ok`,
|
||||
pending `--warn` (soft pulse), denied/offline/error `--bad`, neutral
|
||||
`--neutral`. Mapping centralised in `components/ui/status.ts`.
|
||||
- **Type:** Hanken Grotesk for UI; **JetBrains Mono for all technical data**
|
||||
(agent IDs, support codes, IPs, versions, timestamps, key fingerprints).
|
||||
- **Motion (restrained):** staggered row fade-in, the consent pulse, the live
|
||||
relay pip, hover transitions. All disabled under `prefers-reduced-motion`.
|
||||
|
||||
## Auth
|
||||
|
||||
`POST /api/auth/login` → `{ token, user }`. The token is held in an in-memory
|
||||
ref and mirrored to **sessionStorage** (never localStorage), so it clears when
|
||||
the tab closes. `GET /api/auth/me` restores the session on reload;
|
||||
`POST /api/auth/logout` revokes it server-side. The client attaches
|
||||
`Authorization: Bearer <token>` to every request and bounces to `/login` on any
|
||||
401. Admin-only UI (per-agent key management) is gated on `role === "admin"`.
|
||||
|
||||
The API uses **two** error envelopes — `{ error }` and
|
||||
`{ detail, error_code, status_code }`. `api/client.ts` extracts a message from
|
||||
whichever is present (and falls back to plain-text bodies that some routes
|
||||
return), so callers see one normalized `ApiError`.
|
||||
|
||||
## Dev proxy
|
||||
|
||||
`vite.config.ts` proxies `/api` and `/ws` to the local GC server
|
||||
(`http://localhost:3002`). Run the Rust server locally, then `npm run dev` —
|
||||
same-origin requests reach the backend with no CORS setup.
|
||||
|
||||
To develop the UI against a *remote* backend instead, set `VITE_API_URL`
|
||||
(see `.env.example`).
|
||||
|
||||
## Production serving — follow-up (NOT wired in this pass)
|
||||
|
||||
The build uses `base: "./"` so emitted assets use relative paths. Production
|
||||
serving means copying `dist/` into the GC server's static directory and adding a
|
||||
catch-all route that returns `index.html` for non-API, non-asset paths (so deep
|
||||
links like `/machines` survive a hard reload under the `BrowserRouter`).
|
||||
|
||||
That Rust-side wiring is a **deploy concern** and is intentionally left for a
|
||||
later step:
|
||||
|
||||
1. Copy `dist/` → `server/static/` (or serve `dist/` directly).
|
||||
2. Add an Axum fallback route serving `index.html` for unmatched GET paths,
|
||||
*after* the `/api/*`, `/ws/*`, and static-asset routes.
|
||||
3. If the dashboard is mounted under a sub-path rather than the server root,
|
||||
switch Vite `base` to that path and pass the same `basename` to
|
||||
`<BrowserRouter>`.
|
||||
|
||||
No server/Rust changes were made in this pass.
|
||||
32
dashboard/eslint.config.js
Normal file
32
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
13
dashboard/index.html
Normal file
13
dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>GuruConnect — Operator Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3331
dashboard/package-lock.json
generated
Normal file
3331
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,37 @@
|
||||
{
|
||||
"name": "@guruconnect/dashboard",
|
||||
"version": "0.2.0",
|
||||
"description": "GuruConnect Remote Desktop Viewer Components",
|
||||
"version": "2.0.0",
|
||||
"description": "GuruConnect v2 operator dashboard",
|
||||
"author": "AZ Computer Guru",
|
||||
"license": "Proprietary",
|
||||
"main": "src/components/index.ts",
|
||||
"types": "src/components/index.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"fzstd": "^0.1.1"
|
||||
"@fontsource/hanken-grotesk": "^5.0.8",
|
||||
"@fontsource/jetbrains-mono": "^5.0.18",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.9.0",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
41
dashboard/src/App.tsx
Normal file
41
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Navigate, Route, BrowserRouter, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { ProtectedRoute } from "./auth/ProtectedRoute";
|
||||
import { AppShell } from "./components/layout/AppShell";
|
||||
import { ToastProvider } from "./components/ui/toast";
|
||||
import { LoginPage } from "./features/auth/LoginPage";
|
||||
import { MachinesPage } from "./features/machines/MachinesPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/machines" element={<MachinesPage />} />
|
||||
{/* Sessions / Codes / Users land in later passes. */}
|
||||
<Route path="/" element={<Navigate to="/machines" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/machines" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
20
dashboard/src/api/auth.ts
Normal file
20
dashboard/src/api/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { http } from "./client";
|
||||
import type { LoginRequest, LoginResponse, User } from "./types";
|
||||
|
||||
/** POST /api/auth/login — exchange credentials for a JWT + user record. */
|
||||
export function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
// skipAuthRedirect: a 401 here is "bad credentials", not "session expired".
|
||||
return http.post<LoginResponse>("/api/auth/login", credentials, {
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/auth/me — restore the current user from a stored token. */
|
||||
export function getMe(): Promise<User> {
|
||||
return http.get<User>("/api/auth/me");
|
||||
}
|
||||
|
||||
/** POST /api/auth/logout — revoke the current token server-side. */
|
||||
export function logout(): Promise<{ message: string }> {
|
||||
return http.post<{ message: string }>("/api/auth/logout");
|
||||
}
|
||||
131
dashboard/src/api/client.ts
Normal file
131
dashboard/src/api/client.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Typed fetch wrapper for the GuruConnect API.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Resolve the base URL (VITE_API_URL, default same-origin).
|
||||
// - Attach `Authorization: Bearer <token>` from a pluggable token provider.
|
||||
// - Normalize the *two* inconsistent server error envelopes into one
|
||||
// ApiError shape so callers/UI never have to branch on which one came back.
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
/** A normalized API error. `code` is present only for the structured envelope. */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// The token lives in memory in the auth layer. We read it through a provider so
|
||||
// the client has no hard dependency on React state and stays testable.
|
||||
let tokenProvider: () => string | null = () => null;
|
||||
|
||||
export function setTokenProvider(provider: () => string | null): void {
|
||||
tokenProvider = provider;
|
||||
}
|
||||
|
||||
// Called when any request returns 401 — lets the auth layer tear down session
|
||||
// state and bounce to /login. Set by AuthProvider.
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
// Suppress the global 401 handler (used by the login call itself).
|
||||
skipAuthRedirect?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** The server's two error envelopes, unioned. We extract a message from either. */
|
||||
interface ErrorEnvelope {
|
||||
error?: string;
|
||||
detail?: string;
|
||||
error_code?: string;
|
||||
status_code?: number;
|
||||
}
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function extractError(res: Response): Promise<ApiError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
let code: string | undefined;
|
||||
|
||||
const raw = await res.text();
|
||||
if (raw) {
|
||||
try {
|
||||
const env = JSON.parse(raw) as ErrorEnvelope;
|
||||
// Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`.
|
||||
const msg = env.detail ?? env.error;
|
||||
if (typeof msg === "string" && msg.length > 0) message = msg;
|
||||
if (typeof env.error_code === "string") code = env.error_code;
|
||||
} catch {
|
||||
// Non-JSON body (e.g. the machines routes return plain &'static str on
|
||||
// error). Use the trimmed text as the message if it looks sane.
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed && trimmed.length < 300) message = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return new ApiError(message, res.status, code);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = tokenProvider();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path), {
|
||||
method: opts.method ?? "GET",
|
||||
headers,
|
||||
body,
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
throw new ApiError("Network error — could not reach the server.", 0);
|
||||
}
|
||||
|
||||
if (res.status === 401 && !opts.skipAuthRedirect) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw await extractError(res);
|
||||
}
|
||||
|
||||
// 204 No Content / empty body.
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string, signal?: AbortSignal) =>
|
||||
request<T>(path, { method: "GET", signal }),
|
||||
post: <T>(path: string, body?: unknown, opts?: Partial<RequestOptions>) =>
|
||||
request<T>(path, { method: "POST", body, ...opts }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: "PUT", body }),
|
||||
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
5
dashboard/src/api/index.ts
Normal file
5
dashboard/src/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./types";
|
||||
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
|
||||
export * as authApi from "./auth";
|
||||
export * as machinesApi from "./machines";
|
||||
export * as stubsApi from "./stubs";
|
||||
73
dashboard/src/api/machines.ts
Normal file
73
dashboard/src/api/machines.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { http } from "./client";
|
||||
import type {
|
||||
CreatedKey,
|
||||
DeleteMachineParams,
|
||||
DeleteMachineResponse,
|
||||
KeyMetadata,
|
||||
Machine,
|
||||
MachineHistory,
|
||||
} from "./types";
|
||||
|
||||
/** GET /api/machines — the real machines endpoint (NOT /api/sessions). */
|
||||
export function listMachines(signal?: AbortSignal): Promise<Machine[]> {
|
||||
return http.get<Machine[]>("/api/machines", signal);
|
||||
}
|
||||
|
||||
/** GET /api/machines/:agent_id — single machine. */
|
||||
export function getMachine(agentId: string): Promise<Machine> {
|
||||
return http.get<Machine>(`/api/machines/${encodeURIComponent(agentId)}`);
|
||||
}
|
||||
|
||||
/** GET /api/machines/:agent_id/history — past sessions + events. */
|
||||
export function getMachineHistory(
|
||||
agentId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MachineHistory> {
|
||||
return http.get<MachineHistory>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/history`,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/machines/:agent_id — remove a machine, optionally uninstall/export. */
|
||||
export function deleteMachine(
|
||||
agentId: string,
|
||||
params: DeleteMachineParams = {},
|
||||
): Promise<DeleteMachineResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.uninstall) qs.set("uninstall", "true");
|
||||
if (params.export) qs.set("export", "true");
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
return http.del<DeleteMachineResponse>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Admin: per-agent keys --------------------------------------------------
|
||||
|
||||
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */
|
||||
export function listMachineKeys(agentId: string): Promise<KeyMetadata[]> {
|
||||
return http.get<KeyMetadata[]>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/keys`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/machines/:agent_id/keys — mint a new per-agent key (admin only).
|
||||
* The plaintext `key` is returned ONCE in the response — never again.
|
||||
*/
|
||||
export function createMachineKey(agentId: string): Promise<CreatedKey> {
|
||||
return http.post<CreatedKey>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/keys`,
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/machines/:agent_id/keys/:key_id — revoke a key (admin only). */
|
||||
export function revokeMachineKey(
|
||||
agentId: string,
|
||||
keyId: string,
|
||||
): Promise<void> {
|
||||
return http.del<void>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/keys/${encodeURIComponent(keyId)}`,
|
||||
);
|
||||
}
|
||||
23
dashboard/src/api/stubs.ts
Normal file
23
dashboard/src/api/stubs.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Scaffolds for later passes. These endpoints exist on the server but their
|
||||
// views (Sessions, Codes, Users) are out of scope for pass 1. Typed signatures
|
||||
// are stubbed here so the API surface is discoverable and future passes can
|
||||
// flesh out the response interfaces against the Rust source.
|
||||
//
|
||||
// Intentionally minimal: do NOT build UI against these yet.
|
||||
|
||||
import { http } from "./client";
|
||||
|
||||
/** GET /api/sessions — active/historical sessions. Pass 2. */
|
||||
export function listSessions(signal?: AbortSignal): Promise<unknown[]> {
|
||||
return http.get<unknown[]>("/api/sessions", signal);
|
||||
}
|
||||
|
||||
/** GET /api/codes — one-time support codes. Pass 2. */
|
||||
export function listCodes(signal?: AbortSignal): Promise<unknown[]> {
|
||||
return http.get<unknown[]>("/api/codes", signal);
|
||||
}
|
||||
|
||||
/** GET /api/users — dashboard users (admin). Pass 2. */
|
||||
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
|
||||
return http.get<unknown[]>("/api/users", signal);
|
||||
}
|
||||
115
dashboard/src/api/types.ts
Normal file
115
dashboard/src/api/types.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Typed mirrors of the GuruConnect server API responses.
|
||||
// Shapes match server/src/api/*.rs exactly. Keep in sync with the Rust source
|
||||
// of truth — these are hand-maintained, not generated.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "viewer";
|
||||
|
||||
export type Permission =
|
||||
| "view"
|
||||
| "control"
|
||||
| "transfer"
|
||||
| "manage_users"
|
||||
| "manage_clients";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
// role/permission come from the server as plain strings; widen defensively.
|
||||
role: Role | string;
|
||||
permissions: (Permission | string)[];
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MachineStatus = "online" | "offline";
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
os_version: string | null;
|
||||
is_elevated: boolean;
|
||||
is_persistent: boolean;
|
||||
first_seen: string; // RFC3339
|
||||
last_seen: string; // RFC3339
|
||||
status: MachineStatus | string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
duration_secs: number | null;
|
||||
is_support_session: boolean;
|
||||
support_code: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface EventRecord {
|
||||
id: number;
|
||||
session_id: string;
|
||||
event_type: string;
|
||||
timestamp: string;
|
||||
viewer_id: string | null;
|
||||
viewer_name: string | null;
|
||||
details: unknown | null;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export interface MachineHistory {
|
||||
machine: Machine;
|
||||
sessions: SessionRecord[];
|
||||
events: EventRecord[];
|
||||
exported_at: string;
|
||||
}
|
||||
|
||||
export interface DeleteMachineParams {
|
||||
/** Send an uninstall command to the agent if it is online. */
|
||||
uninstall?: boolean;
|
||||
/** Include full history in the delete response before removal. */
|
||||
export?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteMachineResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
uninstall_sent: boolean;
|
||||
history: MachineHistory | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-agent keys (admin plane)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KeyMetadata {
|
||||
id: string;
|
||||
machine_id: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
/** Returned exactly once when a key is minted. `key` is plaintext `cak_...`. */
|
||||
export interface CreatedKey {
|
||||
id: string;
|
||||
machine_id: string;
|
||||
key: string;
|
||||
created_at: string;
|
||||
}
|
||||
21
dashboard/src/auth/AuthContext.tsx
Normal file
21
dashboard/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Permission, Role, User } from "../api/types";
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
/** True while restoring a session from a stored token on first load. */
|
||||
initializing: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
hasRole: (role: Role) => boolean;
|
||||
hasPermission: (perm: Permission) => boolean;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
100
dashboard/src/auth/AuthProvider.tsx
Normal file
100
dashboard/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as authApi from "../api/auth";
|
||||
import { setTokenProvider, setUnauthorizedHandler } from "../api/client";
|
||||
import type { Permission, Role, User } from "../api/types";
|
||||
import { AuthContext, type AuthState } from "./AuthContext";
|
||||
|
||||
const STORAGE_KEY = "gc.token";
|
||||
|
||||
/**
|
||||
* Token storage policy: the source of truth is an in-memory ref (survives
|
||||
* re-renders, never serialized into React state to avoid accidental logging).
|
||||
* It is mirrored into sessionStorage — NOT localStorage — so it clears when the
|
||||
* tab closes and never leaks across browser sessions.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const tokenRef = useRef<string | null>(sessionStorage.getItem(STORAGE_KEY));
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
const setToken = useCallback((token: string | null) => {
|
||||
tokenRef.current = token;
|
||||
if (token) sessionStorage.setItem(STORAGE_KEY, token);
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
// Wire the API client to read our token and to notify us on 401.
|
||||
useEffect(() => {
|
||||
setTokenProvider(() => tokenRef.current);
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, [setToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(clearSession);
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [clearSession]);
|
||||
|
||||
// Restore session on first load if a token is present.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function restore() {
|
||||
if (!tokenRef.current) {
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await authApi.getMe();
|
||||
if (!cancelled) setUser(me);
|
||||
} catch {
|
||||
// Invalid/expired token — clear it. The 401 handler also fires, but
|
||||
// guard here for non-401 failures too.
|
||||
if (!cancelled) clearSession();
|
||||
} finally {
|
||||
if (!cancelled) setInitializing(false);
|
||||
}
|
||||
}
|
||||
void restore();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clearSession]);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
const res = await authApi.login({ username, password });
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
},
|
||||
[setToken],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
// Best-effort server-side revocation; clear locally regardless.
|
||||
await authApi.logout();
|
||||
} catch {
|
||||
// ignore — token may already be invalid
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}, [clearSession]);
|
||||
|
||||
const value = useMemo<AuthState>(() => {
|
||||
const role = user?.role;
|
||||
return {
|
||||
user,
|
||||
initializing,
|
||||
login,
|
||||
logout,
|
||||
isAdmin: role === "admin",
|
||||
hasRole: (r: Role) => role === r,
|
||||
hasPermission: (p: Permission) => user?.permissions.includes(p) ?? false,
|
||||
};
|
||||
}, [user, initializing, login, logout]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
27
dashboard/src/auth/ProtectedRoute.tsx
Normal file
27
dashboard/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { Spinner } from "../components/ui/Spinner";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
/**
|
||||
* Gate for authenticated routes. While restoring a session from a stored token
|
||||
* we show a spinner (avoids a login-flash on reload). No user -> /login,
|
||||
* preserving the attempted location for post-login return.
|
||||
*/
|
||||
export function ProtectedRoute() {
|
||||
const { user, initializing } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="auth-gate">
|
||||
<Spinner label="Restoring session" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* RemoteViewer Component
|
||||
*
|
||||
* Canvas-based remote desktop viewer that connects to a GuruConnect
|
||||
* agent via the relay server. Handles frame rendering and input capture.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
|
||||
import type { VideoFrame, ConnectionStatus, MouseEventType } from '../types/protocol';
|
||||
|
||||
interface RemoteViewerProps {
|
||||
serverUrl: string;
|
||||
sessionId: string;
|
||||
className?: string;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
autoConnect?: boolean;
|
||||
showStatusBar?: boolean;
|
||||
}
|
||||
|
||||
export const RemoteViewer: React.FC<RemoteViewerProps> = ({
|
||||
serverUrl,
|
||||
sessionId,
|
||||
className = '',
|
||||
onStatusChange,
|
||||
autoConnect = true,
|
||||
showStatusBar = true,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// Display dimensions from received frames
|
||||
const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 });
|
||||
|
||||
// Frame buffer for rendering
|
||||
const frameBufferRef = useRef<ImageData | null>(null);
|
||||
|
||||
// Handle incoming video frames
|
||||
const handleFrame = useCallback((frame: VideoFrame) => {
|
||||
if (!frame.raw || !canvasRef.current) return;
|
||||
|
||||
const { width, height, data, compressed, isKeyframe } = frame.raw;
|
||||
|
||||
// Update display size if changed
|
||||
if (width !== displaySize.width || height !== displaySize.height) {
|
||||
setDisplaySize({ width, height });
|
||||
}
|
||||
|
||||
// Get or create context
|
||||
if (!ctxRef.current) {
|
||||
ctxRef.current = canvasRef.current.getContext('2d', {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
// For MVP, we assume raw BGRA frames
|
||||
// In production, handle compressed frames with fzstd
|
||||
let frameData = data;
|
||||
|
||||
// Create or reuse ImageData
|
||||
if (!frameBufferRef.current ||
|
||||
frameBufferRef.current.width !== width ||
|
||||
frameBufferRef.current.height !== height) {
|
||||
frameBufferRef.current = ctx.createImageData(width, height);
|
||||
}
|
||||
|
||||
const imageData = frameBufferRef.current;
|
||||
|
||||
// Convert BGRA to RGBA for canvas
|
||||
const pixels = imageData.data;
|
||||
const len = Math.min(frameData.length, pixels.length);
|
||||
|
||||
for (let i = 0; i < len; i += 4) {
|
||||
pixels[i] = frameData[i + 2]; // R <- B
|
||||
pixels[i + 1] = frameData[i + 1]; // G <- G
|
||||
pixels[i + 2] = frameData[i]; // B <- R
|
||||
pixels[i + 3] = 255; // A (opaque)
|
||||
}
|
||||
|
||||
// Draw to canvas
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, [displaySize]);
|
||||
|
||||
// Set up session
|
||||
const { status, connect, disconnect, sendMouseEvent, sendKeyEvent } = useRemoteSession({
|
||||
serverUrl,
|
||||
sessionId,
|
||||
onFrame: handleFrame,
|
||||
onStatusChange,
|
||||
});
|
||||
|
||||
// Auto-connect on mount
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect();
|
||||
}
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [autoConnect, connect, disconnect]);
|
||||
|
||||
// Update canvas size when display size changes
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.width = displaySize.width;
|
||||
canvasRef.current.height = displaySize.height;
|
||||
// Reset context reference
|
||||
ctxRef.current = null;
|
||||
frameBufferRef.current = null;
|
||||
}
|
||||
}, [displaySize]);
|
||||
|
||||
// Get canvas rect for coordinate translation
|
||||
const getCanvasRect = useCallback(() => {
|
||||
return canvasRef.current?.getBoundingClientRect() ?? new DOMRect();
|
||||
}, []);
|
||||
|
||||
// Mouse event handlers
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0);
|
||||
sendMouseEvent(event);
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1);
|
||||
sendMouseEvent(event);
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2);
|
||||
sendMouseEvent(event);
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const baseEvent = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 3);
|
||||
sendMouseEvent({
|
||||
...baseEvent,
|
||||
wheelDeltaX: Math.round(e.deltaX),
|
||||
wheelDeltaY: Math.round(e.deltaY),
|
||||
});
|
||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault(); // Prevent browser context menu
|
||||
}, []);
|
||||
|
||||
// Keyboard event handlers
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const event = createKeyEvent(e, true);
|
||||
sendKeyEvent(event);
|
||||
}, [sendKeyEvent]);
|
||||
|
||||
const handleKeyUp = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const event = createKeyEvent(e, false);
|
||||
sendKeyEvent(event);
|
||||
}, [sendKeyEvent]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`remote-viewer ${className}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: `${displaySize.width} / ${displaySize.height}`,
|
||||
cursor: 'none', // Hide cursor, remote cursor is shown in frame
|
||||
outline: 'none',
|
||||
backgroundColor: '#1a1a1a',
|
||||
}}
|
||||
/>
|
||||
|
||||
{showStatusBar && (
|
||||
<div className="remote-viewer-status" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
<span>
|
||||
{status.connected ? (
|
||||
<span style={{ color: '#4ade80' }}>Connected</span>
|
||||
) : (
|
||||
<span style={{ color: '#f87171' }}>Disconnected</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{displaySize.width}x{displaySize.height}</span>
|
||||
{status.fps !== undefined && <span>{status.fps} FPS</span>}
|
||||
{status.latencyMs !== undefined && <span>{status.latencyMs}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoteViewer;
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Session Controls Component
|
||||
*
|
||||
* Toolbar for controlling the remote session (quality, displays, special keys)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import type { QualitySettings, Display } from '../types/protocol';
|
||||
|
||||
interface SessionControlsProps {
|
||||
displays?: Display[];
|
||||
currentDisplay?: number;
|
||||
onDisplayChange?: (displayId: number) => void;
|
||||
quality?: QualitySettings;
|
||||
onQualityChange?: (settings: QualitySettings) => void;
|
||||
onSpecialKey?: (key: 'ctrl-alt-del' | 'lock-screen' | 'print-screen') => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export const SessionControls: React.FC<SessionControlsProps> = ({
|
||||
displays = [],
|
||||
currentDisplay = 0,
|
||||
onDisplayChange,
|
||||
quality,
|
||||
onQualityChange,
|
||||
onSpecialKey,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
const handleQualityPreset = (preset: 'auto' | 'low' | 'balanced' | 'high') => {
|
||||
onQualityChange?.({
|
||||
preset,
|
||||
codec: 'auto',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="session-controls" style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#222',
|
||||
borderBottom: '1px solid #444',
|
||||
}}>
|
||||
{/* Display selector */}
|
||||
{displays.length > 1 && (
|
||||
<select
|
||||
value={currentDisplay}
|
||||
onChange={(e) => onDisplayChange?.(Number(e.target.value))}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{displays.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name || `Display ${d.id + 1}`}
|
||||
{d.isPrimary ? ' (Primary)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Quality dropdown */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowQuality(!showQuality)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Quality: {quality?.preset || 'auto'}
|
||||
</button>
|
||||
|
||||
{showQuality && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
backgroundColor: '#333',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
{(['auto', 'low', 'balanced', 'high'] as const).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => {
|
||||
handleQualityPreset(preset);
|
||||
setShowQuality(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: quality?.preset === preset ? '#444' : 'transparent',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{preset.charAt(0).toUpperCase() + preset.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Special keys */}
|
||||
<button
|
||||
onClick={() => onSpecialKey?.('ctrl-alt-del')}
|
||||
title="Send Ctrl+Alt+Delete"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSpecialKey?.('lock-screen')}
|
||||
title="Lock Screen (Win+L)"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSpecialKey?.('print-screen')}
|
||||
title="Print Screen"
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
PrtSc
|
||||
</button>
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Disconnect */}
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#dc2626',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionControls;
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* GuruConnect Dashboard Components
|
||||
*
|
||||
* Export all components for use in GuruRMM dashboard
|
||||
*/
|
||||
|
||||
export { RemoteViewer } from './RemoteViewer';
|
||||
export { SessionControls } from './SessionControls';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
ConnectionStatus,
|
||||
Display,
|
||||
DisplayInfo,
|
||||
QualitySettings,
|
||||
VideoFrame,
|
||||
MouseEvent as ProtoMouseEvent,
|
||||
KeyEvent as ProtoKeyEvent,
|
||||
} from '../types/protocol';
|
||||
|
||||
// Re-export hooks
|
||||
export { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
|
||||
17
dashboard/src/components/layout/AppShell.tsx
Normal file
17
dashboard/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import "./layout.css";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
/** Persistent chrome: left sidebar + top bar around the routed page. */
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="shell">
|
||||
<Sidebar />
|
||||
<Topbar />
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
dashboard/src/components/layout/PageHeader.tsx
Normal file
21
dashboard/src/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
/** Primary action slot, right-aligned. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/** Standard page title block with an action slot. */
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="page__header">
|
||||
<div className="page__titles">
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <div className="page__subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="page__actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
dashboard/src/components/layout/Sidebar.tsx
Normal file
71
dashboard/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import {
|
||||
CodesIcon,
|
||||
MachinesIcon,
|
||||
SessionsIcon,
|
||||
UsersIcon,
|
||||
} from "./icons";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
/** Pass-1 stubs are disabled until their views land in later passes. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
|
||||
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: false },
|
||||
{ to: "/codes", label: "Codes", Icon: CodesIcon, enabled: false },
|
||||
{ to: "/users", label: "Users", Icon: UsersIcon, enabled: false },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__brand">
|
||||
<span className="sidebar__logo" aria-hidden="true">
|
||||
GC
|
||||
</span>
|
||||
<span className="sidebar__name">
|
||||
GuruConnect
|
||||
<small>Operator Console</small>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="sidebar__nav" aria-label="Primary">
|
||||
<span className="sidebar__section">Operations</span>
|
||||
{NAV.map(({ to, label, Icon, enabled }) =>
|
||||
enabled ? (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`navlink${isActive ? " navlink--active" : ""}`
|
||||
}
|
||||
>
|
||||
<span className="navlink__icon">
|
||||
<Icon />
|
||||
</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
) : (
|
||||
<span
|
||||
key={to}
|
||||
className="navlink navlink--disabled"
|
||||
aria-disabled="true"
|
||||
title={`${label} — coming in a later pass`}
|
||||
>
|
||||
<span className="navlink__icon">
|
||||
<Icon />
|
||||
</span>
|
||||
{label}
|
||||
<span className="navlink__soon">Soon</span>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
51
dashboard/src/components/layout/Topbar.tsx
Normal file
51
dashboard/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { useRelayStatus } from "../../lib/useRelayStatus";
|
||||
import { Badge } from "../ui/Badge";
|
||||
import { Button } from "../ui/Button";
|
||||
import { LogoutIcon } from "./icons";
|
||||
|
||||
function roleTone(role: string | undefined): "accent" | "ok" | "neutral" {
|
||||
if (role === "admin") return "accent";
|
||||
if (role === "operator") return "ok";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const { user, logout } = useAuth();
|
||||
const { live, checking } = useRelayStatus();
|
||||
|
||||
const relayClass = live ? "relay relay--live" : "relay relay--down";
|
||||
const relayLabel = checking ? "probing" : live ? "live" : "offline";
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div
|
||||
className={relayClass}
|
||||
title="GuruConnect relay connection"
|
||||
aria-label={`Relay ${relayLabel}`}
|
||||
>
|
||||
<span className="relay__pip" aria-hidden="true" />
|
||||
<span>Relay</span>
|
||||
<span className="relay__label mono">{relayLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="topbar__spacer" />
|
||||
|
||||
<div className="topbar__user">
|
||||
<div className="topbar__id">
|
||||
<span className="topbar__username">{user?.username}</span>
|
||||
</div>
|
||||
<Badge tone={roleTone(user?.role)}>{user?.role ?? "—"}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void logout()}
|
||||
aria-label="Log out"
|
||||
>
|
||||
<LogoutIcon width={15} height={15} />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
113
dashboard/src/components/layout/icons.tsx
Normal file
113
dashboard/src/components/layout/icons.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// Inline stroke icons (no icon-library dependency). 18px on a 24 viewBox.
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
function base(props: IconProps) {
|
||||
return {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
export function MachinesIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M4 6h16M4 12h16M4 18h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodesIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M8 6 3 12l5 6M16 6l5 6-5 6M13 4l-2 16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="m10.7 12.3 8.3-8.3M16 6l3 3M14 8l2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
215
dashboard/src/components/layout/layout.css
Normal file
215
dashboard/src/components/layout/layout.css
Normal file
@@ -0,0 +1,215 @@
|
||||
/* ============================================================= App shell === */
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ================================================================ Sidebar === */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background: var(--panel-2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: var(--topbar-h);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar__logo {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-press));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent-ink);
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar__name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.sidebar__name small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.sidebar__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar__section {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
padding: 14px 10px 6px;
|
||||
}
|
||||
.navlink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
height: 38px;
|
||||
padding: 0 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.navlink:hover {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
.navlink--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-ring);
|
||||
}
|
||||
.navlink--disabled {
|
||||
color: var(--text-faint);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
.navlink__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.navlink__soon {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* ================================================================= Topbar === */
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.relay {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.relay__pip {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.relay--live .relay__pip {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 8px var(--ok);
|
||||
animation: gc-live 1.8s var(--ease) infinite;
|
||||
}
|
||||
.relay--down .relay__pip {
|
||||
background: var(--bad);
|
||||
}
|
||||
.relay__label.mono {
|
||||
font-size: 11px;
|
||||
}
|
||||
.topbar__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.topbar__id {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.topbar__username {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ================================================================== Main === */
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.page {
|
||||
padding: 22px 24px 40px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.page__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.page__titles h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.page__subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-gate {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
25
dashboard/src/components/ui/Badge.tsx
Normal file
25
dashboard/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { StatusTone } from "./status";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
|
||||
type BadgeTone = StatusTone | "accent";
|
||||
|
||||
interface BadgeProps {
|
||||
tone?: BadgeTone;
|
||||
/** Render a leading status dot inside the badge. */
|
||||
dot?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pill label using the status vocabulary. With `dot`, pairs the label with a
|
||||
* matching StatusDot so the dot+label convention reads consistently.
|
||||
*/
|
||||
export function Badge({ tone = "neutral", dot = false, children }: BadgeProps) {
|
||||
return (
|
||||
<span className={`badge badge--${tone}`}>
|
||||
{dot && tone !== "accent" && <StatusDot tone={tone} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
53
dashboard/src/components/ui/Button.tsx
Normal file
53
dashboard/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "ghost" | "danger";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
/** Compact 28px height for table-row actions and tight toolbars. */
|
||||
size?: "sm" | "md";
|
||||
/** Stretch to fill the container width (e.g. login submit). */
|
||||
block?: boolean;
|
||||
/** Show a spinner and disable while an async action is in flight. */
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one button. Variants map to the design language:
|
||||
* - primary: accent-solid, the single high-signal action per surface
|
||||
* - ghost: bordered, secondary
|
||||
* - danger: destructive (delete machine, revoke key)
|
||||
*/
|
||||
export function Button({
|
||||
variant = "ghost",
|
||||
size = "md",
|
||||
block = false,
|
||||
loading = false,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
"btn",
|
||||
`btn--${variant}`,
|
||||
size === "sm" && "btn--sm",
|
||||
block && "btn--block",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <span className="btn__spin" aria-hidden="true" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
55
dashboard/src/components/ui/ConfirmDialog.tsx
Normal file
55
dashboard/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Style the confirm button as destructive. */
|
||||
danger?: boolean;
|
||||
/** Disable controls + spin the confirm button while the action runs. */
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Small yes/no confirmation built on Modal. */
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={busy ? () => {} : onCancel}
|
||||
dismissable={!busy}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={danger ? "danger" : "primary"}
|
||||
onClick={onConfirm}
|
||||
loading={busy}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
150
dashboard/src/components/ui/Drawer.tsx
Normal file
150
dashboard/src/components/ui/Drawer.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
hasOpenDialog,
|
||||
isTopDialog,
|
||||
popDialog,
|
||||
pushDialog,
|
||||
} from "./dialogStack";
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
/** Accessible name when `title` is not plain text. */
|
||||
ariaLabel?: string;
|
||||
/** Optional secondary line under the title (status, id). */
|
||||
subtitle?: ReactNode;
|
||||
onClose: () => void;
|
||||
/** Sticky footer slot for actions. */
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Right-anchored side panel for read and inspect flows (machine detail and
|
||||
* history) where a modal would over-interrupt. Shares the dialog a11y contract:
|
||||
* Tab focus is trapped, the rest of the page is inert, Escape closes, and focus
|
||||
* returns to the trigger on close.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
title,
|
||||
ariaLabel,
|
||||
subtitle,
|
||||
onClose,
|
||||
footer,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocused = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const panel = panelRef.current;
|
||||
lastFocused.current = document.activeElement as HTMLElement | null;
|
||||
const token = pushDialog();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
root?.setAttribute("inert", "");
|
||||
|
||||
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel)?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!isTopDialog(token)) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panel) return;
|
||||
const items = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const firstEl = items[0];
|
||||
const lastEl = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement;
|
||||
if (e.shiftKey && (active === firstEl || active === panel)) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
popDialog(token);
|
||||
if (!hasOpenDialog()) root?.removeAttribute("inert");
|
||||
lastFocused.current?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="drawer__scrim"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className="drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="drawer__head">
|
||||
<div className="drawer__titles">
|
||||
<h2 className="drawer__title" id={titleId}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && <div className="drawer__subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div className="drawer__body">{children}</div>
|
||||
{footer && <footer className="drawer__footer">{footer}</footer>}
|
||||
</aside>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
36
dashboard/src/components/ui/Input.tsx
Normal file
36
dashboard/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
/** Render technical/data values in JetBrains Mono. */
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/** Bare styled text input. Compose with <Field> for a labeled control. */
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ mono, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const classes = ["input", mono && "input--mono", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return <input ref={ref} className={classes} {...rest} />;
|
||||
});
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Label + control wrapper for forms. */
|
||||
export function Field({ label, htmlFor, children }: FieldProps) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
dashboard/src/components/ui/Modal.tsx
Normal file
160
dashboard/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
hasOpenDialog,
|
||||
isTopDialog,
|
||||
popDialog,
|
||||
pushDialog,
|
||||
} from "./dialogStack";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
/** Accessible name for the dialog when `title` is not plain text. */
|
||||
ariaLabel?: string;
|
||||
onClose: () => void;
|
||||
/** Footer slot, typically the action buttons. */
|
||||
footer?: ReactNode;
|
||||
/** Wider layout for content-heavy dialogs (key management). */
|
||||
wide?: boolean;
|
||||
/** Disable overlay-click and Escape dismissal (e.g. during a pending action). */
|
||||
dismissable?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Accessible modal dialog. Closes on Escape and overlay click (unless
|
||||
* `dismissable` is false), traps Tab focus inside, marks the rest of the page
|
||||
* inert, and restores focus to the trigger on close.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
ariaLabel,
|
||||
onClose,
|
||||
footer,
|
||||
wide,
|
||||
dismissable = true,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocused = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const panel = panelRef.current;
|
||||
lastFocused.current = document.activeElement as HTMLElement | null;
|
||||
const token = pushDialog();
|
||||
|
||||
// Mark everything outside the dialog inert so focus and clicks can't reach
|
||||
// the page behind. Dialogs are portaled to <body>, so this targets #root.
|
||||
const root = document.getElementById("root");
|
||||
root?.setAttribute("inert", "");
|
||||
|
||||
// Move focus to the first focusable control, falling back to the panel.
|
||||
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel)?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
// Only the topmost dialog reacts (don't close a stack all at once).
|
||||
if (!isTopDialog(token)) return;
|
||||
if (e.key === "Escape" && dismissable) {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panel) return;
|
||||
// Cycle focus within the dialog.
|
||||
const items = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const firstEl = items[0];
|
||||
const lastEl = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement;
|
||||
if (e.shiftKey && (active === firstEl || active === panel)) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
|
||||
// Lock background scroll while the dialog is open.
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
popDialog(token);
|
||||
// Only lift inert once the last dialog has closed.
|
||||
if (!hasOpenDialog()) root?.removeAttribute("inert");
|
||||
lastFocused.current?.focus?.();
|
||||
};
|
||||
}, [open, dismissable, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const labelledBy = ariaLabel ? undefined : titleId;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal__overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget && dismissable) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`modal${wide ? " modal--wide" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={labelledBy}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="modal__head">
|
||||
<h2 className="modal__title" id={titleId}>
|
||||
{title}
|
||||
</h2>
|
||||
{dismissable && (
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="modal__body">{children}</div>
|
||||
{footer && <footer className="modal__footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
27
dashboard/src/components/ui/Panel.tsx
Normal file
27
dashboard/src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PanelProps {
|
||||
/** Optional header title. When omitted, no header bar is rendered. */
|
||||
title?: ReactNode;
|
||||
/** Optional right-aligned header slot (actions, counts). */
|
||||
actions?: ReactNode;
|
||||
/** Remove default body padding (e.g. when embedding a flush table). */
|
||||
flush?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** A bordered surface card. The base building block for content panels. */
|
||||
export function Panel({ title, actions, flush, className, children }: PanelProps) {
|
||||
return (
|
||||
<section className={["panel", className].filter(Boolean).join(" ")}>
|
||||
{(title || actions) && (
|
||||
<header className="panel__header">
|
||||
{title ? <h2 className="panel__title">{title}</h2> : <span />}
|
||||
{actions}
|
||||
</header>
|
||||
)}
|
||||
<div className={flush ? undefined : "panel__body"}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
14
dashboard/src/components/ui/Spinner.tsx
Normal file
14
dashboard/src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface SpinnerProps {
|
||||
/** Optional caption rendered under the ring. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Indeterminate loading ring with an optional label. */
|
||||
export function Spinner({ label }: SpinnerProps) {
|
||||
return (
|
||||
<div className="spinner" role="status" aria-live="polite">
|
||||
<span className="spinner__ring" aria-hidden="true" />
|
||||
{label && <span>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
dashboard/src/components/ui/States.tsx
Normal file
30
dashboard/src/components/ui/States.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface StateProps {
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
/** Optional action (e.g. a retry button). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Neutral "nothing here" placeholder. */
|
||||
export function EmptyState({ title, message, action }: StateProps) {
|
||||
return (
|
||||
<div className="state">
|
||||
<div className="state__title">{title}</div>
|
||||
{message && <div className="state__msg">{message}</div>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Error placeholder — surfaces a failure instead of silently empty. */
|
||||
export function ErrorState({ title, message, action }: StateProps) {
|
||||
return (
|
||||
<div className="state state--error" role="alert">
|
||||
<div className="state__title">{title}</div>
|
||||
{message && <div className="state__msg">{message}</div>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
dashboard/src/components/ui/StatusDot.tsx
Normal file
24
dashboard/src/components/ui/StatusDot.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { StatusTone } from "./status";
|
||||
|
||||
interface StatusDotProps {
|
||||
tone: StatusTone;
|
||||
/** Accessible label describing what the dot represents. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small colored status dot. `warn` pulses (consent-pending language). When a
|
||||
* `label` is given it is an accessible image; without one (e.g. paired with a
|
||||
* visible label inside a Badge) it is decorative and hidden from assistive tech.
|
||||
*/
|
||||
export function StatusDot({ tone, label }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className={`statusdot statusdot--${tone}`}
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
dashboard/src/components/ui/Table.tsx
Normal file
95
dashboard/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./table.css";
|
||||
|
||||
export interface Column<T> {
|
||||
/** Unique column key. */
|
||||
key: string;
|
||||
/** Header label. Omit for the status / actions rails. */
|
||||
header?: ReactNode;
|
||||
/** Cell renderer. */
|
||||
render: (row: T) => ReactNode;
|
||||
/** Extra class on the <td> (e.g. dt__status, dt__actions). */
|
||||
cellClass?: string;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T) => string;
|
||||
/** Optional per-row activation (opens detail). Bound to click, Enter, Space. */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Accessible label for the row's primary activation, e.g. the hostname. */
|
||||
rowLabel?: (row: T) => string;
|
||||
/** Cap the staggered fade-in so large lists don't crawl in. */
|
||||
maxStaggerRows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dense, console-style data table. Sticky header, hover highlight, hover-
|
||||
* revealed row actions, and a staggered fade-in on mount (capped so big lists
|
||||
* appear promptly). Column-driven so callers compose cells declaratively.
|
||||
*/
|
||||
export function Table<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
rowLabel,
|
||||
maxStaggerRows = 14,
|
||||
}: TableProps<T>) {
|
||||
return (
|
||||
<div className="dt-wrap">
|
||||
<table className="dt">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.key} className={c.cellClass}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const delay = i < maxStaggerRows ? `${i * 22}ms` : "0ms";
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
style={{
|
||||
animationDelay: delay,
|
||||
cursor: onRowClick ? "pointer" : undefined,
|
||||
}}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
aria-label={
|
||||
onRowClick && rowLabel
|
||||
? `Open detail for ${rowLabel(row)}`
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
// Activate on Enter or Space, the standard for a
|
||||
// button-like row. Space must not scroll the page.
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.preventDefault();
|
||||
onRowClick(row);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className={c.cellClass}>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
dashboard/src/components/ui/TableSkeleton.tsx
Normal file
54
dashboard/src/components/ui/TableSkeleton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
interface TableSkeletonProps {
|
||||
/** Header labels, rendered in the sticky head so columns line up. */
|
||||
headers: string[];
|
||||
/** Number of placeholder rows. */
|
||||
rows?: number;
|
||||
/** Per-column placeholder bar widths (CSS lengths). Falls back to a default. */
|
||||
widths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton table that mirrors the real table's layout while data loads. Shows
|
||||
* the column structure so the page does not jump when rows arrive, and reads as
|
||||
* progress without a blocking spinner.
|
||||
*/
|
||||
export function TableSkeleton({
|
||||
headers,
|
||||
rows = 8,
|
||||
widths = [],
|
||||
}: TableSkeletonProps) {
|
||||
const colWidths =
|
||||
widths.length === headers.length
|
||||
? widths
|
||||
: headers.map((_, i) => (i === 0 ? "8px" : `${60 + ((i * 23) % 40)}%`));
|
||||
|
||||
return (
|
||||
<div className="dt-wrap" aria-hidden="true">
|
||||
<table className="dt">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className={i === 0 ? "dt__status" : undefined}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<tr key={r}>
|
||||
{headers.map((_, c) => (
|
||||
<td key={c} className={c === 0 ? "dt__status" : undefined}>
|
||||
<span
|
||||
className={`dt__skel${c === 0 ? " dt__skel--dot" : ""}`}
|
||||
style={c === 0 ? undefined : { width: colWidths[c] }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
dashboard/src/components/ui/dialogStack.ts
Normal file
28
dashboard/src/components/ui/dialogStack.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// A tiny module-level stack so only the topmost open dialog (Modal or Drawer)
|
||||
// reacts to Escape and owns the background `inert` toggle. This keeps stacked
|
||||
// dialogs (e.g. a confirm on top of a management modal) from all closing at once.
|
||||
|
||||
const stack: symbol[] = [];
|
||||
|
||||
/** Push a dialog onto the stack. Returns its token. */
|
||||
export function pushDialog(): symbol {
|
||||
const token = Symbol("dialog");
|
||||
stack.push(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Remove a dialog from the stack by token. */
|
||||
export function popDialog(token: symbol): void {
|
||||
const i = stack.lastIndexOf(token);
|
||||
if (i !== -1) stack.splice(i, 1);
|
||||
}
|
||||
|
||||
/** True when `token` is the topmost open dialog. */
|
||||
export function isTopDialog(token: symbol): boolean {
|
||||
return stack.length > 0 && stack[stack.length - 1] === token;
|
||||
}
|
||||
|
||||
/** True when any dialog is open. */
|
||||
export function hasOpenDialog(): boolean {
|
||||
return stack.length > 0;
|
||||
}
|
||||
28
dashboard/src/components/ui/status.ts
Normal file
28
dashboard/src/components/ui/status.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Central status-language mapping. Every status indicator in the app resolves
|
||||
// through here so the dot color + label vocabulary stays consistent:
|
||||
// ok = online / granted / success -> --ok (green)
|
||||
// warn = pending (gets the consent pulse) -> --warn (amber)
|
||||
// bad = denied / offline / error -> --bad (red)
|
||||
// neutral = not_required / unknown -> --neutral (slate)
|
||||
|
||||
export type StatusTone = "ok" | "warn" | "bad" | "neutral";
|
||||
|
||||
/** Map a machine `status` string to a tone. */
|
||||
export function machineTone(status: string): StatusTone {
|
||||
return status === "online" ? "ok" : "bad";
|
||||
}
|
||||
|
||||
/** Map an attended-consent state to a tone. `pending` pulses. */
|
||||
export function consentTone(state: string): StatusTone {
|
||||
switch (state) {
|
||||
case "granted":
|
||||
return "ok";
|
||||
case "pending":
|
||||
return "warn";
|
||||
case "denied":
|
||||
return "bad";
|
||||
case "not_required":
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
153
dashboard/src/components/ui/table.css
Normal file
153
dashboard/src/components/ui/table.css
Normal file
@@ -0,0 +1,153 @@
|
||||
/* ============================================================ Data table === */
|
||||
.dt {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dt thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--panel-2);
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dt tbody td {
|
||||
padding: 0 14px;
|
||||
height: var(--row-h);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dt tbody tr {
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
animation: gc-row-in var(--dur) var(--ease) both;
|
||||
}
|
||||
.dt tbody tr:hover {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.dt tbody tr:hover .dt__rowactions,
|
||||
.dt tbody tr:focus-within .dt__rowactions {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Keyboard focus on the row itself reads as a clear inset ring. */
|
||||
.dt tbody tr:focus-visible {
|
||||
outline: none;
|
||||
background: var(--panel-2);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
||||
}
|
||||
|
||||
/* Status-dot column — fixed narrow left rail. */
|
||||
.dt__status {
|
||||
width: 30px;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Cell affordances. */
|
||||
.dt__mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dt__strong {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.dt__muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Right-aligned row actions. Dimmed at rest, full on row hover/focus, but
|
||||
always present and reachable by keyboard and touch (never pointer-events:none,
|
||||
which would hide them from Tab and tap). */
|
||||
.dt__actions {
|
||||
width: 1%;
|
||||
text-align: right;
|
||||
}
|
||||
.dt__rowactions {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--dur-fast) var(--ease);
|
||||
}
|
||||
/* When any action button is keyboard-focused, surface the whole group. */
|
||||
.dt__rowactions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
/* Touch devices have no hover: keep actions fully legible at all times. */
|
||||
.dt__rowactions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dt-wrap {
|
||||
max-height: calc(100vh - 230px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Skeleton loading rows: preview the table shape instead of a bare spinner. */
|
||||
.dt__skel {
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--border) 0%,
|
||||
var(--border-strong) 50%,
|
||||
var(--border) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: gc-shimmer 1.4s var(--ease) infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dt__skel--dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Search / toolbar above the table. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.searchbox {
|
||||
position: relative;
|
||||
flex: 0 0 320px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.searchbox__icon {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
.searchbox .input {
|
||||
width: 100%;
|
||||
padding-left: 34px;
|
||||
}
|
||||
.toolbar__count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.toolbar__count .mono {
|
||||
color: var(--text);
|
||||
}
|
||||
25
dashboard/src/components/ui/toast-context.ts
Normal file
25
dashboard/src/components/ui/toast-context.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ToastTone = "success" | "error" | "info";
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
tone: ToastTone;
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
success: (title: string, message?: string) => void;
|
||||
error: (title: string, message?: string) => void;
|
||||
info: (title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
/** Imperative toast notifications. Auto-dismiss after a few seconds. */
|
||||
export function useToast(): ToastApi {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
|
||||
return ctx;
|
||||
}
|
||||
116
dashboard/src/components/ui/toast.tsx
Normal file
116
dashboard/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ToastContext,
|
||||
type ToastApi,
|
||||
type ToastItem,
|
||||
type ToastTone,
|
||||
} from "./toast-context";
|
||||
|
||||
const AUTO_DISMISS_MS = 4500;
|
||||
|
||||
/** Mounts the toast stack and provides the imperative toast API to descendants. */
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(tone: ToastTone, title: string, message?: string) => {
|
||||
const id = nextId.current++;
|
||||
setToasts((prev) => [...prev, { id, tone, title, message }]);
|
||||
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
success: (title, message) => push("success", title, message),
|
||||
error: (title, message) => push("error", title, message),
|
||||
info: (title, message) => push("info", title, message),
|
||||
}),
|
||||
[push],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
{/* Polite region for success/info; errors below are assertive. */}
|
||||
<div className="toast-stack">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast--${t.tone}`}
|
||||
role={t.tone === "error" ? "alert" : "status"}
|
||||
aria-live={t.tone === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
<span className={`toast__icon toast__icon--${t.tone}`} aria-hidden="true">
|
||||
<ToastGlyph tone={t.tone} />
|
||||
</span>
|
||||
<div className="toast__body">
|
||||
<div className="toast__title">{t.title}</div>
|
||||
{t.message && <div className="toast__msg">{t.message}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastGlyph({ tone }: { tone: ToastTone }) {
|
||||
const common = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
if (tone === "success") {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (tone === "error") {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v5M12 16h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
454
dashboard/src/components/ui/ui.css
Normal file
454
dashboard/src/components/ui/ui.css
Normal file
@@ -0,0 +1,454 @@
|
||||
/* ------------------------------------------------------------------ Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease),
|
||||
opacity var(--dur-fast) var(--ease);
|
||||
user-select: none;
|
||||
}
|
||||
.btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn--sm {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--accent-press);
|
||||
}
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--panel);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border-color: var(--bad-line);
|
||||
color: var(--bad);
|
||||
}
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--bad-soft);
|
||||
border-color: var(--bad);
|
||||
}
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
.btn__spin {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
opacity: 0.85;
|
||||
animation: gc-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Status dot/badge */
|
||||
.statusdot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.statusdot--ok {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 6px var(--ok-soft);
|
||||
}
|
||||
.statusdot--warn {
|
||||
background: var(--warn);
|
||||
animation: gc-pulse 1.6s var(--ease) infinite;
|
||||
}
|
||||
.statusdot--bad {
|
||||
background: var(--bad);
|
||||
}
|
||||
.statusdot--neutral {
|
||||
background: var(--neutral);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.badge--ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
border-color: transparent;
|
||||
}
|
||||
.badge--warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
border-color: transparent;
|
||||
}
|
||||
.badge--bad {
|
||||
color: var(--bad);
|
||||
background: var(--bad-soft);
|
||||
border-color: transparent;
|
||||
}
|
||||
.badge--neutral {
|
||||
color: var(--text-muted);
|
||||
background: var(--neutral-soft);
|
||||
border-color: transparent;
|
||||
}
|
||||
.badge--accent {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Card / Panel */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.panel__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.panel__body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Spinner */
|
||||
.spinner {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.spinner__ring {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
animation: gc-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes gc-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- Empty / Error states */
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.state__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.state__msg {
|
||||
font-size: 13px;
|
||||
max-width: 380px;
|
||||
}
|
||||
.state--error .state__title {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- Modal */
|
||||
/* Shared icon button (modal close, toast dismiss). 28px square hit target. */
|
||||
.iconbtn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease),
|
||||
background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.iconbtn:hover {
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.iconbtn:focus-visible {
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||
}
|
||||
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: oklch(15% 0.01 var(--brand-hue) / 0.66);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: var(--z-modal);
|
||||
animation: gc-fade 120ms var(--ease);
|
||||
}
|
||||
@keyframes gc-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
animation: gc-pop 140ms var(--ease);
|
||||
}
|
||||
@keyframes gc-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
}
|
||||
.modal--wide {
|
||||
max-width: 720px;
|
||||
}
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
.modal__body {
|
||||
padding: 18px;
|
||||
}
|
||||
.modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- Drawer */
|
||||
.drawer__scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: oklch(15% 0.01 var(--brand-hue) / 0.5);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
z-index: var(--z-drawer);
|
||||
animation: gc-fade 120ms var(--ease);
|
||||
}
|
||||
.drawer {
|
||||
width: min(520px, 100%);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-pop);
|
||||
animation: gc-drawer-in var(--dur-panel) var(--ease-out);
|
||||
}
|
||||
.drawer__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.drawer__titles {
|
||||
min-width: 0;
|
||||
}
|
||||
.drawer__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.drawer__subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.drawer__body {
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.drawer__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- Toast */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: var(--z-toast);
|
||||
max-width: 360px;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-2);
|
||||
font-size: 13px;
|
||||
animation: gc-toast-in 180ms var(--ease);
|
||||
}
|
||||
@keyframes gc-toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
}
|
||||
.toast__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.toast__icon--success {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.toast__icon--error {
|
||||
color: var(--bad);
|
||||
background: var(--bad-soft);
|
||||
}
|
||||
.toast__icon--info {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.toast__body {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.toast__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.toast__msg {
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- Input */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input--mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* React hook for managing remote desktop session connection
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { ConnectionStatus, VideoFrame, MouseEvent as ProtoMouseEvent, KeyEvent as ProtoKeyEvent, MouseEventType, KeyEventType, Modifiers } from '../types/protocol';
|
||||
import { encodeMouseEvent, encodeKeyEvent, decodeVideoFrame } from '../lib/protobuf';
|
||||
|
||||
interface UseRemoteSessionOptions {
|
||||
serverUrl: string;
|
||||
sessionId: string;
|
||||
onFrame?: (frame: VideoFrame) => void;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
}
|
||||
|
||||
interface UseRemoteSessionReturn {
|
||||
status: ConnectionStatus;
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
sendMouseEvent: (event: ProtoMouseEvent) => void;
|
||||
sendKeyEvent: (event: ProtoKeyEvent) => void;
|
||||
}
|
||||
|
||||
export function useRemoteSession(options: UseRemoteSessionOptions): UseRemoteSessionReturn {
|
||||
const { serverUrl, sessionId, onFrame, onStatusChange } = options;
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>({
|
||||
connected: false,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const frameCountRef = useRef(0);
|
||||
const lastFpsUpdateRef = useRef(Date.now());
|
||||
|
||||
// Update status and notify
|
||||
const updateStatus = useCallback((newStatus: Partial<ConnectionStatus>) => {
|
||||
setStatus(prev => {
|
||||
const updated = { ...prev, ...newStatus };
|
||||
onStatusChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [onStatusChange]);
|
||||
|
||||
// Calculate FPS
|
||||
const updateFps = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastFpsUpdateRef.current;
|
||||
if (elapsed >= 1000) {
|
||||
const fps = Math.round((frameCountRef.current * 1000) / elapsed);
|
||||
updateStatus({ fps });
|
||||
frameCountRef.current = 0;
|
||||
lastFpsUpdateRef.current = now;
|
||||
}
|
||||
}, [updateStatus]);
|
||||
|
||||
// Handle incoming WebSocket messages
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then(buffer => {
|
||||
const data = new Uint8Array(buffer);
|
||||
const frame = decodeVideoFrame(data);
|
||||
if (frame) {
|
||||
frameCountRef.current++;
|
||||
updateFps();
|
||||
onFrame?.(frame);
|
||||
}
|
||||
});
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
const data = new Uint8Array(event.data);
|
||||
const frame = decodeVideoFrame(data);
|
||||
if (frame) {
|
||||
frameCountRef.current++;
|
||||
updateFps();
|
||||
onFrame?.(frame);
|
||||
}
|
||||
}
|
||||
}, [onFrame, updateFps]);
|
||||
|
||||
// Connect to server
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `${serverUrl}/ws/viewer?session_id=${encodeURIComponent(sessionId)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
updateStatus({
|
||||
connected: true,
|
||||
sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = handleMessage;
|
||||
|
||||
ws.onclose = (event) => {
|
||||
updateStatus({
|
||||
connected: false,
|
||||
latencyMs: undefined,
|
||||
fps: undefined,
|
||||
});
|
||||
|
||||
// Auto-reconnect after 2 seconds
|
||||
if (!event.wasClean) {
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connect();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
updateStatus({ connected: false });
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [serverUrl, sessionId, handleMessage, updateStatus]);
|
||||
|
||||
// Disconnect from server
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'User disconnected');
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
updateStatus({
|
||||
connected: false,
|
||||
sessionId: undefined,
|
||||
latencyMs: undefined,
|
||||
fps: undefined,
|
||||
});
|
||||
}, [updateStatus]);
|
||||
|
||||
// Send mouse event
|
||||
const sendMouseEvent = useCallback((event: ProtoMouseEvent) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const data = encodeMouseEvent(event);
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send key event
|
||||
const sendKeyEvent = useCallback((event: ProtoKeyEvent) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const data = encodeKeyEvent(event);
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [disconnect]);
|
||||
|
||||
return {
|
||||
status,
|
||||
connect,
|
||||
disconnect,
|
||||
sendMouseEvent,
|
||||
sendKeyEvent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create mouse event from DOM mouse event
|
||||
*/
|
||||
export function createMouseEvent(
|
||||
domEvent: React.MouseEvent<HTMLElement>,
|
||||
canvasRect: DOMRect,
|
||||
displayWidth: number,
|
||||
displayHeight: number,
|
||||
eventType: MouseEventType
|
||||
): ProtoMouseEvent {
|
||||
// Calculate position relative to canvas and scale to display coordinates
|
||||
const scaleX = displayWidth / canvasRect.width;
|
||||
const scaleY = displayHeight / canvasRect.height;
|
||||
|
||||
const x = Math.round((domEvent.clientX - canvasRect.left) * scaleX);
|
||||
const y = Math.round((domEvent.clientY - canvasRect.top) * scaleY);
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
buttons: {
|
||||
left: (domEvent.buttons & 1) !== 0,
|
||||
right: (domEvent.buttons & 2) !== 0,
|
||||
middle: (domEvent.buttons & 4) !== 0,
|
||||
x1: (domEvent.buttons & 8) !== 0,
|
||||
x2: (domEvent.buttons & 16) !== 0,
|
||||
},
|
||||
wheelDeltaX: 0,
|
||||
wheelDeltaY: 0,
|
||||
eventType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create key event from DOM keyboard event
|
||||
*/
|
||||
export function createKeyEvent(
|
||||
domEvent: React.KeyboardEvent<HTMLElement>,
|
||||
down: boolean
|
||||
): ProtoKeyEvent {
|
||||
const modifiers: Modifiers = {
|
||||
ctrl: domEvent.ctrlKey,
|
||||
alt: domEvent.altKey,
|
||||
shift: domEvent.shiftKey,
|
||||
meta: domEvent.metaKey,
|
||||
capsLock: domEvent.getModifierState('CapsLock'),
|
||||
numLock: domEvent.getModifierState('NumLock'),
|
||||
};
|
||||
|
||||
// Use key code for special keys, unicode for regular characters
|
||||
const isCharacter = domEvent.key.length === 1;
|
||||
|
||||
return {
|
||||
down,
|
||||
keyType: isCharacter ? 2 : 0, // KEY_UNICODE or KEY_VK
|
||||
vkCode: domEvent.keyCode,
|
||||
scanCode: 0, // Not available in browser
|
||||
unicode: isCharacter ? domEvent.key : undefined,
|
||||
modifiers,
|
||||
};
|
||||
}
|
||||
@@ -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
60
dashboard/src/lib/time.ts
Normal 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`;
|
||||
}
|
||||
48
dashboard/src/lib/useClipboard.ts
Normal file
48
dashboard/src/lib/useClipboard.ts
Normal 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 };
|
||||
}
|
||||
28
dashboard/src/lib/useRelayStatus.ts
Normal file
28
dashboard/src/lib/useRelayStatus.ts
Normal 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 };
|
||||
}
|
||||
21
dashboard/src/main.tsx
Normal file
21
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import "@fontsource/hanken-grotesk/400.css";
|
||||
import "@fontsource/hanken-grotesk/500.css";
|
||||
import "@fontsource/hanken-grotesk/600.css";
|
||||
import "@fontsource/hanken-grotesk/700.css";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "./styles/tokens.css";
|
||||
import "./components/ui/ui.css";
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Root element #root not found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
223
dashboard/src/styles/tokens.css
Normal file
223
dashboard/src/styles/tokens.css
Normal file
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* GuruConnect "Operations terminal" design tokens.
|
||||
* Dark control-room console. Not a generic dashboard.
|
||||
*/
|
||||
:root {
|
||||
/*
|
||||
* Palette is OKLCH. Every neutral is tinted toward the signal-cyan brand hue
|
||||
* (~185deg) at a low chroma so the slate surfaces feel cohesive with the
|
||||
* accent without reading as "colored". Lightness drives the surface scale;
|
||||
* chroma drops near the extremes so nothing goes garish.
|
||||
*/
|
||||
--brand-hue: 184;
|
||||
|
||||
/* Surfaces — dark control room, lighter = more elevated */
|
||||
--bg: oklch(17% 0.012 var(--brand-hue));
|
||||
--panel: oklch(22.5% 0.014 var(--brand-hue));
|
||||
--panel-2: oklch(19.5% 0.013 var(--brand-hue));
|
||||
--border: oklch(28% 0.014 var(--brand-hue));
|
||||
--border-strong: oklch(34% 0.016 var(--brand-hue));
|
||||
|
||||
/* Text — tinted toward brand, AA-verified on --panel and --panel-2 */
|
||||
--text: oklch(93% 0.008 var(--brand-hue));
|
||||
--text-muted: oklch(70% 0.014 var(--brand-hue));
|
||||
--text-faint: oklch(62% 0.016 var(--brand-hue));
|
||||
|
||||
/* Accent — signal cyan */
|
||||
--accent: oklch(78% 0.13 184);
|
||||
--accent-press: oklch(70% 0.12 184);
|
||||
--accent-ink: oklch(24% 0.06 184); /* text on accent fills */
|
||||
--accent-soft: oklch(78% 0.13 184 / 0.12);
|
||||
--accent-ring: oklch(78% 0.13 184 / 0.4);
|
||||
|
||||
/* Status color language */
|
||||
--ok: oklch(74% 0.16 150);
|
||||
--warn: oklch(80% 0.14 86);
|
||||
--bad: oklch(66% 0.19 27);
|
||||
--neutral: oklch(62% 0.02 var(--brand-hue));
|
||||
--ok-soft: oklch(74% 0.16 150 / 0.15);
|
||||
--warn-soft: oklch(80% 0.14 86 / 0.15);
|
||||
--bad-soft: oklch(66% 0.19 27 / 0.16);
|
||||
--bad-line: oklch(66% 0.19 27 / 0.42);
|
||||
--neutral-soft: oklch(62% 0.02 var(--brand-hue) / 0.14);
|
||||
|
||||
/* Typography */
|
||||
--font-ui: "Hanken Grotesk", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 5px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* Elevation — shadow carries the brand-tinted near-black, never pure #000. */
|
||||
--shadow-ink: oklch(12% 0.02 var(--brand-hue));
|
||||
--shadow-1: 0 1px 2px oklch(12% 0.02 var(--brand-hue) / 0.4);
|
||||
--shadow-2: 0 8px 24px oklch(12% 0.02 var(--brand-hue) / 0.45);
|
||||
--shadow-pop: 0 16px 48px oklch(12% 0.02 var(--brand-hue) / 0.6);
|
||||
|
||||
/* Layout */
|
||||
--sidebar-w: 224px;
|
||||
--topbar-h: 56px;
|
||||
--row-h: 38px;
|
||||
|
||||
/* Motion — ease-out only (no bounce). --ease-out is a sharper expo curve
|
||||
reserved for reveals (drawer, toast); --ease is the everyday transition. */
|
||||
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur: 180ms;
|
||||
--dur-panel: 240ms;
|
||||
|
||||
/* z-index scale — semantic, no magic numbers */
|
||||
--z-sticky: 200;
|
||||
--z-drawer: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 500;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
/* Light text on dark reads heavier; trim weight slightly for even color. */
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-ring);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Scrollbar — keep it console-quiet */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "ss01", "zero";
|
||||
}
|
||||
|
||||
/* Screen-reader-only utility (announce state that's otherwise visual). */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Global keyboard focus ring for interactive elements without a bespoke one. */
|
||||
:where(a, [tabindex]):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Consent-pending pulse (status language) */
|
||||
@keyframes gc-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--warn-soft);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px transparent;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
/* Live relay indicator pulse */
|
||||
@keyframes gc-live {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered row fade-in for the data table */
|
||||
@keyframes gc-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Drawer slide-in from the right edge */
|
||||
@keyframes gc-drawer-in {
|
||||
from {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton shimmer for loading rows */
|
||||
@keyframes gc-shimmer {
|
||||
to {
|
||||
background-position: 180% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* TypeScript types matching guruconnect.proto definitions
|
||||
* These are used for WebSocket message handling in the viewer
|
||||
*/
|
||||
|
||||
export enum SessionType {
|
||||
SCREEN_CONTROL = 0,
|
||||
VIEW_ONLY = 1,
|
||||
BACKSTAGE = 2,
|
||||
FILE_TRANSFER = 3,
|
||||
}
|
||||
|
||||
export interface SessionRequest {
|
||||
agentId: string;
|
||||
sessionToken: string;
|
||||
sessionType: SessionType;
|
||||
clientVersion: string;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
success: boolean;
|
||||
sessionId: string;
|
||||
error?: string;
|
||||
displayInfo?: DisplayInfo;
|
||||
}
|
||||
|
||||
export interface DisplayInfo {
|
||||
displays: Display[];
|
||||
primaryDisplay: number;
|
||||
}
|
||||
|
||||
export interface Display {
|
||||
id: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface DirtyRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RawFrame {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
compressed: boolean;
|
||||
dirtyRects: DirtyRect[];
|
||||
isKeyframe: boolean;
|
||||
}
|
||||
|
||||
export interface EncodedFrame {
|
||||
data: Uint8Array;
|
||||
keyframe: boolean;
|
||||
pts: number;
|
||||
dts: number;
|
||||
}
|
||||
|
||||
export interface VideoFrame {
|
||||
timestamp: number;
|
||||
displayId: number;
|
||||
sequence: number;
|
||||
encoding: 'raw' | 'vp9' | 'h264' | 'h265';
|
||||
raw?: RawFrame;
|
||||
encoded?: EncodedFrame;
|
||||
}
|
||||
|
||||
export enum MouseEventType {
|
||||
MOUSE_MOVE = 0,
|
||||
MOUSE_DOWN = 1,
|
||||
MOUSE_UP = 2,
|
||||
MOUSE_WHEEL = 3,
|
||||
}
|
||||
|
||||
export interface MouseButtons {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
middle: boolean;
|
||||
x1: boolean;
|
||||
x2: boolean;
|
||||
}
|
||||
|
||||
export interface MouseEvent {
|
||||
x: number;
|
||||
y: number;
|
||||
buttons: MouseButtons;
|
||||
wheelDeltaX: number;
|
||||
wheelDeltaY: number;
|
||||
eventType: MouseEventType;
|
||||
}
|
||||
|
||||
export enum KeyEventType {
|
||||
KEY_VK = 0,
|
||||
KEY_SCAN = 1,
|
||||
KEY_UNICODE = 2,
|
||||
}
|
||||
|
||||
export interface Modifiers {
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
meta: boolean;
|
||||
capsLock: boolean;
|
||||
numLock: boolean;
|
||||
}
|
||||
|
||||
export interface KeyEvent {
|
||||
down: boolean;
|
||||
keyType: KeyEventType;
|
||||
vkCode: number;
|
||||
scanCode: number;
|
||||
unicode?: string;
|
||||
modifiers: Modifiers;
|
||||
}
|
||||
|
||||
export interface QualitySettings {
|
||||
preset: 'auto' | 'low' | 'balanced' | 'high';
|
||||
customFps?: number;
|
||||
customBitrate?: number;
|
||||
codec: 'auto' | 'raw' | 'vp9' | 'h264' | 'h265';
|
||||
}
|
||||
|
||||
export interface ConnectionStatus {
|
||||
connected: boolean;
|
||||
sessionId?: string;
|
||||
latencyMs?: number;
|
||||
fps?: number;
|
||||
bitrateKbps?: number;
|
||||
}
|
||||
10
dashboard/src/vite-env.d.ts
vendored
Normal file
10
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** Base URL for the GuruConnect API. Defaults to same-origin when unset. */
|
||||
readonly VITE_API_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
27
dashboard/tsconfig.app.json
Normal file
27
dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
20
dashboard/tsconfig.node.json
Normal file
20
dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
35
dashboard/vite.config.ts
Normal file
35
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Dev proxy targets the local GuruConnect (GC) server. `/api` and `/ws` are
|
||||
// forwarded to the Rust server on :3002 so `npm run dev` works against a real
|
||||
// backend without CORS gymnastics.
|
||||
//
|
||||
// `base` is "./" so the built assets reference relative paths — production
|
||||
// serving copies `dist/` into the server's static dir and a catch-all route
|
||||
// serves index.html. Wiring that catch-all in the Rust server is a DEPLOY
|
||||
// concern (see README), not done in this pass.
|
||||
const GC_SERVER = "http://localhost:3002";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5273,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: GC_SERVER,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: GC_SERVER,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user