refactor(console-ui): split dashboard responsibilities

Reduce the root TanStack route to orchestration and shell state. Move backend-to-view mapping and binary invocation formatting into console-model, presentation views into components/views, and modal workflows into components/dialogs.

Update the rendered artifact test to assert behavior at the new module boundaries without changing the generated UI or routes.
This commit is contained in:
Maofeng
2026-07-30 07:18:29 +08:00
parent 68218b2eae
commit 0f0effb3c3
5 changed files with 1621 additions and 1541 deletions
+379
View File
@@ -0,0 +1,379 @@
import {
ArrowRight,
Clock3,
CloudUpload,
FileCode2,
Package,
Play,
RadioTower,
RefreshCw,
ShieldCheck,
SquareTerminal,
X,
} from "lucide-react";
import { FormEvent, ReactNode, useState } from "react";
import { InvokeResult, RegisterComponentInput } from "../api";
import {
FormattedInvocationOutput,
Service,
formatInvocationOutput,
invocationInputSize,
parseInvocationInput,
} from "../console-model";
function DialogFrame({
title,
description,
icon,
onClose,
closeDisabled = false,
children,
}: {
title: string;
description: string;
icon: ReactNode;
onClose: () => void;
closeDisabled?: boolean;
children: ReactNode;
}) {
return (
<div className="dialog-backdrop">
<dialog
open
className="dialog"
aria-label={title}
onCancel={(event) => {
if (closeDisabled) event.preventDefault();
else onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape" && !closeDisabled) 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="关闭"
disabled={closeDisabled}
onClick={onClose}
>
<X size={18} />
</button>
</div>
{children}
</dialog>
</div>
);
}
export function ActivateDeploymentDialog({
service,
currentRevision,
submitting,
onClose,
onConfirm,
}: {
service: Service;
currentRevision: string | null;
submitting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<DialogFrame
title="切换对外版本"
description="更新该服务在 Gateway 上接收请求的活动版本。"
icon={<RadioTower size={19} />}
onClose={onClose}
closeDisabled={submitting}
>
<div className="deployment-confirmation">
<div className="deployment-route">
<div>
<span></span>
<strong>{currentRevision ?? "尚未部署"}</strong>
</div>
<ArrowRight size={18} />
<div className="deployment-target">
<span></span>
<strong>{service.revision}</strong>
</div>
</div>
<p>
Wasmeld {service.id}@{service.revision} Actor
</p>
</div>
<div className="dialog-footer">
<button className="secondary-button" type="button" disabled={submitting} onClick={onClose}>
</button>
<button className="primary-button" type="button" disabled={submitting} onClick={onConfirm}>
{submitting ? <RefreshCw className="spin" size={16} /> : <RadioTower size={16} />}
{submitting ? "正在切换" : "确认切换"}
</button>
</div>
</DialogFrame>
);
}
export 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>
);
}
export 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>
);
}
export 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 [outputBytes, setOutputBytes] = useState<number | null>(null);
const [outputFormat, setOutputFormat] = useState<FormattedInvocationOutput | null>(null);
const [running, setRunning] = useState(false);
const [failed, setFailed] = useState(false);
const [latency, setLatency] = useState<number | null>(null);
async function invoke() {
setRunning(true);
setOutput("");
setOutputBytes(null);
setOutputFormat(null);
setFailed(false);
setLatency(null);
try {
const result = await onInvoke(service, parseInvocationInput(input, format));
const formatted = formatInvocationOutput(service, result.output, format);
setOutput(formatted.text);
setOutputBytes(result.outputBytes);
setOutputFormat(formatted);
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" aria-label="输入编码">
<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 && outputBytes !== null && outputFormat && (
<div className="invoke-output-meta">
<span>
{outputBytes} B · {outputFormat.format}
{outputFormat.automatic ? " · 自动" : ""}
</span>
<small>
<Clock3 size={13} />
{latency} ms
</small>
</div>
)}
</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>
);
}
+986
View File
@@ -0,0 +1,986 @@
import {
Activity,
AlertTriangle,
Archive,
ArrowRight,
Box,
Braces,
Check,
CircleStop,
CloudUpload,
Cpu,
Download,
FileCode2,
Package,
Play,
RadioTower,
RefreshCw,
RotateCcw,
Search,
Server,
SquareTerminal,
X,
Zap,
} from "lucide-react";
import { ReactNode, useState } from "react";
import {
BackendCapability,
BackendRuntime,
BackendWitPackage,
witPackageDownloadUrl,
} from "../api";
import {
EventTone,
RuntimeEvent,
Service,
ServiceStatus,
STATUS_META,
formatDuration,
serviceKey,
} from "../console-model";
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>
);
}
export function Overview({
services,
filteredServices,
events,
running,
faulted,
totalCalls,
deploymentCount,
runtime,
selectedService,
onSelect,
onDeploy,
onInvoke,
onStatus,
onActivate,
onViewAll,
onRefresh,
onRuntimeAction,
runtimeAction,
deploymentAction,
}: {
services: Service[];
filteredServices: Service[];
events: RuntimeEvent[];
running: number;
faulted: number;
totalCalls: number;
deploymentCount: number;
runtime: BackendRuntime | null;
selectedService: Service | null;
onSelect: (service: Service) => void;
onDeploy: () => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
onViewAll: () => void;
onRefresh: () => void;
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
runtimeAction: "start" | "stop" | "restart" | null;
deploymentAction: string | 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={`${deploymentCount} 个对外服务`}
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}
onActivate={onActivate}
canActivate={runtime?.status === "running"}
deploymentAction={deploymentAction}
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">
{!selectedService.active && (
<button
className="secondary-button"
type="button"
disabled={runtime?.status !== "running" || deploymentAction !== null}
onClick={() => onActivate(selectedService)}
>
<RadioTower size={16} />
</button>
)}
<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 CapabilitySummary({ capabilities }: { capabilities: BackendCapability[] }) {
if (capabilities.length === 0) {
return <span className="capability-empty"></span>;
}
const [first, ...remaining] = capabilities;
return (
<span
className="capability-summary"
title={capabilities.map((capability) => capability.interface).join("\n")}
>
<Braces size={12} />
<span>
{first.package}/{first.name} @{first.version}
</span>
{remaining.length > 0 && <small>+{remaining.length}</small>}
</span>
);
}
function ServiceTable({
services,
selectedKey,
onSelect,
onInvoke,
onStatus,
onActivate,
canActivate,
deploymentAction,
compact = false,
}: {
services: Service[];
selectedKey?: string;
onSelect: (service: Service) => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
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 ${compact ? "service-table-compact" : "service-table-full"}`}>
<thead>
<tr>
<th></th>
<th></th>
{!compact && <th></th>}
{!compact && <th>Host </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>
<span className="service-title">
<strong>{service.id}</strong>
{service.active && (
<span className="active-deployment-badge">
<RadioTower size={10} />
</span>
)}
</span>
<small>
{service.revision} · {service.description}
</small>
</span>
</button>
</td>
<td>
<StatusBadge status={service.status} />
</td>
{!compact && <td className="artifact-cell">{service.artifact}</td>}
{!compact && (
<td>
<CapabilitySummary capabilities={service.capabilities} />
</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">
<button
className={service.active ? "active-deployment-button" : ""}
type="button"
aria-label={
service.active
? `${service.id}@${service.revision} 是当前对外版本`
: `${service.id}@${service.revision} 设为对外版本`
}
title={
service.active
? "当前对外版本"
: canActivate
? "设为对外版本"
: "请先启动 Runtime"
}
disabled={service.active || !canActivate || deploymentAction !== null}
onClick={() => onActivate(service)}
>
{deploymentAction === serviceKey(service) ? (
<RefreshCw className="spin" size={15} />
) : (
<RadioTower size={15} />
)}
</button>
{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>
);
}
export function ServicesView({
services,
statusFilter,
onFilter,
onDeploy,
onInvoke,
onSelect,
onStatus,
onActivate,
canActivate,
deploymentAction,
}: {
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;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
}) {
return (
<>
<PageHeading
eyebrow="REGISTRY"
title="服务版本"
description="管理不可变 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}
onActivate={onActivate}
canActivate={canActivate}
deploymentAction={deploymentAction}
/>
</section>
</>
);
}
export 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>
</>
);
}
export 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>
</>
);
}
export 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>
</>
);
}
export 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>
</>
);
}
+208
View File
@@ -0,0 +1,208 @@
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;
}
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -83,9 +83,22 @@ test("server-renders the Wasm management console", async () => {
}); });
test("uses TanStack Start routing and produces Node artifacts", async () => { test("uses TanStack Start routing and produces Node artifacts", async () => {
const [rootRoute, indexRoute, apiClient, router, packageJson, viteConfig] = await Promise.all([ const [
rootRoute,
indexRoute,
views,
dialogs,
consoleModel,
apiClient,
router,
packageJson,
viteConfig,
] = await Promise.all([
readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"), readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"), readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/views.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/dialogs.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/console-model.ts", import.meta.url), "utf8"),
readFile(new URL("../src/api.ts", import.meta.url), "utf8"), readFile(new URL("../src/api.ts", import.meta.url), "utf8"),
readFile(new URL("../src/router.tsx", import.meta.url), "utf8"), readFile(new URL("../src/router.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"), readFile(new URL("../package.json", import.meta.url), "utf8"),
@@ -96,11 +109,11 @@ test("uses TanStack Start routing and produces Node artifacts", async () => {
assert.match(rootRoute, /<HeadContent \/>/); assert.match(rootRoute, /<HeadContent \/>/);
assert.match(rootRoute, /<Scripts \/>/); assert.match(rootRoute, /<Scripts \/>/);
assert.match(indexRoute, /createFileRoute\("\/"\)/); assert.match(indexRoute, /createFileRoute\("\/"\)/);
assert.match(indexRoute, /切换对外版本/); assert.match(dialogs, /切换对外版本/);
assert.match(indexRoute, /Host 能力/); assert.match(views, /Host 能力/);
assert.match(indexRoute, /service\.capabilities/); assert.match(views, /service\.capabilities/);
assert.match(indexRoute, /TextDecoder\("utf-8", \{ fatal: true \}\)/); assert.match(consoleModel, /TextDecoder\("utf-8", \{ fatal: true \}\)/);
assert.match(indexRoute, /outputFormat\.automatic/); assert.match(dialogs, /outputFormat\.automatic/);
assert.match(apiClient, /\/api\/v1\/deployments/); assert.match(apiClient, /\/api\/v1\/deployments/);
assert.match(apiClient, /capabilities: BackendCapability\[\]/); assert.match(apiClient, /capabilities: BackendCapability\[\]/);
assert.match(router, /createRouter/); assert.match(router, /createRouter/);