Files
wasmeld/crates/wasmeld-console/web/src/host/dialogs.tsx
T

358 lines
11 KiB
TypeScript
Raw Normal View History

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>
);
}