209 lines
6.4 KiB
TypeScript
209 lines
6.4 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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",
|
||
|
|
) {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|