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:
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" }),
|
||||
};
|
||||
Reference in New Issue
Block a user