feat(console-web): replace TanStack UI with Solid host pages
- 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
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user