90742837fc
- move the management frontend under the wasmeld-console crate\n- use a persistent Host shell with disposable iframe page documents\n- version Host/Page postMessage state and command contracts\n- migrate runtime, service, WIT, invocation, and settings workflows\n- add Tailwind CSS v4, responsive layouts, and Host-owned dialogs\n- emit a dependency-free PWA worker with API cache exclusions\n- remove the superseded root TanStack Start application
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|