1720 lines
52 KiB
TypeScript
1720 lines
52 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|