Add "Cancel Operation" functionality for Edge Gateways with UI, API, and state management integration.

This commit is contained in:
Jeppe Bundgaard
2026-04-22 15:35:17 +02:00
parent dfea80034a
commit 1e21df7c68
7 changed files with 210 additions and 10 deletions
+115 -7
View File
@@ -724,15 +724,25 @@ function mergeEdgeGatewayFixtureRecord(baseGateway, overrides = {}) {
function createFixtureOperation(type, request = {}, overrides = {}) {
const startedAt = overrides.started_at || null;
const completedAt = overrides.completed_at || null;
const status = overrides.status || "PENDING";
return {
id: overrides.id || Date.now(),
type,
status: overrides.status || "PENDING",
status,
request,
summary: overrides.summary || {
label: overrides.status === "COMPLETED" ? "Completed" : overrides.status === "FAILED" ? "Failed" : "Queued",
progress: overrides.status === "COMPLETED" ? 100 : overrides.status === "FAILED" ? 100 : 0,
retryable: overrides.status !== "FAILED",
label:
status === "COMPLETED"
? "Completed"
: status === "FAILED"
? "Failed"
: status === "CANCELLED"
? "Cancelled"
: status === "CANCEL_REQUESTED"
? "Cancellation requested"
: "Queued",
progress: ["COMPLETED", "FAILED"].includes(status) ? 100 : 0,
retryable: status !== "FAILED",
},
result: overrides.result || {},
error_code: overrides.error_code || null,
@@ -752,6 +762,11 @@ function buildEdgeGatewayOperationSummary(operations = []) {
const status = String(operation.status || "PENDING");
if (status === "PENDING") summary.pending += 1;
if (status === "IN_PROGRESS") summary.in_progress += 1;
if (status === "CANCEL_REQUESTED") summary.cancel_requested += 1;
if (status === "CANCELLED") {
summary.cancelled += 1;
summary.latest_cancelled_at = summary.latest_cancelled_at || operation.completed_at || null;
}
if (status === "COMPLETED") {
summary.completed += 1;
summary.latest_completed_at = summary.latest_completed_at || operation.completed_at || null;
@@ -770,8 +785,11 @@ function buildEdgeGatewayOperationSummary(operations = []) {
total: operations.length,
pending: 0,
in_progress: 0,
cancel_requested: 0,
cancelled: 0,
completed: 0,
failed: 0,
latest_cancelled_at: null,
latest_completed_at: null,
latest_failed_at: null,
latest_type: null,
@@ -806,7 +824,7 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
const cloudRelays = relayHealth.filter((relay) => relay.execution_path === "cloud");
const operations = Array.isArray(gateway.operations) ? gateway.operations : [];
const activeOperation =
operations.find((operation) => ["PENDING", "IN_PROGRESS"].includes(String(operation.status))) || null;
operations.find((operation) => ["PENDING", "IN_PROGRESS", "CANCEL_REQUESTED"].includes(String(operation.status))) || null;
const versionDrift =
gateway.installed_version && gateway.target_version && gateway.installed_version !== gateway.target_version;
const credentialFreshnessState = gateway.metadata?.credentials_rotated_at ? "FRESH" : "UNKNOWN";
@@ -829,10 +847,14 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
const containerHealth = gateway.container_health ||
gateway.metadata?.container_health || {
state: gateway.status === "OFFLINE" ? "OFFLINE" : "ONLINE",
summary: gateway.status === "OFFLINE" ? "0/2 containers healthy" : "2/2 containers healthy",
summary: gateway.status === "OFFLINE" ? "0/6 containers healthy" : "6/6 containers healthy",
services: [
{ name: "edge-agent", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
{ name: "lan-worker", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
{ name: "redis", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
{ name: "mariadb", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
{ name: "minio", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
{ name: "auto-updater", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
],
};
const outboxStatus = gateway.outbox_status ||
@@ -931,7 +953,7 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
gateway.error_state ||
(diagnostics[0]
? { ...diagnostics[0] }
: activeOperation?.error_code
: activeOperation?.error_code && !["CANCEL_REQUESTED", "CANCELLED"].includes(String(activeOperation?.status || ""))
? { code: activeOperation.error_code, message: activeOperation.error_message }
: null),
};
@@ -2926,6 +2948,74 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return true;
}
if (/\/edge-gateways\/\d+\/operations\/\d+\/cancel$/.test(pathname) && method === "POST") {
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/operations\/\d+\/cancel$/);
const operationId = extractMatchId(/\/operations\/(\d+)\/cancel$/);
const gateway = findGateway(gatewayId);
const now = toSqlDateTime();
if (!gateway) {
await route.fulfill(json({ message: "Gateway not found" }, 404));
return true;
}
const operation = (gateway.operations || []).find((entry) => Number(entry.id) === Number(operationId)) || null;
if (!operation) {
await route.fulfill(json({ message: "Operation not found" }, 404));
return true;
}
if (operation.status === "IN_PROGRESS" || operation.status === "CANCEL_REQUESTED") {
operation.status = "CANCELLED";
operation.completed_at = now;
operation.updated_at = now;
operation.error_code = "EDGE_GATEWAY_CANCELLED";
operation.error_message = "Operation cancelled by operator";
operation.summary = {
...(operation.summary || {}),
label: "Cancelled",
retryable: true,
};
operation.events = [
...(operation.events || []),
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "WARNING",
code: "OPERATION_CANCELLED",
message: "Operation cancelled by operator",
created_at: now,
},
];
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
} else if (operation.status === "PENDING") {
operation.status = "CANCELLED";
operation.completed_at = now;
operation.updated_at = now;
operation.error_code = "EDGE_GATEWAY_CANCELLED";
operation.error_message = "Operation cancelled by operator";
operation.summary = {
...(operation.summary || {}),
label: "Cancelled",
retryable: true,
};
}
gateway.audit_logs = [
{ id: Date.now(), created_at: now, action: "GATEWAY_OPERATION_CANCELLED", actor_type: "USER" },
...(gateway.audit_logs || []),
];
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
await route.fulfill(
json({
data: {
operation: cloneJson(operation),
gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true),
},
})
);
return true;
}
if (/\/edge-gateways\/\d+\/operations$/.test(pathname) && method === "POST") {
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/operations$/);
const gateway = findGateway(gatewayId);
@@ -2935,6 +3025,24 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return true;
}
const activeOperation = (gateway.operations || []).find((entry) =>
["PENDING", "IN_PROGRESS", "CANCEL_REQUESTED"].includes(String(entry.status || ""))
);
if (activeOperation) {
await route.fulfill(
json(
{
data: {
message: "Another gateway operation is already active",
error_code: "EDGE_GATEWAY_CONFLICT",
},
},
409
)
);
return true;
}
const operationType = String(body.type || "").toUpperCase();
const operationRequest = body.request || {};
const now = toSqlDateTime();