445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
|
|
import {
|
||
|
|
Boxes,
|
||
|
|
FileCode2,
|
||
|
|
History,
|
||
|
|
LayoutDashboard,
|
||
|
|
Package,
|
||
|
|
Search,
|
||
|
|
Server,
|
||
|
|
Settings,
|
||
|
|
ShieldCheck,
|
||
|
|
WifiOff,
|
||
|
|
} from "lucide-solid";
|
||
|
|
import {
|
||
|
|
createEffect,
|
||
|
|
createMemo,
|
||
|
|
createSignal,
|
||
|
|
For,
|
||
|
|
onCleanup,
|
||
|
|
onMount,
|
||
|
|
Show,
|
||
|
|
type Component,
|
||
|
|
} from "solid-js";
|
||
|
|
import type { LucideProps } from "lucide-solid";
|
||
|
|
import {
|
||
|
|
activateDeployment,
|
||
|
|
changeRuntimeState,
|
||
|
|
changeServiceState,
|
||
|
|
fetchSnapshot,
|
||
|
|
getApiBase,
|
||
|
|
invokeComponent,
|
||
|
|
publishWitPackage,
|
||
|
|
registerComponent,
|
||
|
|
saveApiBase,
|
||
|
|
type BackendSnapshot,
|
||
|
|
type InvokeResult,
|
||
|
|
} from "../lib/api";
|
||
|
|
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
||
|
|
import { servicesFrom } from "../lib/view-state";
|
||
|
|
import { disposePage, pageUrl, sendState } from "../sdk/host";
|
||
|
|
import {
|
||
|
|
isPageMessage,
|
||
|
|
type HostSharedState,
|
||
|
|
type PageCommand,
|
||
|
|
type RuntimeAction,
|
||
|
|
} from "../sdk/protocol";
|
||
|
|
import { DialogLayer, type DialogState } from "./dialogs";
|
||
|
|
|
||
|
|
const NAVIGATION: Array<{
|
||
|
|
id: View;
|
||
|
|
label: string;
|
||
|
|
icon: Component<LucideProps>;
|
||
|
|
}> = [
|
||
|
|
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
|
||
|
|
{ id: "services", label: "服务版本", icon: Package },
|
||
|
|
{ id: "instances", label: "运行实例", icon: Server },
|
||
|
|
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
|
||
|
|
{ id: "activity", label: "调用记录", icon: History },
|
||
|
|
{ id: "settings", label: "运行设置", icon: Settings },
|
||
|
|
];
|
||
|
|
|
||
|
|
export default function HostApp() {
|
||
|
|
// All backend ownership stays in this persistent document. Page iframes are
|
||
|
|
// disposable renderers and can only request typed commands through the SDK.
|
||
|
|
const [view, setView] = createSignal(initialView());
|
||
|
|
const [frameInstance, setFrameInstance] = createSignal(1);
|
||
|
|
const [frameReady, setFrameReady] = createSignal(false);
|
||
|
|
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
||
|
|
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
||
|
|
const [apiBase, setApiBase] = createSignal(getApiBase());
|
||
|
|
const [query, setQuery] = createSignal("");
|
||
|
|
const [runtimeAction, setRuntimeAction] = createSignal<RuntimeAction | null>(null);
|
||
|
|
const [serviceAction, setServiceAction] = createSignal<string | null>(null);
|
||
|
|
const [deploymentAction, setDeploymentAction] = createSignal<string | null>(null);
|
||
|
|
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
||
|
|
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||
|
|
const [toast, setToast] = createSignal<string | null>(null);
|
||
|
|
let iframe: HTMLIFrameElement | undefined;
|
||
|
|
let toastTimer: number | undefined;
|
||
|
|
let refreshGeneration = 0;
|
||
|
|
|
||
|
|
const state = createMemo<HostSharedState>(() => ({
|
||
|
|
view: view(),
|
||
|
|
snapshot: snapshot(),
|
||
|
|
connection: connection(),
|
||
|
|
query: query(),
|
||
|
|
apiBase: apiBase(),
|
||
|
|
runtimeAction: runtimeAction(),
|
||
|
|
serviceAction: serviceAction(),
|
||
|
|
deploymentAction: deploymentAction(),
|
||
|
|
}));
|
||
|
|
const activeServices = createMemo(
|
||
|
|
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||
|
|
);
|
||
|
|
const frameSource = createMemo(() => pageUrl(view(), frameInstance()));
|
||
|
|
const title = createMemo(
|
||
|
|
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
|
||
|
|
);
|
||
|
|
|
||
|
|
async function refresh() {
|
||
|
|
const generation = ++refreshGeneration;
|
||
|
|
try {
|
||
|
|
const next = await fetchSnapshot(apiBase());
|
||
|
|
if (generation !== refreshGeneration) return;
|
||
|
|
setSnapshot(next);
|
||
|
|
setConnection("online");
|
||
|
|
} catch {
|
||
|
|
if (generation !== refreshGeneration) return;
|
||
|
|
setSnapshot(null);
|
||
|
|
setConnection("offline");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function notify(message: string) {
|
||
|
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||
|
|
setToast(message);
|
||
|
|
toastTimer = window.setTimeout(() => setToast(null), 2800);
|
||
|
|
}
|
||
|
|
|
||
|
|
function navigate(next: View, pushHistory = true) {
|
||
|
|
if (next === view()) return;
|
||
|
|
// Give page-owned libraries a synchronous cleanup hook, then change the
|
||
|
|
// keyed URL so Solid removes the old iframe realm in the same transition.
|
||
|
|
const target = iframe?.contentWindow;
|
||
|
|
if (target) disposePage(target);
|
||
|
|
setFrameReady(false);
|
||
|
|
setView(next);
|
||
|
|
setFrameInstance((instance) => instance + 1);
|
||
|
|
setQuery("");
|
||
|
|
if (pushHistory) {
|
||
|
|
const url = new URL(window.location.href);
|
||
|
|
url.searchParams.set("view", next);
|
||
|
|
window.history.pushState({ view: next }, "", url);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function findService(key: string): Service | null {
|
||
|
|
return servicesFrom(state()).find((service) => serviceKey(service) === key) ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function performRuntimeAction(action: RuntimeAction) {
|
||
|
|
setRuntimeAction(action);
|
||
|
|
try {
|
||
|
|
await changeRuntimeState(apiBase(), action);
|
||
|
|
await refresh();
|
||
|
|
notify(
|
||
|
|
action === "start"
|
||
|
|
? "Runtime 已启动"
|
||
|
|
: action === "stop"
|
||
|
|
? "Runtime 已停止"
|
||
|
|
: "Runtime 已重启",
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
notify(error instanceof Error ? error.message : "Runtime 操作失败");
|
||
|
|
} finally {
|
||
|
|
setRuntimeAction(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function performServiceAction(key: string, action: "start" | "stop" | "restart") {
|
||
|
|
const service = findService(key);
|
||
|
|
if (!service) return;
|
||
|
|
setServiceAction(key);
|
||
|
|
try {
|
||
|
|
await changeServiceState(apiBase(), service, action);
|
||
|
|
await refresh();
|
||
|
|
notify(
|
||
|
|
`${service.id} ${action === "stop" ? "已停止" : action === "restart" ? "已重启" : "已启动"}`,
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
notify(error instanceof Error ? error.message : "Actor 操作失败");
|
||
|
|
} finally {
|
||
|
|
setServiceAction(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleCommand(command: PageCommand) {
|
||
|
|
switch (command.type) {
|
||
|
|
case "refresh":
|
||
|
|
await refresh();
|
||
|
|
return;
|
||
|
|
case "navigate":
|
||
|
|
navigate(command.view);
|
||
|
|
return;
|
||
|
|
case "open-register":
|
||
|
|
setDialog({ type: "register" });
|
||
|
|
return;
|
||
|
|
case "open-wit-publish":
|
||
|
|
setDialog({ type: "wit-publish" });
|
||
|
|
return;
|
||
|
|
case "open-invoke": {
|
||
|
|
const service = findService(command.serviceKey);
|
||
|
|
if (service) setDialog({ type: "invoke", service });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
case "open-activate": {
|
||
|
|
const service = findService(command.serviceKey);
|
||
|
|
if (!service) return;
|
||
|
|
const currentRevision =
|
||
|
|
snapshot()?.deployments.find((item) => item.service_id === service.id)?.active_revision ??
|
||
|
|
null;
|
||
|
|
setDialog({ type: "activate", service, currentRevision });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
case "runtime-action":
|
||
|
|
await performRuntimeAction(command.action);
|
||
|
|
return;
|
||
|
|
case "service-action":
|
||
|
|
await performServiceAction(command.serviceKey, command.action);
|
||
|
|
return;
|
||
|
|
case "save-api-base":
|
||
|
|
try {
|
||
|
|
const next = saveApiBase(command.value);
|
||
|
|
setApiBase(next);
|
||
|
|
setSnapshot(null);
|
||
|
|
setConnection("connecting");
|
||
|
|
await refresh();
|
||
|
|
notify("API 地址已保存");
|
||
|
|
} catch (error) {
|
||
|
|
notify(error instanceof Error ? error.message : "API 地址无效");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const receive = (event: MessageEvent<unknown>) => {
|
||
|
|
if (
|
||
|
|
event.origin !== window.location.origin ||
|
||
|
|
event.source !== iframe?.contentWindow ||
|
||
|
|
!isPageMessage(event.data) ||
|
||
|
|
event.data.view !== view()
|
||
|
|
) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (event.data.type === "ready") {
|
||
|
|
setFrameReady(true);
|
||
|
|
if (iframe?.contentWindow) sendState(iframe.contentWindow, state());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
void handleCommand(event.data.command);
|
||
|
|
};
|
||
|
|
|
||
|
|
const popState = () => {
|
||
|
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||
|
|
navigate(isView(requested) ? requested : "overview", false);
|
||
|
|
};
|
||
|
|
|
||
|
|
onMount(() => {
|
||
|
|
window.addEventListener("message", receive);
|
||
|
|
window.addEventListener("popstate", popState);
|
||
|
|
void refresh();
|
||
|
|
const interval = window.setInterval(() => void refresh(), 5000);
|
||
|
|
onCleanup(() => window.clearInterval(interval));
|
||
|
|
});
|
||
|
|
|
||
|
|
onCleanup(() => {
|
||
|
|
window.removeEventListener("message", receive);
|
||
|
|
window.removeEventListener("popstate", popState);
|
||
|
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||
|
|
});
|
||
|
|
|
||
|
|
createEffect(() => {
|
||
|
|
const next = state();
|
||
|
|
if (frameReady() && iframe?.contentWindow) sendState(iframe.contentWindow, next);
|
||
|
|
});
|
||
|
|
|
||
|
|
async function register(file: File) {
|
||
|
|
setDialogBusy(true);
|
||
|
|
try {
|
||
|
|
const registered = await registerComponent(apiBase(), file);
|
||
|
|
await refresh();
|
||
|
|
setDialog(null);
|
||
|
|
notify(`${registered.id}@${registered.revision} 已注册`);
|
||
|
|
} finally {
|
||
|
|
setDialogBusy(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function publishWit(file: File) {
|
||
|
|
setDialogBusy(true);
|
||
|
|
try {
|
||
|
|
const published = await publishWitPackage(apiBase(), file);
|
||
|
|
await refresh();
|
||
|
|
setDialog(null);
|
||
|
|
notify(`${published.name}@${published.version} 已发布`);
|
||
|
|
} finally {
|
||
|
|
setDialogBusy(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function activate(service: Service) {
|
||
|
|
setDialogBusy(true);
|
||
|
|
setDeploymentAction(serviceKey(service));
|
||
|
|
try {
|
||
|
|
await activateDeployment(apiBase(), service);
|
||
|
|
await refresh();
|
||
|
|
setDialog(null);
|
||
|
|
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
||
|
|
} finally {
|
||
|
|
setDialogBusy(false);
|
||
|
|
setDeploymentAction(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function invoke(service: Service, input: Uint8Array): Promise<InvokeResult> {
|
||
|
|
const result = await invokeComponent(apiBase(), service, input);
|
||
|
|
await refresh();
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div class="grid h-dvh min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[auto_56px_minmax(0,1fr)_30px] overflow-hidden bg-canvas lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-[64px_minmax(0,1fr)_30px]">
|
||
|
|
<aside class="border-line min-w-0 bg-[#16221f] text-white lg:row-span-3 lg:flex lg:min-h-0 lg:flex-col lg:border-r">
|
||
|
|
<div class="flex h-16 shrink-0 items-center gap-3 px-4 lg:px-5">
|
||
|
|
<span class="flex size-9 items-center justify-center rounded-md bg-[#e5f4ef] text-[#155a46]">
|
||
|
|
<Boxes size={19} />
|
||
|
|
</span>
|
||
|
|
<div>
|
||
|
|
<strong class="block text-[15px]">Wasmeld</strong>
|
||
|
|
<span class="block text-[10px] text-[#9ab0aa]">COMPONENT RUNTIME</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<nav class="flex gap-1 overflow-x-auto px-3 pb-3 lg:block lg:min-h-0 lg:flex-1 lg:space-y-1 lg:overflow-y-auto lg:pb-0">
|
||
|
|
<For each={NAVIGATION}>
|
||
|
|
{(item) => {
|
||
|
|
const Icon = item.icon;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
class="flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm text-[#b8c7c3] transition-colors hover:bg-white/7 hover:text-white lg:w-full"
|
||
|
|
classList={{ "bg-white/10 text-white": view() === item.id }}
|
||
|
|
type="button"
|
||
|
|
onClick={() => navigate(item.id)}
|
||
|
|
>
|
||
|
|
<Icon size={16} />
|
||
|
|
{item.label}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
</For>
|
||
|
|
</nav>
|
||
|
|
<div class="hidden border-t border-white/10 p-4 lg:block">
|
||
|
|
<div class="flex items-center justify-between text-xs">
|
||
|
|
<span class="text-[#9ab0aa]">连接状态</span>
|
||
|
|
<span
|
||
|
|
classList={{
|
||
|
|
"font-semibold text-[#76d5b4]": connection() === "online",
|
||
|
|
"font-semibold text-[#ffac93]": connection() === "offline",
|
||
|
|
"font-semibold text-[#f5cf7a]": connection() === "connecting",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{connection() === "online" ? "在线" : connection() === "offline" ? "离线" : "连接中"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div class="mt-3 flex items-center justify-between text-xs">
|
||
|
|
<span class="text-[#9ab0aa]">活跃 Actor</span>
|
||
|
|
<strong>{activeServices()}</strong>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
|
||
|
|
<header class="border-line col-start-1 flex min-w-0 items-center justify-between gap-3 border-b bg-white px-4 lg:col-start-2 lg:px-6">
|
||
|
|
<div class="min-w-0">
|
||
|
|
<span class="hidden text-xs text-muted sm:inline">运行平台 / </span>
|
||
|
|
<strong class="text-sm">{title()}</strong>
|
||
|
|
</div>
|
||
|
|
<label class="relative w-full max-w-80">
|
||
|
|
<Search
|
||
|
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||
|
|
size={15}
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
class="input h-9 pl-9"
|
||
|
|
value={query()}
|
||
|
|
aria-label="搜索"
|
||
|
|
placeholder="搜索服务、制品或事件"
|
||
|
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
||
|
|
<Show keyed when={frameSource()}>
|
||
|
|
{(source) => (
|
||
|
|
<iframe
|
||
|
|
ref={(element) => {
|
||
|
|
iframe = element;
|
||
|
|
}}
|
||
|
|
class="size-full border-0 bg-canvas"
|
||
|
|
src={source}
|
||
|
|
title={title()}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</Show>
|
||
|
|
<Show when={!frameReady()}>
|
||
|
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
||
|
|
<div class="flex items-center gap-2 text-sm text-muted">
|
||
|
|
<span class="size-2 animate-pulse rounded-full bg-brand" />
|
||
|
|
正在加载 {title()}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Show>
|
||
|
|
<Show when={connection() === "offline"}>
|
||
|
|
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-md border border-coral-strong/20 bg-coral-soft px-4 py-2 text-xs font-semibold text-coral-strong shadow-lg">
|
||
|
|
<WifiOff size={14} />
|
||
|
|
管理 API 不可用
|
||
|
|
</div>
|
||
|
|
</Show>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<footer class="border-line col-start-1 flex items-center justify-between border-t bg-white px-4 text-[10px] text-muted lg:col-start-2 lg:px-6">
|
||
|
|
<span class="inline-flex items-center gap-1.5">
|
||
|
|
<ShieldCheck size={12} />
|
||
|
|
{snapshot()?.runtime.status === "running" ? "Sandbox 正常" : "Runtime 已停止"}
|
||
|
|
</span>
|
||
|
|
<span>Component Model · WASI P2</span>
|
||
|
|
</footer>
|
||
|
|
|
||
|
|
<Show when={dialog()}>
|
||
|
|
{(activeDialog) => (
|
||
|
|
<DialogLayer
|
||
|
|
dialog={activeDialog()}
|
||
|
|
busy={dialogBusy()}
|
||
|
|
onClose={() => !dialogBusy() && setDialog(null)}
|
||
|
|
onRegister={register}
|
||
|
|
onPublishWit={publishWit}
|
||
|
|
onActivate={activate}
|
||
|
|
onInvoke={invoke}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</Show>
|
||
|
|
|
||
|
|
<Show when={toast()}>
|
||
|
|
{(message) => (
|
||
|
|
<div class="fixed bottom-12 right-5 z-[70] max-w-sm rounded-md bg-[#16221f] px-4 py-3 text-sm text-white shadow-xl">
|
||
|
|
{message()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function initialView(): View {
|
||
|
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||
|
|
return isView(requested) ? requested : "overview";
|
||
|
|
}
|