feat(console-web): replace TanStack UI with Solid host pages
- move the management frontend under the wasmeld-console crate - use a persistent Host shell with disposable iframe page documents - version Host/Page postMessage state and command contracts - migrate runtime, service, WIT, invocation, and settings workflows - add Tailwind CSS v4, responsive layouts, and Host-owned dialogs - emit a dependency-free PWA worker with API cache exclusions - remove the superseded root TanStack Start application
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user