feat(console-web): replace TanStack UI with Solid host pages
- move the management frontend under the wasmeld-console crate - use a persistent Host shell with disposable iframe page documents - version Host/Page postMessage state and command contracts - migrate runtime, service, WIT, invocation, and settings workflows - add Tailwind CSS v4, responsive layouts, and Host-owned dialogs - emit a dependency-free PWA worker with API cache exclusions - remove the superseded root TanStack Start application
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": ["src/routeTree.gen.ts"]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "node"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {},
|
||||
"ignorePatterns": ["src/routeTree.gen.ts"],
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
# Wasmeld Console
|
||||
|
||||
WebAssembly Runtime 的管理控制台,使用 TanStack Start、TanStack Router、
|
||||
React 和 srvx 构建,通过 Node.js 运行。页面只调用 `wasmeld-console` HTTP API,
|
||||
不直接访问数据库或 Wasmeld Runtime。
|
||||
|
||||
运行概览可以启动、停止和重启整个 Runtime;服务版本与运行实例页面负责管理单个
|
||||
Wasm Actor。停止 Runtime 不会断开管理 API。
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认访问 `http://localhost:3000`。
|
||||
|
||||
管理 API 默认使用 `http://127.0.0.1:8080`。可以在控制台的“运行设置”中修改,
|
||||
或在构建时设置 `VITE_WASMELD_API_URL`。
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
生产构建完成后可以运行:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
`npm test` 会生成 Node.js 生产构建,并验证 srvx 服务端渲染和静态资源。
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "wasmeld-console",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"start": "srvx serve --prod --entry dist/server/server.js --static ../client",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||
"lint": "oxlint . --deny-warnings",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tanstack/react-start": "^1.168.32",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"srvx": "^0.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"oxfmt": "^0.60.0",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^8.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,986 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/* oxlint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/'
|
||||
id: '__root__' | '/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
export function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
});
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import appCss from "../styles/app.css?url";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
{ title: "Wasmeld Console" },
|
||||
{
|
||||
name: "description",
|
||||
content: "WebAssembly Runtime 管理控制台",
|
||||
},
|
||||
{ property: "og:title", content: "Wasmeld Console" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "WebAssembly Runtime 管理控制台",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:image", content: "/og.png" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: "Wasmeld Console" },
|
||||
{
|
||||
name: "twitter:description",
|
||||
content: "WebAssembly Runtime 管理控制台",
|
||||
},
|
||||
{ name: "twitter:image", content: "/og.png" },
|
||||
],
|
||||
links: [{ rel: "stylesheet", href: appCss }],
|
||||
}),
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<RootDocument>
|
||||
<Outlet />
|
||||
</RootDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, Braces, CheckCircle2, Search, Server, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BackendDeployment,
|
||||
BackendRuntime,
|
||||
BackendWitPackage,
|
||||
RegisterComponentInput,
|
||||
activateDeployment,
|
||||
changeRuntimeState,
|
||||
changeServiceState,
|
||||
fetchSnapshot,
|
||||
getApiBase,
|
||||
invokeComponent,
|
||||
publishWitPackage,
|
||||
registerComponent,
|
||||
saveApiBase,
|
||||
} from "../api";
|
||||
import {
|
||||
ConnectionState,
|
||||
NAV_ITEMS,
|
||||
RuntimeEvent,
|
||||
Service,
|
||||
ServiceStatus,
|
||||
View,
|
||||
formatApiHost,
|
||||
formatDuration,
|
||||
serviceKey,
|
||||
toEvent,
|
||||
toService,
|
||||
} from "../console-model";
|
||||
import {
|
||||
ActivateDeploymentDialog,
|
||||
DeployDialog,
|
||||
InvokeDialog,
|
||||
WitPackageDialog,
|
||||
} from "../components/dialogs";
|
||||
import {
|
||||
ActivityView,
|
||||
InstancesView,
|
||||
Overview,
|
||||
ServicesView,
|
||||
SettingsView,
|
||||
WitPackagesView,
|
||||
} from "../components/views";
|
||||
|
||||
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 [deployments, setDeployments] = useState<BackendDeployment[]>([]);
|
||||
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 [activationKey, setActivationKey] = useState<string | null>(null);
|
||||
const [deploymentAction, setDeploymentAction] = 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 activationService =
|
||||
services.find((service) => serviceKey(service) === activationKey) ?? 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 activeRevisions = new Map(
|
||||
snapshot.deployments.map((deployment) => [
|
||||
deployment.service_id,
|
||||
deployment.active_revision,
|
||||
]),
|
||||
);
|
||||
const nextServices = snapshot.services.map((service) =>
|
||||
toService(service, activeRevisions.get(service.id)),
|
||||
);
|
||||
setServices(nextServices);
|
||||
setDeployments(snapshot.deployments);
|
||||
setEvents(snapshot.events.map(toEvent));
|
||||
setRuntime(snapshot.runtime);
|
||||
setWitPackages(snapshot.witPackages);
|
||||
// Preserve operator context when possible. Registrations and cleanup can
|
||||
// remove the selected revision between polls, in which case the first
|
||||
// remaining service becomes the deterministic fallback.
|
||||
setSelectedKey((current) =>
|
||||
nextServices.some((service) => serviceKey(service) === current)
|
||||
? current
|
||||
: nextServices[0]
|
||||
? serviceKey(nextServices[0])
|
||||
: "",
|
||||
);
|
||||
setConnection("online");
|
||||
} catch {
|
||||
// Clear the complete snapshot on any request failure. Mixing stale
|
||||
// services with live deployments or Runtime state would present actions
|
||||
// against identities that may no longer exist.
|
||||
setServices([]);
|
||||
setDeployments([]);
|
||||
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 activateService(service: Service) {
|
||||
const key = serviceKey(service);
|
||||
setDeploymentAction(key);
|
||||
try {
|
||||
await activateDeployment(apiBase, service);
|
||||
await refreshSnapshot();
|
||||
setActivationKey(null);
|
||||
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "Deployment 切换失败");
|
||||
} finally {
|
||||
setDeploymentAction(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}
|
||||
deploymentCount={deployments.length}
|
||||
runtime={runtime}
|
||||
selectedService={selectedService}
|
||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||
onDeploy={() => setDeployOpen(true)}
|
||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||
onStatus={setServiceStatus}
|
||||
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||
onViewAll={() => setView("services")}
|
||||
onRefresh={() => void refreshSnapshot()}
|
||||
onRuntimeAction={(action) => void setRuntimeStatus(action)}
|
||||
runtimeAction={runtimeAction}
|
||||
deploymentAction={deploymentAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "services" && (
|
||||
<ServicesView
|
||||
services={filteredServices}
|
||||
statusFilter={statusFilter}
|
||||
onFilter={setStatusFilter}
|
||||
onDeploy={() => setDeployOpen(true)}
|
||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||
onStatus={setServiceStatus}
|
||||
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||
canActivate={runtime?.status === "running"}
|
||||
deploymentAction={deploymentAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activationService && (
|
||||
<ActivateDeploymentDialog
|
||||
service={activationService}
|
||||
currentRevision={
|
||||
services.find((service) => service.id === activationService.id && service.active)
|
||||
?.revision ?? null
|
||||
}
|
||||
submitting={deploymentAction === serviceKey(activationService)}
|
||||
onClose={() => setActivationKey(null)}
|
||||
onConfirm={() => void activateService(activationService)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,127 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test, { after, before } from "node:test";
|
||||
|
||||
let serverProcess;
|
||||
let serverOrigin;
|
||||
|
||||
async function reservePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert(address && typeof address === "object");
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return address.port;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const port = await reservePort();
|
||||
serverOrigin = `http://127.0.0.1:${port}`;
|
||||
serverProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(new URL("../node_modules/srvx/bin/srvx.mjs", import.meta.url)),
|
||||
"serve",
|
||||
"--prod",
|
||||
"--entry",
|
||||
"dist/server/server.js",
|
||||
"--static",
|
||||
"../client",
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(port),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (serverProcess.exitCode !== null) {
|
||||
throw new Error(`Node server exited with code ${serverProcess.exitCode}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(serverOrigin);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// The server is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("Node server did not become ready");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
serverProcess?.kill("SIGTERM");
|
||||
});
|
||||
|
||||
test("server-renders the Wasm management console", async () => {
|
||||
const response = await fetch(serverOrigin, {
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
|
||||
const html = await response.text();
|
||||
assert.match(html, /<title>Wasmeld Console<\/title>/i);
|
||||
assert.match(html, /运行概览/);
|
||||
assert.match(html, /WIT 包/);
|
||||
assert.match(html, /连接中/);
|
||||
assert.match(html, /0 个对外服务/);
|
||||
assert.match(html, /Component Model/);
|
||||
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
|
||||
});
|
||||
|
||||
test("uses TanStack Start routing and produces Node artifacts", async () => {
|
||||
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/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/router.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(rootRoute, /createRootRoute/);
|
||||
assert.match(rootRoute, /<HeadContent \/>/);
|
||||
assert.match(rootRoute, /<Scripts \/>/);
|
||||
assert.match(indexRoute, /createFileRoute\("\/"\)/);
|
||||
assert.match(dialogs, /切换对外版本/);
|
||||
assert.match(views, /Host 能力/);
|
||||
assert.match(views, /service\.capabilities/);
|
||||
assert.match(consoleModel, /TextDecoder\("utf-8", \{ fatal: true \}\)/);
|
||||
assert.match(dialogs, /outputFormat\.automatic/);
|
||||
assert.match(apiClient, /\/api\/v1\/deployments/);
|
||||
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
|
||||
assert.match(router, /createRouter/);
|
||||
assert.match(packageJson, /"@tanstack\/react-start"/);
|
||||
assert.match(packageJson, /"srvx"/);
|
||||
assert.doesNotMatch(packageJson, /"next"|"vinext"|"drizzle-orm"/);
|
||||
assert.match(viteConfig, /tanstackStart\(\{/);
|
||||
|
||||
await access(new URL("../dist/server/server.js", import.meta.url));
|
||||
await access(new URL("../dist/client/og.png", import.meta.url));
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: { port: 3000 },
|
||||
plugins: [
|
||||
tanstackStart({
|
||||
router: {
|
||||
routeTreeFileHeader: [
|
||||
"/* oxlint-disable */",
|
||||
"// @ts-nocheck",
|
||||
"// noinspection JSUnusedGlobalSymbols",
|
||||
],
|
||||
},
|
||||
}),
|
||||
viteReact(),
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/.tanstack/
|
||||
*.tsbuildinfo
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "error",
|
||||
"no-console": "warn"
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "node_modules/**"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#173f35" />
|
||||
<meta name="description" content="WebAssembly Component Runtime 管理控制台" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Wasmeld Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/host/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1331
-1149
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "wasmeld-console-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run build && node --test tests/build-output.test.mjs",
|
||||
"lint": "oxlint . --deny-warnings",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-solid": "1.27.0",
|
||||
"solid-js": "1.9.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/node": "24.10.1",
|
||||
"oxfmt": "0.61.0",
|
||||
"oxlint": "1.76.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.5",
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="frame-root">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f4f7f6" />
|
||||
<title>Wasmeld Console Page</title>
|
||||
</head>
|
||||
<body class="frame-document">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/pages/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="88" fill="#173f35"/>
|
||||
<path d="M104 132h70l36 196 46-143h52l46 143 36-196h70l-69 248h-67l-42-128-42 128h-67z" fill="#e5f4ef"/>
|
||||
<rect x="104" y="98" width="304" height="18" rx="9" fill="#45b68d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Wasmeld Console",
|
||||
"short_name": "Wasmeld",
|
||||
"description": "WebAssembly Component Runtime management console",
|
||||
"theme_color": "#173f35",
|
||||
"background_color": "#f3f6f5",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,13 @@
|
||||
import type { Component } from "solid-js";
|
||||
import type { LucideProps } from "lucide-solid";
|
||||
|
||||
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<div class="empty-state">
|
||||
<Icon size={22} />
|
||||
<strong>{props.title}</strong>
|
||||
<span>{props.detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { JSXElement } from "solid-js";
|
||||
|
||||
export function PageHeading(props: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: JSXElement;
|
||||
}) {
|
||||
return (
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">{props.eyebrow}</span>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
{props.actions && <div class="heading-actions">{props.actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
Box,
|
||||
Braces,
|
||||
CircleStop,
|
||||
Play,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
SquareTerminal,
|
||||
} from "lucide-solid";
|
||||
import { For, Show } from "solid-js";
|
||||
import { serviceKey, type Service } from "../../lib/model";
|
||||
import type { HostSharedState, PageCommand } from "../../sdk/protocol";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
export function ServiceTable(props: {
|
||||
services: Service[];
|
||||
state: HostSharedState;
|
||||
command: (command: PageCommand) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Show
|
||||
when={props.services.length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
|
||||
}
|
||||
>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务</th>
|
||||
<th>状态</th>
|
||||
<Show when={!props.compact}>
|
||||
<th>制品</th>
|
||||
<th>Host 能力</th>
|
||||
</Show>
|
||||
<th>调用</th>
|
||||
<th>延迟</th>
|
||||
<th>
|
||||
<span class="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.services}>
|
||||
{(service) => {
|
||||
const key = serviceKey(service);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex min-w-48 items-center gap-3">
|
||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Box size={15} />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="flex items-center gap-2">
|
||||
<strong class="truncate">{service.id}</strong>
|
||||
<Show when={service.active}>
|
||||
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
|
||||
<RadioTower size={10} />
|
||||
对外
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={service.status} />
|
||||
</td>
|
||||
<Show when={!props.compact}>
|
||||
<td class="code max-w-48 truncate text-muted" title={service.artifact}>
|
||||
{service.artifact}
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={service.capabilities.length > 0}
|
||||
fallback={<span class="text-xs text-muted">无</span>}
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
|
||||
title={service.capabilities
|
||||
.map((capability) => capability.interface)
|
||||
.join("\n")}
|
||||
>
|
||||
<Braces size={12} />
|
||||
<span class="truncate">
|
||||
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
|
||||
</span>
|
||||
<Show when={service.capabilities.length > 1}>
|
||||
<small>+{service.capabilities.length - 1}</small>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
</Show>
|
||||
<td class="font-mono text-xs">{service.calls.toLocaleString("zh-CN")}</td>
|
||||
<td class="font-mono text-xs">
|
||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="设为对外版本"
|
||||
title={service.active ? "当前对外版本" : "设为对外版本"}
|
||||
disabled={
|
||||
service.active ||
|
||||
props.state.snapshot?.runtime.status !== "running" ||
|
||||
props.state.deploymentAction !== null
|
||||
}
|
||||
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
|
||||
>
|
||||
{props.state.deploymentAction === key ? (
|
||||
<RefreshCw class="spin" size={15} />
|
||||
) : (
|
||||
<RadioTower size={15} />
|
||||
)}
|
||||
</button>
|
||||
<Show
|
||||
when={service.status === "running"}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`启动 ${service.id}`}
|
||||
title="启动"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`调用 ${service.id}`}
|
||||
title="测试调用"
|
||||
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`重启 ${service.id}`}
|
||||
title="重启"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "restart",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`停止 ${service.id}`}
|
||||
title="停止"
|
||||
disabled={props.state.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: key,
|
||||
action: "stop",
|
||||
})
|
||||
}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { STATUS_META, type ServiceStatus } from "../../lib/model";
|
||||
|
||||
export function StatusBadge(props: { status: ServiceStatus }) {
|
||||
const meta = () => STATUS_META[props.status];
|
||||
return <span class={`status-badge ${meta().className}`}>{meta().label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import {
|
||||
Boxes,
|
||||
FileCode2,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
WifiOff,
|
||||
} from "lucide-solid";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import type { LucideProps } from "lucide-solid";
|
||||
import {
|
||||
activateDeployment,
|
||||
changeRuntimeState,
|
||||
changeServiceState,
|
||||
fetchSnapshot,
|
||||
getApiBase,
|
||||
invokeComponent,
|
||||
publishWitPackage,
|
||||
registerComponent,
|
||||
saveApiBase,
|
||||
type BackendSnapshot,
|
||||
type InvokeResult,
|
||||
} from "../lib/api";
|
||||
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
||||
import { servicesFrom } from "../lib/view-state";
|
||||
import { disposePage, pageUrl, sendState } from "../sdk/host";
|
||||
import {
|
||||
isPageMessage,
|
||||
type HostSharedState,
|
||||
type PageCommand,
|
||||
type RuntimeAction,
|
||||
} from "../sdk/protocol";
|
||||
import { DialogLayer, type DialogState } from "./dialogs";
|
||||
|
||||
const NAVIGATION: Array<{
|
||||
id: View;
|
||||
label: string;
|
||||
icon: Component<LucideProps>;
|
||||
}> = [
|
||||
{ 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 default function HostApp() {
|
||||
// All backend ownership stays in this persistent document. Page iframes are
|
||||
// disposable renderers and can only request typed commands through the SDK.
|
||||
const [view, setView] = createSignal(initialView());
|
||||
const [frameInstance, setFrameInstance] = createSignal(1);
|
||||
const [frameReady, setFrameReady] = createSignal(false);
|
||||
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
||||
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
||||
const [apiBase, setApiBase] = createSignal(getApiBase());
|
||||
const [query, setQuery] = createSignal("");
|
||||
const [runtimeAction, setRuntimeAction] = createSignal<RuntimeAction | null>(null);
|
||||
const [serviceAction, setServiceAction] = createSignal<string | null>(null);
|
||||
const [deploymentAction, setDeploymentAction] = createSignal<string | null>(null);
|
||||
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||||
const [toast, setToast] = createSignal<string | null>(null);
|
||||
let iframe: HTMLIFrameElement | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
let refreshGeneration = 0;
|
||||
|
||||
const state = createMemo<HostSharedState>(() => ({
|
||||
view: view(),
|
||||
snapshot: snapshot(),
|
||||
connection: connection(),
|
||||
query: query(),
|
||||
apiBase: apiBase(),
|
||||
runtimeAction: runtimeAction(),
|
||||
serviceAction: serviceAction(),
|
||||
deploymentAction: deploymentAction(),
|
||||
}));
|
||||
const activeServices = createMemo(
|
||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||||
);
|
||||
const frameSource = createMemo(() => pageUrl(view(), frameInstance()));
|
||||
const title = createMemo(
|
||||
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
|
||||
);
|
||||
|
||||
async function refresh() {
|
||||
const generation = ++refreshGeneration;
|
||||
try {
|
||||
const next = await fetchSnapshot(apiBase());
|
||||
if (generation !== refreshGeneration) return;
|
||||
setSnapshot(next);
|
||||
setConnection("online");
|
||||
} catch {
|
||||
if (generation !== refreshGeneration) return;
|
||||
setSnapshot(null);
|
||||
setConnection("offline");
|
||||
}
|
||||
}
|
||||
|
||||
function notify(message: string) {
|
||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||
setToast(message);
|
||||
toastTimer = window.setTimeout(() => setToast(null), 2800);
|
||||
}
|
||||
|
||||
function navigate(next: View, pushHistory = true) {
|
||||
if (next === view()) return;
|
||||
// Give page-owned libraries a synchronous cleanup hook, then change the
|
||||
// keyed URL so Solid removes the old iframe realm in the same transition.
|
||||
const target = iframe?.contentWindow;
|
||||
if (target) disposePage(target);
|
||||
setFrameReady(false);
|
||||
setView(next);
|
||||
setFrameInstance((instance) => instance + 1);
|
||||
setQuery("");
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("view", next);
|
||||
window.history.pushState({ view: next }, "", url);
|
||||
}
|
||||
}
|
||||
|
||||
function findService(key: string): Service | null {
|
||||
return servicesFrom(state()).find((service) => serviceKey(service) === key) ?? null;
|
||||
}
|
||||
|
||||
async function performRuntimeAction(action: RuntimeAction) {
|
||||
setRuntimeAction(action);
|
||||
try {
|
||||
await changeRuntimeState(apiBase(), action);
|
||||
await refresh();
|
||||
notify(
|
||||
action === "start"
|
||||
? "Runtime 已启动"
|
||||
: action === "stop"
|
||||
? "Runtime 已停止"
|
||||
: "Runtime 已重启",
|
||||
);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "Runtime 操作失败");
|
||||
} finally {
|
||||
setRuntimeAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function performServiceAction(key: string, action: "start" | "stop" | "restart") {
|
||||
const service = findService(key);
|
||||
if (!service) return;
|
||||
setServiceAction(key);
|
||||
try {
|
||||
await changeServiceState(apiBase(), service, action);
|
||||
await refresh();
|
||||
notify(
|
||||
`${service.id} ${action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "Actor 操作失败");
|
||||
} finally {
|
||||
setServiceAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(command: PageCommand) {
|
||||
switch (command.type) {
|
||||
case "refresh":
|
||||
await refresh();
|
||||
return;
|
||||
case "navigate":
|
||||
navigate(command.view);
|
||||
return;
|
||||
case "open-register":
|
||||
setDialog({ type: "register" });
|
||||
return;
|
||||
case "open-wit-publish":
|
||||
setDialog({ type: "wit-publish" });
|
||||
return;
|
||||
case "open-invoke": {
|
||||
const service = findService(command.serviceKey);
|
||||
if (service) setDialog({ type: "invoke", service });
|
||||
return;
|
||||
}
|
||||
case "open-activate": {
|
||||
const service = findService(command.serviceKey);
|
||||
if (!service) return;
|
||||
const currentRevision =
|
||||
snapshot()?.deployments.find((item) => item.service_id === service.id)?.active_revision ??
|
||||
null;
|
||||
setDialog({ type: "activate", service, currentRevision });
|
||||
return;
|
||||
}
|
||||
case "runtime-action":
|
||||
await performRuntimeAction(command.action);
|
||||
return;
|
||||
case "service-action":
|
||||
await performServiceAction(command.serviceKey, command.action);
|
||||
return;
|
||||
case "save-api-base":
|
||||
try {
|
||||
const next = saveApiBase(command.value);
|
||||
setApiBase(next);
|
||||
setSnapshot(null);
|
||||
setConnection("connecting");
|
||||
await refresh();
|
||||
notify("API 地址已保存");
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : "API 地址无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const receive = (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== iframe?.contentWindow ||
|
||||
!isPageMessage(event.data) ||
|
||||
event.data.view !== view()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.data.type === "ready") {
|
||||
setFrameReady(true);
|
||||
if (iframe?.contentWindow) sendState(iframe.contentWindow, state());
|
||||
return;
|
||||
}
|
||||
void handleCommand(event.data.command);
|
||||
};
|
||||
|
||||
const popState = () => {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
navigate(isView(requested) ? requested : "overview", false);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("message", receive);
|
||||
window.addEventListener("popstate", popState);
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 5000);
|
||||
onCleanup(() => window.clearInterval(interval));
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", receive);
|
||||
window.removeEventListener("popstate", popState);
|
||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const next = state();
|
||||
if (frameReady() && iframe?.contentWindow) sendState(iframe.contentWindow, next);
|
||||
});
|
||||
|
||||
async function register(file: File) {
|
||||
setDialogBusy(true);
|
||||
try {
|
||||
const registered = await registerComponent(apiBase(), file);
|
||||
await refresh();
|
||||
setDialog(null);
|
||||
notify(`${registered.id}@${registered.revision} 已注册`);
|
||||
} finally {
|
||||
setDialogBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishWit(file: File) {
|
||||
setDialogBusy(true);
|
||||
try {
|
||||
const published = await publishWitPackage(apiBase(), file);
|
||||
await refresh();
|
||||
setDialog(null);
|
||||
notify(`${published.name}@${published.version} 已发布`);
|
||||
} finally {
|
||||
setDialogBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(service: Service) {
|
||||
setDialogBusy(true);
|
||||
setDeploymentAction(serviceKey(service));
|
||||
try {
|
||||
await activateDeployment(apiBase(), service);
|
||||
await refresh();
|
||||
setDialog(null);
|
||||
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
||||
} finally {
|
||||
setDialogBusy(false);
|
||||
setDeploymentAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke(service: Service, input: Uint8Array): Promise<InvokeResult> {
|
||||
const result = await invokeComponent(apiBase(), service, input);
|
||||
await refresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="grid h-dvh min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_56px_minmax(0,1fr)_30px] overflow-hidden bg-canvas lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-[64px_minmax(0,1fr)_30px]">
|
||||
<aside class="border-line min-w-0 bg-[#16221f] text-white lg:row-span-3 lg:flex lg:min-h-0 lg:flex-col lg:border-r">
|
||||
<div class="flex h-16 shrink-0 items-center gap-3 px-4 lg:px-5">
|
||||
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-[#155a46]">
|
||||
<Boxes size={19} />
|
||||
</span>
|
||||
<div>
|
||||
<strong class="block text-[15px]">Wasmeld</strong>
|
||||
<span class="block text-[10px] text-[#9ab0aa]">COMPONENT RUNTIME</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex gap-1 overflow-x-auto px-3 pb-3 lg:block lg:min-h-0 lg:flex-1 lg:space-y-1 lg:overflow-y-auto lg:pb-0">
|
||||
<For each={NAVIGATION}>
|
||||
{(item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
class="flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm text-[#b8c7c3] transition-colors hover:bg-white/7 hover:text-white lg:w-full"
|
||||
classList={{ "bg-white/10 text-white": view() === item.id }}
|
||||
type="button"
|
||||
onClick={() => navigate(item.id)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
<div class="hidden border-t border-white/10 p-4 lg:block">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-[#9ab0aa]">连接状态</span>
|
||||
<span
|
||||
classList={{
|
||||
"font-semibold text-[#76d5b4]": connection() === "online",
|
||||
"font-semibold text-[#ffac93]": connection() === "offline",
|
||||
"font-semibold text-[#f5cf7a]": connection() === "connecting",
|
||||
}}
|
||||
>
|
||||
{connection() === "online" ? "在线" : connection() === "offline" ? "离线" : "连接中"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between text-xs">
|
||||
<span class="text-[#9ab0aa]">活跃 Actor</span>
|
||||
<strong>{activeServices()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<header class="border-line col-start-1 flex min-w-0 items-center justify-between gap-3 border-b bg-white px-4 lg:col-start-2 lg:px-6">
|
||||
<div class="min-w-0">
|
||||
<span class="hidden text-xs text-muted sm:inline">运行平台 / </span>
|
||||
<strong class="text-sm">{title()}</strong>
|
||||
</div>
|
||||
<label class="relative w-full max-w-80">
|
||||
<Search
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
size={15}
|
||||
/>
|
||||
<input
|
||||
class="input h-9 pl-9"
|
||||
value={query()}
|
||||
aria-label="搜索"
|
||||
placeholder="搜索服务、制品或事件"
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
||||
<Show keyed when={frameSource()}>
|
||||
{(source) => (
|
||||
<iframe
|
||||
ref={(element) => {
|
||||
iframe = element;
|
||||
}}
|
||||
class="size-full border-0 bg-canvas"
|
||||
src={source}
|
||||
title={title()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!frameReady()}>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<span class="size-2 animate-pulse rounded-full bg-brand" />
|
||||
正在加载 {title()}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={connection() === "offline"}>
|
||||
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-md border border-coral-strong/20 bg-coral-soft px-4 py-2 text-xs font-semibold text-coral-strong shadow-lg">
|
||||
<WifiOff size={14} />
|
||||
管理 API 不可用
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<footer class="border-line col-start-1 flex items-center justify-between border-t bg-white px-4 text-[10px] text-muted lg:col-start-2 lg:px-6">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck size={12} />
|
||||
{snapshot()?.runtime.status === "running" ? "Sandbox 正常" : "Runtime 已停止"}
|
||||
</span>
|
||||
<span>Component Model · WASI P2</span>
|
||||
</footer>
|
||||
|
||||
<Show when={dialog()}>
|
||||
{(activeDialog) => (
|
||||
<DialogLayer
|
||||
dialog={activeDialog()}
|
||||
busy={dialogBusy()}
|
||||
onClose={() => !dialogBusy() && setDialog(null)}
|
||||
onRegister={register}
|
||||
onPublishWit={publishWit}
|
||||
onActivate={activate}
|
||||
onInvoke={invoke}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={toast()}>
|
||||
{(message) => (
|
||||
<div class="fixed bottom-12 right-5 z-[70] max-w-sm rounded-md bg-[#16221f] px-4 py-3 text-sm text-white shadow-xl">
|
||||
{message()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initialView(): View {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
return isView(requested) ? requested : "overview";
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock3,
|
||||
CloudUpload,
|
||||
FileCode2,
|
||||
Package,
|
||||
Play,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-solid";
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show, type JSXElement } from "solid-js";
|
||||
import type { InvokeResult } from "../lib/api";
|
||||
import {
|
||||
formatInvocationOutput,
|
||||
invocationInputSize,
|
||||
parseInvocationInput,
|
||||
type Service,
|
||||
} from "../lib/model";
|
||||
|
||||
export type DialogState =
|
||||
| { type: "register" }
|
||||
| { type: "wit-publish" }
|
||||
| { type: "invoke"; service: Service }
|
||||
| { type: "activate"; service: Service; currentRevision: string | null };
|
||||
|
||||
export function DialogLayer(props: {
|
||||
dialog: DialogState;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onRegister: (file: File) => Promise<void>;
|
||||
onPublishWit: (file: File) => Promise<void>;
|
||||
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
|
||||
onActivate: (service: Service) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.dialog.type === "register"}>
|
||||
<UploadDialog
|
||||
kind="component"
|
||||
busy={props.busy}
|
||||
onClose={props.onClose}
|
||||
onSubmit={props.onRegister}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.dialog.type === "wit-publish"}>
|
||||
<UploadDialog
|
||||
kind="wit"
|
||||
busy={props.busy}
|
||||
onClose={props.onClose}
|
||||
onSubmit={props.onPublishWit}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.dialog.type === "invoke" && "service" in props.dialog}>
|
||||
<InvokeDialog
|
||||
service={(props.dialog as Extract<DialogState, { type: "invoke" }>).service}
|
||||
onClose={props.onClose}
|
||||
onInvoke={props.onInvoke}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.dialog.type === "activate" && "service" in props.dialog}>
|
||||
<ActivateDialog
|
||||
dialog={props.dialog as Extract<DialogState, { type: "activate" }>}
|
||||
busy={props.busy}
|
||||
onClose={props.onClose}
|
||||
onActivate={props.onActivate}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFrame(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: JSXElement;
|
||||
closeDisabled?: boolean;
|
||||
onClose: () => void;
|
||||
children: JSXElement;
|
||||
footer: JSXElement;
|
||||
}) {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !props.closeDisabled) props.onClose();
|
||||
};
|
||||
onMount(() => window.addEventListener("keydown", onKeyDown));
|
||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
||||
|
||||
return (
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<dialog open class="dialog" aria-label={props.title}>
|
||||
<header class="dialog-header">
|
||||
<div class="dialog-title">
|
||||
<span>{props.icon}</span>
|
||||
<div>
|
||||
<h2>{props.title}</h2>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
disabled={props.closeDisabled}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div class="dialog-body">{props.children}</div>
|
||||
<footer class="dialog-footer">{props.footer}</footer>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadDialog(props: {
|
||||
kind: "component" | "wit";
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const [file, setFile] = createSignal<File | null>(null);
|
||||
const [error, setError] = createSignal("");
|
||||
const isWit = () => props.kind === "wit";
|
||||
const title = () => (isWit() ? "发布 WIT 包" : "注册组件包");
|
||||
|
||||
async function submit() {
|
||||
const selected = file();
|
||||
if (!selected || props.busy) return;
|
||||
const extension = isWit() ? ".wasm" : ".wasmpkg";
|
||||
const limit = isWit() ? 4 * 1024 * 1024 : 64 * 1024 * 1024;
|
||||
if (!selected.name.toLowerCase().endsWith(extension)) {
|
||||
setError(`请选择 ${extension} 文件`);
|
||||
return;
|
||||
}
|
||||
if (selected.size > limit) {
|
||||
setError(`文件不能超过 ${isWit() ? "4 MB" : "64 MB"}`);
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
await props.onSubmit(selected).catch((submitError: unknown) => {
|
||||
setError(submitError instanceof Error ? submitError.message : `${title()}失败`);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFrame
|
||||
title={title()}
|
||||
description={
|
||||
isWit()
|
||||
? "包名、版本和直接依赖从二进制 WIT Package 读取。"
|
||||
: "组件身份、WIT World 和运行边界由平台自动校验。"
|
||||
}
|
||||
icon={isWit() ? <FileCode2 size={19} /> : <CloudUpload size={19} />}
|
||||
closeDisabled={props.busy}
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
disabled={props.busy || !file()}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{props.busy ? <RefreshCw class="spin" size={16} /> : <ShieldCheck size={16} />}
|
||||
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label class="upload-zone">
|
||||
<input
|
||||
type="file"
|
||||
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
|
||||
onChange={(event) => {
|
||||
setFile(event.currentTarget.files?.[0] ?? null);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{file() ? (
|
||||
isWit() ? (
|
||||
<FileCode2 size={28} />
|
||||
) : (
|
||||
<Package size={28} />
|
||||
)
|
||||
) : (
|
||||
<CloudUpload size={28} />
|
||||
)}
|
||||
<strong>{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}</strong>
|
||||
<span>
|
||||
{file()
|
||||
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
|
||||
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
|
||||
</span>
|
||||
</label>
|
||||
<Show when={error()}>
|
||||
<div class="form-error">{error()}</div>
|
||||
</Show>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivateDialog(props: {
|
||||
dialog: Extract<DialogState, { type: "activate" }>;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onActivate: (service: Service) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<DialogFrame
|
||||
title="切换对外版本"
|
||||
description="更新该服务在 Gateway 上接收请求的活动版本。"
|
||||
icon={<RadioTower size={19} />}
|
||||
closeDisabled={props.busy}
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<button class="btn btn-secondary" disabled={props.busy} onClick={props.onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
disabled={props.busy}
|
||||
onClick={() => void props.onActivate(props.dialog.service)}
|
||||
>
|
||||
{props.busy ? <RefreshCw class="spin" size={16} /> : <RadioTower size={16} />}
|
||||
{props.busy ? "正在切换" : "确认切换"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
|
||||
<div>
|
||||
<span class="text-xs text-muted">当前版本</span>
|
||||
<strong class="code mt-1 block">{props.dialog.currentRevision ?? "尚未部署"}</strong>
|
||||
</div>
|
||||
<ArrowRight size={18} class="text-muted" />
|
||||
<div>
|
||||
<span class="text-xs text-muted">目标版本</span>
|
||||
<strong class="code mt-1 block text-brand">{props.dialog.service.revision}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm leading-6 text-muted">
|
||||
Wasmeld 将先确认 {props.dialog.service.id}@{props.dialog.service.revision} 的 Actor
|
||||
可用,再更新对外路由。
|
||||
</p>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function InvokeDialog(props: {
|
||||
service: Service;
|
||||
onClose: () => void;
|
||||
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
|
||||
}) {
|
||||
const [input, setInput] = createSignal(props.service.id === "echo" ? "hello wasm" : "");
|
||||
const [format, setFormat] = createSignal<"utf8" | "hex">("utf8");
|
||||
const [output, setOutput] = createSignal("");
|
||||
const [outputMeta, setOutputMeta] = createSignal("");
|
||||
const [running, setRunning] = createSignal(false);
|
||||
const [failed, setFailed] = createSignal(false);
|
||||
const inputBytes = createMemo(() => invocationInputSize(input(), format()));
|
||||
|
||||
async function invoke() {
|
||||
setRunning(true);
|
||||
setOutput("");
|
||||
setOutputMeta("");
|
||||
setFailed(false);
|
||||
try {
|
||||
const result = await props.onInvoke(props.service, parseInvocationInput(input(), format()));
|
||||
const formatted = formatInvocationOutput(props.service, result.output, format());
|
||||
setOutput(formatted.text);
|
||||
setOutputMeta(
|
||||
`${result.outputBytes} B · ${formatted.format}${formatted.automatic ? " · 自动" : ""} · ${result.latencyMs} ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
setFailed(true);
|
||||
setOutput(error instanceof Error ? error.message : "组件调用失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFrame
|
||||
title={`调用 ${props.service.id}@${props.service.revision}`}
|
||||
description="请求将进入该实例的串行 mailbox。"
|
||||
icon={<SquareTerminal size={19} />}
|
||||
closeDisabled={running()}
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<button class="btn btn-secondary" disabled={running()} onClick={props.onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button class="btn btn-primary" disabled={running()} onClick={() => void invoke()}>
|
||||
{running() ? <RefreshCw class="spin" size={16} /> : <Play size={16} />}
|
||||
执行调用
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="segmented">
|
||||
<button classList={{ active: format() === "utf8" }} onClick={() => setFormat("utf8")}>
|
||||
UTF-8
|
||||
</button>
|
||||
<button classList={{ active: format() === "hex" }} onClick={() => setFormat("hex")}>
|
||||
HEX
|
||||
</button>
|
||||
</div>
|
||||
<span class="code text-muted">{inputBytes()} B</span>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>输入</span>
|
||||
<textarea
|
||||
class="textarea"
|
||||
value={input()}
|
||||
spellcheck={false}
|
||||
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
|
||||
onInput={(event) => setInput(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-muted">输出</span>
|
||||
<Show when={outputMeta()}>
|
||||
<span class="inline-flex items-center gap-1 font-mono text-[11px] text-brand">
|
||||
<Clock3 size={12} />
|
||||
{outputMeta()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<pre
|
||||
classList={{
|
||||
"min-h-32 overflow-auto rounded-md border p-4 font-mono text-sm whitespace-pre-wrap": true,
|
||||
"border-coral-strong/30 bg-coral-soft text-coral-strong": failed(),
|
||||
"border-[#263532] bg-[#16221f] text-[#c8eee1]": !failed(),
|
||||
}}
|
||||
>
|
||||
{running() ? "invoking..." : output() || "等待调用"}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render } from "solid-js/web";
|
||||
import "../styles/app.css";
|
||||
import HostApp from "./app";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
if (!root) throw new Error("missing #app root");
|
||||
render(() => <HostApp />, root);
|
||||
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js");
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Typed management-plane client for `wasmeld-console`.
|
||||
* Typed client for the Rust management API.
|
||||
*
|
||||
* These DTOs intentionally preserve the Rust API's snake_case fields; view
|
||||
* formatting belongs in `console-model.ts`. Management invocation bytes use
|
||||
* base64 inside JSON for inspectability. Public clients should call the
|
||||
* gateway's `application/octet-stream` endpoint instead.
|
||||
* Production uses a same-origin base so the embedded Console follows the
|
||||
* address used to open it. Vite development keeps the existing local backend
|
||||
* default and relies on Wasmeld's development CORS policy.
|
||||
*/
|
||||
|
||||
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
|
||||
export const DEFAULT_API_BASE =
|
||||
import.meta.env.VITE_WASMELD_API_URL ?? (import.meta.env.DEV ? "http://127.0.0.1:8080" : "");
|
||||
|
||||
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
|
||||
|
||||
@@ -78,8 +78,12 @@ export type BackendWitPackage = {
|
||||
dependencies: BackendWitDependency[];
|
||||
};
|
||||
|
||||
export type RegisterComponentInput = {
|
||||
file: File;
|
||||
export type BackendSnapshot = {
|
||||
services: BackendService[];
|
||||
deployments: BackendDeployment[];
|
||||
events: BackendEvent[];
|
||||
runtime: BackendRuntime;
|
||||
witPackages: BackendWitPackage[];
|
||||
};
|
||||
|
||||
export type InvokeResult = {
|
||||
@@ -89,14 +93,20 @@ export type InvokeResult = {
|
||||
};
|
||||
|
||||
export function getApiBase(): string {
|
||||
// TanStack Start can evaluate modules during SSR, where localStorage does
|
||||
// not exist. The environment default keeps server rendering deterministic.
|
||||
if (typeof window === "undefined") return DEFAULT_API_BASE;
|
||||
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
export function displayApiBase(value: string): string {
|
||||
return value || (typeof window === "undefined" ? "same origin" : window.location.origin);
|
||||
}
|
||||
|
||||
export function saveApiBase(value: string): string {
|
||||
const normalized = value.trim().replace(/\/+$/, "");
|
||||
if (!normalized) {
|
||||
window.localStorage.removeItem(API_BASE_STORAGE_KEY);
|
||||
return "";
|
||||
}
|
||||
const parsed = new URL(normalized);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error("API 地址必须使用 http 或 https");
|
||||
@@ -105,10 +115,7 @@ export function saveApiBase(value: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function fetchSnapshot(apiBase: string) {
|
||||
// Parallel reads minimize refresh latency but are not a database
|
||||
// transaction. A deployment can change between responses; the five-second
|
||||
// poll in the route reconciles that short-lived inconsistency.
|
||||
export async function fetchSnapshot(apiBase: string): Promise<BackendSnapshot> {
|
||||
const [services, deployments, events, runtime, witPackages] = await Promise.all([
|
||||
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
||||
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
||||
@@ -125,30 +132,27 @@ export async function fetchSnapshot(apiBase: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function activateDeployment(
|
||||
export function activateDeployment(
|
||||
apiBase: string,
|
||||
service: Pick<BackendService, "id" | "revision">,
|
||||
): Promise<BackendDeployment> {
|
||||
return request(apiBase, `/api/v1/deployments/${encodeURIComponent(service.id)}/activate`, {
|
||||
return request(apiBase, `/api/v1/deployments/${segment(service.id)}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ revision: service.revision }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerComponent(
|
||||
apiBase: string,
|
||||
input: RegisterComponentInput,
|
||||
): Promise<BackendService> {
|
||||
export function registerComponent(apiBase: string, file: File): Promise<BackendService> {
|
||||
const form = new FormData();
|
||||
form.set("package", input.file, input.file.name);
|
||||
form.set("package", file, file.name);
|
||||
return request(apiBase, "/api/v1/services", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
|
||||
export function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
|
||||
const form = new FormData();
|
||||
form.set("package", file, file.name);
|
||||
return request(apiBase, "/api/v1/wit/packages", {
|
||||
@@ -162,17 +166,17 @@ export function witPackageDownloadUrl(
|
||||
packageMetadata: Pick<BackendWitPackage, "name" | "version">,
|
||||
): string {
|
||||
const [namespace, name] = packageMetadata.name.split(":");
|
||||
return `${apiBase}/api/v1/wit/packages/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/${encodeURIComponent(packageMetadata.version)}/content`;
|
||||
return `${apiBase}/api/v1/wit/packages/${segment(namespace)}/${segment(name)}/${segment(packageMetadata.version)}/content`;
|
||||
}
|
||||
|
||||
export async function changeServiceState(
|
||||
export function changeServiceState(
|
||||
apiBase: string,
|
||||
service: Pick<BackendService, "id" | "revision">,
|
||||
action: "start" | "stop" | "restart",
|
||||
): Promise<BackendService> {
|
||||
return request(
|
||||
apiBase,
|
||||
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/${action}`,
|
||||
`/api/v1/services/${segment(service.id)}/${segment(service.revision)}/${action}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -181,13 +185,11 @@ export async function changeServiceState(
|
||||
);
|
||||
}
|
||||
|
||||
export async function changeRuntimeState(
|
||||
export function changeRuntimeState(
|
||||
apiBase: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
): Promise<BackendRuntime> {
|
||||
return request(apiBase, `/api/v1/runtime/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
return request(apiBase, `/api/v1/runtime/${action}`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function invokeComponent(
|
||||
@@ -195,22 +197,15 @@ export async function invokeComponent(
|
||||
service: Pick<BackendService, "id" | "revision">,
|
||||
input: Uint8Array,
|
||||
): Promise<InvokeResult> {
|
||||
// Example: `invokeComponent(base, service, new TextEncoder().encode("ping"))`.
|
||||
// The management endpoint targets an exact revision and is intended for the
|
||||
// operator console, not public application traffic.
|
||||
const response = await request<{
|
||||
output_base64: string;
|
||||
output_bytes: number;
|
||||
latency_ms: number;
|
||||
}>(
|
||||
apiBase,
|
||||
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/invoke`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
|
||||
},
|
||||
);
|
||||
}>(apiBase, `/api/v1/services/${segment(service.id)}/${segment(service.revision)}/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
|
||||
});
|
||||
return {
|
||||
output: base64ToBytes(response.output_base64),
|
||||
outputBytes: response.output_bytes,
|
||||
@@ -229,13 +224,10 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`无法连接 Wasmeld:${apiBase}`);
|
||||
throw new Error(`无法连接 Wasmeld:${displayApiBase(apiBase)}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Both management and gateway errors use `{ error: { code, message } }`.
|
||||
// The UI displays only the server-safe message and falls back to status
|
||||
// when a proxy returns HTML or an otherwise unrelated response.
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
@@ -244,9 +236,11 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function segment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
// Invocation input is bounded by the Runtime, so constructing this binary
|
||||
// string cannot grow beyond the configured management request limit.
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return window.btoa(binary);
|
||||
@@ -1,23 +1,24 @@
|
||||
/**
|
||||
* Pure conversion and presentation helpers for the management UI.
|
||||
*
|
||||
* No function in this module performs I/O or changes backend state. Keeping
|
||||
* byte decoding here prevents React views from silently treating arbitrary
|
||||
* Component output as readable text.
|
||||
*/
|
||||
import type { BackendEvent, BackendService } from "./api";
|
||||
|
||||
import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react";
|
||||
import { BackendCapability, BackendEvent, BackendService } from "./api";
|
||||
export const VIEWS = [
|
||||
"overview",
|
||||
"services",
|
||||
"instances",
|
||||
"wit-packages",
|
||||
"activity",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
export type View = "overview" | "services" | "instances" | "wit-packages" | "activity" | "settings";
|
||||
export type View = (typeof VIEWS)[number];
|
||||
export type ConnectionState = "connecting" | "online" | "offline";
|
||||
export type ServiceStatus = "running" | "stopped" | "faulted";
|
||||
export type EventTone = "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
export type Service = {
|
||||
id: string;
|
||||
revision: string;
|
||||
description: string;
|
||||
artifact: string;
|
||||
world: string;
|
||||
status: ServiceStatus;
|
||||
updatedAt: string;
|
||||
memoryMb: number;
|
||||
@@ -25,7 +26,7 @@ export type Service = {
|
||||
deadlineMs: number;
|
||||
mailbox: number;
|
||||
maxInputKb: number;
|
||||
capabilities: BackendCapability[];
|
||||
capabilities: BackendService["capabilities"];
|
||||
calls: number;
|
||||
errors: number;
|
||||
latencyMs: number | null;
|
||||
@@ -40,20 +41,11 @@ export type RuntimeEvent = {
|
||||
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 type FormattedInvocationOutput = {
|
||||
text: string;
|
||||
format: "UTF-8" | "HEX" | "U64 LE";
|
||||
automatic: boolean;
|
||||
};
|
||||
|
||||
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
|
||||
running: { label: "运行中", className: "status-running" },
|
||||
@@ -61,7 +53,11 @@ export const STATUS_META: Record<ServiceStatus, { label: string; className: stri
|
||||
faulted: { label: "故障", className: "status-faulted" },
|
||||
};
|
||||
|
||||
export function serviceKey(service: Pick<Service, "id" | "revision">) {
|
||||
export function isView(value: string | null): value is View {
|
||||
return VIEWS.includes(value as View);
|
||||
}
|
||||
|
||||
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
|
||||
return `${service.id}@${service.revision}`;
|
||||
}
|
||||
|
||||
@@ -69,12 +65,10 @@ export function toService(service: BackendService, activeRevision?: string): Ser
|
||||
return {
|
||||
id: service.id,
|
||||
revision: service.revision,
|
||||
description: "Wasm Component",
|
||||
artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact,
|
||||
world: service.world,
|
||||
status: service.status,
|
||||
updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
}),
|
||||
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,
|
||||
@@ -102,22 +96,20 @@ export function toEvent(event: BackendEvent): RuntimeEvent {
|
||||
};
|
||||
return {
|
||||
id: event.id,
|
||||
time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
}),
|
||||
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) {
|
||||
export function compactNumber(value: number): string {
|
||||
if (value >= 1_000_000) return `${value / 1_000_000}M`;
|
||||
if (value >= 1_000) return `${value / 1_000}K`;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number) {
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
@@ -125,15 +117,7 @@ export function formatDuration(milliseconds: number) {
|
||||
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") {
|
||||
export function parseInvocationInput(value: string, format: "utf8" | "hex"): Uint8Array {
|
||||
if (format === "utf8") return new TextEncoder().encode(value);
|
||||
const compact = value.replace(/\s+/g, "");
|
||||
if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) {
|
||||
@@ -142,7 +126,7 @@ export function parseInvocationInput(value: string, format: "utf8" | "hex") {
|
||||
return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []);
|
||||
}
|
||||
|
||||
export function invocationInputSize(value: string, format: "utf8" | "hex") {
|
||||
export function invocationInputSize(value: string, format: "utf8" | "hex"): number {
|
||||
try {
|
||||
return parseInvocationInput(value, format).byteLength;
|
||||
} catch {
|
||||
@@ -150,45 +134,11 @@ export function invocationInputSize(value: string, format: "utf8" | "hex") {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Valid UTF-8 can still contain terminal controls. Reject them so the output
|
||||
// panel cannot render invisible or misleading content; tab/newline/CR remain
|
||||
// useful for ordinary textual responses.
|
||||
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",
|
||||
) {
|
||||
// Counter is the one built-in example with a documented numeric wire
|
||||
// format. Other eight-byte services remain generic bytes and are not
|
||||
// guessed as integers.
|
||||
): FormattedInvocationOutput {
|
||||
if (service.id === "counter" && output.byteLength === 8) {
|
||||
return {
|
||||
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
|
||||
@@ -196,29 +146,33 @@ export function formatInvocationOutput(
|
||||
.toString(),
|
||||
format: "U64 LE",
|
||||
automatic: false,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
};
|
||||
}
|
||||
if (format === "hex") {
|
||||
return {
|
||||
text: formatHex(output) || "(empty)",
|
||||
format: "HEX",
|
||||
automatic: false,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false };
|
||||
}
|
||||
|
||||
// UTF-8 decoding is fatal rather than replacement-based. If one byte is
|
||||
// invalid or contains a disallowed control, show the exact bytes as HEX.
|
||||
const text = decodeReadableUtf8(output);
|
||||
if (text !== null) {
|
||||
return {
|
||||
text: text || "(empty)",
|
||||
format: "UTF-8",
|
||||
automatic: false,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
return { text: text || "(empty)", format: "UTF-8", automatic: false };
|
||||
}
|
||||
return {
|
||||
text: formatHex(output),
|
||||
format: "HEX",
|
||||
automatic: true,
|
||||
} satisfies FormattedInvocationOutput;
|
||||
return { text: formatHex(output), format: "HEX", automatic: true };
|
||||
}
|
||||
|
||||
function formatHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
||||
}
|
||||
|
||||
function decodeReadableUtf8(bytes: Uint8Array): string | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
|
||||
if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { HostSharedState } from "../sdk/protocol";
|
||||
import { toEvent, toService, type RuntimeEvent, type Service } from "./model";
|
||||
|
||||
export function servicesFrom(state: HostSharedState | null): Service[] {
|
||||
if (!state?.snapshot) return [];
|
||||
const deployments = new Map(
|
||||
state.snapshot.deployments.map((deployment) => [
|
||||
deployment.service_id,
|
||||
deployment.active_revision,
|
||||
]),
|
||||
);
|
||||
return state.snapshot.services.map((service) => toService(service, deployments.get(service.id)));
|
||||
}
|
||||
|
||||
export function eventsFrom(state: HostSharedState | null): RuntimeEvent[] {
|
||||
return state?.snapshot?.events.map(toEvent) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { History, RefreshCw } from "lucide-solid";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { eventsFrom } from "../../lib/view-state";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
const toneClass = {
|
||||
success: "bg-brand",
|
||||
warning: "bg-amber-strong",
|
||||
danger: "bg-coral-strong",
|
||||
neutral: "bg-muted",
|
||||
};
|
||||
|
||||
export default function ActivityPage(props: PageProps) {
|
||||
const events = createMemo(() => {
|
||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
||||
return eventsFrom(props.state()).filter(
|
||||
(event) =>
|
||||
!query ||
|
||||
event.title.toLowerCase().includes(query) ||
|
||||
event.detail.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="EVENTS"
|
||||
title="调用记录"
|
||||
description="最近 256 条持久化生命周期与调用事件。"
|
||||
actions={
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<Show
|
||||
when={events().length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={History} title="暂无事件" detail="Runtime 操作和调用会显示在这里。" />
|
||||
}
|
||||
>
|
||||
<div class="divide-y divide-line">
|
||||
<For each={events()}>
|
||||
{(event) => (
|
||||
<article class="grid gap-2 px-5 py-4 sm:grid-cols-[10px_minmax(180px,0.8fr)_2fr_84px] sm:items-center">
|
||||
<span class={`size-2 rounded-full ${toneClass[event.tone]}`} />
|
||||
<strong class="text-sm">{event.title}</strong>
|
||||
<p class="text-xs leading-5 text-muted">{event.detail}</p>
|
||||
<time class="font-mono text-xs text-muted sm:text-right">{event.time}</time>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { StatusBadge } from "../../components/ui/status-badge";
|
||||
import { serviceKey } from "../../lib/model";
|
||||
import { servicesFrom } from "../../lib/view-state";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function InstancesPage(props: PageProps) {
|
||||
const services = createMemo(() => servicesFrom(props.state()));
|
||||
const running = createMemo(
|
||||
() => services().filter((service) => service.status === "running").length,
|
||||
);
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="ACTORS"
|
||||
title="运行实例"
|
||||
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
|
||||
actions={
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Show
|
||||
when={services().length > 0}
|
||||
fallback={
|
||||
<div class="panel">
|
||||
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={services()}>
|
||||
{(service) => (
|
||||
<article class="panel p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
|
||||
<Cpu size={18} />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<strong class="block truncate">{service.id}</strong>
|
||||
<span class="code mt-1 block truncate text-muted">{service.revision}</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={service.status} />
|
||||
</div>
|
||||
<dl class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-line pt-4 text-xs">
|
||||
<div>
|
||||
<dt class="text-muted">内存上限</dt>
|
||||
<dd class="mt-1 font-semibold">{service.memoryMb} MB</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">Mailbox</dt>
|
||||
<dd class="mt-1 font-semibold">{service.mailbox}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">Deadline</dt>
|
||||
<dd class="mt-1 font-semibold">{service.deadlineMs} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted">最近延迟</dt>
|
||||
<dd class="mt-1 font-semibold">
|
||||
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Show when={service.status === "running"}>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "open-invoke",
|
||||
serviceKey: serviceKey(service),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquareTerminal size={15} />
|
||||
调用
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
disabled={props.state()?.serviceAction !== null}
|
||||
onClick={() =>
|
||||
props.command({
|
||||
type: "service-action",
|
||||
serviceKey: serviceKey(service),
|
||||
action: service.status === "running" ? "stop" : "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
{service.status === "running" ? "停止" : "启动"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { AlertTriangle, LoaderCircle } from "lucide-solid";
|
||||
import { lazy, Show, Suspense, type Component } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
import { isView, type View } from "../lib/model";
|
||||
import { createFrameBridge } from "../primitives/create-frame-bridge";
|
||||
import "../styles/app.css";
|
||||
import type { PageProps } from "./types";
|
||||
|
||||
// Each view is a dynamic import so a freshly-created iframe evaluates only the
|
||||
// page it owns. Destroying that iframe releases the chunk's runtime objects.
|
||||
const pages: Record<View, Component<PageProps>> = {
|
||||
overview: lazy(() => import("./overview")),
|
||||
services: lazy(() => import("./services")),
|
||||
instances: lazy(() => import("./instances")),
|
||||
"wit-packages": lazy(() => import("./wit-packages")),
|
||||
activity: lazy(() => import("./activity")),
|
||||
settings: lazy(() => import("./settings")),
|
||||
};
|
||||
|
||||
function FramePage() {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
const view = isView(requested) ? requested : null;
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={view}
|
||||
fallback={
|
||||
<main class="flex min-h-dvh items-center justify-center p-6 text-center text-coral-strong">
|
||||
<div>
|
||||
<AlertTriangle class="mx-auto mb-3" size={28} />
|
||||
<strong class="block">未知页面</strong>
|
||||
<span class="mt-1 block text-sm text-muted">{requested ?? "(empty)"}</span>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
{(activeView) => {
|
||||
const bridge = createFrameBridge(activeView());
|
||||
const Page = pages[activeView()];
|
||||
document.title = `${activeView()} · Wasmeld`;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main class="flex min-h-dvh items-center justify-center text-muted">
|
||||
<LoaderCircle class="spin" size={24} />
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<Page state={bridge.state} command={bridge.command} />
|
||||
</Suspense>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("app");
|
||||
if (!root) throw new Error("missing #app root");
|
||||
render(() => <FramePage />, root);
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CircleStop,
|
||||
CloudUpload,
|
||||
Cpu,
|
||||
Package,
|
||||
Play,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Zap,
|
||||
} from "lucide-solid";
|
||||
import { createMemo, For, Show, type JSXElement } from "solid-js";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { ServiceTable } from "../../components/ui/service-table";
|
||||
import { eventsFrom, servicesFrom } from "../../lib/view-state";
|
||||
import { formatDuration } from "../../lib/model";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function OverviewPage(props: PageProps) {
|
||||
const services = createMemo(() => servicesFrom(props.state()));
|
||||
const events = createMemo(() => eventsFrom(props.state()));
|
||||
const running = createMemo(
|
||||
() => services().filter((service) => service.status === "running").length,
|
||||
);
|
||||
const totalCalls = createMemo(() =>
|
||||
services().reduce((total, service) => total + service.calls, 0),
|
||||
);
|
||||
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<Show when={props.state()} fallback={<OverviewSkeleton />}>
|
||||
{(state) => (
|
||||
<>
|
||||
<PageHeading
|
||||
eyebrow="LOCAL RUNTIME"
|
||||
title="运行概览"
|
||||
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => props.command({ type: "refresh" })}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => props.command({ type: "open-register" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
注册组件
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<section class="metric-grid" aria-label="关键指标">
|
||||
<Metric
|
||||
icon={<Package size={18} />}
|
||||
tone="bg-cyan-soft text-cyan-strong"
|
||||
label="服务版本"
|
||||
value={services().length.toString()}
|
||||
note={`${state().snapshot?.deployments.length ?? 0} 个对外服务`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity size={18} />}
|
||||
tone="bg-brand-soft text-brand"
|
||||
label="运行实例"
|
||||
value={running().toString()}
|
||||
note={`${services().length - running()} 个未运行`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Zap size={18} />}
|
||||
tone="bg-amber-soft text-amber-strong"
|
||||
label="累计调用"
|
||||
value={totalCalls().toLocaleString("zh-CN")}
|
||||
note="持久化统计"
|
||||
/>
|
||||
<Metric
|
||||
icon={<AlertTriangle size={18} />}
|
||||
tone="bg-coral-soft text-coral-strong"
|
||||
label="调用异常"
|
||||
value={errors().toString()}
|
||||
note={errors() > 0 ? "需要检查" : "无异常"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]">
|
||||
<div class="flex items-center gap-3 border-b border-line px-5 py-4 lg:border-b-0 lg:border-r">
|
||||
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Cpu size={18} />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<strong class="block">Wasmeld Runtime</strong>
|
||||
<span class="mt-0.5 block truncate text-xs text-muted">
|
||||
{state().snapshot?.runtime.status === "running"
|
||||
? "Wasmtime sandbox 正常"
|
||||
: "Engine 与 Actor 已释放"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Show
|
||||
when={state().snapshot?.runtime.status === "running"}
|
||||
fallback={
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="启动 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "start" })}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="重启 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn text-coral-strong"
|
||||
title="停止 Runtime"
|
||||
disabled={state().runtimeAction !== null}
|
||||
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<RuntimeStat
|
||||
label="已加载版本"
|
||||
value={(state().snapshot?.runtime.registered_services ?? 0).toString()}
|
||||
detail="已编译 Component"
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="活跃 Store"
|
||||
value={(state().snapshot?.runtime.running_services ?? 0).toString()}
|
||||
detail="串行 Actor"
|
||||
/>
|
||||
<RuntimeStat
|
||||
label="运行时间"
|
||||
value={
|
||||
state().snapshot?.runtime.status === "running"
|
||||
? formatDuration(state().snapshot?.runtime.uptime_ms ?? 0)
|
||||
: "—"
|
||||
}
|
||||
detail="当前 Runtime"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
|
||||
<section class="panel min-w-0">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>服务状态</h2>
|
||||
<p>已注册版本及当前 Actor 状态</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary h-9"
|
||||
onClick={() => props.command({ type: "navigate", view: "services" })}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<ServiceTable
|
||||
services={services().slice(0, 5)}
|
||||
state={state()}
|
||||
command={props.command}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>最近事件</h2>
|
||||
<p>生命周期与调用结果</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand">
|
||||
<span class="size-1.5 rounded-full bg-brand" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-line">
|
||||
<For
|
||||
each={events().slice(0, 6)}
|
||||
fallback={<p class="px-5 py-12 text-center text-sm text-muted">暂无事件</p>}
|
||||
>
|
||||
{(event) => (
|
||||
<div class="px-5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="truncate text-xs">{event.title}</strong>
|
||||
<span class="font-mono text-[10px] text-muted">{event.time}</span>
|
||||
</div>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">{event.detail}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric(props: {
|
||||
icon: JSXElement;
|
||||
tone: string;
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
}) {
|
||||
return (
|
||||
<article class="metric">
|
||||
<div class={`metric-icon ${props.tone}`}>{props.icon}</div>
|
||||
<div class="metric-copy">
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
</div>
|
||||
<small>{props.note}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeStat(props: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div class="border-b border-line px-5 py-4 last:border-b-0 lg:border-b-0 lg:border-r lg:last:border-r-0">
|
||||
<span class="block text-xs text-muted">{props.label}</span>
|
||||
<strong class="mt-1 block text-lg">{props.value}</strong>
|
||||
<small class="mt-0.5 block text-[11px] text-muted">{props.detail}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
<div class="space-y-5 py-5">
|
||||
<div class="skeleton-line w-36" />
|
||||
<div class="skeleton-line h-8 w-64" />
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
|
||||
import { createMemo, createSignal, Show } from "solid-js";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { ServiceTable } from "../../components/ui/service-table";
|
||||
import { servicesFrom } from "../../lib/view-state";
|
||||
import type { ServiceStatus } from "../../lib/model";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function ServicesPage(props: PageProps) {
|
||||
const [status, setStatus] = createSignal<"all" | ServiceStatus>("all");
|
||||
const services = createMemo(() => servicesFrom(props.state()));
|
||||
const filtered = createMemo(() => {
|
||||
const query = props.state()?.query.trim().toLowerCase() ?? "";
|
||||
return services().filter(
|
||||
(service) =>
|
||||
(status() === "all" || service.status === status()) &&
|
||||
(!query ||
|
||||
service.id.toLowerCase().includes(query) ||
|
||||
service.revision.toLowerCase().includes(query) ||
|
||||
service.artifact.toLowerCase().includes(query)),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="COMPONENTS"
|
||||
title="服务版本"
|
||||
description="管理不可变 Component Revision、Actor 状态和对外 Deployment。"
|
||||
actions={
|
||||
<>
|
||||
<label class="relative">
|
||||
<Filter
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
size={14}
|
||||
/>
|
||||
<select
|
||||
class="select w-36 pl-9"
|
||||
value={status()}
|
||||
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="stopped">已停止</option>
|
||||
<option value="faulted">故障</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => props.command({ type: "open-register" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
注册组件
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>已注册版本</h2>
|
||||
<p>{filtered().length} 个匹配项</p>
|
||||
</div>
|
||||
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.state()}>
|
||||
{(state) => (
|
||||
<ServiceTable services={filtered()} state={state()} command={props.command} />
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import { displayApiBase } from "../../lib/api";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function SettingsPage(props: PageProps) {
|
||||
const [endpoint, setEndpoint] = createSignal("");
|
||||
let initialized = false;
|
||||
createEffect(() => {
|
||||
const state = props.state();
|
||||
if (state && !initialized) {
|
||||
setEndpoint(state.apiBase);
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="CONFIGURATION"
|
||||
title="运行设置"
|
||||
description="配置管理 API 连接;运行边界由后端策略统一控制。"
|
||||
/>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>管理 API</h2>
|
||||
<p>留空表示使用当前页面同源地址</p>
|
||||
</div>
|
||||
<Globe2 size={18} class="text-cyan-strong" />
|
||||
</div>
|
||||
<form
|
||||
class="p-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
props.command({ type: "save-api-base", value: endpoint() });
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span>Endpoint</span>
|
||||
<input
|
||||
class="input"
|
||||
type="url"
|
||||
value={endpoint()}
|
||||
placeholder={window.location.origin}
|
||||
onInput={(event) => setEndpoint(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
当前解析地址:{displayApiBase(props.state()?.apiBase ?? "")}
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<Save size={15} />
|
||||
保存连接
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<aside class="space-y-3">
|
||||
<Info
|
||||
icon={ShieldCheck}
|
||||
title="Sandbox"
|
||||
detail="网络与 Host 能力默认拒绝,按 WIT import 精确链接。"
|
||||
/>
|
||||
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<article class="panel flex gap-3 p-4">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong class="text-sm">{props.title}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
||||
|
||||
export type PageProps = {
|
||||
state: Accessor<HostSharedState | null>;
|
||||
command: (command: PageCommand) => void;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { PageHeading } from "../../components/ui/page-heading";
|
||||
import { witPackageDownloadUrl } from "../../lib/api";
|
||||
import type { PageProps } from "../types";
|
||||
|
||||
export default function WitPackagesPage(props: PageProps) {
|
||||
const packages = createMemo(() => {
|
||||
const state = props.state();
|
||||
const query = state?.query.trim().toLowerCase() ?? "";
|
||||
return (
|
||||
state?.snapshot?.witPackages.filter(
|
||||
(item) =>
|
||||
!query ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.version.toLowerCase().includes(query) ||
|
||||
item.sha256.toLowerCase().includes(query),
|
||||
) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="page">
|
||||
<PageHeading
|
||||
eyebrow="INTERFACES"
|
||||
title="WIT 包"
|
||||
description="管理不可变、版本化的 Component 接口依赖。"
|
||||
actions={
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => props.command({ type: "open-wit-publish" })}
|
||||
>
|
||||
<CloudUpload size={16} />
|
||||
发布 WIT 包
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Registry</h2>
|
||||
<p>{packages().length} 个匹配包版本</p>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={packages().length > 0}
|
||||
fallback={
|
||||
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
|
||||
}
|
||||
>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>包</th>
|
||||
<th>版本</th>
|
||||
<th>直接依赖</th>
|
||||
<th>SHA-256</th>
|
||||
<th>
|
||||
<span class="sr-only">下载</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={packages()}>
|
||||
{(item) => (
|
||||
<tr>
|
||||
<td>
|
||||
<span class="flex items-center gap-2">
|
||||
<FileCode2 size={15} class="text-cyan-strong" />
|
||||
<strong>{item.name}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td class="code">{item.version}</td>
|
||||
<td class="text-xs text-muted">
|
||||
{item.dependencies.length
|
||||
? item.dependencies
|
||||
.map((dependency) => `${dependency.name}@${dependency.version}`)
|
||||
.join(", ")
|
||||
: "无"}
|
||||
</td>
|
||||
<td class="code max-w-64 truncate text-muted" title={item.sha256}>
|
||||
{item.sha256}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a
|
||||
class="icon-btn"
|
||||
title="下载"
|
||||
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
|
||||
>
|
||||
<Download size={15} />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||
import type { View } from "../lib/model";
|
||||
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
|
||||
import { notifyReady, sendCommand } from "../sdk/page";
|
||||
|
||||
/**
|
||||
* Connects one iframe document to the persistent Host Shell.
|
||||
*
|
||||
* The listener and state belong to the iframe's Solid owner. Navigating or
|
||||
* removing the iframe runs `onCleanup`, so the old document cannot continue to
|
||||
* receive Host snapshots.
|
||||
*/
|
||||
export function createFrameBridge(view: View) {
|
||||
const [state, setState] = createSignal<HostSharedState | null>(null);
|
||||
|
||||
const receive = (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
if (!isHostMessage(event.data)) return;
|
||||
if (event.data.type === "state" && event.data.state.view === view) {
|
||||
setState(event.data.state);
|
||||
}
|
||||
if (event.data.type === "dispose") {
|
||||
window.dispatchEvent(new Event("wasmeld:dispose"));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("message", receive);
|
||||
notifyReady(view);
|
||||
});
|
||||
|
||||
onCleanup(() => window.removeEventListener("message", receive));
|
||||
|
||||
return {
|
||||
state,
|
||||
command(command: PageCommand) {
|
||||
sendCommand(view, command);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { View } from "../lib/model";
|
||||
import { SDK_CHANNEL, SDK_VERSION, type HostMessage, type HostSharedState } from "./protocol";
|
||||
|
||||
export function sendState(target: Window, state: HostSharedState): void {
|
||||
send(target, {
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "host",
|
||||
type: "state",
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
export function disposePage(target: Window): void {
|
||||
send(target, {
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "host",
|
||||
type: "dispose",
|
||||
});
|
||||
}
|
||||
|
||||
export function pageUrl(view: View, instance: number): string {
|
||||
const url = new URL("/page.html", window.location.origin);
|
||||
url.searchParams.set("view", view);
|
||||
url.searchParams.set("instance", instance.toString());
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
function send(target: Window, message: HostMessage): void {
|
||||
target.postMessage(message, window.location.origin);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { View } from "../lib/model";
|
||||
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
|
||||
|
||||
export function notifyReady(view: View): void {
|
||||
send({
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "page",
|
||||
type: "ready",
|
||||
view,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendCommand(view: View, command: PageCommand): void {
|
||||
send({
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "page",
|
||||
type: "command",
|
||||
view,
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
function send(message: PageMessage): void {
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { BackendSnapshot } from "../lib/api";
|
||||
import type { ConnectionState, View } from "../lib/model";
|
||||
|
||||
export const SDK_CHANNEL = "wasmeld-console";
|
||||
|
||||
/**
|
||||
* Compatibility version for the same-origin Host/Page document protocol.
|
||||
*
|
||||
* Additive optional messages may keep the version. Removing a field or
|
||||
* changing its meaning requires a version bump and an atomic Host/Page
|
||||
* release, which is naturally provided by the embedded Console binary.
|
||||
*/
|
||||
export const SDK_VERSION = 1;
|
||||
|
||||
export type RuntimeAction = "start" | "stop" | "restart";
|
||||
export type ServiceAction = "start" | "stop" | "restart";
|
||||
|
||||
export type HostSharedState = {
|
||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
||||
view: View;
|
||||
snapshot: BackendSnapshot | null;
|
||||
connection: ConnectionState;
|
||||
query: string;
|
||||
apiBase: string;
|
||||
runtimeAction: RuntimeAction | null;
|
||||
serviceAction: string | null;
|
||||
deploymentAction: string | null;
|
||||
};
|
||||
|
||||
export type PageCommand =
|
||||
| { type: "refresh" }
|
||||
| { type: "navigate"; view: View }
|
||||
| { type: "open-register" }
|
||||
| { type: "open-wit-publish" }
|
||||
| { type: "open-invoke"; serviceKey: string }
|
||||
| { type: "open-activate"; serviceKey: string }
|
||||
| { type: "runtime-action"; action: RuntimeAction }
|
||||
| { type: "service-action"; serviceKey: string; action: ServiceAction }
|
||||
| { type: "save-api-base"; value: string };
|
||||
|
||||
export type PageMessage =
|
||||
| {
|
||||
channel: typeof SDK_CHANNEL;
|
||||
version: typeof SDK_VERSION;
|
||||
source: "page";
|
||||
type: "ready";
|
||||
view: View;
|
||||
}
|
||||
| {
|
||||
channel: typeof SDK_CHANNEL;
|
||||
version: typeof SDK_VERSION;
|
||||
source: "page";
|
||||
type: "command";
|
||||
view: View;
|
||||
command: PageCommand;
|
||||
};
|
||||
|
||||
export type HostMessage =
|
||||
| {
|
||||
channel: typeof SDK_CHANNEL;
|
||||
version: typeof SDK_VERSION;
|
||||
source: "host";
|
||||
type: "state";
|
||||
state: HostSharedState;
|
||||
}
|
||||
| {
|
||||
channel: typeof SDK_CHANNEL;
|
||||
version: typeof SDK_VERSION;
|
||||
source: "host";
|
||||
type: "dispose";
|
||||
};
|
||||
|
||||
export function isPageMessage(value: unknown): value is PageMessage {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value.channel === SDK_CHANNEL &&
|
||||
value.version === SDK_VERSION &&
|
||||
value.source === "page" &&
|
||||
(value.type === "ready" || value.type === "command")
|
||||
);
|
||||
}
|
||||
|
||||
export function isHostMessage(value: unknown): value is HostMessage {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value.channel === SDK_CHANNEL &&
|
||||
value.version === SDK_VERSION &&
|
||||
value.source === "host" &&
|
||||
(value.type === "state" || value.type === "dispose")
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-canvas: #f3f6f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-line: #d8e0de;
|
||||
--color-ink: #17211f;
|
||||
--color-muted: #64716e;
|
||||
--color-brand: #1f775e;
|
||||
--color-brand-strong: #155a46;
|
||||
--color-brand-soft: #e7f3ef;
|
||||
--color-cyan-soft: #e5f5f7;
|
||||
--color-cyan-strong: #147386;
|
||||
--color-amber-soft: #fff3d8;
|
||||
--color-amber-strong: #9b6507;
|
||||
--color-coral-soft: #ffebe5;
|
||||
--color-coral-strong: #b9492f;
|
||||
--font-sans:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC",
|
||||
sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-canvas font-sans text-ink antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-brand;
|
||||
}
|
||||
|
||||
.frame-root,
|
||||
.frame-document {
|
||||
@apply overflow-x-hidden bg-canvas;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border-line bg-white text-ink hover:bg-canvas;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply border-coral-strong bg-white text-coral-strong hover:bg-coral-soft;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply border-line bg-surface rounded-md border;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply border-line flex min-h-16 items-center justify-between gap-4 border-b px-5 py-3;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
@apply mt-0.5 text-xs text-muted;
|
||||
}
|
||||
|
||||
.page {
|
||||
@apply mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
@apply mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
@apply mt-1 text-2xl font-bold text-ink;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
@apply mt-1 max-w-3xl text-sm text-muted;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-cyan-strong text-[11px] font-bold uppercase;
|
||||
}
|
||||
|
||||
.heading-actions {
|
||||
@apply flex shrink-0 items-center gap-2;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@apply mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
@apply flex size-10 shrink-0 items-center justify-center rounded-md;
|
||||
}
|
||||
|
||||
.metric-copy {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.metric-copy span {
|
||||
@apply block text-xs text-muted;
|
||||
}
|
||||
|
||||
.metric-copy strong {
|
||||
@apply mt-1 block text-2xl font-bold;
|
||||
}
|
||||
|
||||
.metric > small {
|
||||
@apply self-end whitespace-nowrap pb-1 text-[11px] text-muted;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
@apply w-full overflow-x-auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@apply w-full min-w-[760px] border-collapse text-left text-sm;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@apply border-line bg-canvas border-b px-4 py-2.5 text-[11px] font-semibold uppercase text-muted;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
@apply border-line border-b px-4 py-3 align-middle;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
@apply transition-colors hover:bg-[#f8faf9];
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: "";
|
||||
@apply size-1.5 rounded-full bg-current;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
@apply text-brand;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
@apply text-muted;
|
||||
}
|
||||
|
||||
.status-faulted {
|
||||
@apply text-coral-strong;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
@apply flex items-center justify-end gap-1;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
@apply inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-35;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted;
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
@apply text-sm text-ink;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.code {
|
||||
@apply font-mono text-xs;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
@apply mb-1.5 block text-xs font-semibold text-muted;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
@apply border-line w-full rounded-md border bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
@apply h-10;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@apply min-h-36 resize-y py-3 font-mono;
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px];
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply border-line max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border bg-white p-0 shadow-2xl;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@apply border-line flex items-start justify-between gap-4 border-b px-5 py-4 sm:px-7 sm:py-5;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
@apply flex min-w-0 items-start gap-3;
|
||||
}
|
||||
|
||||
.dialog-title > span {
|
||||
@apply bg-brand-soft text-brand flex size-10 shrink-0 items-center justify-center rounded-md;
|
||||
}
|
||||
|
||||
.dialog-title h2 {
|
||||
@apply text-lg font-bold sm:text-xl;
|
||||
}
|
||||
|
||||
.dialog-title p {
|
||||
@apply mt-1 text-sm text-muted;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
@apply px-5 py-5 sm:px-7;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
@apply border-line flex items-center justify-end gap-2 border-t bg-[#fafbfb] px-5 py-4 sm:px-7;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
@apply border-line flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft;
|
||||
}
|
||||
|
||||
.upload-zone input {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
.upload-zone strong {
|
||||
@apply text-sm text-ink;
|
||||
}
|
||||
|
||||
.upload-zone span {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply border-coral-strong/30 bg-coral-soft text-coral-strong mt-3 rounded-md border px-3 py-2 text-sm;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
@apply border-line inline-flex rounded-md border bg-canvas p-1;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
@apply h-8 rounded-sm px-3 text-xs font-semibold text-muted;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
@apply bg-white text-ink shadow-sm;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
@apply h-3 animate-pulse rounded-sm bg-[#dfe6e4];
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("build emits independent host and iframe documents", async () => {
|
||||
const [host, page] = await Promise.all([
|
||||
readFile(new URL("../dist/index.html", import.meta.url), "utf8"),
|
||||
readFile(new URL("../dist/page.html", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(host, /Wasmeld Console/);
|
||||
assert.match(page, /Wasmeld Console Page/);
|
||||
assert.notEqual(host, page);
|
||||
});
|
||||
|
||||
test("build emits an installable PWA worker and manifest", async () => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(new URL("../dist/manifest.webmanifest", import.meta.url), "utf8"),
|
||||
);
|
||||
assert.equal(manifest.short_name, "Wasmeld");
|
||||
assert.equal(manifest.start_url, "/");
|
||||
assert.ok((await stat(new URL("../dist/sw.js", import.meta.url))).size > 0);
|
||||
});
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
|
||||
const outputDirectory = process.env.WASMELD_WEB_OUT_DIR ?? "dist";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
plugins: [solid(), tailwindcss(), pwaWorker()],
|
||||
build: {
|
||||
outDir: outputDirectory,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
// Host and Page remain separate HTML documents. Dynamic page imports
|
||||
// then create one lazy chunk per primary management view.
|
||||
input: {
|
||||
host: resolve(import.meta.dirname, "index.html"),
|
||||
page: resolve(import.meta.dirname, "page.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function pwaWorker(): Plugin {
|
||||
return {
|
||||
name: "wasmeld-pwa-worker",
|
||||
apply: "build",
|
||||
generateBundle(_, bundle) {
|
||||
// Derive the cache name from emitted filenames and unhashed public
|
||||
// assets. A changed build activates a new cache without another PWA
|
||||
// dependency or a generated source file in the repository.
|
||||
const publicFiles = ["icon.svg", "manifest.webmanifest", "og.png"];
|
||||
const generatedFiles = Object.keys(bundle).filter((file) =>
|
||||
/\.(?:html|js|css|svg|png|webmanifest)$/.test(file),
|
||||
);
|
||||
const files = [...new Set([...generatedFiles, ...publicFiles])].sort();
|
||||
const digest = createHash("sha256");
|
||||
for (const file of files) digest.update(file);
|
||||
for (const file of publicFiles) {
|
||||
digest.update(readFileSync(resolve(import.meta.dirname, "public", file)));
|
||||
}
|
||||
const cacheName = `wasmeld-${digest.digest("hex").slice(0, 16)}`;
|
||||
const urls = files.map((file) => `/${file}`);
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "sw.js",
|
||||
source: serviceWorkerSource(cacheName, urls),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serviceWorkerSource(cacheName: string, urls: string[]): string {
|
||||
// Runtime state must never be satisfied from an old cache. Only documents
|
||||
// and build assets participate in offline behavior.
|
||||
return `const CACHE = ${JSON.stringify(cacheName)};
|
||||
const PRECACHE = ${JSON.stringify(urls)};
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()));
|
||||
});
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key.startsWith("wasmeld-") && key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname === "/healthz" || url.pathname.startsWith("/api/")) return;
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request).then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cached = await caches.match(event.request);
|
||||
if (cached) return cached;
|
||||
return caches.match(url.pathname === "/page.html" ? "/page.html" : "/index.html");
|
||||
}));
|
||||
return;
|
||||
}
|
||||
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request)));
|
||||
});
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user