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:
+22
-2
@@ -27,7 +27,7 @@ export type BackendService = {
|
|||||||
export type BackendEvent = {
|
export type BackendEvent = {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp_ms: number;
|
timestamp_ms: number;
|
||||||
kind: "registered" | "started" | "stopped" | "invoked" | "failed";
|
kind: "registered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
|
||||||
service_id: string | null;
|
service_id: string | null;
|
||||||
revision: string | null;
|
revision: string | null;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -41,6 +41,13 @@ export type BackendRuntime = {
|
|||||||
running_services: number;
|
running_services: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BackendDeployment = {
|
||||||
|
service_id: string;
|
||||||
|
active_revision: string;
|
||||||
|
status: BackendServiceStatus;
|
||||||
|
updated_at_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type BackendWitDependency = {
|
export type BackendWitDependency = {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -80,20 +87,33 @@ export function saveApiBase(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSnapshot(apiBase: 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<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
||||||
|
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
||||||
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
|
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
|
||||||
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
|
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
|
||||||
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
|
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
services: services.services,
|
services: services.services,
|
||||||
|
deployments: deployments.deployments,
|
||||||
events: events.events,
|
events: events.events,
|
||||||
runtime,
|
runtime,
|
||||||
witPackages: witPackages.packages,
|
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(
|
export async function registerComponent(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
input: RegisterComponentInput,
|
input: RegisterComponentInput,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
Play,
|
Play,
|
||||||
|
RadioTower,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
@@ -31,11 +32,13 @@ import {
|
|||||||
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BackendEvent,
|
BackendEvent,
|
||||||
|
BackendDeployment,
|
||||||
BackendRuntime,
|
BackendRuntime,
|
||||||
BackendService,
|
BackendService,
|
||||||
BackendWitPackage,
|
BackendWitPackage,
|
||||||
InvokeResult,
|
InvokeResult,
|
||||||
RegisterComponentInput,
|
RegisterComponentInput,
|
||||||
|
activateDeployment,
|
||||||
changeRuntimeState,
|
changeRuntimeState,
|
||||||
changeServiceState,
|
changeServiceState,
|
||||||
fetchSnapshot,
|
fetchSnapshot,
|
||||||
@@ -66,6 +69,7 @@ type Service = {
|
|||||||
calls: number;
|
calls: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeEvent = {
|
type RuntimeEvent = {
|
||||||
@@ -101,7 +105,7 @@ function serviceKey(service: Pick<Service, "id" | "revision">) {
|
|||||||
return `${service.id}@${service.revision}`;
|
return `${service.id}@${service.revision}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toService(service: BackendService): Service {
|
function toService(service: BackendService, activeRevision?: string): Service {
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
revision: service.revision,
|
revision: service.revision,
|
||||||
@@ -119,6 +123,7 @@ function toService(service: BackendService): Service {
|
|||||||
calls: service.calls,
|
calls: service.calls,
|
||||||
errors: service.errors,
|
errors: service.errors,
|
||||||
latencyMs: service.last_latency_ms,
|
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";
|
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
|
||||||
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
|
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
|
||||||
registered: { label: "已注册", tone: "success" },
|
registered: { label: "已注册", tone: "success" },
|
||||||
|
deployed: { label: "部署切换", tone: "success" },
|
||||||
started: { label: "已启动", tone: "success" },
|
started: { label: "已启动", tone: "success" },
|
||||||
stopped: { label: "已停止", tone: "neutral" },
|
stopped: { label: "已停止", tone: "neutral" },
|
||||||
invoked: { label: "调用完成", tone: "success" },
|
invoked: { label: "调用完成", tone: "success" },
|
||||||
@@ -202,6 +208,7 @@ function Home() {
|
|||||||
const [view, setView] = useState<View>("overview");
|
const [view, setView] = useState<View>("overview");
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||||
|
const [deployments, setDeployments] = useState<BackendDeployment[]>([]);
|
||||||
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
|
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
|
||||||
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
|
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
|
||||||
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
|
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
|
||||||
@@ -213,11 +220,15 @@ function Home() {
|
|||||||
const [deployOpen, setDeployOpen] = useState(false);
|
const [deployOpen, setDeployOpen] = useState(false);
|
||||||
const [witPublishOpen, setWitPublishOpen] = useState(false);
|
const [witPublishOpen, setWitPublishOpen] = useState(false);
|
||||||
const [invokeKey, setInvokeKey] = useState<string | null>(null);
|
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 [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedService =
|
const selectedService =
|
||||||
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
|
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
|
||||||
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
|
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
|
||||||
|
const activationService =
|
||||||
|
services.find((service) => serviceKey(service) === activationKey) ?? null;
|
||||||
|
|
||||||
const filteredServices = useMemo(() => {
|
const filteredServices = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
@@ -250,8 +261,17 @@ function Home() {
|
|||||||
const refreshSnapshot = useCallback(async () => {
|
const refreshSnapshot = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const snapshot = await fetchSnapshot(apiBase);
|
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);
|
setServices(nextServices);
|
||||||
|
setDeployments(snapshot.deployments);
|
||||||
setEvents(snapshot.events.map(toEvent));
|
setEvents(snapshot.events.map(toEvent));
|
||||||
setRuntime(snapshot.runtime);
|
setRuntime(snapshot.runtime);
|
||||||
setWitPackages(snapshot.witPackages);
|
setWitPackages(snapshot.witPackages);
|
||||||
@@ -265,6 +285,7 @@ function Home() {
|
|||||||
setConnection("online");
|
setConnection("online");
|
||||||
} catch {
|
} catch {
|
||||||
setServices([]);
|
setServices([]);
|
||||||
|
setDeployments([]);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
setRuntime(null);
|
setRuntime(null);
|
||||||
setWitPackages([]);
|
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) {
|
async function runInvocation(service: Service, input: Uint8Array) {
|
||||||
const result = await invokeComponent(apiBase, service, input);
|
const result = await invokeComponent(apiBase, service, input);
|
||||||
await refreshSnapshot();
|
await refreshSnapshot();
|
||||||
@@ -405,16 +441,19 @@ function Home() {
|
|||||||
running={running}
|
running={running}
|
||||||
faulted={faulted}
|
faulted={faulted}
|
||||||
totalCalls={totalCalls}
|
totalCalls={totalCalls}
|
||||||
|
deploymentCount={deployments.length}
|
||||||
runtime={runtime}
|
runtime={runtime}
|
||||||
selectedService={selectedService}
|
selectedService={selectedService}
|
||||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||||
onDeploy={() => setDeployOpen(true)}
|
onDeploy={() => setDeployOpen(true)}
|
||||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||||
onStatus={setServiceStatus}
|
onStatus={setServiceStatus}
|
||||||
|
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||||
onViewAll={() => setView("services")}
|
onViewAll={() => setView("services")}
|
||||||
onRefresh={() => void refreshSnapshot()}
|
onRefresh={() => void refreshSnapshot()}
|
||||||
onRuntimeAction={(action) => void setRuntimeStatus(action)}
|
onRuntimeAction={(action) => void setRuntimeStatus(action)}
|
||||||
runtimeAction={runtimeAction}
|
runtimeAction={runtimeAction}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -427,6 +466,9 @@ function Home() {
|
|||||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||||
onStatus={setServiceStatus}
|
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 && (
|
{toast && (
|
||||||
<output className="toast">
|
<output className="toast">
|
||||||
<CheckCircle2 size={17} />
|
<CheckCircle2 size={17} />
|
||||||
@@ -613,16 +668,19 @@ function Overview({
|
|||||||
running,
|
running,
|
||||||
faulted,
|
faulted,
|
||||||
totalCalls,
|
totalCalls,
|
||||||
|
deploymentCount,
|
||||||
runtime,
|
runtime,
|
||||||
selectedService,
|
selectedService,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDeploy,
|
onDeploy,
|
||||||
onInvoke,
|
onInvoke,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onActivate,
|
||||||
onViewAll,
|
onViewAll,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRuntimeAction,
|
onRuntimeAction,
|
||||||
runtimeAction,
|
runtimeAction,
|
||||||
|
deploymentAction,
|
||||||
}: {
|
}: {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
filteredServices: Service[];
|
filteredServices: Service[];
|
||||||
@@ -630,16 +688,19 @@ function Overview({
|
|||||||
running: number;
|
running: number;
|
||||||
faulted: number;
|
faulted: number;
|
||||||
totalCalls: number;
|
totalCalls: number;
|
||||||
|
deploymentCount: number;
|
||||||
runtime: BackendRuntime | null;
|
runtime: BackendRuntime | null;
|
||||||
selectedService: Service | null;
|
selectedService: Service | null;
|
||||||
onSelect: (service: Service) => void;
|
onSelect: (service: Service) => void;
|
||||||
onDeploy: () => void;
|
onDeploy: () => void;
|
||||||
onInvoke: (service: Service) => void;
|
onInvoke: (service: Service) => void;
|
||||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||||
|
onActivate: (service: Service) => void;
|
||||||
onViewAll: () => void;
|
onViewAll: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
|
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
|
||||||
runtimeAction: "start" | "stop" | "restart" | null;
|
runtimeAction: "start" | "stop" | "restart" | null;
|
||||||
|
deploymentAction: string | null;
|
||||||
}) {
|
}) {
|
||||||
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
|
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
|
||||||
const latencies = services
|
const latencies = services
|
||||||
@@ -677,7 +738,7 @@ function Overview({
|
|||||||
icon={<Package size={18} />}
|
icon={<Package size={18} />}
|
||||||
label="服务版本"
|
label="服务版本"
|
||||||
value={services.length.toString()}
|
value={services.length.toString()}
|
||||||
note="来自 Wasmeld"
|
note={`${deploymentCount} 个对外服务`}
|
||||||
tone="cyan"
|
tone="cyan"
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
@@ -794,6 +855,9 @@ function Overview({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onInvoke={onInvoke}
|
onInvoke={onInvoke}
|
||||||
onStatus={onStatus}
|
onStatus={onStatus}
|
||||||
|
onActivate={onActivate}
|
||||||
|
canActivate={runtime?.status === "running"}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -832,6 +896,17 @@ function Overview({
|
|||||||
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
|
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
|
||||||
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
|
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
|
||||||
<div className="detail-actions">
|
<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
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -908,6 +983,9 @@ function ServiceTable({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onInvoke,
|
onInvoke,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onActivate,
|
||||||
|
canActivate,
|
||||||
|
deploymentAction,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
@@ -915,6 +993,9 @@ function ServiceTable({
|
|||||||
onSelect: (service: Service) => void;
|
onSelect: (service: Service) => void;
|
||||||
onInvoke: (service: Service) => void;
|
onInvoke: (service: Service) => void;
|
||||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||||
|
onActivate: (service: Service) => void;
|
||||||
|
canActivate: boolean;
|
||||||
|
deploymentAction: string | null;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (services.length === 0) {
|
if (services.length === 0) {
|
||||||
@@ -959,7 +1040,15 @@ function ServiceTable({
|
|||||||
<Box size={16} />
|
<Box size={16} />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
|
<span className="service-title">
|
||||||
<strong>{service.id}</strong>
|
<strong>{service.id}</strong>
|
||||||
|
{service.active && (
|
||||||
|
<span className="active-deployment-badge">
|
||||||
|
<RadioTower size={10} />
|
||||||
|
对外
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<small>
|
<small>
|
||||||
{service.revision} · {service.description}
|
{service.revision} · {service.description}
|
||||||
</small>
|
</small>
|
||||||
@@ -976,6 +1065,30 @@ function ServiceTable({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row-actions">
|
<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" ? (
|
{service.status === "running" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1055,6 +1168,9 @@ function ServicesView({
|
|||||||
onInvoke,
|
onInvoke,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onActivate,
|
||||||
|
canActivate,
|
||||||
|
deploymentAction,
|
||||||
}: {
|
}: {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
statusFilter: "all" | ServiceStatus;
|
statusFilter: "all" | ServiceStatus;
|
||||||
@@ -1063,13 +1179,16 @@ function ServicesView({
|
|||||||
onInvoke: (service: Service) => void;
|
onInvoke: (service: Service) => void;
|
||||||
onSelect: (service: Service) => void;
|
onSelect: (service: Service) => void;
|
||||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||||
|
onActivate: (service: Service) => void;
|
||||||
|
canActivate: boolean;
|
||||||
|
deploymentAction: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="REGISTRY"
|
eyebrow="REGISTRY"
|
||||||
title="服务版本"
|
title="服务版本"
|
||||||
description="管理 Runtime 已注册的不可变 Wasm Component 制品。"
|
description="管理不可变 Wasm Component 制品及当前对外版本。"
|
||||||
actions={
|
actions={
|
||||||
<button className="primary-button" type="button" onClick={onDeploy}>
|
<button className="primary-button" type="button" onClick={onDeploy}>
|
||||||
<CloudUpload size={17} />
|
<CloudUpload size={17} />
|
||||||
@@ -1103,6 +1222,9 @@ function ServicesView({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onInvoke={onInvoke}
|
onInvoke={onInvoke}
|
||||||
onStatus={onStatus}
|
onStatus={onStatus}
|
||||||
|
onActivate={onActivate}
|
||||||
|
canActivate={canActivate}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
@@ -1435,12 +1557,14 @@ function DialogFrame({
|
|||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
onClose,
|
onClose,
|
||||||
|
closeDisabled = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
closeDisabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1449,9 +1573,12 @@ function DialogFrame({
|
|||||||
open
|
open
|
||||||
className="dialog"
|
className="dialog"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onCancel={onClose}
|
onCancel={(event) => {
|
||||||
|
if (closeDisabled) event.preventDefault();
|
||||||
|
else onClose();
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") onClose();
|
if (event.key === "Escape" && !closeDisabled) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="dialog-header">
|
<div className="dialog-header">
|
||||||
@@ -1467,6 +1594,7 @@ function DialogFrame({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
|
disabled={closeDisabled}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<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({
|
function DeployDialog({
|
||||||
onClose,
|
onClose,
|
||||||
onRegister,
|
onRegister,
|
||||||
|
|||||||
@@ -785,7 +785,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-table th:nth-last-child(1) {
|
.data-table th:nth-last-child(1) {
|
||||||
width: 122px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table td {
|
.data-table td {
|
||||||
@@ -849,6 +849,33 @@ button:disabled {
|
|||||||
font-size: 11px;
|
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 {
|
.service-cell small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
@@ -917,7 +944,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
min-width: 100px;
|
min-width: 128px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -943,6 +970,14 @@ button:disabled {
|
|||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-actions .active-deployment-button:disabled {
|
||||||
|
color: #197253;
|
||||||
|
background: #e7f4ef;
|
||||||
|
border-color: #c6e4d9;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.event-list {
|
.event-list {
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
}
|
}
|
||||||
@@ -1094,6 +1129,7 @@ button:disabled {
|
|||||||
.detail-actions {
|
.detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-panel {
|
.full-panel {
|
||||||
@@ -1597,6 +1633,54 @@ button:disabled {
|
|||||||
margin: 18px -18px -18px;
|
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 {
|
.invoke-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,15 +77,16 @@ test("server-renders the Wasm management console", async () => {
|
|||||||
assert.match(html, /运行概览/);
|
assert.match(html, /运行概览/);
|
||||||
assert.match(html, /WIT 包/);
|
assert.match(html, /WIT 包/);
|
||||||
assert.match(html, /连接中/);
|
assert.match(html, /连接中/);
|
||||||
assert.match(html, /来自 Wasmeld/);
|
assert.match(html, /0 个对外服务/);
|
||||||
assert.match(html, /Component Model/);
|
assert.match(html, /Component Model/);
|
||||||
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
|
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses TanStack Start routing and produces Node artifacts", async () => {
|
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/__root.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/routes/index.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("../src/router.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../vite.config.ts", 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, /<HeadContent \/>/);
|
||||||
assert.match(rootRoute, /<Scripts \/>/);
|
assert.match(rootRoute, /<Scripts \/>/);
|
||||||
assert.match(indexRoute, /createFileRoute\("\/"\)/);
|
assert.match(indexRoute, /createFileRoute\("\/"\)/);
|
||||||
|
assert.match(indexRoute, /切换对外版本/);
|
||||||
|
assert.match(apiClient, /\/api\/v1\/deployments/);
|
||||||
assert.match(router, /createRouter/);
|
assert.match(router, /createRouter/);
|
||||||
assert.match(packageJson, /"@tanstack\/react-start"/);
|
assert.match(packageJson, /"@tanstack\/react-start"/);
|
||||||
assert.match(packageJson, /"srvx"/);
|
assert.match(packageJson, /"srvx"/);
|
||||||
|
|||||||
Reference in New Issue
Block a user