Files
wasmeld/console/src/routes/index.tsx
T
Maofeng 8de300b634 feat(console-ui): add TanStack management console
- provide Runtime overview, service lifecycle, invocation, and event views
- add immutable WIT package listing, publication, and download workflows
- connect all state to the Rust management API without direct database access
- use TanStack Start with Node serving, Oxlint, Oxfmt, and SSR checks
- include responsive operational layouts and empty Actor placeholders
2026-07-27 05:02:35 +08:00

1720 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute } from "@tanstack/react-router";
import {
Activity,
AlertTriangle,
Archive,
ArrowRight,
Box,
Braces,
Check,
CheckCircle2,
CircleStop,
Clock3,
CloudUpload,
Cpu,
Download,
FileCode2,
History,
LayoutDashboard,
Package,
Play,
RefreshCw,
RotateCcw,
Search,
Server,
Settings,
ShieldCheck,
SquareTerminal,
X,
Zap,
} from "lucide-react";
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import {
BackendEvent,
BackendRuntime,
BackendService,
BackendWitPackage,
InvokeResult,
RegisterComponentInput,
changeRuntimeState,
changeServiceState,
fetchSnapshot,
getApiBase,
invokeComponent,
publishWitPackage,
registerComponent,
saveApiBase,
witPackageDownloadUrl,
} from "../api";
type View = "overview" | "services" | "instances" | "wit-packages" | "activity" | "settings";
type ServiceStatus = "running" | "stopped" | "faulted";
type EventTone = "success" | "warning" | "danger" | "neutral";
type Service = {
id: string;
revision: string;
description: string;
artifact: string;
status: ServiceStatus;
updatedAt: string;
memoryMb: number;
fuel: string;
deadlineMs: number;
mailbox: number;
maxInputKb: number;
calls: number;
errors: number;
latencyMs: number | null;
};
type RuntimeEvent = {
id: number;
time: string;
title: string;
detail: string;
tone: EventTone;
};
type ConnectionState = "connecting" | "online" | "offline";
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 },
];
const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
running: { label: "运行中", className: "status-running" },
stopped: { label: "已停止", className: "status-stopped" },
faulted: { label: "故障", className: "status-faulted" },
};
function serviceKey(service: Pick<Service, "id" | "revision">) {
return `${service.id}@${service.revision}`;
}
function toService(service: BackendService): 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),
calls: service.calls,
errors: service.errors,
latencyMs: service.last_latency_ms,
};
}
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" },
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,
};
}
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();
}
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`;
}
function formatApiHost(value: string) {
try {
return new URL(value).host;
} catch {
return value;
}
}
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)) ?? []);
}
function invocationInputSize(value: string, format: "utf8" | "hex") {
try {
return parseInvocationInput(value, format).byteLength;
} catch {
return 0;
}
}
function formatInvocationOutput(service: Service, output: Uint8Array, format: "utf8" | "hex") {
if (service.id === "counter" && output.byteLength === 8) {
return new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString();
}
if (format === "hex") {
return Array.from(output, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
return new TextDecoder().decode(output) || "(empty)";
}
export const Route = createFileRoute("/")({
component: Home,
});
function Home() {
const [view, setView] = useState<View>("overview");
const [services, setServices] = useState<Service[]>([]);
const [events, setEvents] = useState<RuntimeEvent[]>([]);
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
const [connection, setConnection] = useState<ConnectionState>("connecting");
const [apiBase, setApiBase] = useState(getApiBase);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | ServiceStatus>("all");
const [selectedKey, setSelectedKey] = useState("");
const [deployOpen, setDeployOpen] = useState(false);
const [witPublishOpen, setWitPublishOpen] = useState(false);
const [invokeKey, setInvokeKey] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const selectedService =
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
const filteredServices = useMemo(() => {
const normalized = query.trim().toLowerCase();
return services.filter((service) => {
const matchesText =
!normalized ||
service.id.toLowerCase().includes(normalized) ||
service.description.toLowerCase().includes(normalized) ||
service.artifact.toLowerCase().includes(normalized);
const matchesStatus = statusFilter === "all" || service.status === statusFilter;
return matchesText && matchesStatus;
});
}, [query, services, statusFilter]);
const filteredWitPackages = useMemo(() => {
const normalized = query.trim().toLowerCase();
return witPackages.filter(
(packageMetadata) =>
!normalized ||
packageMetadata.name.toLowerCase().includes(normalized) ||
packageMetadata.version.toLowerCase().includes(normalized) ||
packageMetadata.sha256.toLowerCase().includes(normalized),
);
}, [query, witPackages]);
const running = services.filter((service) => service.status === "running").length;
const faulted = services.filter((service) => service.status === "faulted").length;
const totalCalls = services.reduce((sum, service) => sum + service.calls, 0);
const refreshSnapshot = useCallback(async () => {
try {
const snapshot = await fetchSnapshot(apiBase);
const nextServices = snapshot.services.map(toService);
setServices(nextServices);
setEvents(snapshot.events.map(toEvent));
setRuntime(snapshot.runtime);
setWitPackages(snapshot.witPackages);
setSelectedKey((current) =>
nextServices.some((service) => serviceKey(service) === current)
? current
: nextServices[0]
? serviceKey(nextServices[0])
: "",
);
setConnection("online");
} catch {
setServices([]);
setEvents([]);
setRuntime(null);
setWitPackages([]);
setConnection("offline");
}
}, [apiBase]);
useEffect(() => {
void refreshSnapshot();
const interval = window.setInterval(() => void refreshSnapshot(), 5000);
return () => window.clearInterval(interval);
}, [refreshSnapshot]);
function notify(message: string) {
setToast(message);
window.setTimeout(() => setToast(null), 2600);
}
async function setServiceStatus(service: Service, status: ServiceStatus) {
const action =
status === "stopped" ? "stop" : service.status === "running" ? "restart" : "start";
try {
await changeServiceState(apiBase, service, action);
await refreshSnapshot();
notify(
`${service.id} ${
action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"
}`,
);
} catch (error) {
notify(error instanceof Error ? error.message : "运行时操作失败");
}
}
async function setRuntimeStatus(action: "start" | "stop" | "restart") {
setRuntimeAction(action);
try {
await changeRuntimeState(apiBase, action);
await refreshSnapshot();
notify(
action === "start"
? "Runtime 已启动"
: action === "stop"
? "Runtime 已停止"
: "Runtime 已重启",
);
} catch (error) {
notify(error instanceof Error ? error.message : "Runtime 操作失败");
} finally {
setRuntimeAction(null);
}
}
async function runInvocation(service: Service, input: Uint8Array) {
const result = await invokeComponent(apiBase, service, input);
await refreshSnapshot();
return result;
}
async function registerService(input: RegisterComponentInput) {
const registered = await registerComponent(apiBase, input);
await refreshSnapshot();
setSelectedKey(serviceKey(registered));
setView("services");
setDeployOpen(false);
notify("组件注册成功");
}
async function publishWit(file: File) {
const packageMetadata = await publishWitPackage(apiBase, file);
await refreshSnapshot();
setWitPublishOpen(false);
notify(`${packageMetadata.name}@${packageMetadata.version} 已发布`);
}
function updateApiBase(value: string) {
try {
const nextBase = saveApiBase(value);
setConnection("connecting");
setApiBase(nextBase);
notify("API 地址已保存");
} catch (error) {
notify(error instanceof Error ? error.message : "API 地址无效");
}
}
const title = NAV_ITEMS.find((item) => item.id === view)?.label ?? "运行概览";
return (
<div className="app-shell">
<Sidebar
view={view}
onChange={setView}
running={running}
connection={connection}
apiBase={apiBase}
uptimeMs={runtime?.status === "running" ? runtime.uptime_ms : null}
runtimeStatus={runtime?.status ?? null}
/>
<div className="app-main">
<header className="topbar">
<div className="breadcrumb">
<span>运行平台</span>
<ArrowRight size={14} />
<strong>{title}</strong>
</div>
<div className="topbar-actions">
<label className="global-search">
<Search size={16} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索服务或制品"
aria-label="搜索服务或制品"
/>
</label>
<div className={`source-state source-${connection}`} title={`管理 API${apiBase}`}>
<span className="source-dot" />
{connection === "online"
? "后端在线"
: connection === "connecting"
? "连接中"
: "后端离线"}
</div>
<div className="avatar-button" aria-hidden="true">
BD
</div>
</div>
</header>
<main className="content">
{view === "overview" && (
<Overview
services={services}
filteredServices={filteredServices}
events={events}
running={running}
faulted={faulted}
totalCalls={totalCalls}
runtime={runtime}
selectedService={selectedService}
onSelect={(service) => setSelectedKey(serviceKey(service))}
onDeploy={() => setDeployOpen(true)}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onStatus={setServiceStatus}
onViewAll={() => setView("services")}
onRefresh={() => void refreshSnapshot()}
onRuntimeAction={(action) => void setRuntimeStatus(action)}
runtimeAction={runtimeAction}
/>
)}
{view === "services" && (
<ServicesView
services={filteredServices}
statusFilter={statusFilter}
onFilter={setStatusFilter}
onDeploy={() => setDeployOpen(true)}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onSelect={(service) => setSelectedKey(serviceKey(service))}
onStatus={setServiceStatus}
/>
)}
{view === "instances" && (
<InstancesView
services={services}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onStatus={setServiceStatus}
onRefresh={() => void refreshSnapshot()}
/>
)}
{view === "activity" && <ActivityView events={events} services={services} />}
{view === "wit-packages" && (
<WitPackagesView
apiBase={apiBase}
packages={filteredWitPackages}
onPublish={() => setWitPublishOpen(true)}
/>
)}
{view === "settings" && (
<SettingsView endpoint={apiBase} onSaveEndpoint={updateApiBase} />
)}
</main>
<footer className="statusbar">
<span>
<ShieldCheck size={14} />
{connection !== "online"
? "等待 Wasmeld"
: runtime?.status === "running"
? "Wasmtime sandbox 正常"
: "Wasmeld Runtime 已停止"}
</span>
<span>Component Model · WASI P2</span>
<span>Runtime 0.1.0</span>
</footer>
</div>
{deployOpen && (
<DeployDialog onClose={() => setDeployOpen(false)} onRegister={registerService} />
)}
{witPublishOpen && (
<WitPackageDialog onClose={() => setWitPublishOpen(false)} onPublish={publishWit} />
)}
{invokeService && (
<InvokeDialog
service={invokeService}
onClose={() => setInvokeKey(null)}
onInvoke={runInvocation}
/>
)}
{toast && (
<output className="toast">
<CheckCircle2 size={17} />
{toast}
</output>
)}
</div>
);
}
function Sidebar({
view,
onChange,
running,
connection,
apiBase,
uptimeMs,
runtimeStatus,
}: {
view: View;
onChange: (view: View) => void;
running: number;
connection: ConnectionState;
apiBase: string;
uptimeMs: number | null;
runtimeStatus: BackendRuntime["status"] | null;
}) {
const runtimeLabel =
connection !== "online"
? "离线"
: runtimeStatus === "running"
? "运行中"
: runtimeStatus === "stopped"
? "已停止"
: "连接中";
const runtimeHealthClass =
connection !== "online"
? "health-pulse health-offline"
: runtimeStatus === "running"
? "health-pulse"
: "health-pulse health-stopped";
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">
<Braces size={19} strokeWidth={2.2} />
</div>
<div>
<strong>Wasmeld Console</strong>
<span>Runtime Control</span>
</div>
</div>
<div className="environment-switcher">
<span className="environment-icon">
<Server size={15} />
</span>
<span>
<small>环境</small>
<strong>Local Runtime</strong>
</span>
</div>
<nav className="primary-nav" aria-label="主导航">
<span className="nav-label">管理</span>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<button
type="button"
key={item.id}
className={view === item.id ? "nav-item active" : "nav-item"}
onClick={() => onChange(item.id)}
>
<Icon size={17} />
<span>{item.label}</span>
{item.id === "instances" && running > 0 && (
<span className="nav-count">{running}</span>
)}
</button>
);
})}
</nav>
<div className="sidebar-runtime">
<div className="runtime-title">
<span className={runtimeHealthClass} />
Runtime {runtimeLabel}
</div>
<div className="runtime-meta">
<span>{formatApiHost(apiBase)}</span>
<span>{uptimeMs === null ? "—" : formatDuration(uptimeMs)}</span>
</div>
</div>
</aside>
);
}
function PageHeading({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="page-heading">
<div>
<span className="eyebrow">{eyebrow}</span>
<h1>{title}</h1>
<p>{description}</p>
</div>
{actions && <div className="heading-actions">{actions}</div>}
</div>
);
}
function Overview({
services,
filteredServices,
events,
running,
faulted,
totalCalls,
runtime,
selectedService,
onSelect,
onDeploy,
onInvoke,
onStatus,
onViewAll,
onRefresh,
onRuntimeAction,
runtimeAction,
}: {
services: Service[];
filteredServices: Service[];
events: RuntimeEvent[];
running: number;
faulted: number;
totalCalls: number;
runtime: BackendRuntime | null;
selectedService: Service | null;
onSelect: (service: Service) => void;
onDeploy: () => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onViewAll: () => void;
onRefresh: () => void;
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
runtimeAction: "start" | "stop" | "restart" | null;
}) {
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
const latencies = services
.map((service) => service.latencyMs)
.filter((latency): latency is number => latency !== null);
const latestLatency = latencies.length ? Math.max(...latencies) : null;
return (
<>
<PageHeading
eyebrow="LOCAL RUNTIME"
title="运行概览"
description={`${services.length} 个已注册版本,${running} 个 Actor 正在运行。`}
actions={
<>
<button
className="icon-button"
type="button"
aria-label="刷新"
title="刷新"
onClick={onRefresh}
>
<RefreshCw size={17} />
</button>
<button className="primary-button" type="button" onClick={onDeploy}>
<CloudUpload size={17} />
注册组件
</button>
</>
}
/>
<section className="metric-grid" aria-label="关键指标">
<Metric
icon={<Package size={18} />}
label="服务版本"
value={services.length.toString()}
note="来自 Wasmeld"
tone="cyan"
/>
<Metric
icon={<Activity size={18} />}
label="运行实例"
value={running.toString()}
note={`${services.length - running} 个未运行`}
tone="green"
/>
<Metric
icon={<Zap size={18} />}
label="累计调用"
value={totalCalls.toLocaleString("zh-CN")}
note="持久化统计"
tone="amber"
/>
<Metric
icon={<AlertTriangle size={18} />}
label="异常"
value={(faulted + errorCount).toString()}
note={faulted + errorCount ? "需要检查" : "无异常"}
tone="coral"
/>
</section>
<section className="runtime-band">
<div className="runtime-band-title">
<div className="runtime-symbol">
<Cpu size={18} />
</div>
<div>
<strong>Wasmeld Runtime</strong>
<span>
{runtime?.status === "running"
? "Engine 已启用 fuel 与 epoch interruption"
: "Engine 与所有 Actor 已释放"}
</span>
</div>
<div className="runtime-controls" aria-label="Runtime 生命周期">
{runtime?.status === "stopped" ? (
<button
className="icon-button"
type="button"
aria-label="启动 Runtime"
title="启动 Runtime"
disabled={runtimeAction !== null}
onClick={() => onRuntimeAction("start")}
>
<Play size={15} />
</button>
) : (
<>
<button
className="icon-button"
type="button"
aria-label="重启 Runtime"
title="重启 Runtime"
disabled={runtimeAction !== null || !runtime}
onClick={() => onRuntimeAction("restart")}
>
<RotateCcw size={15} />
</button>
<button
className="icon-button runtime-stop-button"
type="button"
aria-label="停止 Runtime"
title="停止 Runtime"
disabled={runtimeAction !== null || !runtime}
onClick={() => onRuntimeAction("stop")}
>
<CircleStop size={15} />
</button>
</>
)}
</div>
</div>
<RuntimeStat
label="已加载版本"
value={(runtime?.registered_services ?? 0).toString()}
detail={`${runtime?.managed_services ?? services.length} 个受管制品`}
/>
<RuntimeStat
label="活跃 Store"
value={(runtime?.running_services ?? running).toString()}
detail="串行 Actor"
/>
<RuntimeStat
label="最近延迟"
value={latestLatency === null ? "—" : `${latestLatency} ms`}
detail="各服务最近调用"
/>
<RuntimeStat
label="运行时间"
value={runtime?.status === "running" ? formatDuration(runtime.uptime_ms) : "—"}
detail="当前 Runtime 实例"
/>
</section>
<div className="overview-grid">
<section className="panel service-panel">
<div className="panel-header">
<div>
<h2>服务状态</h2>
<p>已注册版本及当前 Actor 状态</p>
</div>
<button className="text-button" type="button" onClick={onViewAll}>
查看全部
<ArrowRight size={15} />
</button>
</div>
<ServiceTable
services={filteredServices.slice(0, 5)}
selectedKey={selectedService ? serviceKey(selectedService) : ""}
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
compact
/>
</section>
<aside className="panel event-panel">
<div className="panel-header">
<div>
<h2>实时事件</h2>
<p>Runtime 生命周期与调用结果</p>
</div>
<span className="live-label">
<span />
LIVE
</span>
</div>
<EventList events={events.slice(0, 5)} />
</aside>
</div>
{selectedService && (
<section className="detail-strip">
<div className="detail-identity">
<div className="artifact-icon">
<FileCode2 size={20} />
</div>
<div>
<span>当前选中</span>
<strong>
{selectedService.id}
<small>@{selectedService.revision}</small>
</strong>
</div>
</div>
<DetailValue label="内存上限" value={`${selectedService.memoryMb} MB`} />
<DetailValue label="Fuel / 调用" value={selectedService.fuel} />
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
<div className="detail-actions">
<button
className="secondary-button"
type="button"
disabled={selectedService.status !== "running"}
onClick={() => onInvoke(selectedService)}
>
<SquareTerminal size={16} />
测试调用
</button>
</div>
</section>
)}
</>
);
}
function Metric({
icon,
label,
value,
note,
tone,
}: {
icon: ReactNode;
label: string;
value: string;
note: string;
tone: string;
}) {
return (
<article className="metric">
<div className={`metric-icon metric-${tone}`}>{icon}</div>
<div className="metric-copy">
<span>{label}</span>
<strong>{value}</strong>
</div>
<small>{note}</small>
</article>
);
}
function RuntimeStat({ label, value, detail }: { label: string; value: string; detail: string }) {
return (
<div className="runtime-stat">
<span>{label}</span>
<strong>{value}</strong>
<small>{detail}</small>
</div>
);
}
function DetailValue({ label, value }: { label: string; value: string }) {
return (
<div className="detail-value">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function StatusBadge({ status }: { status: ServiceStatus }) {
const meta = STATUS_META[status];
return (
<span className={`status-badge ${meta.className}`}>
<span />
{meta.label}
</span>
);
}
function ServiceTable({
services,
selectedKey,
onSelect,
onInvoke,
onStatus,
compact = false,
}: {
services: Service[];
selectedKey?: string;
onSelect: (service: Service) => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
compact?: boolean;
}) {
if (services.length === 0) {
return (
<div className="empty-state">
<Search size={22} />
<strong>没有匹配的服务</strong>
<span>调整搜索关键词或状态筛选。</span>
</div>
);
}
return (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>服务</th>
<th>状态</th>
{!compact && <th>制品</th>}
<th>调用</th>
<th>延迟</th>
<th>
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
<tbody>
{services.map((service) => (
<tr
key={`${service.id}-${service.revision}`}
className={selectedKey === serviceKey(service) ? "selected" : ""}
>
<td>
<button
className="service-cell service-select"
type="button"
aria-label={`选择 ${service.id}@${service.revision}`}
onClick={() => onSelect(service)}
>
<span className="service-glyph">
<Box size={16} />
</span>
<span>
<strong>{service.id}</strong>
<small>
{service.revision} · {service.description}
</small>
</span>
</button>
</td>
<td>
<StatusBadge status={service.status} />
</td>
{!compact && <td className="artifact-cell">{service.artifact}</td>}
<td className="numeric-cell">{service.calls.toLocaleString("zh-CN")}</td>
<td className="numeric-cell">
{service.latencyMs ? `${service.latencyMs} ms` : "—"}
</td>
<td>
<div className="row-actions">
{service.status === "running" ? (
<>
<button
type="button"
aria-label={`调用 ${service.id}`}
title="测试调用"
onClick={() => onInvoke(service)}
>
<SquareTerminal size={15} />
</button>
<button
type="button"
aria-label={`重启 ${service.id}`}
title="重启"
onClick={() => onStatus(service, "running")}
>
<RotateCcw size={15} />
</button>
<button
type="button"
aria-label={`停止 ${service.id}`}
title="停止"
onClick={() => onStatus(service, "stopped")}
>
<CircleStop size={15} />
</button>
</>
) : (
<button
type="button"
aria-label={`启动 ${service.id}`}
title="启动"
onClick={() => onStatus(service, "running")}
>
<Play size={15} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EventList({ events }: { events: RuntimeEvent[] }) {
const icons: Record<EventTone, ReactNode> = {
success: <Check size={14} />,
warning: <AlertTriangle size={14} />,
danger: <X size={14} />,
neutral: <CircleStop size={14} />,
};
return (
<div className="event-list">
{events.map((event) => (
<div className="event-row" key={event.id}>
<span className={`event-icon event-${event.tone}`}>{icons[event.tone]}</span>
<div>
<strong>{event.title}</strong>
<span>{event.detail}</span>
</div>
<time>{event.time}</time>
</div>
))}
</div>
);
}
function ServicesView({
services,
statusFilter,
onFilter,
onDeploy,
onInvoke,
onSelect,
onStatus,
}: {
services: Service[];
statusFilter: "all" | ServiceStatus;
onFilter: (status: "all" | ServiceStatus) => void;
onDeploy: () => void;
onInvoke: (service: Service) => void;
onSelect: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
}) {
return (
<>
<PageHeading
eyebrow="REGISTRY"
title="服务版本"
description="管理 Runtime 已注册的不可变 Wasm Component 制品。"
actions={
<button className="primary-button" type="button" onClick={onDeploy}>
<CloudUpload size={17} />
注册组件
</button>
}
/>
<section className="panel full-panel">
<div className="filterbar">
<div className="segmented" aria-label="状态筛选">
{[
["all", "全部"],
["running", "运行中"],
["stopped", "已停止"],
["faulted", "故障"],
].map(([value, label]) => (
<button
type="button"
key={value}
className={statusFilter === value ? "active" : ""}
onClick={() => onFilter(value as "all" | ServiceStatus)}
>
{label}
</button>
))}
</div>
<span className="result-count">{services.length} 个版本</span>
</div>
<ServiceTable
services={services}
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
/>
</section>
</>
);
}
function WitPackagesView({
apiBase,
packages,
onPublish,
}: {
apiBase: string;
packages: BackendWitPackage[];
onPublish: () => void;
}) {
function downloadPackage(packageMetadata: BackendWitPackage) {
const anchor = document.createElement("a");
anchor.href = witPackageDownloadUrl(apiBase, packageMetadata);
anchor.download = `${packageMetadata.name.replace(":", "-")}-${packageMetadata.version}.wasm`;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
return (
<>
<PageHeading
eyebrow="WIT REGISTRY"
title="WIT 包"
description="管理组件开发所依赖的不可变、版本化 WIT Package。"
actions={
<button className="primary-button" type="button" onClick={onPublish}>
<CloudUpload size={17} />
发布 WIT
</button>
}
/>
<section className="panel full-panel">
<div className="filterbar">
<span className="registry-note">发布后不可覆盖;包名、版本与依赖由制品自动解析。</span>
<span className="result-count">{packages.length} 个包版本</span>
</div>
{packages.length === 0 ? (
<div className="empty-state">
<FileCode2 size={22} />
<strong>没有匹配的 WIT </strong>
<span>发布二进制 WIT Package,或调整搜索关键词。</span>
</div>
) : (
<div className="table-scroll">
<table className="data-table wit-package-table">
<thead>
<tr>
<th>Package</th>
<th>依赖</th>
<th>SHA-256</th>
<th>
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
<tbody>
{packages.map((packageMetadata) => {
const dependencies = packageMetadata.dependencies
.map((dependency) => `${dependency.name}@${dependency.version}`)
.join(", ");
return (
<tr key={`${packageMetadata.name}@${packageMetadata.version}`}>
<td aria-label={`${packageMetadata.name}@${packageMetadata.version}`}>
<div className="service-cell">
<span className="service-glyph">
<FileCode2 size={16} />
</span>
<span>
<strong>{packageMetadata.name}</strong>
<small>{packageMetadata.version}</small>
</span>
</div>
</td>
<td className="artifact-cell" title={dependencies || "无直接依赖"}>
{dependencies || "无直接依赖"}
</td>
<td className="artifact-cell" title={packageMetadata.sha256}>
{packageMetadata.sha256}
</td>
<td>
<div className="row-actions">
<button
type="button"
title="下载 WIT 包"
aria-label={`下载 ${packageMetadata.name}@${packageMetadata.version}`}
onClick={() => downloadPackage(packageMetadata)}
>
<Download size={15} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</>
);
}
function InstancesView({
services,
onInvoke,
onStatus,
onRefresh,
}: {
services: Service[];
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onRefresh: () => void;
}) {
const instances = services.filter(
(service) => service.status === "running" || service.status === "faulted",
);
return (
<>
<PageHeading
eyebrow="ACTORS"
title="运行实例"
description={`${instances.length} 个 Actor 保持独立 Store 与 Instance。`}
actions={
<button
className="icon-button"
type="button"
title="刷新"
aria-label="刷新"
onClick={onRefresh}
>
<RefreshCw size={17} />
</button>
}
/>
<section className="instance-list">
{instances.length === 0 ? (
<output className="instance-placeholder">
<div className="instance-placeholder-icon">
<Cpu size={22} />
</div>
<strong>暂无运行实例</strong>
<span>0 ACTIVE ACTORS</span>
</output>
) : (
instances.map((service) => (
<article className="instance-row" key={service.id}>
<div className="instance-main">
<span className={`instance-indicator ${service.status}`} />
<div className="artifact-icon">
<Cpu size={19} />
</div>
<div>
<strong>
{service.id}@{service.revision}
</strong>
<span>actor/{service.id}-01</span>
</div>
</div>
<DetailValue
label="Store 内存"
value={
service.status === "running"
? `${Math.round(service.memoryMb * 0.42)} MB`
: "已释放"
}
/>
<DetailValue label="调用总数" value={service.calls.toLocaleString("zh-CN")} />
<DetailValue
label="最近延迟"
value={service.latencyMs ? `${service.latencyMs} ms` : "—"}
/>
<div className="instance-status">
<StatusBadge status={service.status} />
<span>{service.updatedAt}</span>
</div>
<div className="row-actions">
<button
type="button"
title="测试调用"
aria-label={`调用 ${service.id}`}
disabled={service.status !== "running"}
onClick={() => onInvoke(service)}
>
<SquareTerminal size={16} />
</button>
<button
type="button"
title="重启"
aria-label={`重启 ${service.id}`}
onClick={() => onStatus(service, "running")}
>
<RotateCcw size={16} />
</button>
<button
type="button"
title="停止"
aria-label={`停止 ${service.id}`}
disabled={service.status !== "running"}
onClick={() => onStatus(service, "stopped")}
>
<CircleStop size={16} />
</button>
</div>
</article>
))
)}
</section>
</>
);
}
function ActivityView({ events, services }: { events: RuntimeEvent[]; services: Service[] }) {
const calls = services.reduce((sum, service) => sum + service.calls, 0);
const errors = services.reduce((sum, service) => sum + service.errors, 0);
const latencies = services
.map((service) => service.latencyMs)
.filter((latency): latency is number => latency !== null);
const averageLatency = latencies.length
? latencies.reduce((sum, latency) => sum + latency, 0) / latencies.length
: null;
function exportEvents() {
const blob = new Blob([JSON.stringify(events, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "wasmeld-events.json";
anchor.click();
URL.revokeObjectURL(url);
}
return (
<>
<PageHeading
eyebrow="EVENT STREAM"
title="调用记录"
description="Runtime 生命周期、调用结果与沙箱拒绝记录。"
actions={
<button className="secondary-button" type="button" onClick={exportEvents}>
<Archive size={16} />
导出记录
</button>
}
/>
<section className="panel full-panel">
<div className="activity-summary">
<div>
<span>时间范围</span>
<strong>当前进程</strong>
</div>
<div>
<span>成功率</span>
<strong>{calls ? `${(((calls - errors) / calls) * 100).toFixed(2)}%` : "—"}</strong>
</div>
<div>
<span>平均延迟</span>
<strong>{averageLatency === null ? "—" : `${averageLatency.toFixed(1)} ms`}</strong>
</div>
<div>
<span>记录数</span>
<strong>{events.length}</strong>
</div>
</div>
<EventList events={events} />
</section>
</>
);
}
function SettingsView({
endpoint,
onSaveEndpoint,
}: {
endpoint: string;
onSaveEndpoint: (value: string) => void;
}) {
const [value, setValue] = useState(endpoint);
return (
<>
<PageHeading
eyebrow="CONFIGURATION"
title="运行设置"
description="本地控制台连接与 Runtime 默认参数。"
/>
<form
className="settings-form"
onSubmit={(event) => {
event.preventDefault();
onSaveEndpoint(value);
}}
>
<section className="settings-section">
<div className="settings-heading">
<Server size={18} />
<div>
<h2>控制端连接</h2>
<p>管理 API 的本地访问地址。</p>
</div>
</div>
<div className="form-field wide-field">
<label htmlFor="endpoint">API 地址</label>
<input id="endpoint" value={value} onChange={(event) => setValue(event.target.value)} />
<span>连接状态将在顶部工具栏显示。</span>
</div>
</section>
<div className="form-footer">
<button className="primary-button" type="submit">
保存设置
</button>
</div>
</form>
</>
);
}
function DialogFrame({
title,
description,
icon,
onClose,
children,
}: {
title: string;
description: string;
icon: ReactNode;
onClose: () => void;
children: ReactNode;
}) {
return (
<div className="dialog-backdrop">
<dialog
open
className="dialog"
aria-label={title}
onCancel={onClose}
onKeyDown={(event) => {
if (event.key === "Escape") onClose();
}}
>
<div className="dialog-header">
<div className="dialog-title">
<span>{icon}</span>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
title="关闭"
onClick={onClose}
>
<X size={18} />
</button>
</div>
{children}
</dialog>
</div>
);
}
function DeployDialog({
onClose,
onRegister,
}: {
onClose: () => void;
onRegister: (input: RegisterComponentInput) => Promise<void>;
}) {
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const fileName = file?.name ?? "";
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
if (!file.name.toLowerCase().endsWith(".wasmpkg")) {
setError("请选择 .wasmpkg 组件包");
return;
}
setSubmitting(true);
setError("");
try {
await onRegister({ file });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "组件注册失败");
setSubmitting(false);
}
}
return (
<DialogFrame
title="注册组件包"
description="组件元数据与运行边界由平台自动校验。"
icon={<CloudUpload size={19} />}
onClose={onClose}
>
<form className="dialog-form" onSubmit={submit}>
<label className={fileName ? "upload-zone has-file" : "upload-zone"}>
<input
type="file"
accept=".wasmpkg,application/zip,application/vnd.wasm.component-package"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
setError("");
}}
/>
{fileName ? <Package size={28} /> : <CloudUpload size={28} />}
<strong>{fileName || "选择组件包"}</strong>
<span>
{file
? `${(file.size / 1024 / 1024).toFixed(2)} MB · 等待校验`
: ".wasmpkg · 最大 64 MB"}
</span>
</label>
{error && <div className="form-error">{error}</div>}
<div className="dialog-footer">
<button className="secondary-button" type="button" onClick={onClose}>
取消
</button>
<button className="primary-button" type="submit" disabled={submitting || !fileName}>
{submitting ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
{submitting ? "正在注册" : "校验并注册"}
</button>
</div>
</form>
</DialogFrame>
);
}
function WitPackageDialog({
onClose,
onPublish,
}: {
onClose: () => void;
onPublish: (file: File) => Promise<void>;
}) {
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const fileName = file?.name ?? "";
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
if (!file.name.toLowerCase().endsWith(".wasm")) {
setError("请选择由 wasmeld wit build 生成的 .wasm 文件");
return;
}
if (file.size > 4 * 1024 * 1024) {
setError("WIT 包不能超过 4 MB");
return;
}
setSubmitting(true);
setError("");
try {
await onPublish(file);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "WIT 包发布失败");
setSubmitting(false);
}
}
return (
<DialogFrame
title="发布 WIT 包"
description="包名、语义版本和直接依赖将从二进制 WIT Package 读取。"
icon={<FileCode2 size={19} />}
onClose={onClose}
>
<form className="dialog-form" onSubmit={submit}>
<label className={fileName ? "upload-zone has-file" : "upload-zone"}>
<input
type="file"
accept=".wasm,application/wasm"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
setError("");
}}
/>
{fileName ? <FileCode2 size={28} /> : <CloudUpload size={28} />}
<strong>{fileName || "选择二进制 WIT Package"}</strong>
<span>
{file ? `${(file.size / 1024).toFixed(1)} KB · 等待校验` : ".wasm · 最大 4 MB"}
</span>
</label>
{error && <div className="form-error">{error}</div>}
<div className="dialog-footer">
<button className="secondary-button" type="button" onClick={onClose}>
取消
</button>
<button className="primary-button" type="submit" disabled={submitting || !fileName}>
{submitting ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
{submitting ? "正在发布" : "校验并发布"}
</button>
</div>
</form>
</DialogFrame>
);
}
function InvokeDialog({
service,
onClose,
onInvoke,
}: {
service: Service;
onClose: () => void;
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
}) {
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
const [output, setOutput] = useState("");
const [running, setRunning] = useState(false);
const [failed, setFailed] = useState(false);
const [latency, setLatency] = useState<number | null>(null);
async function invoke() {
setRunning(true);
setOutput("");
setFailed(false);
setLatency(null);
try {
const result = await onInvoke(service, parseInvocationInput(input, format));
setOutput(formatInvocationOutput(service, result.output, format));
setLatency(result.latencyMs);
} catch (invokeError) {
setFailed(true);
setOutput(invokeError instanceof Error ? invokeError.message : "组件调用失败");
} finally {
setRunning(false);
}
}
return (
<DialogFrame
title={`调用 ${service.id}@${service.revision}`}
description="请求将进入该实例的串行 mailbox。"
icon={<SquareTerminal size={19} />}
onClose={onClose}
>
<div className="invoke-body">
<div className="invoke-toolbar">
<div className="segmented">
<button
type="button"
className={format === "utf8" ? "active" : ""}
onClick={() => setFormat("utf8")}
>
UTF-8
</button>
<button
type="button"
className={format === "hex" ? "active" : ""}
onClick={() => setFormat("hex")}
>
HEX
</button>
</div>
<span>{invocationInputSize(input, format)} B</span>
</div>
<label className="code-field">
<span>输入</span>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
spellCheck={false}
placeholder={format === "utf8" ? "输入请求内容" : "00 ff a1"}
/>
</label>
<div className={failed ? "invoke-output failed" : "invoke-output"}>
<div>
<span>输出</span>
{output && latency !== null && (
<small>
<Clock3 size={13} />
{latency} ms
</small>
)}
</div>
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
</div>
</div>
<div className="dialog-footer">
<button className="secondary-button" type="button" onClick={onClose}>
关闭
</button>
<button className="primary-button" type="button" disabled={running} onClick={invoke}>
{running ? <RefreshCw className="spin" size={16} /> : <Play size={16} />}
执行调用
</button>
</div>
</DialogFrame>
);
}