Files
wasmeld/console/src/components/dialogs.tsx
T

380 lines
11 KiB
TypeScript
Raw Normal View History

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