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