diff --git a/console/src/api.ts b/console/src/api.ts index 59e48f3..2996c7c 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -27,7 +27,7 @@ export type BackendService = { export type BackendEvent = { id: number; timestamp_ms: number; - kind: "registered" | "started" | "stopped" | "invoked" | "failed"; + kind: "registered" | "deployed" | "started" | "stopped" | "invoked" | "failed"; service_id: string | null; revision: string | null; message: string; @@ -41,6 +41,13 @@ export type BackendRuntime = { 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; @@ -80,20 +87,33 @@ export function saveApiBase(value: string): string { } export async function fetchSnapshot(apiBase: string) { - const [services, events, runtime, witPackages] = await Promise.all([ + 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(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 async function activateDeployment( + apiBase: string, + service: Pick, +): Promise { + return request(apiBase, `/api/v1/deployments/${encodeURIComponent(service.id)}/activate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ revision: service.revision }), + }); +} + export async function registerComponent( apiBase: string, input: RegisterComponentInput, diff --git a/console/src/routes/index.tsx b/console/src/routes/index.tsx index ee9c59d..b090ba5 100644 --- a/console/src/routes/index.tsx +++ b/console/src/routes/index.tsx @@ -18,6 +18,7 @@ import { LayoutDashboard, Package, Play, + RadioTower, RefreshCw, RotateCcw, Search, @@ -31,11 +32,13 @@ import { import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { BackendEvent, + BackendDeployment, BackendRuntime, BackendService, BackendWitPackage, InvokeResult, RegisterComponentInput, + activateDeployment, changeRuntimeState, changeServiceState, fetchSnapshot, @@ -66,6 +69,7 @@ type Service = { calls: number; errors: number; latencyMs: number | null; + active: boolean; }; type RuntimeEvent = { @@ -101,7 +105,7 @@ function serviceKey(service: Pick) { return `${service.id}@${service.revision}`; } -function toService(service: BackendService): Service { +function toService(service: BackendService, activeRevision?: string): Service { return { id: service.id, revision: service.revision, @@ -119,6 +123,7 @@ function toService(service: BackendService): Service { calls: service.calls, errors: service.errors, latencyMs: service.last_latency_ms, + active: service.revision === activeRevision, }; } @@ -127,6 +132,7 @@ function toEvent(event: BackendEvent): RuntimeEvent { event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld"; const meta: Record = { registered: { label: "已注册", tone: "success" }, + deployed: { label: "部署切换", tone: "success" }, started: { label: "已启动", tone: "success" }, stopped: { label: "已停止", tone: "neutral" }, invoked: { label: "调用完成", tone: "success" }, @@ -202,6 +208,7 @@ function Home() { const [view, setView] = useState("overview"); const [services, setServices] = useState([]); const [events, setEvents] = useState([]); + const [deployments, setDeployments] = useState([]); const [witPackages, setWitPackages] = useState([]); const [runtime, setRuntime] = useState(null); const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null); @@ -213,11 +220,15 @@ function Home() { const [deployOpen, setDeployOpen] = useState(false); const [witPublishOpen, setWitPublishOpen] = useState(false); const [invokeKey, setInvokeKey] = useState(null); + const [activationKey, setActivationKey] = useState(null); + const [deploymentAction, setDeploymentAction] = useState(null); const [toast, setToast] = useState(null); const selectedService = services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null; const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null; + const activationService = + services.find((service) => serviceKey(service) === activationKey) ?? null; const filteredServices = useMemo(() => { const normalized = query.trim().toLowerCase(); @@ -250,8 +261,17 @@ function Home() { const refreshSnapshot = useCallback(async () => { try { const snapshot = await fetchSnapshot(apiBase); - const nextServices = snapshot.services.map(toService); + const activeRevisions = new Map( + snapshot.deployments.map((deployment) => [ + deployment.service_id, + deployment.active_revision, + ]), + ); + const nextServices = snapshot.services.map((service) => + toService(service, activeRevisions.get(service.id)), + ); setServices(nextServices); + setDeployments(snapshot.deployments); setEvents(snapshot.events.map(toEvent)); setRuntime(snapshot.runtime); setWitPackages(snapshot.witPackages); @@ -265,6 +285,7 @@ function Home() { setConnection("online"); } catch { setServices([]); + setDeployments([]); setEvents([]); setRuntime(null); setWitPackages([]); @@ -318,6 +339,21 @@ function Home() { } } + async function activateService(service: Service) { + const key = serviceKey(service); + setDeploymentAction(key); + try { + await activateDeployment(apiBase, service); + await refreshSnapshot(); + setActivationKey(null); + notify(`${service.id}@${service.revision} 已设为对外版本`); + } catch (error) { + notify(error instanceof Error ? error.message : "Deployment 切换失败"); + } finally { + setDeploymentAction(null); + } + } + async function runInvocation(service: Service, input: Uint8Array) { const result = await invokeComponent(apiBase, service, input); await refreshSnapshot(); @@ -405,16 +441,19 @@ function Home() { running={running} faulted={faulted} totalCalls={totalCalls} + deploymentCount={deployments.length} runtime={runtime} selectedService={selectedService} onSelect={(service) => setSelectedKey(serviceKey(service))} onDeploy={() => setDeployOpen(true)} onInvoke={(service) => setInvokeKey(serviceKey(service))} onStatus={setServiceStatus} + onActivate={(service) => setActivationKey(serviceKey(service))} onViewAll={() => setView("services")} onRefresh={() => void refreshSnapshot()} onRuntimeAction={(action) => void setRuntimeStatus(action)} runtimeAction={runtimeAction} + deploymentAction={deploymentAction} /> )} @@ -427,6 +466,9 @@ function Home() { onInvoke={(service) => setInvokeKey(serviceKey(service))} onSelect={(service) => setSelectedKey(serviceKey(service))} onStatus={setServiceStatus} + onActivate={(service) => setActivationKey(serviceKey(service))} + canActivate={runtime?.status === "running"} + deploymentAction={deploymentAction} /> )} @@ -484,6 +526,19 @@ function Home() { /> )} + {activationService && ( + service.id === activationService.id && service.active) + ?.revision ?? null + } + submitting={deploymentAction === serviceKey(activationService)} + onClose={() => setActivationKey(null)} + onConfirm={() => void activateService(activationService)} + /> + )} + {toast && ( @@ -613,16 +668,19 @@ function Overview({ running, faulted, totalCalls, + deploymentCount, runtime, selectedService, onSelect, onDeploy, onInvoke, onStatus, + onActivate, onViewAll, onRefresh, onRuntimeAction, runtimeAction, + deploymentAction, }: { services: Service[]; filteredServices: Service[]; @@ -630,16 +688,19 @@ function Overview({ running: number; faulted: number; totalCalls: number; + deploymentCount: number; runtime: BackendRuntime | null; selectedService: Service | null; onSelect: (service: Service) => void; onDeploy: () => void; onInvoke: (service: Service) => void; onStatus: (service: Service, status: ServiceStatus) => void; + onActivate: (service: Service) => void; onViewAll: () => void; onRefresh: () => void; onRuntimeAction: (action: "start" | "stop" | "restart") => void; runtimeAction: "start" | "stop" | "restart" | null; + deploymentAction: string | null; }) { const errorCount = services.reduce((sum, service) => sum + service.errors, 0); const latencies = services @@ -677,7 +738,7 @@ function Overview({ icon={} label="服务版本" value={services.length.toString()} - note="来自 Wasmeld" + note={`${deploymentCount} 个对外服务`} tone="cyan" /> @@ -832,6 +896,17 @@ function Overview({
+ {!selectedService.active && ( + + )} {service.status === "running" ? ( <> + +
+ + ); +} + function DeployDialog({ onClose, onRegister, diff --git a/console/src/styles/app.css b/console/src/styles/app.css index 718dac3..8f3c764 100644 --- a/console/src/styles/app.css +++ b/console/src/styles/app.css @@ -785,7 +785,7 @@ button:disabled { } .data-table th:nth-last-child(1) { - width: 122px; + width: 150px; } .data-table td { @@ -849,6 +849,33 @@ button:disabled { font-size: 11px; } +.service-title { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.service-title strong { + overflow: hidden; + text-overflow: ellipsis; +} + +.active-deployment-badge { + flex: 0 0 auto; + height: 17px; + padding: 0 5px; + display: inline-flex; + align-items: center; + gap: 3px; + color: #176b4d; + background: #e4f3ed; + border: 1px solid #c5e4d8; + border-radius: 9px; + font-size: 7px; + font-weight: 700; +} + .service-cell small { margin-top: 3px; color: var(--ink-faint); @@ -917,7 +944,7 @@ button:disabled { } .row-actions { - min-width: 100px; + min-width: 128px; display: flex; align-items: center; justify-content: flex-end; @@ -943,6 +970,14 @@ button:disabled { border-color: var(--line); } +.row-actions .active-deployment-button:disabled { + color: #197253; + background: #e7f4ef; + border-color: #c6e4d9; + cursor: default; + opacity: 1; +} + .event-list { padding: 4px 14px; } @@ -1094,6 +1129,7 @@ button:disabled { .detail-actions { display: flex; justify-content: flex-end; + gap: 7px; } .full-panel { @@ -1597,6 +1633,54 @@ button:disabled { margin: 18px -18px -18px; } +.deployment-confirmation { + padding: 18px; +} + +.deployment-route { + min-height: 72px; + display: grid; + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); + align-items: center; + gap: 12px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.deployment-route > svg { + color: var(--ink-faint); +} + +.deployment-route span, +.deployment-route strong { + display: block; +} + +.deployment-route span { + margin-bottom: 5px; + color: var(--ink-faint); + font-size: 8px; +} + +.deployment-route strong { + overflow: hidden; + font-family: var(--font-geist-mono), monospace; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deployment-target { + color: #176b4d; +} + +.deployment-confirmation p { + margin: 13px 0 0; + color: var(--ink-soft); + font-size: 9px; + line-height: 16px; +} + .invoke-body { padding: 16px; } diff --git a/console/tests/rendered-html.test.mjs b/console/tests/rendered-html.test.mjs index a5ca63b..2614f89 100644 --- a/console/tests/rendered-html.test.mjs +++ b/console/tests/rendered-html.test.mjs @@ -77,15 +77,16 @@ test("server-renders the Wasm management console", async () => { assert.match(html, /运行概览/); assert.match(html, /WIT 包/); assert.match(html, /连接中/); - assert.match(html, /来自 Wasmeld/); + assert.match(html, /0 个对外服务/); 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([ + const [rootRoute, indexRoute, apiClient, 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/api.ts", 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"), @@ -95,6 +96,8 @@ test("uses TanStack Start routing and produces Node artifacts", async () => { assert.match(rootRoute, //); assert.match(rootRoute, //); assert.match(indexRoute, /createFileRoute\("\/"\)/); + assert.match(indexRoute, /切换对外版本/); + assert.match(apiClient, /\/api\/v1\/deployments/); assert.match(router, /createRouter/); assert.match(packageJson, /"@tanstack\/react-start"/); assert.match(packageJson, /"srvx"/);