feat(console-ui): manage active deployments

- load deployment state with the console snapshot
- mark externally routed revisions in service tables
- add guarded activation confirmation and pending states
- cover deployment API wiring in SSR tests
This commit is contained in:
Maofeng
2026-07-29 16:32:16 +08:00
parent 1d3fac4be8
commit 9160362fc0
4 changed files with 298 additions and 13 deletions
+22 -2
View File
@@ -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<BackendRuntime>(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<BackendService, "id" | "revision">,
): Promise<BackendDeployment> {
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,
+184 -6
View File
@@ -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<Service, "id" | "revision">) {
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<BackendEvent["kind"], { label: string; tone: EventTone }> = {
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<View>("overview");
const [services, setServices] = useState<Service[]>([]);
const [events, setEvents] = useState<RuntimeEvent[]>([]);
const [deployments, setDeployments] = useState<BackendDeployment[]>([]);
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
const [runtime, setRuntime] = useState<BackendRuntime | null>(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<string | null>(null);
const [activationKey, setActivationKey] = useState<string | null>(null);
const [deploymentAction, setDeploymentAction] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(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 && (
<ActivateDeploymentDialog
service={activationService}
currentRevision={
services.find((service) => service.id === activationService.id && service.active)
?.revision ?? null
}
submitting={deploymentAction === serviceKey(activationService)}
onClose={() => setActivationKey(null)}
onConfirm={() => void activateService(activationService)}
/>
)}
{toast && (
<output className="toast">
<CheckCircle2 size={17} />
@@ -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={<Package size={18} />}
label="服务版本"
value={services.length.toString()}
note="来自 Wasmeld"
note={`${deploymentCount} 个对外服务`}
tone="cyan"
/>
<Metric
@@ -794,6 +855,9 @@ function Overview({
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
onActivate={onActivate}
canActivate={runtime?.status === "running"}
deploymentAction={deploymentAction}
compact
/>
</section>
@@ -832,6 +896,17 @@ function Overview({
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
<div className="detail-actions">
{!selectedService.active && (
<button
className="secondary-button"
type="button"
disabled={runtime?.status !== "running" || deploymentAction !== null}
onClick={() => onActivate(selectedService)}
>
<RadioTower size={16} />
</button>
)}
<button
className="secondary-button"
type="button"
@@ -908,6 +983,9 @@ function ServiceTable({
onSelect,
onInvoke,
onStatus,
onActivate,
canActivate,
deploymentAction,
compact = false,
}: {
services: Service[];
@@ -915,6 +993,9 @@ function ServiceTable({
onSelect: (service: Service) => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
compact?: boolean;
}) {
if (services.length === 0) {
@@ -959,7 +1040,15 @@ function ServiceTable({
<Box size={16} />
</span>
<span>
<span className="service-title">
<strong>{service.id}</strong>
{service.active && (
<span className="active-deployment-badge">
<RadioTower size={10} />
</span>
)}
</span>
<small>
{service.revision} · {service.description}
</small>
@@ -976,6 +1065,30 @@ function ServiceTable({
</td>
<td>
<div className="row-actions">
<button
className={service.active ? "active-deployment-button" : ""}
type="button"
aria-label={
service.active
? `${service.id}@${service.revision} 是当前对外版本`
: `${service.id}@${service.revision} 设为对外版本`
}
title={
service.active
? "当前对外版本"
: canActivate
? "设为对外版本"
: "请先启动 Runtime"
}
disabled={service.active || !canActivate || deploymentAction !== null}
onClick={() => onActivate(service)}
>
{deploymentAction === serviceKey(service) ? (
<RefreshCw className="spin" size={15} />
) : (
<RadioTower size={15} />
)}
</button>
{service.status === "running" ? (
<>
<button
@@ -1055,6 +1168,9 @@ function ServicesView({
onInvoke,
onSelect,
onStatus,
onActivate,
canActivate,
deploymentAction,
}: {
services: Service[];
statusFilter: "all" | ServiceStatus;
@@ -1063,13 +1179,16 @@ function ServicesView({
onInvoke: (service: Service) => void;
onSelect: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
}) {
return (
<>
<PageHeading
eyebrow="REGISTRY"
title="服务版本"
description="管理 Runtime 已注册的不可变 Wasm Component 制品。"
description="管理不可变 Wasm Component 制品及当前对外版本。"
actions={
<button className="primary-button" type="button" onClick={onDeploy}>
<CloudUpload size={17} />
@@ -1103,6 +1222,9 @@ function ServicesView({
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
onActivate={onActivate}
canActivate={canActivate}
deploymentAction={deploymentAction}
/>
</section>
</>
@@ -1435,12 +1557,14 @@ function DialogFrame({
description,
icon,
onClose,
closeDisabled = false,
children,
}: {
title: string;
description: string;
icon: ReactNode;
onClose: () => void;
closeDisabled?: boolean;
children: ReactNode;
}) {
return (
@@ -1449,9 +1573,12 @@ function DialogFrame({
open
className="dialog"
aria-label={title}
onCancel={onClose}
onCancel={(event) => {
if (closeDisabled) event.preventDefault();
else onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape") onClose();
if (event.key === "Escape" && !closeDisabled) onClose();
}}
>
<div className="dialog-header">
@@ -1467,6 +1594,7 @@ function DialogFrame({
type="button"
aria-label="关闭"
title="关闭"
disabled={closeDisabled}
onClick={onClose}
>
<X size={18} />
@@ -1478,6 +1606,56 @@ function DialogFrame({
);
}
function ActivateDeploymentDialog({
service,
currentRevision,
submitting,
onClose,
onConfirm,
}: {
service: Service;
currentRevision: string | null;
submitting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<DialogFrame
title="切换对外版本"
description="更新该服务在 Gateway 上接收请求的活动版本。"
icon={<RadioTower size={19} />}
onClose={onClose}
closeDisabled={submitting}
>
<div className="deployment-confirmation">
<div className="deployment-route">
<div>
<span></span>
<strong>{currentRevision ?? "尚未部署"}</strong>
</div>
<ArrowRight size={18} />
<div className="deployment-target">
<span></span>
<strong>{service.revision}</strong>
</div>
</div>
<p>
Wasmeld {service.id}@{service.revision} Actor
</p>
</div>
<div className="dialog-footer">
<button className="secondary-button" type="button" disabled={submitting} onClick={onClose}>
</button>
<button className="primary-button" type="button" disabled={submitting} onClick={onConfirm}>
{submitting ? <RefreshCw className="spin" size={16} /> : <RadioTower size={16} />}
{submitting ? "正在切换" : "确认切换"}
</button>
</div>
</DialogFrame>
);
}
function DeployDialog({
onClose,
onRegister,
+86 -2
View File
@@ -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;
}
+5 -2
View File
@@ -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, /<HeadContent \/>/);
assert.match(rootRoute, /<Scripts \/>/);
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"/);