0f0effb3c3
Reduce the root TanStack route to orchestration and shell state. Move backend-to-view mapping and binary invocation formatting into console-model, presentation views into components/views, and modal workflows into components/dialogs. Update the rendered artifact test to assert behavior at the new module boundaries without changing the generated UI or routes.
380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|