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
+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