/** * 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"; export type View = "overview" | "services" | "instances" | "wit-packages" | "activity" | "settings"; export type ServiceStatus = "running" | "stopped" | "faulted"; export type EventTone = "success" | "warning" | "danger" | "neutral"; export type Service = { id: string; revision: string; description: string; artifact: string; status: ServiceStatus; updatedAt: string; memoryMb: number; fuel: string; deadlineMs: number; mailbox: number; maxInputKb: number; capabilities: BackendCapability[]; calls: number; errors: number; latencyMs: number | null; active: boolean; }; export type RuntimeEvent = { id: number; time: string; title: string; detail: string; tone: EventTone; }; export type ConnectionState = "connecting" | "online" | "offline"; export const NAV_ITEMS: Array<{ id: View; label: string; icon: typeof LayoutDashboard; }> = [ { id: "overview", label: "运行概览", icon: LayoutDashboard }, { id: "services", label: "服务版本", icon: Package }, { id: "instances", label: "运行实例", icon: Server }, { id: "wit-packages", label: "WIT 包", icon: FileCode2 }, { id: "activity", label: "调用记录", icon: History }, { id: "settings", label: "运行设置", icon: Settings }, ]; export const STATUS_META: Record = { running: { label: "运行中", className: "status-running" }, stopped: { label: "已停止", className: "status-stopped" }, faulted: { label: "故障", className: "status-faulted" }, }; export function serviceKey(service: Pick) { return `${service.id}@${service.revision}`; } export function toService(service: BackendService, activeRevision?: string): Service { return { id: service.id, revision: service.revision, description: "Wasm Component", artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact, status: service.status, updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", { hour12: false, }), memoryMb: Math.round(service.limits.memory_bytes / 1024 / 1024), fuel: compactNumber(service.limits.fuel_per_call), deadlineMs: service.limits.deadline_ms, mailbox: service.limits.mailbox_capacity, maxInputKb: Math.round(service.limits.max_input_bytes / 1024), capabilities: service.capabilities, calls: service.calls, errors: service.errors, latencyMs: service.last_latency_ms, active: service.revision === activeRevision, }; } export function toEvent(event: BackendEvent): RuntimeEvent { const identity = event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld"; const meta: Record = { registered: { label: "已注册", tone: "success" }, unregistered: { label: "已注销", tone: "neutral" }, deployed: { label: "部署切换", tone: "success" }, started: { label: "已启动", tone: "success" }, stopped: { label: "已停止", tone: "neutral" }, invoked: { label: "调用完成", tone: "success" }, failed: { label: "操作失败", tone: "danger" }, }; return { id: event.id, time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", { hour12: false, }), title: `${identity} ${meta[event.kind].label}`, detail: event.message, tone: meta[event.kind].tone, }; } export function compactNumber(value: number) { if (value >= 1_000_000) return `${value / 1_000_000}M`; if (value >= 1_000) return `${value / 1_000}K`; return value.toString(); } export function formatDuration(milliseconds: number) { const totalSeconds = Math.floor(milliseconds / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`; } export function formatApiHost(value: string) { try { return new URL(value).host; } catch { return value; } } export function parseInvocationInput(value: string, format: "utf8" | "hex") { if (format === "utf8") return new TextEncoder().encode(value); const compact = value.replace(/\s+/g, ""); if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) { throw new Error("HEX 输入必须由成对的十六进制字符组成"); } return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []); } export function invocationInputSize(value: string, format: "utf8" | "hex") { try { return parseInvocationInput(value, format).byteLength; } catch { return 0; } } export type FormattedInvocationOutput = { text: string; format: "UTF-8" | "HEX" | "U64 LE"; automatic: boolean; }; export function formatHex(bytes: Uint8Array) { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); } export function decodeReadableUtf8(bytes: Uint8Array) { let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { 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; if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) { return null; } } return text; } export function formatInvocationOutput( service: Service, 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) .getBigUint64(0, true) .toString(), format: "U64 LE", automatic: false, } satisfies FormattedInvocationOutput; } if (format === "hex") { return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false, } 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 { text: text || "(empty)", format: "UTF-8", automatic: false, } satisfies FormattedInvocationOutput; } return { text: formatHex(output), format: "HEX", automatic: true, } satisfies FormattedInvocationOutput; }