diff --git a/console/src/api.ts b/console/src/api.ts index 15796df..7f0215a 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -1,3 +1,12 @@ +/** + * Typed management-plane client for `wasmeld-console`. + * + * These DTOs intentionally preserve the Rust API's snake_case fields; view + * formatting belongs in `console-model.ts`. Management invocation bytes use + * base64 inside JSON for inspectability. Public clients should call the + * gateway's `application/octet-stream` endpoint instead. + */ + export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080"; const API_BASE_STORAGE_KEY = "wasmeld-api-base"; @@ -80,6 +89,8 @@ export type InvokeResult = { }; export function getApiBase(): string { + // TanStack Start can evaluate modules during SSR, where localStorage does + // not exist. The environment default keeps server rendering deterministic. if (typeof window === "undefined") return DEFAULT_API_BASE; return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE; } @@ -95,6 +106,9 @@ export function saveApiBase(value: string): string { } export async function fetchSnapshot(apiBase: string) { + // Parallel reads minimize refresh latency but are not a database + // transaction. A deployment can change between responses; the five-second + // poll in the route reconciles that short-lived inconsistency. const [services, deployments, events, runtime, witPackages] = await Promise.all([ request<{ services: BackendService[] }>(apiBase, "/api/v1/services"), request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"), @@ -181,6 +195,9 @@ export async function invokeComponent( service: Pick, input: Uint8Array, ): Promise { + // Example: `invokeComponent(base, service, new TextEncoder().encode("ping"))`. + // The management endpoint targets an exact revision and is intended for the + // operator console, not public application traffic. const response = await request<{ output_base64: string; output_bytes: number; @@ -216,6 +233,9 @@ async function request(apiBase: string, path: string, init?: RequestInit): Pr } if (!response.ok) { + // Both management and gateway errors use `{ error: { code, message } }`. + // The UI displays only the server-safe message and falls back to status + // when a proxy returns HTML or an otherwise unrelated response. const body = (await response.json().catch(() => null)) as { error?: { message?: string }; } | null; @@ -225,6 +245,8 @@ async function request(apiBase: string, path: string, init?: RequestInit): Pr } function bytesToBase64(bytes: Uint8Array): string { + // Invocation input is bounded by the Runtime, so constructing this binary + // string cannot grow beyond the configured management request limit. let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return window.btoa(binary); diff --git a/console/src/console-model.ts b/console/src/console-model.ts index 9436685..e8ead6c 100644 --- a/console/src/console-model.ts +++ b/console/src/console-model.ts @@ -1,3 +1,11 @@ +/** + * Pure conversion and presentation helpers for the management UI. + * + * No function in this module performs I/O or changes backend state. Keeping + * byte decoding here prevents React views from silently treating arbitrary + * Component output as readable text. + */ + import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react"; import { BackendCapability, BackendEvent, BackendService } from "./api"; @@ -160,6 +168,9 @@ export function decodeReadableUtf8(bytes: Uint8Array) { return null; } + // Valid UTF-8 can still contain terminal controls. Reject them so the output + // panel cannot render invisible or misleading content; tab/newline/CR remain + // useful for ordinary textual responses. for (const character of text) { const codePoint = character.codePointAt(0) ?? 0; const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d; @@ -175,6 +186,9 @@ export function formatInvocationOutput( output: Uint8Array, format: "utf8" | "hex", ) { + // Counter is the one built-in example with a documented numeric wire + // format. Other eight-byte services remain generic bytes and are not + // guessed as integers. if (service.id === "counter" && output.byteLength === 8) { return { text: new DataView(output.buffer, output.byteOffset, output.byteLength) @@ -192,6 +206,8 @@ export function formatInvocationOutput( } satisfies FormattedInvocationOutput; } + // UTF-8 decoding is fatal rather than replacement-based. If one byte is + // invalid or contains a disallowed control, show the exact bytes as HEX. const text = decodeReadableUtf8(output); if (text !== null) { return { diff --git a/console/src/routes/index.tsx b/console/src/routes/index.tsx index 976ba7e..5130a78 100644 --- a/console/src/routes/index.tsx +++ b/console/src/routes/index.tsx @@ -119,6 +119,9 @@ function Home() { setEvents(snapshot.events.map(toEvent)); setRuntime(snapshot.runtime); setWitPackages(snapshot.witPackages); + // Preserve operator context when possible. Registrations and cleanup can + // remove the selected revision between polls, in which case the first + // remaining service becomes the deterministic fallback. setSelectedKey((current) => nextServices.some((service) => serviceKey(service) === current) ? current @@ -128,6 +131,9 @@ function Home() { ); setConnection("online"); } catch { + // Clear the complete snapshot on any request failure. Mixing stale + // services with live deployments or Runtime state would present actions + // against identities that may no longer exist. setServices([]); setDeployments([]); setEvents([]);