feat(console-ui): add TanStack management console

- provide Runtime overview, service lifecycle, invocation, and event views\n- add immutable WIT package listing, publication, and download workflows\n- connect all state to the Rust management API without direct database access\n- use TanStack Start with Node serving, Oxlint, Oxfmt, and SSR checks\n- include responsive operational layouts and empty Actor placeholders
This commit is contained in:
Maofeng
2026-07-27 05:02:35 +08:00
parent b7fffd0d0f
commit d32895f5ca
16 changed files with 7390 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
/node_modules/
/dist/
/.tanstack/
*.tsbuildinfo
.env*
!.env.example
.DS_Store
*.log
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "node"],
"categories": {
"correctness": "error"
},
"rules": {},
"ignorePatterns": ["src/routeTree.gen.ts"],
"env": {
"builtin": true,
"browser": true,
"node": true
}
}
+36
View File
@@ -0,0 +1,36 @@
# Wasmeld Console
WebAssembly Runtime 的管理控制台,使用 TanStack Start、TanStack Router、
React 和 srvx 构建,通过 Node.js 运行。页面只调用 `wasmeld-console` HTTP API
不直接访问数据库或 Wasmeld Runtime。
运行概览可以启动、停止和重启整个 Runtime;服务版本与运行实例页面负责管理单个
Wasm Actor。停止 Runtime 不会断开管理 API。
## Development
```bash
npm install
npm run dev
```
默认访问 `http://localhost:3000`
管理 API 默认使用 `http://127.0.0.1:8080`。可以在控制台的“运行设置”中修改,
或在构建时设置 `VITE_WASMELD_API_URL`
## Validation
```bash
npm run format:check
npm run lint
npm test
```
生产构建完成后可以运行:
```bash
npm start
```
`npm test` 会生成 Node.js 生产构建,并验证 srvx 服务端渲染和静态资源。
+3121
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "wasmeld-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc --noEmit",
"start": "srvx serve --prod --entry dist/server/server.js --static ../client",
"preview": "vite preview",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "oxlint . --deny-warnings",
"format": "oxfmt .",
"format:check": "oxfmt --check ."
},
"dependencies": {
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"lucide-react": "^1.27.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"srvx": "^0.12.4"
},
"devDependencies": {
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"oxfmt": "^0.60.0",
"oxlint": "^1.75.0",
"typescript": "5.9.3",
"vite": "^8.1.5"
},
"engines": {
"node": ">=22.13.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+208
View File
@@ -0,0 +1,208 @@
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
export type BackendServiceStatus = "running" | "stopped" | "faulted";
export type BackendService = {
id: string;
revision: string;
artifact: string;
world: string;
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" | "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 BackendWitDependency = {
name: string;
version: string;
};
export type BackendWitPackage = {
schema_version: number;
name: string;
version: string;
sha256: string;
dependencies: BackendWitDependency[];
};
export type RegisterComponentInput = {
file: File;
};
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 saveApiBase(value: string): string {
const normalized = value.trim().replace(/\/+$/, "");
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) {
const [services, events, runtime, witPackages] = await Promise.all([
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
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,
events: events.events,
runtime,
witPackages: witPackages.packages,
};
}
export async function registerComponent(
apiBase: string,
input: RegisterComponentInput,
): Promise<BackendService> {
const form = new FormData();
form.set("package", input.file, input.file.name);
return request(apiBase, "/api/v1/services", {
method: "POST",
body: form,
});
}
export async 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/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/${encodeURIComponent(packageMetadata.version)}/content`;
}
export async function changeServiceState(
apiBase: string,
service: Pick<BackendService, "id" | "revision">,
action: "start" | "stop" | "restart",
): Promise<BackendService> {
return request(
apiBase,
`/api/v1/services/${encodeURIComponent(service.id)}/${encodeURIComponent(service.revision)}/${action}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
},
);
}
export async 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/${encodeURIComponent(service.id)}/${encodeURIComponent(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${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 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));
}
+68
View File
@@ -0,0 +1,68 @@
/* oxlint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+16
View File
@@ -0,0 +1,16 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+60
View File
@@ -0,0 +1,60 @@
/// <reference types="vite/client" />
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import type { ReactNode } from "react";
import appCss from "../styles/app.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{ title: "Wasmeld Console" },
{
name: "description",
content: "WebAssembly Runtime 管理控制台",
},
{ property: "og:title", content: "Wasmeld Console" },
{
property: "og:description",
content: "WebAssembly Runtime 管理控制台",
},
{ property: "og:type", content: "website" },
{ property: "og:image", content: "/og.png" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: "Wasmeld Console" },
{
name: "twitter:description",
content: "WebAssembly Runtime 管理控制台",
},
{ name: "twitter:image", content: "/og.png" },
],
links: [{ rel: "stylesheet", href: appCss }],
}),
component: RootComponent,
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="zh-CN">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { createServer } from "node:net";
import { fileURLToPath } from "node:url";
import test, { after, before } from "node:test";
let serverProcess;
let serverOrigin;
async function reservePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert(address && typeof address === "object");
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return address.port;
}
before(async () => {
const port = await reservePort();
serverOrigin = `http://127.0.0.1:${port}`;
serverProcess = spawn(
process.execPath,
[
fileURLToPath(new URL("../node_modules/srvx/bin/srvx.mjs", import.meta.url)),
"serve",
"--prod",
"--entry",
"dist/server/server.js",
"--static",
"../client",
],
{
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
},
stdio: ["ignore", "pipe", "pipe"],
},
);
for (let attempt = 0; attempt < 50; attempt += 1) {
if (serverProcess.exitCode !== null) {
throw new Error(`Node server exited with code ${serverProcess.exitCode}`);
}
try {
const response = await fetch(serverOrigin);
if (response.ok) return;
} catch {
// The server is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Node server did not become ready");
});
after(() => {
serverProcess?.kill("SIGTERM");
});
test("server-renders the Wasm management console", async () => {
const response = await fetch(serverOrigin, {
headers: { accept: "text/html" },
});
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /<title>Wasmeld Console<\/title>/i);
assert.match(html, /运行概览/);
assert.match(html, /WIT 包/);
assert.match(html, /连接中/);
assert.match(html, /来自 Wasmeld/);
assert.match(html, /Component Model/);
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
});
test("uses TanStack Start routing and produces Node artifacts", async () => {
const [rootRoute, indexRoute, router, packageJson, viteConfig] = await Promise.all([
readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/router.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
]);
assert.match(rootRoute, /createRootRoute/);
assert.match(rootRoute, /<HeadContent \/>/);
assert.match(rootRoute, /<Scripts \/>/);
assert.match(indexRoute, /createFileRoute\("\/"\)/);
assert.match(router, /createRouter/);
assert.match(packageJson, /"@tanstack\/react-start"/);
assert.match(packageJson, /"srvx"/);
assert.doesNotMatch(packageJson, /"next"|"vinext"|"drizzle-orm"/);
assert.match(viteConfig, /tanstackStart\(\{/);
await access(new URL("../dist/server/server.js", import.meta.url));
await access(new URL("../dist/client/og.png", import.meta.url));
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
"exclude": ["node_modules"]
}
+19
View File
@@ -0,0 +1,19 @@
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
server: { port: 3000 },
plugins: [
tanstackStart({
router: {
routeTreeFileHeader: [
"/* oxlint-disable */",
"// @ts-nocheck",
"// noinspection JSUnusedGlobalSymbols",
],
},
}),
viteReact(),
],
});