feat(console-web): replace TanStack UI with Solid host pages

- move the management frontend under the wasmeld-console crate
- use a persistent Host shell with disposable iframe page documents
- version Host/Page postMessage state and command contracts
- migrate runtime, service, WIT, invocation, and settings workflows
- add Tailwind CSS v4, responsive layouts, and Host-owned dialogs
- emit a dependency-free PWA worker with API cache exclusions
- remove the superseded root TanStack Start application
This commit is contained in:
Maofeng
2026-07-30 09:21:55 +08:00
parent 4e32a1246b
commit 70ee5e33da
49 changed files with 3996 additions and 5648 deletions
+178
View File
@@ -0,0 +1,178 @@
import type { BackendEvent, BackendService } from "./api";
export const VIEWS = [
"overview",
"services",
"instances",
"wit-packages",
"activity",
"settings",
] as const;
export type View = (typeof VIEWS)[number];
export type ConnectionState = "connecting" | "online" | "offline";
export type ServiceStatus = "running" | "stopped" | "faulted";
export type EventTone = "success" | "warning" | "danger" | "neutral";
export type Service = {
id: string;
revision: string;
artifact: string;
world: string;
status: ServiceStatus;
updatedAt: string;
memoryMb: number;
fuel: string;
deadlineMs: number;
mailbox: number;
maxInputKb: number;
capabilities: BackendService["capabilities"];
calls: number;
errors: number;
latencyMs: number | null;
active: boolean;
};
export type RuntimeEvent = {
id: number;
time: string;
title: string;
detail: string;
tone: EventTone;
};
export type FormattedInvocationOutput = {
text: string;
format: "UTF-8" | "HEX" | "U64 LE";
automatic: boolean;
};
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 isView(value: string | null): value is View {
return VIEWS.includes(value as View);
}
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
return `${service.id}@${service.revision}`;
}
export function toService(service: BackendService, activeRevision?: string): Service {
return {
id: service.id,
revision: service.revision,
artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact,
world: service.world,
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): string {
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): string {
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 parseInvocationInput(value: string, format: "utf8" | "hex"): Uint8Array {
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"): number {
try {
return parseInvocationInput(value, format).byteLength;
} catch {
return 0;
}
}
export function formatInvocationOutput(
service: Service,
output: Uint8Array,
format: "utf8" | "hex",
): FormattedInvocationOutput {
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,
};
}
if (format === "hex") {
return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false };
}
const text = decodeReadableUtf8(output);
if (text !== null) {
return { text: text || "(empty)", format: "UTF-8", automatic: false };
}
return { text: formatHex(output), format: "HEX", automatic: true };
}
function formatHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
function decodeReadableUtf8(bytes: Uint8Array): string | null {
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;
}