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:
@@ -0,0 +1,8 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/.tanstack/
|
||||
*.tsbuildinfo
|
||||
.env*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": ["src/routeTree.gen.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 服务端渲染和静态资源。
|
||||
Generated
+3121
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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));
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user