From 71b8272eb82a46921f4f2ca8715e2b2db59f4a75 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 15 Apr 2026 11:53:45 +0200 Subject: [PATCH 1/4] Add tests and functionality for edge gateway updates and lifecycle This commit introduces unit tests, E2E tests, and implementation updates related to edge gateway lifecycle management, including update handling, artifact validation, and rollback mechanisms. It also refines routing, component interaction, and backend methods to improve update tracking, status transitions, and artifact management. --- .../edgeGateway/EdgeGatewayTerminal.vue | 15 +- .../edgeGateway/EdgeGatewayWorkspace.vue | 385 +++++++++++++++--- src/router.js | 6 + .../superUserDashboard/EdgeGateways.vue | 22 +- .../department/DepartmentGateways.vue | 21 +- tests/e2e/edge-gateways.routes.spec.js | 96 +++++ tests/e2e/edge-gateways.smoke.spec.js | 25 +- .../release/edge-gateways.live-smoke.spec.ts | 52 +++ tests/e2e/support/network.js | 44 +- tests/unit/edge-gateway-workspace.spec.js | 32 +- 10 files changed, 619 insertions(+), 79 deletions(-) create mode 100644 tests/e2e/edge-gateways.routes.spec.js create mode 100644 tests/e2e/release/edge-gateways.live-smoke.spec.ts diff --git a/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue b/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue index 3d0e8f01..dbb7c10b 100644 --- a/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue +++ b/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue @@ -245,7 +245,7 @@ watch(terminalHost, (element) => { diff --git a/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue b/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue index 796655d5..5cbd18ed 100644 --- a/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue +++ b/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue @@ -18,8 +18,14 @@ const props = defineProps({ type: Number, default: null, }, + selectedGatewayId: { + type: [Number, String], + default: null, + }, }); +const emit = defineEmits(["open-gateway-page", "select-gateway"]); + const STATUS_META = { ONLINE: { label: "Online", @@ -56,10 +62,23 @@ const DISCOVERY_STATUS_LABELS = { UNKNOWN: "Ukendt", }; +const UPDATE_STATUS_LABELS = { + PENDING: "Afventer", + DISPATCHING: "Udsendt", + VERIFYING: "Verificerer genstart", + COMPLETED: "Fuldført", + FAILED: "Fejlet", + ROLLED_BACK: "Rullet tilbage", + UNKNOWN: "Ukendt", +}; + +const ACTIVE_UPDATE_JOB_STATUSES = ["PENDING", "DISPATCHING", "VERIFYING"]; + const gateways = ref([]); const departments = ref([]); -const selectedGatewayId = ref(null); +const activeGatewayId = ref(null); const selectedGateway = ref(null); +const unavailableGatewayId = ref(null); const fleetQuery = ref(""); const fleetFilter = ref("ALL"); @@ -69,6 +88,8 @@ const installerDepartmentId = ref(props.departmentId ? String(props.departmentId const installerLabel = ref(""); const installerCommand = ref(""); const installerMeta = ref(null); +const rotatedAgentToken = ref(""); +const rotationConfirmationOpen = ref(false); const editableBindings = ref([]); const targetVersion = ref(""); @@ -97,6 +118,17 @@ const GATEWAY_REFRESH_WINDOW_MS = 25_000; const unwrapData = (response, fallback = null) => response?.data?.data ?? fallback; const departmentScoped = computed(() => props.departmentId !== null && props.departmentId !== undefined); +const normalizeGatewayId = (value) => { + if (value === null || value === undefined) { + return null; + } + + const normalized = String(value).trim(); + return normalized === "" ? null : normalized; +}; + +const routeSelectedGatewayId = computed(() => normalizeGatewayId(props.selectedGatewayId)); +const routeSelectionActive = computed(() => routeSelectedGatewayId.value !== null); const departmentsById = computed(() => departments.value.reduce((accumulator, department) => { @@ -316,7 +348,22 @@ const buildGatewayView = (gateway) => { const statusMeta = getStatusMeta(gateway.status); const inventory = Array.isArray(gateway.inventory) ? gateway.inventory : []; const bindings = Array.isArray(gateway.bindings) ? gateway.bindings : []; - const recentUpdates = Array.isArray(gateway.recent_updates) ? gateway.recent_updates : []; + const recentUpdates = (Array.isArray(gateway.recent_updates) ? gateway.recent_updates : []).map((job) => { + const result = job?.result && typeof job.result === "object" ? job.result : {}; + const status = job?.status ?? "UNKNOWN"; + return { + ...job, + result, + statusLabel: UPDATE_STATUS_LABELS[status] ?? status ?? UPDATE_STATUS_LABELS.UNKNOWN, + helper: + result?.error || + (status === "VERIFYING" + ? "Den nye agent er installeret og venter på at bekræfte genstarten." + : status === "ROLLED_BACK" + ? "Agenten blev rullet tilbage efter en fejl under opdateringen." + : ""), + }; + }); const recentShellSessions = Array.isArray(gateway.recent_shell_sessions) ? gateway.recent_shell_sessions : []; const recentCommands = Array.isArray(gateway.recent_commands) ? gateway.recent_commands : []; const auditLogs = Array.isArray(gateway.audit_logs) ? gateway.audit_logs : []; @@ -412,14 +459,25 @@ const selectedGatewayView = computed(() => { return buildGatewayView(selectedGateway.value); } - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return null; } - const gateway = gateways.value.find((candidate) => Number(candidate.id) === Number(selectedGatewayId.value)); + const gateway = gateways.value.find((candidate) => Number(candidate.id) === Number(activeGatewayId.value)); return buildGatewayView(gateway); }); +const unavailableSelectionState = computed(() => { + if (!routeSelectionActive.value || unavailableGatewayId.value === null) { + return null; + } + + return { + title: "Gatewayen er ikke tilgængelig", + description: `Gateway ${unavailableGatewayId.value} findes ikke i din tilgængelige flåde eller kunne ikke indlæses.`, + }; +}); + const selectedDepartmentId = computed(() => { if (departmentScoped.value) { return props.departmentId; @@ -512,6 +570,11 @@ const syncBindingsFromSelection = (gateway) => { editableBindings.value = (gateway?.bindings ?? []).map((binding) => normalizeBinding(binding)); }; +const resetRotationState = () => { + rotatedAgentToken.value = ""; + rotationConfirmationOpen.value = false; +}; + const syncMutableStateFromSelection = (gateway) => { if (!gateway) { editableBindings.value = []; @@ -565,13 +628,34 @@ const clearMessages = () => { errorMessage.value = ""; }; +const copyTextToClipboard = async (value) => { + if (!value) { + return; + } + + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const clipboard = document.createElement("textarea"); + clipboard.value = value; + clipboard.setAttribute("readonly", "readonly"); + clipboard.style.position = "absolute"; + clipboard.style.left = "-9999px"; + document.body.appendChild(clipboard); + clipboard.select(); + document.execCommand("copy"); + document.body.removeChild(clipboard); +}; + const gatewayHasQueuedWork = (gateway) => { if (!gateway) { return false; } const pendingUpdate = (gateway.recent_updates ?? []).some((job) => - ["PENDING", "DISPATCHING"].includes(job?.status) + ACTIVE_UPDATE_JOB_STATUSES.includes(job?.status) ); const pendingCommand = (gateway.recent_commands ?? []).some((job) => ["PENDING", "DISPATCHING"].includes(job?.status) @@ -593,19 +677,19 @@ const stopGatewayRefreshLoop = () => { }; const startGatewayRefreshLoop = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } stopGatewayRefreshLoop(); const refresh = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { stopGatewayRefreshLoop(); return; } - await loadGateway(selectedGatewayId.value); + await loadGateway(activeGatewayId.value); if (!gatewayHasQueuedWork(selectedGateway.value)) { stopGatewayRefreshLoop(); @@ -676,11 +760,34 @@ const resolveEdgeGatewayErrorMessage = (error, fallbackMessage) => { return message; }; -const selectGateway = async (gatewayId) => { +const openGatewayPage = () => { + if (!selectedGatewayView.value?.id) { + return; + } + + emit("open-gateway-page", selectedGatewayView.value.id); +}; + +const selectGatewayLocally = async (gatewayId) => { + clearMessages(); stopGatewayRefreshLoop(); - selectedGatewayId.value = gatewayId; + activeGatewayId.value = normalizeGatewayId(gatewayId); + unavailableGatewayId.value = null; + resetRotationState(); fleetDrawerOpen.value = false; - await loadGateway(gatewayId); + await loadGateway(activeGatewayId.value); +}; + +const selectGateway = async (gatewayId) => { + const normalizedGatewayId = normalizeGatewayId(gatewayId); + emit("select-gateway", normalizedGatewayId); + + if (routeSelectionActive.value) { + fleetDrawerOpen.value = false; + return; + } + + await selectGatewayLocally(normalizedGatewayId); }; const loadDepartments = async () => { @@ -690,6 +797,7 @@ const loadDepartments = async () => { const loadGateway = async (gatewayId) => { if (!gatewayId) { + unavailableGatewayId.value = null; selectedGateway.value = null; syncMutableStateFromSelection(null); return; @@ -699,14 +807,30 @@ const loadGateway = async (gatewayId) => { try { const response = await getEdgeGateway(gatewayId); const gateway = unwrapData(response, null); + if (!gateway) { + throw new Error("Gateway unavailable"); + } + + unavailableGatewayId.value = null; selectedGateway.value = gateway; mergeGatewayIntoList(gateway); syncMutableStateFromSelection(gateway); } catch (error) { - const fallbackGateway = gateways.value.find((candidate) => Number(candidate.id) === Number(gatewayId)); - selectedGateway.value = fallbackGateway ?? null; - syncMutableStateFromSelection(fallbackGateway); - errorMessage.value = "Kunne ikke hente gateway-detaljer. Viser seneste kendte data."; + if (routeSelectionActive.value && String(routeSelectedGatewayId.value) === String(gatewayId)) { + unavailableGatewayId.value = gatewayId; + selectedGateway.value = null; + syncMutableStateFromSelection(null); + errorMessage.value = resolveEdgeGatewayErrorMessage( + error, + "Den valgte gateway er ikke tilgængelig eller kunne ikke indlæses." + ); + } else { + const fallbackGateway = gateways.value.find((candidate) => Number(candidate.id) === Number(gatewayId)); + unavailableGatewayId.value = null; + selectedGateway.value = fallbackGateway ?? null; + syncMutableStateFromSelection(fallbackGateway); + errorMessage.value = "Kunne ikke hente gateway-detaljer. Viser seneste kendte data."; + } } finally { detailLoading.value = false; } @@ -721,17 +845,22 @@ const loadFleet = async () => { if (!gateways.value.length) { stopGatewayRefreshLoop(); - selectedGatewayId.value = null; selectedGateway.value = null; + activeGatewayId.value = routeSelectionActive.value ? routeSelectedGatewayId.value : null; + unavailableGatewayId.value = routeSelectionActive.value ? routeSelectedGatewayId.value : null; syncMutableStateFromSelection(null); return; } - const candidateId = - gateways.value.find((gateway) => Number(gateway.id) === Number(selectedGatewayId.value))?.id ?? - gateways.value[0]?.id; + if (routeSelectionActive.value) { + activeGatewayId.value = routeSelectedGatewayId.value; + await loadGateway(routeSelectedGatewayId.value); + return; + } - selectedGatewayId.value = candidateId; + const candidateId = gateways.value.find((gateway) => Number(gateway.id) === Number(activeGatewayId.value))?.id ?? gateways.value[0]?.id; + activeGatewayId.value = candidateId; + unavailableGatewayId.value = null; await loadGateway(candidateId); }; @@ -886,7 +1015,7 @@ const generateInstaller = async () => { installerCommand.value = installerMeta.value?.install_command ?? ""; flashMessage.value = `Installationskommando oprettet for ${installerDepartmentName.value}.`; } catch (error) { - errorMessage.value = "Kunne ikke generere installationskommando."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke generere installationskommando."); } finally { installerLoading.value = false; } @@ -898,20 +1027,7 @@ const copyInstallerCommand = async () => { } try { - if (navigator?.clipboard?.writeText) { - await navigator.clipboard.writeText(installerCommand.value); - } else { - const clipboard = document.createElement("textarea"); - clipboard.value = installerCommand.value; - clipboard.setAttribute("readonly", "readonly"); - clipboard.style.position = "absolute"; - clipboard.style.left = "-9999px"; - document.body.appendChild(clipboard); - clipboard.select(); - document.execCommand("copy"); - document.body.removeChild(clipboard); - } - + await copyTextToClipboard(installerCommand.value); flashMessage.value = "Installationskommando kopieret."; } catch (error) { errorMessage.value = "Kunne ikke kopiere installationskommandoen."; @@ -919,7 +1035,7 @@ const copyInstallerCommand = async () => { }; const queueDiscovery = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } @@ -927,7 +1043,7 @@ const queueDiscovery = async () => { discoveryLoading.value = true; try { - const response = await triggerEdgeGatewayDiscovery(selectedGatewayId.value); + const response = await triggerEdgeGatewayDiscovery(activeGatewayId.value); const gateway = unwrapData(response, null); if (gateway) { selectedGateway.value = gateway; @@ -944,7 +1060,7 @@ const queueDiscovery = async () => { }; const rotateCredentials = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } @@ -952,23 +1068,40 @@ const rotateCredentials = async () => { rotationLoading.value = true; try { - const response = await rotateEdgeGatewayCredentials(selectedGatewayId.value); + const response = await rotateEdgeGatewayCredentials(activeGatewayId.value); const payload = unwrapData(response, {}); if (payload?.gateway) { selectedGateway.value = payload.gateway; mergeGatewayIntoList(payload.gateway); syncMutableStateFromSelection(payload.gateway); } - flashMessage.value = "Gateway-legitimationsoplysninger er roteret."; + rotatedAgentToken.value = payload?.agent_token ?? ""; + rotationConfirmationOpen.value = false; + flashMessage.value = rotatedAgentToken.value + ? "Gateway-legitimationsoplysninger er roteret. Gem den nye agent-token nu." + : "Gateway-legitimationsoplysninger er roteret."; } catch (error) { - errorMessage.value = "Kunne ikke rotere legitimationsoplysninger."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke rotere legitimationsoplysninger."); } finally { rotationLoading.value = false; } }; +const copyRotatedCredential = async () => { + if (!rotatedAgentToken.value) { + return; + } + + try { + await copyTextToClipboard(rotatedAgentToken.value); + flashMessage.value = "Den nye agent-token er kopieret."; + } catch (error) { + errorMessage.value = "Kunne ikke kopiere den nye agent-token."; + } +}; + const saveBindings = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } @@ -984,7 +1117,7 @@ const saveBindings = async () => { binding_source: binding.binding_source ?? "MANUAL", })); - const response = await saveEdgeGatewayBindings(selectedGatewayId.value, payload); + const response = await saveEdgeGatewayBindings(activeGatewayId.value, payload); const bindings = unwrapData(response, []) ?? []; selectedGateway.value = { @@ -996,14 +1129,14 @@ const saveBindings = async () => { syncBindingsFromSelection(selectedGateway.value); flashMessage.value = "Bindinger gemt."; } catch (error) { - errorMessage.value = "Kunne ikke gemme bindingerne."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke gemme bindingerne."); } finally { bindingSaveLoading.value = false; } }; const queueUpdate = async () => { - if (!selectedGatewayId.value || !targetVersion.value.trim()) { + if (!activeGatewayId.value || !targetVersion.value.trim()) { return; } @@ -1011,7 +1144,7 @@ const queueUpdate = async () => { updateLoading.value = true; try { - const response = await createEdgeGatewayUpdateJob(selectedGatewayId.value, { + const response = await createEdgeGatewayUpdateJob(activeGatewayId.value, { target_version: targetVersion.value.trim(), }); const updateJob = unwrapData(response, null); @@ -1063,7 +1196,7 @@ const applyCutoverMode = async () => { flashMessage.value = "Transporttilstand opdateret for afdelingen."; } catch (error) { - errorMessage.value = "Kunne ikke opdatere transporttilstanden."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke opdatere transporttilstanden."); } finally { cutoverLoading.value = false; } @@ -1073,10 +1206,32 @@ watch( () => props.departmentId, async (departmentId) => { installerDepartmentId.value = departmentId ? String(departmentId) : ""; + resetRotationState(); await initializeWorkspace(); } ); +watch( + () => routeSelectedGatewayId.value, + async (gatewayId, previousGatewayId) => { + if (String(gatewayId ?? "") === String(previousGatewayId ?? "")) { + return; + } + + if (!gatewayId) { + unavailableGatewayId.value = null; + return; + } + + clearMessages(); + stopGatewayRefreshLoop(); + activeGatewayId.value = gatewayId; + unavailableGatewayId.value = null; + resetRotationState(); + await loadGateway(gatewayId); + } +); + watch( () => selectedGatewayView.value?.department_id, (departmentId) => { @@ -1103,21 +1258,22 @@ onBeforeUnmount(() => {