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,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
Reference in New Issue
Block a user