refactor(console-ui): split dashboard responsibilities
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.
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user