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:
Maofeng
2026-07-30 09:21:55 +08:00
parent 4e32a1246b
commit 70ee5e33da
49 changed files with 3996 additions and 5648 deletions
@@ -0,0 +1,61 @@
import { History, RefreshCw } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { eventsFrom } from "../../lib/view-state";
import type { PageProps } from "../types";
const toneClass = {
success: "bg-brand",
warning: "bg-amber-strong",
danger: "bg-coral-strong",
neutral: "bg-muted",
};
export default function ActivityPage(props: PageProps) {
const events = createMemo(() => {
const query = props.state()?.query.trim().toLowerCase() ?? "";
return eventsFrom(props.state()).filter(
(event) =>
!query ||
event.title.toLowerCase().includes(query) ||
event.detail.toLowerCase().includes(query),
);
});
return (
<main class="page">
<PageHeading
eyebrow="EVENTS"
title="调用记录"
description="最近 256 条持久化生命周期与调用事件。"
actions={
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<RefreshCw size={16} />
</button>
}
/>
<section class="panel">
<Show
when={events().length > 0}
fallback={
<EmptyState icon={History} title="暂无事件" detail="Runtime 操作和调用会显示在这里。" />
}
>
<div class="divide-y divide-line">
<For each={events()}>
{(event) => (
<article class="grid gap-2 px-5 py-4 sm:grid-cols-[10px_minmax(180px,0.8fr)_2fr_84px] sm:items-center">
<span class={`size-2 rounded-full ${toneClass[event.tone]}`} />
<strong class="text-sm">{event.title}</strong>
<p class="text-xs leading-5 text-muted">{event.detail}</p>
<time class="font-mono text-xs text-muted sm:text-right">{event.time}</time>
</article>
)}
</For>
</div>
</Show>
</section>
</main>
);
}
@@ -0,0 +1,108 @@
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { StatusBadge } from "../../components/ui/status-badge";
import { serviceKey } from "../../lib/model";
import { servicesFrom } from "../../lib/view-state";
import type { PageProps } from "../types";
export default function InstancesPage(props: PageProps) {
const services = createMemo(() => servicesFrom(props.state()));
const running = createMemo(
() => services().filter((service) => service.status === "running").length,
);
return (
<main class="page">
<PageHeading
eyebrow="ACTORS"
title="运行实例"
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
actions={
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<RefreshCw size={16} />
</button>
}
/>
<Show
when={services().length > 0}
fallback={
<div class="panel">
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
</div>
}
>
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<For each={services()}>
{(service) => (
<article class="panel p-5">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
<Cpu size={18} />
</span>
<div class="min-w-0">
<strong class="block truncate">{service.id}</strong>
<span class="code mt-1 block truncate text-muted">{service.revision}</span>
</div>
</div>
<StatusBadge status={service.status} />
</div>
<dl class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-line pt-4 text-xs">
<div>
<dt class="text-muted"></dt>
<dd class="mt-1 font-semibold">{service.memoryMb} MB</dd>
</div>
<div>
<dt class="text-muted">Mailbox</dt>
<dd class="mt-1 font-semibold">{service.mailbox}</dd>
</div>
<div>
<dt class="text-muted">Deadline</dt>
<dd class="mt-1 font-semibold">{service.deadlineMs} ms</dd>
</div>
<div>
<dt class="text-muted"></dt>
<dd class="mt-1 font-semibold">
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
</dd>
</div>
</dl>
<div class="mt-5 flex justify-end gap-2">
<Show when={service.status === "running"}>
<button
class="btn btn-secondary h-9"
onClick={() =>
props.command({
type: "open-invoke",
serviceKey: serviceKey(service),
})
}
>
<SquareTerminal size={15} />
</button>
</Show>
<button
class="btn btn-secondary h-9"
disabled={props.state()?.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: serviceKey(service),
action: service.status === "running" ? "stop" : "start",
})
}
>
{service.status === "running" ? "停止" : "启动"}
</button>
</div>
</article>
)}
</For>
</section>
</Show>
</main>
);
}
@@ -0,0 +1,59 @@
import { AlertTriangle, LoaderCircle } from "lucide-solid";
import { lazy, Show, Suspense, type Component } from "solid-js";
import { render } from "solid-js/web";
import { isView, type View } from "../lib/model";
import { createFrameBridge } from "../primitives/create-frame-bridge";
import "../styles/app.css";
import type { PageProps } from "./types";
// Each view is a dynamic import so a freshly-created iframe evaluates only the
// page it owns. Destroying that iframe releases the chunk's runtime objects.
const pages: Record<View, Component<PageProps>> = {
overview: lazy(() => import("./overview")),
services: lazy(() => import("./services")),
instances: lazy(() => import("./instances")),
"wit-packages": lazy(() => import("./wit-packages")),
activity: lazy(() => import("./activity")),
settings: lazy(() => import("./settings")),
};
function FramePage() {
const requested = new URLSearchParams(window.location.search).get("view");
const view = isView(requested) ? requested : null;
return (
<Show
when={view}
fallback={
<main class="flex min-h-dvh items-center justify-center p-6 text-center text-coral-strong">
<div>
<AlertTriangle class="mx-auto mb-3" size={28} />
<strong class="block"></strong>
<span class="mt-1 block text-sm text-muted">{requested ?? "(empty)"}</span>
</div>
</main>
}
>
{(activeView) => {
const bridge = createFrameBridge(activeView());
const Page = pages[activeView()];
document.title = `${activeView()} · Wasmeld`;
return (
<Suspense
fallback={
<main class="flex min-h-dvh items-center justify-center text-muted">
<LoaderCircle class="spin" size={24} />
</main>
}
>
<Page state={bridge.state} command={bridge.command} />
</Suspense>
);
}}
</Show>
);
}
const root = document.getElementById("app");
if (!root) throw new Error("missing #app root");
render(() => <FramePage />, root);
@@ -0,0 +1,258 @@
import {
Activity,
AlertTriangle,
CircleStop,
CloudUpload,
Cpu,
Package,
Play,
RefreshCw,
RotateCcw,
Zap,
} from "lucide-solid";
import { createMemo, For, Show, type JSXElement } from "solid-js";
import { PageHeading } from "../../components/ui/page-heading";
import { ServiceTable } from "../../components/ui/service-table";
import { eventsFrom, servicesFrom } from "../../lib/view-state";
import { formatDuration } from "../../lib/model";
import type { PageProps } from "../types";
export default function OverviewPage(props: PageProps) {
const services = createMemo(() => servicesFrom(props.state()));
const events = createMemo(() => eventsFrom(props.state()));
const running = createMemo(
() => services().filter((service) => service.status === "running").length,
);
const totalCalls = createMemo(() =>
services().reduce((total, service) => total + service.calls, 0),
);
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
return (
<main class="page">
<Show when={props.state()} fallback={<OverviewSkeleton />}>
{(state) => (
<>
<PageHeading
eyebrow="LOCAL RUNTIME"
title="运行概览"
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
actions={
<>
<button
class="icon-btn"
type="button"
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
<button
class="btn btn-primary"
type="button"
onClick={() => props.command({ type: "open-register" })}
>
<CloudUpload size={16} />
</button>
</>
}
/>
<section class="metric-grid" aria-label="关键指标">
<Metric
icon={<Package size={18} />}
tone="bg-cyan-soft text-cyan-strong"
label="服务版本"
value={services().length.toString()}
note={`${state().snapshot?.deployments.length ?? 0} 个对外服务`}
/>
<Metric
icon={<Activity size={18} />}
tone="bg-brand-soft text-brand"
label="运行实例"
value={running().toString()}
note={`${services().length - running()} 个未运行`}
/>
<Metric
icon={<Zap size={18} />}
tone="bg-amber-soft text-amber-strong"
label="累计调用"
value={totalCalls().toLocaleString("zh-CN")}
note="持久化统计"
/>
<Metric
icon={<AlertTriangle size={18} />}
tone="bg-coral-soft text-coral-strong"
label="调用异常"
value={errors().toString()}
note={errors() > 0 ? "需要检查" : "无异常"}
/>
</section>
<section class="panel mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]">
<div class="flex items-center gap-3 border-b border-line px-5 py-4 lg:border-b-0 lg:border-r">
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
<Cpu size={18} />
</span>
<div class="min-w-0 flex-1">
<strong class="block">Wasmeld Runtime</strong>
<span class="mt-0.5 block truncate text-xs text-muted">
{state().snapshot?.runtime.status === "running"
? "Wasmtime sandbox 正常"
: "Engine 与 Actor 已释放"}
</span>
</div>
<div class="flex gap-1">
<Show
when={state().snapshot?.runtime.status === "running"}
fallback={
<button
class="icon-btn"
title="启动 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "start" })}
>
<Play size={15} />
</button>
}
>
<button
class="icon-btn"
title="重启 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
>
<RotateCcw size={15} />
</button>
<button
class="icon-btn text-coral-strong"
title="停止 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
>
<CircleStop size={15} />
</button>
</Show>
</div>
</div>
<RuntimeStat
label="已加载版本"
value={(state().snapshot?.runtime.registered_services ?? 0).toString()}
detail="已编译 Component"
/>
<RuntimeStat
label="活跃 Store"
value={(state().snapshot?.runtime.running_services ?? 0).toString()}
detail="串行 Actor"
/>
<RuntimeStat
label="运行时间"
value={
state().snapshot?.runtime.status === "running"
? formatDuration(state().snapshot?.runtime.uptime_ms ?? 0)
: "—"
}
detail="当前 Runtime"
/>
</section>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
<section class="panel min-w-0">
<div class="panel-header">
<div>
<h2></h2>
<p> Actor </p>
</div>
<button
class="btn btn-secondary h-9"
onClick={() => props.command({ type: "navigate", view: "services" })}
>
</button>
</div>
<ServiceTable
services={services().slice(0, 5)}
state={state()}
command={props.command}
compact
/>
</section>
<aside class="panel">
<div class="panel-header">
<div>
<h2></h2>
<p></p>
</div>
<span class="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand">
<span class="size-1.5 rounded-full bg-brand" />
LIVE
</span>
</div>
<div class="divide-y divide-line">
<For
each={events().slice(0, 6)}
fallback={<p class="px-5 py-12 text-center text-sm text-muted"></p>}
>
{(event) => (
<div class="px-5 py-3">
<div class="flex items-center justify-between gap-3">
<strong class="truncate text-xs">{event.title}</strong>
<span class="font-mono text-[10px] text-muted">{event.time}</span>
</div>
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">{event.detail}</p>
</div>
)}
</For>
</div>
</aside>
</div>
</>
)}
</Show>
</main>
);
}
function Metric(props: {
icon: JSXElement;
tone: string;
label: string;
value: string;
note: string;
}) {
return (
<article class="metric">
<div class={`metric-icon ${props.tone}`}>{props.icon}</div>
<div class="metric-copy">
<span>{props.label}</span>
<strong>{props.value}</strong>
</div>
<small>{props.note}</small>
</article>
);
}
function RuntimeStat(props: { label: string; value: string; detail: string }) {
return (
<div class="border-b border-line px-5 py-4 last:border-b-0 lg:border-b-0 lg:border-r lg:last:border-r-0">
<span class="block text-xs text-muted">{props.label}</span>
<strong class="mt-1 block text-lg">{props.value}</strong>
<small class="mt-0.5 block text-[11px] text-muted">{props.detail}</small>
</div>
);
}
function OverviewSkeleton() {
return (
<div class="space-y-5 py-5">
<div class="skeleton-line w-36" />
<div class="skeleton-line h-8 w-64" />
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
import { createMemo, createSignal, Show } from "solid-js";
import { PageHeading } from "../../components/ui/page-heading";
import { ServiceTable } from "../../components/ui/service-table";
import { servicesFrom } from "../../lib/view-state";
import type { ServiceStatus } from "../../lib/model";
import type { PageProps } from "../types";
export default function ServicesPage(props: PageProps) {
const [status, setStatus] = createSignal<"all" | ServiceStatus>("all");
const services = createMemo(() => servicesFrom(props.state()));
const filtered = createMemo(() => {
const query = props.state()?.query.trim().toLowerCase() ?? "";
return services().filter(
(service) =>
(status() === "all" || service.status === status()) &&
(!query ||
service.id.toLowerCase().includes(query) ||
service.revision.toLowerCase().includes(query) ||
service.artifact.toLowerCase().includes(query)),
);
});
return (
<main class="page">
<PageHeading
eyebrow="COMPONENTS"
title="服务版本"
description="管理不可变 Component Revision、Actor 状态和对外 Deployment。"
actions={
<>
<label class="relative">
<Filter
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
size={14}
/>
<select
class="select w-36 pl-9"
value={status()}
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
>
<option value="all"></option>
<option value="running"></option>
<option value="stopped"></option>
<option value="faulted"></option>
</select>
</label>
<button
class="btn btn-primary"
onClick={() => props.command({ type: "open-register" })}
>
<CloudUpload size={16} />
</button>
</>
}
/>
<section class="panel">
<div class="panel-header">
<div>
<h2></h2>
<p>{filtered().length} </p>
</div>
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<RefreshCw size={16} />
</button>
</div>
<Show when={props.state()}>
{(state) => (
<ServiceTable services={filtered()} state={state()} command={props.command} />
)}
</Show>
</section>
</main>
);
}
@@ -0,0 +1,88 @@
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
import { createEffect, createSignal } from "solid-js";
import { displayApiBase } from "../../lib/api";
import { PageHeading } from "../../components/ui/page-heading";
import type { PageProps } from "../types";
export default function SettingsPage(props: PageProps) {
const [endpoint, setEndpoint] = createSignal("");
let initialized = false;
createEffect(() => {
const state = props.state();
if (state && !initialized) {
setEndpoint(state.apiBase);
initialized = true;
}
});
return (
<main class="page">
<PageHeading
eyebrow="CONFIGURATION"
title="运行设置"
description="配置管理 API 连接;运行边界由后端策略统一控制。"
/>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
<section class="panel">
<div class="panel-header">
<div>
<h2> API</h2>
<p>使</p>
</div>
<Globe2 size={18} class="text-cyan-strong" />
</div>
<form
class="p-5"
onSubmit={(event) => {
event.preventDefault();
props.command({ type: "save-api-base", value: endpoint() });
}}
>
<label class="field">
<span>Endpoint</span>
<input
class="input"
type="url"
value={endpoint()}
placeholder={window.location.origin}
onInput={(event) => setEndpoint(event.currentTarget.value)}
/>
</label>
<p class="mt-2 text-xs text-muted">
{displayApiBase(props.state()?.apiBase ?? "")}
</p>
<div class="mt-5 flex justify-end">
<button class="btn btn-primary" type="submit">
<Save size={15} />
</button>
</div>
</form>
</section>
<aside class="space-y-3">
<Info
icon={ShieldCheck}
title="Sandbox"
detail="网络与 Host 能力默认拒绝,按 WIT import 精确链接。"
/>
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
</aside>
</div>
</main>
);
}
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
const Icon = props.icon;
return (
<article class="panel flex gap-3 p-4">
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Icon size={16} />
</span>
<div>
<strong class="text-sm">{props.title}</strong>
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
</div>
</article>
);
}
@@ -0,0 +1,7 @@
import type { Accessor } from "solid-js";
import type { HostSharedState, PageCommand } from "../sdk/protocol";
export type PageProps = {
state: Accessor<HostSharedState | null>;
command: (command: PageCommand) => void;
};
@@ -0,0 +1,105 @@
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { witPackageDownloadUrl } from "../../lib/api";
import type { PageProps } from "../types";
export default function WitPackagesPage(props: PageProps) {
const packages = createMemo(() => {
const state = props.state();
const query = state?.query.trim().toLowerCase() ?? "";
return (
state?.snapshot?.witPackages.filter(
(item) =>
!query ||
item.name.toLowerCase().includes(query) ||
item.version.toLowerCase().includes(query) ||
item.sha256.toLowerCase().includes(query),
) ?? []
);
});
return (
<main class="page">
<PageHeading
eyebrow="INTERFACES"
title="WIT 包"
description="管理不可变、版本化的 Component 接口依赖。"
actions={
<button
class="btn btn-primary"
onClick={() => props.command({ type: "open-wit-publish" })}
>
<CloudUpload size={16} />
WIT
</button>
}
/>
<section class="panel">
<div class="panel-header">
<div>
<h2>Registry</h2>
<p>{packages().length} </p>
</div>
</div>
<Show
when={packages().length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
}
>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA-256</th>
<th>
<span class="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
<For each={packages()}>
{(item) => (
<tr>
<td>
<span class="flex items-center gap-2">
<FileCode2 size={15} class="text-cyan-strong" />
<strong>{item.name}</strong>
</span>
</td>
<td class="code">{item.version}</td>
<td class="text-xs text-muted">
{item.dependencies.length
? item.dependencies
.map((dependency) => `${dependency.name}@${dependency.version}`)
.join(", ")
: "无"}
</td>
<td class="code max-w-64 truncate text-muted" title={item.sha256}>
{item.sha256}
</td>
<td class="text-right">
<a
class="icon-btn"
title="下载"
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
>
<Download size={15} />
</a>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</section>
</main>
);
}