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
+7
View File
@@ -0,0 +1,7 @@
/node_modules/
/dist/
*.tsbuildinfo
.env*
!.env.example
.DS_Store
*.log
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
+12
View File
@@ -0,0 +1,12 @@
{
"plugins": ["typescript"],
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"rules": {
"typescript/no-explicit-any": "error",
"no-console": "warn"
},
"ignorePatterns": ["dist/**", "node_modules/**"]
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#173f35" />
<meta name="description" content="WebAssembly Component Runtime 管理控制台" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Wasmeld Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/host/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "wasmeld-console-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"test": "npm run build && node --test tests/build-output.test.mjs",
"lint": "oxlint . --deny-warnings",
"format": "oxfmt .",
"format:check": "oxfmt --check ."
},
"dependencies": {
"lucide-solid": "1.27.0",
"solid-js": "1.9.14"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.10.1",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vite-plugin-solid": "2.11.14"
},
"engines": {
"node": ">=22.13.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN" class="frame-root">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f7f6" />
<title>Wasmeld Console Page</title>
</head>
<body class="frame-document">
<div id="app"></div>
<script type="module" src="/src/pages/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="88" fill="#173f35"/>
<path d="M104 132h70l36 196 46-143h52l46 143 36-196h70l-69 248h-67l-42-128-42 128h-67z" fill="#e5f4ef"/>
<rect x="104" y="98" width="304" height="18" rx="9" fill="#45b68d"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

@@ -0,0 +1,18 @@
{
"name": "Wasmeld Console",
"short_name": "Wasmeld",
"description": "WebAssembly Component Runtime management console",
"theme_color": "#173f35",
"background_color": "#f3f6f5",
"display": "standalone",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import type { LucideProps } from "lucide-solid";
export function EmptyState(props: { icon: Component<LucideProps>; title: string; detail: string }) {
const Icon = props.icon;
return (
<div class="empty-state">
<Icon size={22} />
<strong>{props.title}</strong>
<span>{props.detail}</span>
</div>
);
}
@@ -0,0 +1,19 @@
import type { JSXElement } from "solid-js";
export function PageHeading(props: {
eyebrow: string;
title: string;
description: string;
actions?: JSXElement;
}) {
return (
<header class="page-heading">
<div>
<span class="eyebrow">{props.eyebrow}</span>
<h1>{props.title}</h1>
<p>{props.description}</p>
</div>
{props.actions && <div class="heading-actions">{props.actions}</div>}
</header>
);
}
@@ -0,0 +1,195 @@
import {
Box,
Braces,
CircleStop,
Play,
RadioTower,
RefreshCw,
RotateCcw,
Search,
SquareTerminal,
} from "lucide-solid";
import { For, Show } from "solid-js";
import { serviceKey, type Service } from "../../lib/model";
import type { HostSharedState, PageCommand } from "../../sdk/protocol";
import { EmptyState } from "./empty-state";
import { StatusBadge } from "./status-badge";
export function ServiceTable(props: {
services: Service[];
state: HostSharedState;
command: (command: PageCommand) => void;
compact?: boolean;
}) {
return (
<Show
when={props.services.length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的服务" detail="调整搜索关键词或状态筛选。" />
}
>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th></th>
<th></th>
<Show when={!props.compact}>
<th></th>
<th>Host </th>
</Show>
<th></th>
<th></th>
<th>
<span class="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
<For each={props.services}>
{(service) => {
const key = serviceKey(service);
return (
<tr>
<td>
<div class="flex min-w-48 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Box size={15} />
</span>
<span class="min-w-0">
<span class="flex items-center gap-2">
<strong class="truncate">{service.id}</strong>
<Show when={service.active}>
<span class="inline-flex items-center gap-1 rounded-sm bg-cyan-soft px-1.5 py-0.5 text-[10px] font-semibold text-cyan-strong">
<RadioTower size={10} />
</span>
</Show>
</span>
<small class="mt-0.5 block text-xs text-muted">{service.revision}</small>
</span>
</div>
</td>
<td>
<StatusBadge status={service.status} />
</td>
<Show when={!props.compact}>
<td class="code max-w-48 truncate text-muted" title={service.artifact}>
{service.artifact}
</td>
<td>
<Show
when={service.capabilities.length > 0}
fallback={<span class="text-xs text-muted"></span>}
>
<span
class="inline-flex max-w-56 items-center gap-1.5 text-xs text-muted"
title={service.capabilities
.map((capability) => capability.interface)
.join("\n")}
>
<Braces size={12} />
<span class="truncate">
{service.capabilities[0]?.package}/{service.capabilities[0]?.name}
</span>
<Show when={service.capabilities.length > 1}>
<small>+{service.capabilities.length - 1}</small>
</Show>
</span>
</Show>
</td>
</Show>
<td class="font-mono text-xs">{service.calls.toLocaleString("zh-CN")}</td>
<td class="font-mono text-xs">
{service.latencyMs === null ? "—" : `${service.latencyMs} ms`}
</td>
<td>
<div class="row-actions">
<button
type="button"
aria-label="设为对外版本"
title={service.active ? "当前对外版本" : "设为对外版本"}
disabled={
service.active ||
props.state.snapshot?.runtime.status !== "running" ||
props.state.deploymentAction !== null
}
onClick={() => props.command({ type: "open-activate", serviceKey: key })}
>
{props.state.deploymentAction === key ? (
<RefreshCw class="spin" size={15} />
) : (
<RadioTower size={15} />
)}
</button>
<Show
when={service.status === "running"}
fallback={
<button
type="button"
aria-label={`启动 ${service.id}`}
title="启动"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "start",
})
}
>
<Play size={15} />
</button>
}
>
<button
type="button"
aria-label={`调用 ${service.id}`}
title="测试调用"
onClick={() => props.command({ type: "open-invoke", serviceKey: key })}
>
<SquareTerminal size={15} />
</button>
<button
type="button"
aria-label={`重启 ${service.id}`}
title="重启"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "restart",
})
}
>
<RotateCcw size={15} />
</button>
<button
type="button"
aria-label={`停止 ${service.id}`}
title="停止"
disabled={props.state.serviceAction !== null}
onClick={() =>
props.command({
type: "service-action",
serviceKey: key,
action: "stop",
})
}
>
<CircleStop size={15} />
</button>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Show>
);
}
@@ -0,0 +1,6 @@
import { STATUS_META, type ServiceStatus } from "../../lib/model";
export function StatusBadge(props: { status: ServiceStatus }) {
const meta = () => STATUS_META[props.status];
return <span class={`status-badge ${meta().className}`}>{meta().label}</span>;
}
+444
View File
@@ -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";
}
@@ -0,0 +1,357 @@
import {
ArrowRight,
Clock3,
CloudUpload,
FileCode2,
Package,
Play,
RadioTower,
RefreshCw,
ShieldCheck,
SquareTerminal,
X,
} from "lucide-solid";
import { createMemo, createSignal, onCleanup, onMount, Show, type JSXElement } from "solid-js";
import type { InvokeResult } from "../lib/api";
import {
formatInvocationOutput,
invocationInputSize,
parseInvocationInput,
type Service,
} from "../lib/model";
export type DialogState =
| { type: "register" }
| { type: "wit-publish" }
| { type: "invoke"; service: Service }
| { type: "activate"; service: Service; currentRevision: string | null };
export function DialogLayer(props: {
dialog: DialogState;
busy: boolean;
onClose: () => void;
onRegister: (file: File) => Promise<void>;
onPublishWit: (file: File) => Promise<void>;
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
onActivate: (service: Service) => Promise<void>;
}) {
return (
<>
<Show when={props.dialog.type === "register"}>
<UploadDialog
kind="component"
busy={props.busy}
onClose={props.onClose}
onSubmit={props.onRegister}
/>
</Show>
<Show when={props.dialog.type === "wit-publish"}>
<UploadDialog
kind="wit"
busy={props.busy}
onClose={props.onClose}
onSubmit={props.onPublishWit}
/>
</Show>
<Show when={props.dialog.type === "invoke" && "service" in props.dialog}>
<InvokeDialog
service={(props.dialog as Extract<DialogState, { type: "invoke" }>).service}
onClose={props.onClose}
onInvoke={props.onInvoke}
/>
</Show>
<Show when={props.dialog.type === "activate" && "service" in props.dialog}>
<ActivateDialog
dialog={props.dialog as Extract<DialogState, { type: "activate" }>}
busy={props.busy}
onClose={props.onClose}
onActivate={props.onActivate}
/>
</Show>
</>
);
}
function DialogFrame(props: {
title: string;
description: string;
icon: JSXElement;
closeDisabled?: boolean;
onClose: () => void;
children: JSXElement;
footer: JSXElement;
}) {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !props.closeDisabled) props.onClose();
};
onMount(() => window.addEventListener("keydown", onKeyDown));
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
return (
<div class="dialog-backdrop" role="presentation">
<dialog open class="dialog" aria-label={props.title}>
<header class="dialog-header">
<div class="dialog-title">
<span>{props.icon}</span>
<div>
<h2>{props.title}</h2>
<p>{props.description}</p>
</div>
</div>
<button
class="icon-btn"
type="button"
aria-label="关闭"
title="关闭"
disabled={props.closeDisabled}
onClick={props.onClose}
>
<X size={18} />
</button>
</header>
<div class="dialog-body">{props.children}</div>
<footer class="dialog-footer">{props.footer}</footer>
</dialog>
</div>
);
}
function UploadDialog(props: {
kind: "component" | "wit";
busy: boolean;
onClose: () => void;
onSubmit: (file: File) => Promise<void>;
}) {
const [file, setFile] = createSignal<File | null>(null);
const [error, setError] = createSignal("");
const isWit = () => props.kind === "wit";
const title = () => (isWit() ? "发布 WIT 包" : "注册组件包");
async function submit() {
const selected = file();
if (!selected || props.busy) return;
const extension = isWit() ? ".wasm" : ".wasmpkg";
const limit = isWit() ? 4 * 1024 * 1024 : 64 * 1024 * 1024;
if (!selected.name.toLowerCase().endsWith(extension)) {
setError(`请选择 ${extension} 文件`);
return;
}
if (selected.size > limit) {
setError(`文件不能超过 ${isWit() ? "4 MB" : "64 MB"}`);
return;
}
setError("");
await props.onSubmit(selected).catch((submitError: unknown) => {
setError(submitError instanceof Error ? submitError.message : `${title()}失败`);
});
}
return (
<DialogFrame
title={title()}
description={
isWit()
? "包名、版本和直接依赖从二进制 WIT Package 读取。"
: "组件身份、WIT World 和运行边界由平台自动校验。"
}
icon={isWit() ? <FileCode2 size={19} /> : <CloudUpload size={19} />}
closeDisabled={props.busy}
onClose={props.onClose}
footer={
<>
<button
class="btn btn-secondary"
type="button"
disabled={props.busy}
onClick={props.onClose}
>
</button>
<button
class="btn btn-primary"
type="button"
disabled={props.busy || !file()}
onClick={() => void submit()}
>
{props.busy ? <RefreshCw class="spin" size={16} /> : <ShieldCheck size={16} />}
{props.busy ? "正在校验" : isWit() ? "校验并发布" : "校验并注册"}
</button>
</>
}
>
<label class="upload-zone">
<input
type="file"
accept={isWit() ? ".wasm,application/wasm" : ".wasmpkg,application/zip"}
onChange={(event) => {
setFile(event.currentTarget.files?.[0] ?? null);
setError("");
}}
/>
{file() ? (
isWit() ? (
<FileCode2 size={28} />
) : (
<Package size={28} />
)
) : (
<CloudUpload size={28} />
)}
<strong>{file()?.name ?? `选择 ${isWit() ? "WIT Package" : "组件包"}`}</strong>
<span>
{file()
? `${(file()!.size / 1024).toFixed(1)} KB · 等待校验`
: `${isWit() ? ".wasm · 最大 4 MB" : ".wasmpkg · 最大 64 MB"}`}
</span>
</label>
<Show when={error()}>
<div class="form-error">{error()}</div>
</Show>
</DialogFrame>
);
}
function ActivateDialog(props: {
dialog: Extract<DialogState, { type: "activate" }>;
busy: boolean;
onClose: () => void;
onActivate: (service: Service) => Promise<void>;
}) {
return (
<DialogFrame
title="切换对外版本"
description="更新该服务在 Gateway 上接收请求的活动版本。"
icon={<RadioTower size={19} />}
closeDisabled={props.busy}
onClose={props.onClose}
footer={
<>
<button class="btn btn-secondary" disabled={props.busy} onClick={props.onClose}>
</button>
<button
class="btn btn-primary"
disabled={props.busy}
onClick={() => void props.onActivate(props.dialog.service)}
>
{props.busy ? <RefreshCw class="spin" size={16} /> : <RadioTower size={16} />}
{props.busy ? "正在切换" : "确认切换"}
</button>
</>
}
>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-md border border-line bg-canvas p-4">
<div>
<span class="text-xs text-muted"></span>
<strong class="code mt-1 block">{props.dialog.currentRevision ?? "尚未部署"}</strong>
</div>
<ArrowRight size={18} class="text-muted" />
<div>
<span class="text-xs text-muted"></span>
<strong class="code mt-1 block text-brand">{props.dialog.service.revision}</strong>
</div>
</div>
<p class="mt-4 text-sm leading-6 text-muted">
Wasmeld {props.dialog.service.id}@{props.dialog.service.revision} Actor
</p>
</DialogFrame>
);
}
function InvokeDialog(props: {
service: Service;
onClose: () => void;
onInvoke: (service: Service, input: Uint8Array) => Promise<InvokeResult>;
}) {
const [input, setInput] = createSignal(props.service.id === "echo" ? "hello wasm" : "");
const [format, setFormat] = createSignal<"utf8" | "hex">("utf8");
const [output, setOutput] = createSignal("");
const [outputMeta, setOutputMeta] = createSignal("");
const [running, setRunning] = createSignal(false);
const [failed, setFailed] = createSignal(false);
const inputBytes = createMemo(() => invocationInputSize(input(), format()));
async function invoke() {
setRunning(true);
setOutput("");
setOutputMeta("");
setFailed(false);
try {
const result = await props.onInvoke(props.service, parseInvocationInput(input(), format()));
const formatted = formatInvocationOutput(props.service, result.output, format());
setOutput(formatted.text);
setOutputMeta(
`${result.outputBytes} B · ${formatted.format}${formatted.automatic ? " · 自动" : ""} · ${result.latencyMs} ms`,
);
} catch (error) {
setFailed(true);
setOutput(error instanceof Error ? error.message : "组件调用失败");
} finally {
setRunning(false);
}
}
return (
<DialogFrame
title={`调用 ${props.service.id}@${props.service.revision}`}
description="请求将进入该实例的串行 mailbox。"
icon={<SquareTerminal size={19} />}
closeDisabled={running()}
onClose={props.onClose}
footer={
<>
<button class="btn btn-secondary" disabled={running()} onClick={props.onClose}>
</button>
<button class="btn btn-primary" disabled={running()} onClick={() => void invoke()}>
{running() ? <RefreshCw class="spin" size={16} /> : <Play size={16} />}
</button>
</>
}
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="segmented">
<button classList={{ active: format() === "utf8" }} onClick={() => setFormat("utf8")}>
UTF-8
</button>
<button classList={{ active: format() === "hex" }} onClick={() => setFormat("hex")}>
HEX
</button>
</div>
<span class="code text-muted">{inputBytes()} B</span>
</div>
<label class="field">
<span></span>
<textarea
class="textarea"
value={input()}
spellcheck={false}
placeholder={format() === "utf8" ? "输入请求内容" : "00 ff a1"}
onInput={(event) => setInput(event.currentTarget.value)}
/>
</label>
<div class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-semibold text-muted"></span>
<Show when={outputMeta()}>
<span class="inline-flex items-center gap-1 font-mono text-[11px] text-brand">
<Clock3 size={12} />
{outputMeta()}
</span>
</Show>
</div>
<pre
classList={{
"min-h-32 overflow-auto rounded-md border p-4 font-mono text-sm whitespace-pre-wrap": true,
"border-coral-strong/30 bg-coral-soft text-coral-strong": failed(),
"border-[#263532] bg-[#16221f] text-[#c8eee1]": !failed(),
}}
>
{running() ? "invoking..." : output() || "等待调用"}
</pre>
</div>
</DialogFrame>
);
}
@@ -0,0 +1,13 @@
import { render } from "solid-js/web";
import "../styles/app.css";
import HostApp from "./app";
const root = document.getElementById("app");
if (!root) throw new Error("missing #app root");
render(() => <HostApp />, root);
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js");
});
}
+252
View File
@@ -0,0 +1,252 @@
/**
* Typed client for the Rust management API.
*
* Production uses a same-origin base so the embedded Console follows the
* address used to open it. Vite development keeps the existing local backend
* default and relies on Wasmeld's development CORS policy.
*/
export const DEFAULT_API_BASE =
import.meta.env.VITE_WASMELD_API_URL ?? (import.meta.env.DEV ? "http://127.0.0.1:8080" : "");
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
export type BackendServiceStatus = "running" | "stopped" | "faulted";
export type BackendCapability = {
interface: string;
package: string;
name: string;
version: string;
};
export type BackendService = {
id: string;
revision: string;
artifact: string;
world: string;
capabilities: BackendCapability[];
status: BackendServiceStatus;
updated_at_ms: number;
limits: {
memory_bytes: number;
fuel_per_call: number;
deadline_ms: number;
mailbox_capacity: number;
max_input_bytes: number;
max_output_bytes: number;
};
calls: number;
errors: number;
last_latency_ms: number | null;
};
export type BackendEvent = {
id: number;
timestamp_ms: number;
kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
service_id: string | null;
revision: string | null;
message: string;
};
export type BackendRuntime = {
status: "running" | "stopped";
uptime_ms: number;
managed_services: number;
registered_services: number;
running_services: number;
};
export type BackendDeployment = {
service_id: string;
active_revision: string;
status: BackendServiceStatus;
updated_at_ms: number;
};
export type BackendWitDependency = {
name: string;
version: string;
};
export type BackendWitPackage = {
schema_version: number;
name: string;
version: string;
sha256: string;
dependencies: BackendWitDependency[];
};
export type BackendSnapshot = {
services: BackendService[];
deployments: BackendDeployment[];
events: BackendEvent[];
runtime: BackendRuntime;
witPackages: BackendWitPackage[];
};
export type InvokeResult = {
output: Uint8Array;
outputBytes: number;
latencyMs: number;
};
export function getApiBase(): string {
if (typeof window === "undefined") return DEFAULT_API_BASE;
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
}
export function displayApiBase(value: string): string {
return value || (typeof window === "undefined" ? "same origin" : window.location.origin);
}
export function saveApiBase(value: string): string {
const normalized = value.trim().replace(/\/+$/, "");
if (!normalized) {
window.localStorage.removeItem(API_BASE_STORAGE_KEY);
return "";
}
const parsed = new URL(normalized);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("API 地址必须使用 http 或 https");
}
window.localStorage.setItem(API_BASE_STORAGE_KEY, normalized);
return normalized;
}
export async function fetchSnapshot(apiBase: string): Promise<BackendSnapshot> {
const [services, deployments, events, runtime, witPackages] = await Promise.all([
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
]);
return {
services: services.services,
deployments: deployments.deployments,
events: events.events,
runtime,
witPackages: witPackages.packages,
};
}
export function activateDeployment(
apiBase: string,
service: Pick<BackendService, "id" | "revision">,
): Promise<BackendDeployment> {
return request(apiBase, `/api/v1/deployments/${segment(service.id)}/activate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ revision: service.revision }),
});
}
export function registerComponent(apiBase: string, file: File): Promise<BackendService> {
const form = new FormData();
form.set("package", file, file.name);
return request(apiBase, "/api/v1/services", {
method: "POST",
body: form,
});
}
export function publishWitPackage(apiBase: string, file: File): Promise<BackendWitPackage> {
const form = new FormData();
form.set("package", file, file.name);
return request(apiBase, "/api/v1/wit/packages", {
method: "POST",
body: form,
});
}
export function witPackageDownloadUrl(
apiBase: string,
packageMetadata: Pick<BackendWitPackage, "name" | "version">,
): string {
const [namespace, name] = packageMetadata.name.split(":");
return `${apiBase}/api/v1/wit/packages/${segment(namespace)}/${segment(name)}/${segment(packageMetadata.version)}/content`;
}
export function changeServiceState(
apiBase: string,
service: Pick<BackendService, "id" | "revision">,
action: "start" | "stop" | "restart",
): Promise<BackendService> {
return request(
apiBase,
`/api/v1/services/${segment(service.id)}/${segment(service.revision)}/${action}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
},
);
}
export function changeRuntimeState(
apiBase: string,
action: "start" | "stop" | "restart",
): Promise<BackendRuntime> {
return request(apiBase, `/api/v1/runtime/${action}`, { method: "POST" });
}
export async function invokeComponent(
apiBase: string,
service: Pick<BackendService, "id" | "revision">,
input: Uint8Array,
): Promise<InvokeResult> {
const response = await request<{
output_base64: string;
output_bytes: number;
latency_ms: number;
}>(apiBase, `/api/v1/services/${segment(service.id)}/${segment(service.revision)}/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input_base64: bytesToBase64(input) }),
});
return {
output: base64ToBytes(response.output_base64),
outputBytes: response.output_bytes,
latencyMs: response.latency_ms,
};
}
async function request<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(`${apiBase}${path}`, {
...init,
headers: {
accept: "application/json",
...init?.headers,
},
});
} catch {
throw new Error(`无法连接 Wasmeld${displayApiBase(apiBase)}`);
}
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: { message?: string };
} | null;
throw new Error(body?.error?.message ?? `请求失败:HTTP ${response.status}`);
}
return (await response.json()) as T;
}
function segment(value: string): string {
return encodeURIComponent(value);
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return window.btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = window.atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
+178
View File
@@ -0,0 +1,178 @@
import type { BackendEvent, BackendService } from "./api";
export const VIEWS = [
"overview",
"services",
"instances",
"wit-packages",
"activity",
"settings",
] as const;
export type View = (typeof VIEWS)[number];
export type ConnectionState = "connecting" | "online" | "offline";
export type ServiceStatus = "running" | "stopped" | "faulted";
export type EventTone = "success" | "warning" | "danger" | "neutral";
export type Service = {
id: string;
revision: string;
artifact: string;
world: string;
status: ServiceStatus;
updatedAt: string;
memoryMb: number;
fuel: string;
deadlineMs: number;
mailbox: number;
maxInputKb: number;
capabilities: BackendService["capabilities"];
calls: number;
errors: number;
latencyMs: number | null;
active: boolean;
};
export type RuntimeEvent = {
id: number;
time: string;
title: string;
detail: string;
tone: EventTone;
};
export type FormattedInvocationOutput = {
text: string;
format: "UTF-8" | "HEX" | "U64 LE";
automatic: boolean;
};
export const STATUS_META: Record<ServiceStatus, { label: string; className: string }> = {
running: { label: "运行中", className: "status-running" },
stopped: { label: "已停止", className: "status-stopped" },
faulted: { label: "故障", className: "status-faulted" },
};
export function isView(value: string | null): value is View {
return VIEWS.includes(value as View);
}
export function serviceKey(service: Pick<Service, "id" | "revision">): string {
return `${service.id}@${service.revision}`;
}
export function toService(service: BackendService, activeRevision?: string): Service {
return {
id: service.id,
revision: service.revision,
artifact: service.artifact.split(/[\\/]/).pop() ?? service.artifact,
world: service.world,
status: service.status,
updatedAt: new Date(service.updated_at_ms).toLocaleString("zh-CN", { hour12: false }),
memoryMb: Math.round(service.limits.memory_bytes / 1024 / 1024),
fuel: compactNumber(service.limits.fuel_per_call),
deadlineMs: service.limits.deadline_ms,
mailbox: service.limits.mailbox_capacity,
maxInputKb: Math.round(service.limits.max_input_bytes / 1024),
capabilities: service.capabilities,
calls: service.calls,
errors: service.errors,
latencyMs: service.last_latency_ms,
active: service.revision === activeRevision,
};
}
export function toEvent(event: BackendEvent): RuntimeEvent {
const identity =
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
registered: { label: "已注册", tone: "success" },
unregistered: { label: "已注销", tone: "neutral" },
deployed: { label: "部署切换", tone: "success" },
started: { label: "已启动", tone: "success" },
stopped: { label: "已停止", tone: "neutral" },
invoked: { label: "调用完成", tone: "success" },
failed: { label: "操作失败", tone: "danger" },
};
return {
id: event.id,
time: new Date(event.timestamp_ms).toLocaleTimeString("zh-CN", { hour12: false }),
title: `${identity} ${meta[event.kind].label}`,
detail: event.message,
tone: meta[event.kind].tone,
};
}
export function compactNumber(value: number): string {
if (value >= 1_000_000) return `${value / 1_000_000}M`;
if (value >= 1_000) return `${value / 1_000}K`;
return value.toString();
}
export function formatDuration(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`;
}
export function parseInvocationInput(value: string, format: "utf8" | "hex"): Uint8Array {
if (format === "utf8") return new TextEncoder().encode(value);
const compact = value.replace(/\s+/g, "");
if (compact.length % 2 !== 0 || /[^0-9a-f]/i.test(compact)) {
throw new Error("HEX 输入必须由成对的十六进制字符组成");
}
return Uint8Array.from(compact.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? []);
}
export function invocationInputSize(value: string, format: "utf8" | "hex"): number {
try {
return parseInvocationInput(value, format).byteLength;
} catch {
return 0;
}
}
export function formatInvocationOutput(
service: Service,
output: Uint8Array,
format: "utf8" | "hex",
): FormattedInvocationOutput {
if (service.id === "counter" && output.byteLength === 8) {
return {
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString(),
format: "U64 LE",
automatic: false,
};
}
if (format === "hex") {
return { text: formatHex(output) || "(empty)", format: "HEX", automatic: false };
}
const text = decodeReadableUtf8(output);
if (text !== null) {
return { text: text || "(empty)", format: "UTF-8", automatic: false };
}
return { text: formatHex(output), format: "HEX", automatic: true };
}
function formatHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
function decodeReadableUtf8(bytes: Uint8Array): string | null {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
for (const character of text) {
const codePoint = character.codePointAt(0) ?? 0;
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) return null;
}
return text;
}
@@ -0,0 +1,17 @@
import type { HostSharedState } from "../sdk/protocol";
import { toEvent, toService, type RuntimeEvent, type Service } from "./model";
export function servicesFrom(state: HostSharedState | null): Service[] {
if (!state?.snapshot) return [];
const deployments = new Map(
state.snapshot.deployments.map((deployment) => [
deployment.service_id,
deployment.active_revision,
]),
);
return state.snapshot.services.map((service) => toService(service, deployments.get(service.id)));
}
export function eventsFrom(state: HostSharedState | null): RuntimeEvent[] {
return state?.snapshot?.events.map(toEvent) ?? [];
}
@@ -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>
);
}
@@ -0,0 +1,40 @@
import { createSignal, onCleanup, onMount } from "solid-js";
import type { View } from "../lib/model";
import { isHostMessage, type HostSharedState, type PageCommand } from "../sdk/protocol";
import { notifyReady, sendCommand } from "../sdk/page";
/**
* Connects one iframe document to the persistent Host Shell.
*
* The listener and state belong to the iframe's Solid owner. Navigating or
* removing the iframe runs `onCleanup`, so the old document cannot continue to
* receive Host snapshots.
*/
export function createFrameBridge(view: View) {
const [state, setState] = createSignal<HostSharedState | null>(null);
const receive = (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
if (!isHostMessage(event.data)) return;
if (event.data.type === "state" && event.data.state.view === view) {
setState(event.data.state);
}
if (event.data.type === "dispose") {
window.dispatchEvent(new Event("wasmeld:dispose"));
}
};
onMount(() => {
window.addEventListener("message", receive);
notifyReady(view);
});
onCleanup(() => window.removeEventListener("message", receive));
return {
state,
command(command: PageCommand) {
sendCommand(view, command);
},
};
}
@@ -0,0 +1,32 @@
import type { View } from "../lib/model";
import { SDK_CHANNEL, SDK_VERSION, type HostMessage, type HostSharedState } from "./protocol";
export function sendState(target: Window, state: HostSharedState): void {
send(target, {
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "host",
type: "state",
state,
});
}
export function disposePage(target: Window): void {
send(target, {
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "host",
type: "dispose",
});
}
export function pageUrl(view: View, instance: number): string {
const url = new URL("/page.html", window.location.origin);
url.searchParams.set("view", view);
url.searchParams.set("instance", instance.toString());
return `${url.pathname}${url.search}`;
}
function send(target: Window, message: HostMessage): void {
target.postMessage(message, window.location.origin);
}
@@ -0,0 +1,27 @@
import type { View } from "../lib/model";
import { SDK_CHANNEL, SDK_VERSION, type PageCommand, type PageMessage } from "./protocol";
export function notifyReady(view: View): void {
send({
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "page",
type: "ready",
view,
});
}
export function sendCommand(view: View, command: PageCommand): void {
send({
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "page",
type: "command",
view,
command,
});
}
function send(message: PageMessage): void {
window.parent.postMessage(message, window.location.origin);
}
@@ -0,0 +1,95 @@
import type { BackendSnapshot } from "../lib/api";
import type { ConnectionState, View } from "../lib/model";
export const SDK_CHANNEL = "wasmeld-console";
/**
* Compatibility version for the same-origin Host/Page document protocol.
*
* Additive optional messages may keep the version. Removing a field or
* changing its meaning requires a version bump and an atomic Host/Page
* release, which is naturally provided by the embedded Console binary.
*/
export const SDK_VERSION = 1;
export type RuntimeAction = "start" | "stop" | "restart";
export type ServiceAction = "start" | "stop" | "restart";
export type HostSharedState = {
// This is a serializable snapshot, never a Solid signal crossing realms.
view: View;
snapshot: BackendSnapshot | null;
connection: ConnectionState;
query: string;
apiBase: string;
runtimeAction: RuntimeAction | null;
serviceAction: string | null;
deploymentAction: string | null;
};
export type PageCommand =
| { type: "refresh" }
| { type: "navigate"; view: View }
| { type: "open-register" }
| { type: "open-wit-publish" }
| { type: "open-invoke"; serviceKey: string }
| { type: "open-activate"; serviceKey: string }
| { type: "runtime-action"; action: RuntimeAction }
| { type: "service-action"; serviceKey: string; action: ServiceAction }
| { type: "save-api-base"; value: string };
export type PageMessage =
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "page";
type: "ready";
view: View;
}
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "page";
type: "command";
view: View;
command: PageCommand;
};
export type HostMessage =
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "host";
type: "state";
state: HostSharedState;
}
| {
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "host";
type: "dispose";
};
export function isPageMessage(value: unknown): value is PageMessage {
if (!isRecord(value)) return false;
return (
value.channel === SDK_CHANNEL &&
value.version === SDK_VERSION &&
value.source === "page" &&
(value.type === "ready" || value.type === "command")
);
}
export function isHostMessage(value: unknown): value is HostMessage {
if (!isRecord(value)) return false;
return (
value.channel === SDK_CHANNEL &&
value.version === SDK_VERSION &&
value.source === "host" &&
(value.type === "state" || value.type === "dispose")
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,329 @@
@import "tailwindcss";
@theme {
--color-canvas: #f3f6f5;
--color-surface: #ffffff;
--color-line: #d8e0de;
--color-ink: #17211f;
--color-muted: #64716e;
--color-brand: #1f775e;
--color-brand-strong: #155a46;
--color-brand-soft: #e7f3ef;
--color-cyan-soft: #e5f5f7;
--color-cyan-strong: #147386;
--color-amber-soft: #fff3d8;
--color-amber-strong: #9b6507;
--color-coral-soft: #ffebe5;
--color-coral-strong: #b9492f;
--font-sans:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC",
sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@layer base {
* {
box-sizing: border-box;
letter-spacing: 0;
}
html,
body,
#app {
min-height: 100%;
margin: 0;
}
body {
@apply bg-canvas font-sans text-ink antialiased;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
@apply outline-2 outline-offset-2 outline-brand;
}
.frame-root,
.frame-document {
@apply overflow-x-hidden bg-canvas;
}
}
@layer components {
.btn {
@apply inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45;
}
.btn-primary {
@apply border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong;
}
.btn-secondary {
@apply border-line bg-white text-ink hover:bg-canvas;
}
.btn-danger {
@apply border-coral-strong bg-white text-coral-strong hover:bg-coral-soft;
}
.icon-btn {
@apply inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45;
}
.panel {
@apply border-line bg-surface rounded-md border;
}
.panel-header {
@apply border-line flex min-h-16 items-center justify-between gap-4 border-b px-5 py-3;
}
.panel-header h2 {
@apply text-base font-semibold;
}
.panel-header p {
@apply mt-0.5 text-xs text-muted;
}
.page {
@apply mx-auto w-full max-w-[1480px] px-4 py-5 sm:px-6 lg:px-8 lg:py-7;
}
.page-heading {
@apply mb-5 flex flex-col justify-between gap-4 sm:flex-row sm:items-end;
}
.page-heading h1 {
@apply mt-1 text-2xl font-bold text-ink;
}
.page-heading p {
@apply mt-1 max-w-3xl text-sm text-muted;
}
.eyebrow {
@apply text-cyan-strong text-[11px] font-bold uppercase;
}
.heading-actions {
@apply flex shrink-0 items-center gap-2;
}
.metric-grid {
@apply mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4;
}
.metric {
@apply flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3;
}
.metric-icon {
@apply flex size-10 shrink-0 items-center justify-center rounded-md;
}
.metric-copy {
@apply min-w-0 flex-1;
}
.metric-copy span {
@apply block text-xs text-muted;
}
.metric-copy strong {
@apply mt-1 block text-2xl font-bold;
}
.metric > small {
@apply self-end whitespace-nowrap pb-1 text-[11px] text-muted;
}
.table-wrap {
@apply w-full overflow-x-auto;
}
.data-table {
@apply w-full min-w-[760px] border-collapse text-left text-sm;
}
.data-table th {
@apply border-line bg-canvas border-b px-4 py-2.5 text-[11px] font-semibold uppercase text-muted;
}
.data-table td {
@apply border-line border-b px-4 py-3 align-middle;
}
.data-table tbody tr {
@apply transition-colors hover:bg-[#f8faf9];
}
.data-table tbody tr:last-child td {
@apply border-b-0;
}
.status-badge {
@apply inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-semibold;
}
.status-badge::before {
content: "";
@apply size-1.5 rounded-full bg-current;
}
.status-running {
@apply text-brand;
}
.status-stopped {
@apply text-muted;
}
.status-faulted {
@apply text-coral-strong;
}
.row-actions {
@apply flex items-center justify-end gap-1;
}
.row-actions button {
@apply inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-35;
}
.empty-state {
@apply flex min-h-56 flex-col items-center justify-center gap-2 px-6 text-center text-muted;
}
.empty-state strong {
@apply text-sm text-ink;
}
.empty-state span {
@apply text-xs;
}
.code {
@apply font-mono text-xs;
}
.field {
@apply block;
}
.field > span {
@apply mb-1.5 block text-xs font-semibold text-muted;
}
.input,
.select,
.textarea {
@apply border-line w-full rounded-md border bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand;
}
.input,
.select {
@apply h-10;
}
.textarea {
@apply min-h-36 resize-y py-3 font-mono;
}
.dialog-backdrop {
@apply fixed inset-0 z-50 flex items-center justify-center bg-[#0d1715]/55 p-3 backdrop-blur-[1px];
}
.dialog {
@apply border-line max-h-[calc(100dvh-24px)] w-full max-w-3xl overflow-auto rounded-md border bg-white p-0 shadow-2xl;
}
.dialog-header {
@apply border-line flex items-start justify-between gap-4 border-b px-5 py-4 sm:px-7 sm:py-5;
}
.dialog-title {
@apply flex min-w-0 items-start gap-3;
}
.dialog-title > span {
@apply bg-brand-soft text-brand flex size-10 shrink-0 items-center justify-center rounded-md;
}
.dialog-title h2 {
@apply text-lg font-bold sm:text-xl;
}
.dialog-title p {
@apply mt-1 text-sm text-muted;
}
.dialog-body {
@apply px-5 py-5 sm:px-7;
}
.dialog-footer {
@apply border-line flex items-center justify-end gap-2 border-t bg-[#fafbfb] px-5 py-4 sm:px-7;
}
.upload-zone {
@apply border-line flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-[#fbfcfc] px-5 text-center text-muted transition-colors hover:border-brand hover:bg-brand-soft;
}
.upload-zone input {
@apply sr-only;
}
.upload-zone strong {
@apply text-sm text-ink;
}
.upload-zone span {
@apply text-xs;
}
.form-error {
@apply border-coral-strong/30 bg-coral-soft text-coral-strong mt-3 rounded-md border px-3 py-2 text-sm;
}
.segmented {
@apply border-line inline-flex rounded-md border bg-canvas p-1;
}
.segmented button {
@apply h-8 rounded-sm px-3 text-xs font-semibold text-muted;
}
.segmented button.active {
@apply bg-white text-ink shadow-sm;
}
.skeleton-line {
@apply h-3 animate-pulse rounded-sm bg-[#dfe6e4];
}
.spin {
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFile, stat } from "node:fs/promises";
import test from "node:test";
test("build emits independent host and iframe documents", async () => {
const [host, page] = await Promise.all([
readFile(new URL("../dist/index.html", import.meta.url), "utf8"),
readFile(new URL("../dist/page.html", import.meta.url), "utf8"),
]);
assert.match(host, /Wasmeld Console/);
assert.match(page, /Wasmeld Console Page/);
assert.notEqual(host, page);
});
test("build emits an installable PWA worker and manifest", async () => {
const manifest = JSON.parse(
await readFile(new URL("../dist/manifest.webmanifest", import.meta.url), "utf8"),
);
assert.equal(manifest.short_name, "Wasmeld");
assert.equal(manifest.start_url, "/");
assert.ok((await stat(new URL("../dist/sw.js", import.meta.url))).size > 0);
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
"exclude": ["dist", "node_modules"]
}
+88
View File
@@ -0,0 +1,88 @@
import tailwindcss from "@tailwindcss/vite";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig, type Plugin } from "vite";
import solid from "vite-plugin-solid";
const outputDirectory = process.env.WASMELD_WEB_OUT_DIR ?? "dist";
export default defineConfig({
server: {
port: 3000,
},
plugins: [solid(), tailwindcss(), pwaWorker()],
build: {
outDir: outputDirectory,
emptyOutDir: true,
rollupOptions: {
// Host and Page remain separate HTML documents. Dynamic page imports
// then create one lazy chunk per primary management view.
input: {
host: resolve(import.meta.dirname, "index.html"),
page: resolve(import.meta.dirname, "page.html"),
},
},
},
});
function pwaWorker(): Plugin {
return {
name: "wasmeld-pwa-worker",
apply: "build",
generateBundle(_, bundle) {
// Derive the cache name from emitted filenames and unhashed public
// assets. A changed build activates a new cache without another PWA
// dependency or a generated source file in the repository.
const publicFiles = ["icon.svg", "manifest.webmanifest", "og.png"];
const generatedFiles = Object.keys(bundle).filter((file) =>
/\.(?:html|js|css|svg|png|webmanifest)$/.test(file),
);
const files = [...new Set([...generatedFiles, ...publicFiles])].sort();
const digest = createHash("sha256");
for (const file of files) digest.update(file);
for (const file of publicFiles) {
digest.update(readFileSync(resolve(import.meta.dirname, "public", file)));
}
const cacheName = `wasmeld-${digest.digest("hex").slice(0, 16)}`;
const urls = files.map((file) => `/${file}`);
this.emitFile({
type: "asset",
fileName: "sw.js",
source: serviceWorkerSource(cacheName, urls),
});
},
};
}
function serviceWorkerSource(cacheName: string, urls: string[]): string {
// Runtime state must never be satisfied from an old cache. Only documents
// and build assets participate in offline behavior.
return `const CACHE = ${JSON.stringify(cacheName)};
const PRECACHE = ${JSON.stringify(urls)};
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key.startsWith("wasmeld-") && key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()));
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || url.pathname === "/healthz" || url.pathname.startsWith("/api/")) return;
if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request).then((response) => {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
return response;
}).catch(async () => {
const cached = await caches.match(event.request);
if (cached) return cached;
return caches.match(url.pathname === "/page.html" ? "/page.html" : "/index.html");
}));
return;
}
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request)));
});
`;
}