Files
wasmeld/crates/wasmeld-console/web/src/host/app.tsx
T

531 lines
18 KiB
TypeScript
Raw Normal View History

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 { pageUrl, sendLifecycle, sendState } from "../sdk/host";
import {
isPageMessage,
type HostSharedState,
type PageCommand,
type RuntimeAction,
} from "../sdk/protocol";
import { DialogLayer, type DialogState } from "./dialogs";
import { FrameCache, type FrameEntry } from "./frame-cache";
const NAVIGATION: Array<{
id: View;
label: string;
icon: Component<LucideProps>;
keepAlive?: boolean;
}> = [
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
{ id: "services", label: "服务版本", icon: Package, keepAlive: true },
{ id: "instances", label: "运行实例", icon: Server },
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
{ id: "activity", label: "调用记录", icon: History },
{ id: "settings", label: "运行设置", icon: Settings, keepAlive: true },
];
const MAX_KEEP_ALIVE_IFRAMES = 3;
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 initial = initialView();
const frameCache = new FrameCache(initial, {
maxKeepAliveEntries: MAX_KEEP_ALIVE_IFRAMES,
shouldKeepAlive: keepsFrameAlive,
});
const [view, setView] = createSignal(initial);
const [frames, setFrames] = createSignal(frameCache.snapshot());
const [readyViews, setReadyViews] = createSignal<ReadonlySet<View>>(new Set<View>());
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);
const frameElements = new Map<View, HTMLIFrameElement>();
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 frameReady = createMemo(() => readyViews().has(view()));
const title = createMemo(
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
);
function stateFor(targetView: View): HostSharedState {
return { ...state(), view: targetView };
}
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;
const previous = view();
const transition = frameCache.activate(next);
const removedViews = new Set(transition.removed.map((entry) => entry.view));
// Retained frames are paused before being hidden. Transient and LRU-evicted
// frames receive dispose before Solid removes their document from the DOM.
if (!removedViews.has(previous)) sendFrameLifecycle(previous, "deactivate");
for (const entry of transition.removed) sendFrameLifecycle(entry.view, "dispose");
setReadyViews((current) => {
const remaining = new Set(current);
for (const removed of removedViews) remaining.delete(removed);
return remaining;
});
setFrames(transition.entries);
setView(next);
setQuery("");
if (readyViews().has(next)) {
sendFrameState(next);
sendFrameLifecycle(next, "activate");
}
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 || !isPageMessage(event.data)) return;
const sourceView = viewForSource(event.source);
if (!sourceView || event.data.view !== sourceView) return;
if (event.data.type === "ready") {
setReadyViews((current) => new Set(current).add(sourceView));
sendFrameState(sourceView);
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
return;
}
// Hidden documents cannot initiate management operations. This matters for
// page-owned timers or libraries that may finish work after deactivation.
if (sourceView !== view()) return;
void handleCommand(event.data.command);
};
function viewForSource(source: MessageEventSource | null): View | null {
for (const [frameView, element] of frameElements) {
if (source === element.contentWindow) return frameView;
}
return null;
}
function sendFrameState(targetView: View) {
const target = frameElements.get(targetView)?.contentWindow;
if (target) sendState(target, stateFor(targetView));
}
function sendFrameLifecycle(targetView: View, phase: "activate" | "deactivate" | "dispose") {
const target = frameElements.get(targetView)?.contentWindow;
if (target) sendLifecycle(target, phase);
}
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);
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
});
createEffect(() => {
state();
const ready = readyViews();
for (const frame of frames()) {
if (ready.has(frame.view)) sendFrameState(frame.view);
}
});
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">
<For each={frames()}>
{(frame) => (
<PageFrame
frame={frame}
active={view() === frame.view}
title={titleFor(frame.view)}
register={(frameView, element) => frameElements.set(frameView, element)}
unregister={(frameView, element) => {
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
}}
/>
)}
</For>
<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";
}
function keepsFrameAlive(view: View): boolean {
return NAVIGATION.some((item) => item.id === view && item.keepAlive === true);
}
function titleFor(view: View): string {
return NAVIGATION.find((item) => item.id === view)?.label ?? view;
}
function PageFrame(props: {
frame: FrameEntry;
active: boolean;
title: string;
register: (view: View, element: HTMLIFrameElement) => void;
unregister: (view: View, element: HTMLIFrameElement) => void;
}) {
let element: HTMLIFrameElement | undefined;
onCleanup(() => {
if (element) props.unregister(props.frame.view, element);
});
return (
<iframe
ref={(next) => {
element = next;
props.register(props.frame.view, next);
}}
class="size-full border-0 bg-canvas"
classList={{ hidden: !props.active }}
src={pageUrl(props.frame.view, props.frame.instance)}
title={props.title}
aria-hidden={!props.active}
tabIndex={props.active ? 0 : -1}
/>
);
}