Files
wasmeld/console/src/console-model.ts
T

225 lines
7.1 KiB
TypeScript
Raw Normal View History

/**
* 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<ServiceStatus, { label: string; className: string }> = {
running: { label: "运行中", className: "status-running" },
stopped: { label: "已停止", className: "status-stopped" },
faulted: { label: "故障", className: "status-faulted" },
};
export function serviceKey(service: Pick<Service, "id" | "revision">) {
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<BackendEvent["kind"], { label: string; tone: EventTone }> = {
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;
}