0f0effb3c3
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.
987 lines
30 KiB
TypeScript
987 lines
30 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|