From ea8db593c5ac37343bc4dd097316817435e7d7fb Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 20 May 2026 17:52:54 +0200 Subject: [PATCH] Add release headers service, runtime-aware API header handling, and i18n updates for release configuration - Implement `releaseHeaders.js` to manage release-related HTTP headers. - Update requests to include runtime-based release headers. - Extend i18n phrases across multiple locales for release runtime and configuration labels. - Enhance unit tests for header building and API availability error handling. --- .../release/ReleaseChannelSelector.vue | 1 + .../release/ReleaseChannelUnavailable.vue | 1 + .../session/authenticatedRequest.vue | 10 +- src/components/session/token/SessionUser.vue | 14 + .../session/unauthenticatedRequest.vue | 3 +- src/i18n/generated/da-v2.json | 18 +- src/i18n/generated/de-v2.json | 18 +- src/i18n/generated/en-v2.json | 18 +- src/i18n/generated/global-v2.json | 14 +- src/i18n/generated/no-v2.json | 18 +- src/i18n/generated/sv-v2.json | 18 +- src/i18n/locales/da.json | 18 +- src/i18n/locales/de.json | 18 +- src/i18n/locales/en.json | 18 +- src/i18n/locales/no.json | 18 +- src/i18n/locales/sv.json | 18 +- .../phrases/compat/configuration/index.json | 18 +- .../phrases/compat/configuration/index.json | 18 +- .../phrases/compat/configuration/index.json | 18 +- .../global/shared/configuration/index.json | 14 +- .../phrases/compat/configuration/index.json | 18 +- .../phrases/compat/configuration/index.json | 18 +- src/services/installAxiosRequestQueue.js | 8 +- src/services/releaseBootstrap.js | 48 +- src/services/releaseChannelAvailability.js | 77 +- src/services/releaseHeaders.js | 61 + src/services/releaseTimeline.js | 15 +- src/services/superuserCoolify.js | 44 +- src/services/superuserReleases.js | 2 +- .../configuration/ConfigurationCoolify.vue | 115 +- .../ConfigurationReleaseManager.vue | 1684 ++++++++++++++++- tests/e2e/coolify-infrastructure.spec.js | 168 +- tests/e2e/release-bootstrap.spec.js | 29 +- tests/e2e/release-channel-unavailable.spec.js | 134 +- tests/e2e/release-manager.spec.js | 399 +++- tests/e2e/release/release.live-smoke.spec.ts | 13 + tests/e2e/support/network.js | 6 + tests/unit/axios-request-queue.spec.js | 11 +- tests/unit/release-bootstrap.spec.js | 54 +- .../unit/release-channel-availability.spec.js | 39 + tests/unit/release-manager-i18n.spec.js | 80 + tests/unit/release-timeline.spec.js | 31 +- tests/unit/superuser-releases.spec.js | 17 + 43 files changed, 3191 insertions(+), 171 deletions(-) create mode 100644 src/services/releaseHeaders.js diff --git a/src/components/release/ReleaseChannelSelector.vue b/src/components/release/ReleaseChannelSelector.vue index d5ab60ef..5f75c0cc 100644 --- a/src/components/release/ReleaseChannelSelector.vue +++ b/src/components/release/ReleaseChannelSelector.vue @@ -54,6 +54,7 @@ const missingLabel = (key) => { if (key === "frontend_base_url") return tr("frontend_base_url", "Frontend URL"); if (key === "api_base_url") return tr("api_base_url", "API URL"); if (key === "frontend_entry") return tr("frontend_entry", "Frontend entry"); + if (key === "release_runtime") return tr("release_runtime", "Release runtime"); return key; }; diff --git a/src/components/release/ReleaseChannelUnavailable.vue b/src/components/release/ReleaseChannelUnavailable.vue index 3c97a9dc..ff4b9657 100644 --- a/src/components/release/ReleaseChannelUnavailable.vue +++ b/src/components/release/ReleaseChannelUnavailable.vue @@ -40,6 +40,7 @@ const missingLabels = computed(() => if (key === "frontend_base_url") return tr("frontend_base_url"); if (key === "api_base_url") return tr("api_base_url"); if (key === "frontend_entry") return tr("frontend_entry"); + if (key === "release_runtime") return tr("release_runtime"); return key; }) ); diff --git a/src/components/session/authenticatedRequest.vue b/src/components/session/authenticatedRequest.vue index 7a108759..a394c8ae 100644 --- a/src/components/session/authenticatedRequest.vue +++ b/src/components/session/authenticatedRequest.vue @@ -1,7 +1,7 @@ diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index a1701915..03ac667e 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -2569,10 +2569,14 @@ "kicker": "Release Manager", "title": "Release-kanalen er ikke klar", "summary_prefix": "Din konto er tildelt", - "summary_suffix": ", men kanalen mangler et aktivt release-bundle.", + "summary_suffix": ", men kanalen mangler påkrævet release-konfiguration.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Tjekker igen om {seconds}s", "refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.", "ignore": "Ignorer de næste 5 minutter", @@ -2605,7 +2609,14 @@ "unavailable": "Ikke klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", - "api_version": "API-version" + "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -2622,6 +2633,7 @@ "remove": "Fjern", "test_access": "Test adgang", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promover", "enable": "Aktiver", "search": "Søg", @@ -2865,7 +2877,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domæne", - "ssl_domain_message": "Skal være et DNS-domæne, der routes til Coolify load balanceren. Eksempel: lb.truckwash.io", + "ssl_domain_message": "Skal være et DNS-domæne, der routes til Coolify load balanceren. Eksempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domæne", "https_domain_for_coolify": "Domæne kontrolleret af load balanceren", "auto_deploy": "Auto deploy", diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 30ccf3a4..5f429b18 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -2679,10 +2679,14 @@ "kicker": "Release Manager", "title": "Release-Kanal ist nicht bereit", "summary_prefix": "Dein Konto ist zugewiesen zu", - "summary_suffix": ", aber diesem Kanal fehlt ein aktives Release-Bundle.", + "summary_suffix": ", aber diesem Kanal fehlt erforderliche Release-Konfiguration.", "release_bundle": "Release-Bundle", "frontend_version": "Frontend-Version", "api_version": "API-Version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-Einstiegspunkt", + "release_runtime": "Release-Laufzeit", "checking_again": "Erneute Prüfung in {seconds}s", "refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.", "ignore": "Nächste 5 Minuten ignorieren", @@ -2715,7 +2719,14 @@ "unavailable": "Nicht bereit", "release_bundle": "Release-Bundle", "frontend_version": "Frontend-Version", - "api_version": "API-Version" + "api_version": "API-Version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-Einstiegspunkt", + "release_runtime": "Release-Laufzeit", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -2732,6 +2743,7 @@ "remove": "Entfernen", "test_access": "Zugriff testen", "deploy": "Deployen", + "redeploy": "Erneut deployen", "promote": "Promoten", "enable": "Aktivieren", "search": "Suchen", @@ -2975,7 +2987,7 @@ "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", "ssl_domain": "Load-Balancer-Domain", - "ssl_domain_message": "Muss eine DNS-Domain sein, die zum Coolify Load Balancer geroutet wird. Beispiel: lb.truckwash.io", + "ssl_domain_message": "Muss eine DNS-Domain sein, die zum Coolify Load Balancer geroutet wird. Beispiel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load-Balancer-Domain", "https_domain_for_coolify": "Domain unter Kontrolle des Load Balancers", "auto_deploy": "Auto-Deploy", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 7a89637e..677734b8 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -2403,10 +2403,14 @@ "kicker": "Release Manager", "title": "Release channel is not ready", "summary_prefix": "Your account is assigned to", - "summary_suffix": ", but that channel is missing an active release bundle.", + "summary_suffix": ", but that channel is missing required release configuration.", "release_bundle": "Release bundle", "frontend_version": "Frontend version", "api_version": "API version", + "frontend_base_url": "Frontend URL", + "api_base_url": "API URL", + "frontend_entry": "Frontend entry", + "release_runtime": "Release runtime", "checking_again": "Checking again in {seconds}s", "refresh_error": "Release status could not be refreshed. The next automatic check will try again.", "ignore": "Ignore next 5 minutes", @@ -2439,7 +2443,14 @@ "unavailable": "Not ready", "release_bundle": "Release bundle", "frontend_version": "Frontend version", - "api_version": "API version" + "api_version": "API version", + "frontend_base_url": "Frontend URL", + "api_base_url": "API URL", + "frontend_entry": "Frontend entry", + "release_runtime": "Release runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "yes", @@ -2456,6 +2467,7 @@ "remove": "Remove", "test_access": "Test access", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promote", "enable": "Enable", "search": "Search", @@ -2699,7 +2711,7 @@ "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", "ssl_domain": "Load balancer domain", - "ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: lb.truckwash.io", + "ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer domain", "https_domain_for_coolify": "Domain controlled by the load balancer", "auto_deploy": "Auto deploy", diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index d0993073..ea3f3c53 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -1632,6 +1632,10 @@ "release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_bundle'}", "frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_version'}", "api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_version'}", + "frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_base_url'}", + "api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_base_url'}", + "frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_entry'}", + "release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_runtime'}", "checking_again": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.checking_again'}", "refresh_error": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.refresh_error'}", "ignore": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.ignore'}", @@ -1664,7 +1668,14 @@ "unavailable": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.unavailable'}", "release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_bundle'}", "frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_version'}", - "api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_version'}" + "api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_version'}", + "frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_base_url'}", + "api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_base_url'}", + "frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_entry'}", + "release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_runtime'}", + "bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.bundle'}", + "frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend'}", + "api": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api'}" }, "common": { "yes": "@:{'templates.generated.compat.configuration.release_manager.common.yes'}", @@ -1681,6 +1692,7 @@ "remove": "@:{'templates.generated.compat.configuration.release_manager.actions.remove'}", "test_access": "@:{'templates.generated.compat.configuration.release_manager.actions.test_access'}", "deploy": "@:{'templates.generated.compat.configuration.release_manager.actions.deploy'}", + "redeploy": "@:{'templates.generated.compat.configuration.release_manager.actions.redeploy'}", "promote": "@:{'templates.generated.compat.configuration.release_manager.actions.promote'}", "enable": "@:{'templates.generated.compat.configuration.release_manager.actions.enable'}", "search": "@:{'templates.generated.compat.configuration.release_manager.actions.search'}", diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index 5085bb63..cd9f116e 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -2680,10 +2680,14 @@ "kicker": "Release Manager", "title": "Release-kanalen er ikke klar", "summary_prefix": "Kontoen din er tildelt", - "summary_suffix": ", men kanalen mangler en aktiv release-bundle.", + "summary_suffix": ", men kanalen mangler påkrevd release-konfigurasjon.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-versjon", "api_version": "API-versjon", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Sjekker igjen om {seconds}s", "refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.", "ignore": "Ignorer de neste 5 minuttene", @@ -2716,7 +2720,14 @@ "unavailable": "Ikke klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-versjon", - "api_version": "API-versjon" + "api_version": "API-versjon", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -2733,6 +2744,7 @@ "remove": "Fjern", "test_access": "Test tilgang", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promoter", "enable": "Aktiver", "search": "Søk", @@ -2976,7 +2988,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domene", - "ssl_domain_message": "Må være et DNS-domene som routes til Coolify load balanceren. Eksempel: lb.truckwash.io", + "ssl_domain_message": "Må være et DNS-domene som routes til Coolify load balanceren. Eksempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domene", "https_domain_for_coolify": "Domene kontrollert av load balanceren", "auto_deploy": "Auto deploy", diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index c929518d..6b182ef1 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -2730,10 +2730,14 @@ "kicker": "Release Manager", "title": "Release-kanalen är inte klar", "summary_prefix": "Ditt konto är tilldelat", - "summary_suffix": ", men kanalen saknar ett aktivt release-bundle.", + "summary_suffix": ", men kanalen saknar obligatorisk release-konfiguration.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Kontrollerar igen om {seconds}s", "refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.", "ignore": "Ignorera de kommande 5 minuterna", @@ -2766,7 +2770,14 @@ "unavailable": "Inte klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", - "api_version": "API-version" + "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -2783,6 +2794,7 @@ "remove": "Ta bort", "test_access": "Testa åtkomst", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promota", "enable": "Aktivera", "search": "Sök", @@ -3026,7 +3038,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domän", - "ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: lb.truckwash.io", + "ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domän", "https_domain_for_coolify": "Domän som kontrolleras av load balancern", "auto_deploy": "Auto deploy", diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index ab6a6be4..44283b15 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -1627,10 +1627,14 @@ "kicker": "Release Manager", "title": "Release-kanalen er ikke klar", "summary_prefix": "Din konto er tildelt", - "summary_suffix": ", men kanalen mangler et aktivt release-bundle.", + "summary_suffix": ", men kanalen mangler påkrævet release-konfiguration.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Tjekker igen om {seconds}s", "refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.", "ignore": "Ignorer de næste 5 minutter", @@ -1663,7 +1667,14 @@ "unavailable": "Ikke klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", - "api_version": "API-version" + "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -1680,6 +1691,7 @@ "remove": "Fjern", "test_access": "Test adgang", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promover", "enable": "Aktiver", "search": "Søg", @@ -1923,7 +1935,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domæne", - "ssl_domain_message": "Skal være et DNS-domæne, der routes til Coolify load balanceren. Eksempel: lb.truckwash.io", + "ssl_domain_message": "Skal være et DNS-domæne, der routes til Coolify load balanceren. Eksempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domæne", "https_domain_for_coolify": "Domæne kontrolleret af load balanceren", "auto_deploy": "Auto deploy", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 5f589051..c280b549 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1627,10 +1627,14 @@ "kicker": "Release Manager", "title": "Release-Kanal ist nicht bereit", "summary_prefix": "Dein Konto ist zugewiesen zu", - "summary_suffix": ", aber diesem Kanal fehlt ein aktives Release-Bundle.", + "summary_suffix": ", aber diesem Kanal fehlt erforderliche Release-Konfiguration.", "release_bundle": "Release-Bundle", "frontend_version": "Frontend-Version", "api_version": "API-Version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-Einstiegspunkt", + "release_runtime": "Release-Laufzeit", "checking_again": "Erneute Prüfung in {seconds}s", "refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.", "ignore": "Nächste 5 Minuten ignorieren", @@ -1663,7 +1667,14 @@ "unavailable": "Nicht bereit", "release_bundle": "Release-Bundle", "frontend_version": "Frontend-Version", - "api_version": "API-Version" + "api_version": "API-Version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-Einstiegspunkt", + "release_runtime": "Release-Laufzeit", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -1680,6 +1691,7 @@ "remove": "Entfernen", "test_access": "Zugriff testen", "deploy": "Deployen", + "redeploy": "Erneut deployen", "promote": "Promoten", "enable": "Aktivieren", "search": "Suchen", @@ -1923,7 +1935,7 @@ "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", "ssl_domain": "Load-Balancer-Domain", - "ssl_domain_message": "Muss eine DNS-Domain sein, die zum Coolify Load Balancer geroutet wird. Beispiel: lb.truckwash.io", + "ssl_domain_message": "Muss eine DNS-Domain sein, die zum Coolify Load Balancer geroutet wird. Beispiel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load-Balancer-Domain", "https_domain_for_coolify": "Domain unter Kontrolle des Load Balancers", "auto_deploy": "Auto-Deploy", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index add73a79..92ae76b9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1627,10 +1627,14 @@ "kicker": "Release Manager", "title": "Release channel is not ready", "summary_prefix": "Your account is assigned to", - "summary_suffix": ", but that channel is missing an active release bundle.", + "summary_suffix": ", but that channel is missing required release configuration.", "release_bundle": "Release bundle", "frontend_version": "Frontend version", "api_version": "API version", + "frontend_base_url": "Frontend URL", + "api_base_url": "API URL", + "frontend_entry": "Frontend entry", + "release_runtime": "Release runtime", "checking_again": "Checking again in {seconds}s", "refresh_error": "Release status could not be refreshed. The next automatic check will try again.", "ignore": "Ignore next 5 minutes", @@ -1663,7 +1667,14 @@ "unavailable": "Not ready", "release_bundle": "Release bundle", "frontend_version": "Frontend version", - "api_version": "API version" + "api_version": "API version", + "frontend_base_url": "Frontend URL", + "api_base_url": "API URL", + "frontend_entry": "Frontend entry", + "release_runtime": "Release runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "yes", @@ -1680,6 +1691,7 @@ "remove": "Remove", "test_access": "Test access", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promote", "enable": "Enable", "search": "Search", @@ -1923,7 +1935,7 @@ "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", "ssl_domain": "Load balancer domain", - "ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: lb.truckwash.io", + "ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer domain", "https_domain_for_coolify": "Domain controlled by the load balancer", "auto_deploy": "Auto deploy", diff --git a/src/i18n/locales/no.json b/src/i18n/locales/no.json index c1bf588a..9b647c09 100644 --- a/src/i18n/locales/no.json +++ b/src/i18n/locales/no.json @@ -1627,10 +1627,14 @@ "kicker": "Release Manager", "title": "Release-kanalen er ikke klar", "summary_prefix": "Kontoen din er tildelt", - "summary_suffix": ", men kanalen mangler en aktiv release-bundle.", + "summary_suffix": ", men kanalen mangler påkrevd release-konfigurasjon.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-versjon", "api_version": "API-versjon", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Sjekker igjen om {seconds}s", "refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.", "ignore": "Ignorer de neste 5 minuttene", @@ -1663,7 +1667,14 @@ "unavailable": "Ikke klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-versjon", - "api_version": "API-versjon" + "api_version": "API-versjon", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -1680,6 +1691,7 @@ "remove": "Fjern", "test_access": "Test tilgang", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promoter", "enable": "Aktiver", "search": "Søk", @@ -1923,7 +1935,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domene", - "ssl_domain_message": "Må være et DNS-domene som routes til Coolify load balanceren. Eksempel: lb.truckwash.io", + "ssl_domain_message": "Må være et DNS-domene som routes til Coolify load balanceren. Eksempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domene", "https_domain_for_coolify": "Domene kontrollert av load balanceren", "auto_deploy": "Auto deploy", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index d810f944..a9228701 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -1627,10 +1627,14 @@ "kicker": "Release Manager", "title": "Release-kanalen är inte klar", "summary_prefix": "Ditt konto är tilldelat", - "summary_suffix": ", men kanalen saknar ett aktivt release-bundle.", + "summary_suffix": ", men kanalen saknar obligatorisk release-konfiguration.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Kontrollerar igen om {seconds}s", "refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.", "ignore": "Ignorera de kommande 5 minuterna", @@ -1663,7 +1667,14 @@ "unavailable": "Inte klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", - "api_version": "API-version" + "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -1680,6 +1691,7 @@ "remove": "Ta bort", "test_access": "Testa åtkomst", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promota", "enable": "Aktivera", "search": "Sök", @@ -1923,7 +1935,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domän", - "ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: lb.truckwash.io", + "ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domän", "https_domain_for_coolify": "Domän som kontrolleras av load balancern", "auto_deploy": "Auto deploy", diff --git a/src/i18n/source/da/phrases/compat/configuration/index.json b/src/i18n/source/da/phrases/compat/configuration/index.json index 1e504f1b..8e60b4d8 100644 --- a/src/i18n/source/da/phrases/compat/configuration/index.json +++ b/src/i18n/source/da/phrases/compat/configuration/index.json @@ -430,10 +430,14 @@ "kicker": "Release Manager", "title": "Release-kanalen er ikke klar", "summary_prefix": "Din konto er tildelt", - "summary_suffix": ", men kanalen mangler et aktivt release-bundle.", + "summary_suffix": ", men kanalen mangler påkrævet release-konfiguration.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Tjekker igen om {seconds}s", "refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.", "ignore": "Ignorer de næste 5 minutter", @@ -466,7 +470,14 @@ "unavailable": "Ikke klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", - "api_version": "API-version" + "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -483,6 +494,7 @@ "remove": "Fjern", "test_access": "Test adgang", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promover", "enable": "Aktiver", "search": "Søg", @@ -726,7 +738,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domæne", - "ssl_domain_message": "Skal være et DNS-domæne, der routes til Coolify load balanceren. Eksempel: lb.truckwash.io", + "ssl_domain_message": "Skal være et DNS-domæne, der routes til Coolify load balanceren. Eksempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domæne", "https_domain_for_coolify": "Domæne kontrolleret af load balanceren", "auto_deploy": "Auto deploy", diff --git a/src/i18n/source/de/phrases/compat/configuration/index.json b/src/i18n/source/de/phrases/compat/configuration/index.json index 58236ea8..653b487d 100644 --- a/src/i18n/source/de/phrases/compat/configuration/index.json +++ b/src/i18n/source/de/phrases/compat/configuration/index.json @@ -430,10 +430,14 @@ "kicker": "Release Manager", "title": "Release-Kanal ist nicht bereit", "summary_prefix": "Dein Konto ist zugewiesen zu", - "summary_suffix": ", aber diesem Kanal fehlt ein aktives Release-Bundle.", + "summary_suffix": ", aber diesem Kanal fehlt erforderliche Release-Konfiguration.", "release_bundle": "Release-Bundle", "frontend_version": "Frontend-Version", "api_version": "API-Version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-Einstiegspunkt", + "release_runtime": "Release-Laufzeit", "checking_again": "Erneute Prüfung in {seconds}s", "refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.", "ignore": "Nächste 5 Minuten ignorieren", @@ -466,7 +470,14 @@ "unavailable": "Nicht bereit", "release_bundle": "Release-Bundle", "frontend_version": "Frontend-Version", - "api_version": "API-Version" + "api_version": "API-Version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-Einstiegspunkt", + "release_runtime": "Release-Laufzeit", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -483,6 +494,7 @@ "remove": "Entfernen", "test_access": "Zugriff testen", "deploy": "Deployen", + "redeploy": "Erneut deployen", "promote": "Promoten", "enable": "Aktivieren", "search": "Suchen", @@ -726,7 +738,7 @@ "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", "ssl_domain": "Load-Balancer-Domain", - "ssl_domain_message": "Muss eine DNS-Domain sein, die zum Coolify Load Balancer geroutet wird. Beispiel: lb.truckwash.io", + "ssl_domain_message": "Muss eine DNS-Domain sein, die zum Coolify Load Balancer geroutet wird. Beispiel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load-Balancer-Domain", "https_domain_for_coolify": "Domain unter Kontrolle des Load Balancers", "auto_deploy": "Auto-Deploy", diff --git a/src/i18n/source/en/phrases/compat/configuration/index.json b/src/i18n/source/en/phrases/compat/configuration/index.json index 4769bc67..c2346ac5 100644 --- a/src/i18n/source/en/phrases/compat/configuration/index.json +++ b/src/i18n/source/en/phrases/compat/configuration/index.json @@ -430,10 +430,14 @@ "kicker": "Release Manager", "title": "Release channel is not ready", "summary_prefix": "Your account is assigned to", - "summary_suffix": ", but that channel is missing an active release bundle.", + "summary_suffix": ", but that channel is missing required release configuration.", "release_bundle": "Release bundle", "frontend_version": "Frontend version", "api_version": "API version", + "frontend_base_url": "Frontend URL", + "api_base_url": "API URL", + "frontend_entry": "Frontend entry", + "release_runtime": "Release runtime", "checking_again": "Checking again in {seconds}s", "refresh_error": "Release status could not be refreshed. The next automatic check will try again.", "ignore": "Ignore next 5 minutes", @@ -466,7 +470,14 @@ "unavailable": "Not ready", "release_bundle": "Release bundle", "frontend_version": "Frontend version", - "api_version": "API version" + "api_version": "API version", + "frontend_base_url": "Frontend URL", + "api_base_url": "API URL", + "frontend_entry": "Frontend entry", + "release_runtime": "Release runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "yes", @@ -483,6 +494,7 @@ "remove": "Remove", "test_access": "Test access", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promote", "enable": "Enable", "search": "Search", @@ -726,7 +738,7 @@ "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", "ssl_domain": "Load balancer domain", - "ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: lb.truckwash.io", + "ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer domain", "https_domain_for_coolify": "Domain controlled by the load balancer", "auto_deploy": "Auto deploy", diff --git a/src/i18n/source/global/shared/configuration/index.json b/src/i18n/source/global/shared/configuration/index.json index 5f36e2ba..557df65f 100644 --- a/src/i18n/source/global/shared/configuration/index.json +++ b/src/i18n/source/global/shared/configuration/index.json @@ -487,6 +487,10 @@ "release_bundle": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.release_bundle'}", "frontend_version": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_version'}", "api_version": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.api_version'}", + "frontend_base_url": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_base_url'}", + "api_base_url": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.api_base_url'}", + "frontend_entry": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_entry'}", + "release_runtime": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.release_runtime'}", "checking_again": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.checking_again'}", "refresh_error": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.refresh_error'}", "ignore": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.ignore'}", @@ -519,7 +523,14 @@ "unavailable": "@:{'phrases.compat.configuration.release_manager.channel_selector.unavailable'}", "release_bundle": "@:{'phrases.compat.configuration.release_manager.channel_selector.release_bundle'}", "frontend_version": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend_version'}", - "api_version": "@:{'phrases.compat.configuration.release_manager.channel_selector.api_version'}" + "api_version": "@:{'phrases.compat.configuration.release_manager.channel_selector.api_version'}", + "frontend_base_url": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend_base_url'}", + "api_base_url": "@:{'phrases.compat.configuration.release_manager.channel_selector.api_base_url'}", + "frontend_entry": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend_entry'}", + "release_runtime": "@:{'phrases.compat.configuration.release_manager.channel_selector.release_runtime'}", + "bundle": "@:{'phrases.compat.configuration.release_manager.channel_selector.bundle'}", + "frontend": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend'}", + "api": "@:{'phrases.compat.configuration.release_manager.channel_selector.api'}" }, "common": { "yes": "@:{'phrases.compat.configuration.release_manager.common.yes'}", @@ -536,6 +547,7 @@ "remove": "@:{'phrases.compat.configuration.release_manager.actions.remove'}", "test_access": "@:{'phrases.compat.configuration.release_manager.actions.test_access'}", "deploy": "@:{'phrases.compat.configuration.release_manager.actions.deploy'}", + "redeploy": "@:{'phrases.compat.configuration.release_manager.actions.redeploy'}", "promote": "@:{'phrases.compat.configuration.release_manager.actions.promote'}", "enable": "@:{'phrases.compat.configuration.release_manager.actions.enable'}", "search": "@:{'phrases.compat.configuration.release_manager.actions.search'}", diff --git a/src/i18n/source/no/phrases/compat/configuration/index.json b/src/i18n/source/no/phrases/compat/configuration/index.json index d87e0a43..2fb81318 100644 --- a/src/i18n/source/no/phrases/compat/configuration/index.json +++ b/src/i18n/source/no/phrases/compat/configuration/index.json @@ -430,10 +430,14 @@ "kicker": "Release Manager", "title": "Release-kanalen er ikke klar", "summary_prefix": "Kontoen din er tildelt", - "summary_suffix": ", men kanalen mangler en aktiv release-bundle.", + "summary_suffix": ", men kanalen mangler påkrevd release-konfigurasjon.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-versjon", "api_version": "API-versjon", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Sjekker igjen om {seconds}s", "refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.", "ignore": "Ignorer de neste 5 minuttene", @@ -466,7 +470,14 @@ "unavailable": "Ikke klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-versjon", - "api_version": "API-versjon" + "api_version": "API-versjon", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -483,6 +494,7 @@ "remove": "Fjern", "test_access": "Test tilgang", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promoter", "enable": "Aktiver", "search": "Søk", @@ -726,7 +738,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domene", - "ssl_domain_message": "Må være et DNS-domene som routes til Coolify load balanceren. Eksempel: lb.truckwash.io", + "ssl_domain_message": "Må være et DNS-domene som routes til Coolify load balanceren. Eksempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domene", "https_domain_for_coolify": "Domene kontrollert av load balanceren", "auto_deploy": "Auto deploy", diff --git a/src/i18n/source/sv/phrases/compat/configuration/index.json b/src/i18n/source/sv/phrases/compat/configuration/index.json index 2ee9a303..6f0b1434 100644 --- a/src/i18n/source/sv/phrases/compat/configuration/index.json +++ b/src/i18n/source/sv/phrases/compat/configuration/index.json @@ -430,10 +430,14 @@ "kicker": "Release Manager", "title": "Release-kanalen är inte klar", "summary_prefix": "Ditt konto är tilldelat", - "summary_suffix": ", men kanalen saknar ett aktivt release-bundle.", + "summary_suffix": ", men kanalen saknar obligatorisk release-konfiguration.", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", "checking_again": "Kontrollerar igen om {seconds}s", "refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.", "ignore": "Ignorera de kommande 5 minuterna", @@ -466,7 +470,14 @@ "unavailable": "Inte klar", "release_bundle": "Release-bundle", "frontend_version": "Frontend-version", - "api_version": "API-version" + "api_version": "API-version", + "frontend_base_url": "Frontend-URL", + "api_base_url": "API-URL", + "frontend_entry": "Frontend-entry", + "release_runtime": "Release-runtime", + "bundle": "Bundle", + "frontend": "Frontend", + "api": "API" }, "common": { "yes": "ja", @@ -483,6 +494,7 @@ "remove": "Ta bort", "test_access": "Testa åtkomst", "deploy": "Deploy", + "redeploy": "Redeploy", "promote": "Promota", "enable": "Aktivera", "search": "Sök", @@ -726,7 +738,7 @@ "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", "ssl_domain": "Load balancer-domän", - "ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: lb.truckwash.io", + "ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: api-v2.truckwash.io", "ssl_domain_placeholder": "Load balancer-domän", "https_domain_for_coolify": "Domän som kontrolleras av load balancern", "auto_deploy": "Auto deploy", diff --git a/src/services/installAxiosRequestQueue.js b/src/services/installAxiosRequestQueue.js index d976ba8a..07a77eb7 100644 --- a/src/services/installAxiosRequestQueue.js +++ b/src/services/installAxiosRequestQueue.js @@ -1,6 +1,6 @@ import axios from "axios"; import { enqueueRequest } from "@/services/requestQueue.js"; -import { rewriteReleaseApiUrl } from "@/services/releaseTimeline.js"; +import { buildCurrentReleaseHeaders, rewriteReleaseApiUrl } from "@/services/releaseTimeline.js"; let requestInterceptorId = null; @@ -36,6 +36,12 @@ export const installAxiosRequestQueue = () => { if (config?.__skipReleaseApiRewrite !== true && config?.baseURL) { config.baseURL = rewriteReleaseApiUrl(config.baseURL); } + if (config?.__skipReleaseApiRewrite !== true) { + config.headers = { + ...buildCurrentReleaseHeaders(), + ...(config.headers || {}), + }; + } if (isQueueBypassed(config) || config?.__queueAdapterWrapped) { return config; diff --git a/src/services/releaseBootstrap.js b/src/services/releaseBootstrap.js index 9883824a..c2376f44 100644 --- a/src/services/releaseBootstrap.js +++ b/src/services/releaseBootstrap.js @@ -1,4 +1,5 @@ import { API_URL } from "@/config.js"; +import { buildReleaseHeaders } from "@/services/releaseHeaders.js"; export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__"; export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug"; @@ -33,6 +34,7 @@ const buildRuntimeHeaders = () => { const storage = browserStorage(); const headers = { Accept: "application/json", + ...buildReleaseHeaders({ channelSlug: readSelectedReleaseChannel() }), }; const token = storage?.getItem("token"); if (token) { @@ -54,6 +56,21 @@ export const runtimeApiUrl = (apiBaseUrl = API_URL) => { return url.href; }; +const parseJsonResponse = async (response, label) => { + if (typeof response?.text === "function") { + const body = await response.text(); + try { + return JSON.parse(body); + } catch (error) { + const prefix = body.trim().slice(0, 120); + const details = prefix ? ` Body starts with: ${prefix}` : ""; + throw new Error(`${label} returned invalid JSON.${details}`, { cause: error }); + } + } + + return response?.json?.(); +}; + export const fetchReleaseRuntime = async ({ fetchFn = globalThis.fetch, apiBaseUrl = API_URL } = {}) => { if (typeof fetchFn !== "function") { return null; @@ -68,13 +85,13 @@ export const fetchReleaseRuntime = async ({ fetchFn = globalThis.fetch, apiBaseU if (!response?.ok) { throw new Error(`Release runtime request failed with HTTP ${response?.status || 0}.`); } - const payload = await response.json(); + const payload = await parseJsonResponse(response, "Release runtime"); return payload?.data || payload || null; }; const runtimeFrontendBaseUrl = (runtime = {}) => { const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {}; - return normalizeBaseUrl(runtime.frontend_base_url || urls.frontend_base_url || ""); + return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || ""); }; const runtimeChannel = (runtime = {}) => runtime?.channel || {}; @@ -108,7 +125,7 @@ export const loadRemoteReleaseEntry = async ({ throw new Error(`Release entry request failed with HTTP ${response?.status || 0}.`); } - const entry = await response.json(); + const entry = await parseJsonResponse(response, "Release entry"); const entryModule = String(entry?.entry || "").trim(); if (!entryModule) { throw new Error("Release entry is missing an app module."); @@ -140,6 +157,27 @@ const unavailableRuntime = (runtime, missing) => ({ }, }); +const unavailableSelectedRuntime = (missing) => { + const selectedChannel = readSelectedReleaseChannel(); + if (!selectedChannel || selectedChannel === "stable") { + return null; + } + + return unavailableRuntime( + { + channel: { + slug: selectedChannel, + name: selectedChannel, + default_channel: false, + }, + availability: { + explicit: true, + }, + }, + missing + ); +}; + export const setReleaseRuntimeGlobal = (runtime) => { if (typeof window !== "undefined") { window[RELEASE_RUNTIME_GLOBAL_KEY] = runtime || null; @@ -163,6 +201,10 @@ export const bootstrapReleaseApp = async ({ setReleaseRuntimeGlobal(runtime); } catch (error) { console.warn("Could not resolve release runtime before app bootstrap.", error); + const unavailable = unavailableSelectedRuntime("release_runtime"); + if (unavailable) { + setReleaseRuntimeGlobal(unavailable); + } } if (!shouldLoadRemoteRelease(runtime)) { diff --git a/src/services/releaseChannelAvailability.js b/src/services/releaseChannelAvailability.js index e74122d2..bc97205d 100644 --- a/src/services/releaseChannelAvailability.js +++ b/src/services/releaseChannelAvailability.js @@ -1,5 +1,5 @@ import { computed, reactive, readonly } from "vue"; -import { releaseRuntimeState } from "@/services/releaseTimeline.js"; +import { configureReleaseRuntime, releaseRuntimeState } from "@/services/releaseTimeline.js"; export const RELEASE_CHANNEL_IGNORE_STORAGE_KEY = "release_channel_unavailable_ignore_until"; export const RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY = "release_channel_switch_notice_seen"; @@ -307,6 +307,81 @@ export const releaseChannelRuntimeRequestParams = () => { return slug ? { release_channel: slug } : {}; }; +const RELEASE_CHANNEL_API_FAILURE_STATUSES = new Set([404, 502, 503, 504]); + +const releaseRuntimeApiBaseUrl = (runtime = {}) => + String(runtime?.apiBaseUrl || runtime?.api_base_url || runtime?.urls?.api_base_url || "") + .trim() + .replace(/\/+$/, ""); + +const releaseRuntimeFrontendBaseUrl = (runtime = {}) => + String(runtime?.frontendBaseUrl || runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || "") + .trim() + .replace(/\/+$/, ""); + +export const isReleaseChannelApiAvailabilityError = (error, runtime = releaseRuntimeState) => { + const status = Number(error?.response?.status || 0); + if (!RELEASE_CHANNEL_API_FAILURE_STATUSES.has(status)) { + return false; + } + + const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug()); + const isDefaultChannel = + runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable"; + if (!channelSlug || isDefaultChannel) { + return false; + } + + const requestUrl = String(error?.config?.url || error?.request?.responseURL || ""); + if (!requestUrl.includes("/auth/session")) { + return false; + } + + const apiBaseUrl = releaseRuntimeApiBaseUrl(runtime); + return !apiBaseUrl || requestUrl.startsWith(apiBaseUrl); +}; + +export const markReleaseChannelApiUnavailable = ( + runtime = releaseRuntimeState, + missingKey = "api_base_url" +) => { + const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug()); + const isDefaultChannel = + runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable"; + if (!channelSlug || isDefaultChannel) { + return null; + } + + const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {}; + const missing = Array.from(new Set([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey])); + const nextRuntime = { + trace_id: runtime?.traceId || runtime?.trace_id || null, + channel: runtime?.channel || { + slug: channelSlug, + name: channelSlug, + default_channel: false, + }, + available_channels: runtime?.availableChannels || runtime?.available_channels || [], + versions: runtime?.versions || {}, + frontend_base_url: releaseRuntimeFrontendBaseUrl(runtime) || null, + api_base_url: releaseRuntimeApiBaseUrl(runtime) || null, + urls: { + frontend_base_url: releaseRuntimeFrontendBaseUrl(runtime) || null, + api_base_url: releaseRuntimeApiBaseUrl(runtime) || null, + }, + availability: { + ...availability, + configured: false, + explicit: true, + missing, + status: "unconfigured", + }, + capture_policy: runtime?.capturePolicy || runtime?.capture_policy || {}, + }; + configureReleaseRuntime(nextRuntime); + return nextRuntime; +}; + export const selectReleaseChannel = (channelOrSlug) => { const slug = normalizeReleaseChannelSlug( typeof channelOrSlug === "string" ? channelOrSlug : channelOrSlug?.slug || channelOrSlug?.channelSlug || "" diff --git a/src/services/releaseHeaders.js b/src/services/releaseHeaders.js new file mode 100644 index 00000000..1d820a43 --- /dev/null +++ b/src/services/releaseHeaders.js @@ -0,0 +1,61 @@ +export const RELEASE_TRACE_STORAGE_KEY = "release_trace_id"; +export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug"; + +const browserStorage = () => { + if (typeof window === "undefined") { + return null; + } + + try { + return window.localStorage || null; + } catch { + return null; + } +}; + +const normalizeIdentifier = (value, maxLength = 128) => + String(value || "") + .trim() + .replace(/[^a-zA-Z0-9_.:-]/g, "") + .slice(0, Math.max(1, maxLength)); + +export const normalizeReleaseChannelSlug = (value) => + String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 64); + +export const selectedReleaseChannelSlugFromStorage = () => + normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || ""); + +export const releaseTraceIdFromStorage = () => + normalizeIdentifier(browserStorage()?.getItem(RELEASE_TRACE_STORAGE_KEY) || "", 64); + +const fallbackFrontendVersion = () => + normalizeIdentifier(import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown", 128); + +export const buildReleaseHeaders = ({ + traceId = "", + channelSlug = "", + frontendVersion = "", +} = {}) => { + const headers = {}; + const normalizedTraceId = normalizeIdentifier(traceId || releaseTraceIdFromStorage(), 64); + const normalizedChannelSlug = normalizeReleaseChannelSlug(channelSlug || selectedReleaseChannelSlugFromStorage()); + const normalizedFrontendVersion = normalizeIdentifier(frontendVersion || fallbackFrontendVersion(), 128); + + if (normalizedTraceId) { + headers["X-Release-Trace"] = normalizedTraceId; + } + if (normalizedChannelSlug) { + headers["X-Release-Channel"] = normalizedChannelSlug; + } + if (normalizedFrontendVersion) { + headers["X-Frontend-Version"] = normalizedFrontendVersion; + } + + return headers; +}; diff --git a/src/services/releaseTimeline.js b/src/services/releaseTimeline.js index 903931da..22cb8ac0 100644 --- a/src/services/releaseTimeline.js +++ b/src/services/releaseTimeline.js @@ -1,7 +1,8 @@ import { reactive, readonly } from "vue"; import { API_URL } from "@/config.js"; +import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js"; -const TRACE_STORAGE_KEY = "release_trace_id"; +const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY; const MAX_QUEUE_SIZE = 50; const MAX_FRONTEND_FAILURE_BUFFER_SIZE = 50; const FRONTEND_FAILURE_EVENT_TYPES = new Set([ @@ -333,6 +334,17 @@ const currentDeviceType = () => { const versionLabel = (version) => version?.version_label || version?.label || null; const commitSha = (version) => version?.commit_sha || version?.commit || null; +export const buildCurrentReleaseHeaders = () => { + const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {}; + const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown"; + + return buildReleaseHeaders({ + traceId: releaseRuntimeStateMutable.traceId, + channelSlug: releaseRuntimeStateMutable.channel?.slug || "", + frontendVersion: commitSha(frontendVersion) || versionLabel(frontendVersion) || fallbackFrontendVersion, + }); +}; + export const buildReleaseTimelineContext = () => { const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : ""; const browser = browserInfoFromUserAgent(userAgent); @@ -404,6 +416,7 @@ export const flushReleaseTimelineEvents = async () => { const headers = { "Content-Type": "application/json", + ...buildCurrentReleaseHeaders(), }; const token = typeof window !== "undefined" ? window.localStorage.getItem("token") : null; if (token) { diff --git a/src/services/superuserCoolify.js b/src/services/superuserCoolify.js index 36ffa6a8..d9686741 100644 --- a/src/services/superuserCoolify.js +++ b/src/services/superuserCoolify.js @@ -1,55 +1,61 @@ -import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; +import { requestReleaseManager } from "@/services/superuserReleases.js"; export const getCoolifySummary = () => - authenticatedRequest("/superuser/coolify", "GET", {}); + requestReleaseManager("/superuser/coolify", "GET", {}); export const getCoolifyLoadBalancer = () => - authenticatedRequest("/superuser/coolify/load-balancer", "GET", {}); + requestReleaseManager("/superuser/coolify/load-balancer", "GET", {}); export const reconcileCoolifyLoadBalancer = (payload) => - authenticatedRequest("/superuser/coolify/load-balancer/reconcile", "POST", payload); + requestReleaseManager("/superuser/coolify/load-balancer/reconcile", "POST", payload); + +export const deployCoolifyGatewayRoutes = (payload) => + requestReleaseManager("/superuser/coolify/load-balancer/routes/deploy", "POST", payload); + +export const deployCoolifyGatewayApiCode = (payload) => + requestReleaseManager("/superuser/coolify/load-balancer/api/deploy", "POST", payload); export const listCoolifyGateways = () => - authenticatedRequest("/superuser/coolify/gateways", "GET", {}); + requestReleaseManager("/superuser/coolify/gateways", "GET", {}); export const saveCoolifyGateway = (payload) => - authenticatedRequest("/superuser/coolify/gateways", "POST", payload); + requestReleaseManager("/superuser/coolify/gateways", "POST", payload); export const testCoolifyGateway = (id) => - authenticatedRequest(`/superuser/coolify/gateways/${id}/test`, "POST", {}); + requestReleaseManager(`/superuser/coolify/gateways/${id}/test`, "POST", {}); export const createCoolifyInstance = (payload) => - authenticatedRequest("/superuser/coolify/instances", "POST", payload); + requestReleaseManager("/superuser/coolify/instances", "POST", payload); export const testCoolifyInstance = (id) => - authenticatedRequest(`/superuser/coolify/instances/${id}/test`, "POST", {}); + requestReleaseManager(`/superuser/coolify/instances/${id}/test`, "POST", {}); export const discoverCoolifyPlacement = (id) => - authenticatedRequest(`/superuser/coolify/instances/${id}/placement`, "GET", {}); + requestReleaseManager(`/superuser/coolify/instances/${id}/placement`, "GET", {}); export const listCoolifyTargets = ({ kind = null } = {}) => - authenticatedRequest("/superuser/coolify/targets", "GET", kind ? { kind } : {}); + requestReleaseManager("/superuser/coolify/targets", "GET", kind ? { kind } : {}); export const createCoolifyTarget = (payload) => - authenticatedRequest("/superuser/coolify/targets", "POST", payload); + requestReleaseManager("/superuser/coolify/targets", "POST", payload); export const reconcileCoolifyTarget = (id) => - authenticatedRequest(`/superuser/coolify/targets/${id}/reconcile`, "POST", {}); + requestReleaseManager(`/superuser/coolify/targets/${id}/reconcile`, "POST", {}); export const deployCoolifyTarget = (id) => - authenticatedRequest(`/superuser/coolify/targets/${id}/deploy`, "POST", {}); + requestReleaseManager(`/superuser/coolify/targets/${id}/deploy`, "POST", {}); export const restartCoolifyTarget = (id) => - authenticatedRequest(`/superuser/coolify/targets/${id}/restart`, "POST", {}); + requestReleaseManager(`/superuser/coolify/targets/${id}/restart`, "POST", {}); export const failoverCoolifyTarget = (id) => - authenticatedRequest(`/superuser/coolify/targets/${id}/failover`, "POST", {}); + requestReleaseManager(`/superuser/coolify/targets/${id}/failover`, "POST", {}); export const deleteCoolifyTarget = (id, payload) => - authenticatedRequest(`/superuser/coolify/targets/${id}`, "DELETE", payload); + requestReleaseManager(`/superuser/coolify/targets/${id}`, "DELETE", payload); export const getCoolifyConfig = (variable = null) => - authenticatedRequest("/coolify/config", "GET", variable ? { variable } : {}); + requestReleaseManager("/coolify/config", "GET", variable ? { variable } : {}); export const setCoolifyConfig = (payload) => - authenticatedRequest("/coolify/config", "POST", payload); + requestReleaseManager("/coolify/config", "POST", payload); diff --git a/src/services/superuserReleases.js b/src/services/superuserReleases.js index a98db79a..d1c314dd 100644 --- a/src/services/superuserReleases.js +++ b/src/services/superuserReleases.js @@ -113,7 +113,7 @@ const buildHeaders = () => { return headers; }; -const requestReleaseManager = (url, method, data = {}) => { +export const requestReleaseManager = (url, method, data = {}) => { const candidates = releaseManagerControlApiCandidates(); const headers = buildHeaders(); diff --git a/src/views/dashboards/superUserDashboard/configuration/ConfigurationCoolify.vue b/src/views/dashboards/superUserDashboard/configuration/ConfigurationCoolify.vue index d2018846..a5e09e7e 100644 --- a/src/views/dashboards/superUserDashboard/configuration/ConfigurationCoolify.vue +++ b/src/views/dashboards/superUserDashboard/configuration/ConfigurationCoolify.vue @@ -11,6 +11,8 @@ import ConfigurationError from "@/components/displays/superuser/configuration/Co import { createCoolifyInstance, deleteCoolifyTarget, + deployCoolifyGatewayApiCode, + deployCoolifyGatewayRoutes, deployCoolifyTarget, failoverCoolifyTarget, getCoolifyConfig, @@ -27,6 +29,8 @@ const config = ref([]); const summary = ref({ instances: [], targets: [], availability: {} }); const errors = ref([]); const busy = ref(null); +const gatewayRouteResult = ref(null); +const gatewayCodeDeployResult = ref(null); const instanceForm = reactive({ label: "Coolify", @@ -39,7 +43,7 @@ const loadBalancerForm = reactive({ lb_automation_mode: "report_only", hetzner_load_balancer_id: "", hetzner_cloud_api_token: "", - public_gateway_host: "lb.truckwash.io", + public_gateway_host: "api-v2.truckwash.io", }); const canManage = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_coolify_manage")); @@ -76,7 +80,7 @@ function hydrateLoadBalancerForm() { loadBalancerForm.lb_automation_enabled = automationEnabled === true || automationEnabled === "true"; loadBalancerForm.lb_automation_mode = configValue("lb_automation_mode") || "report_only"; loadBalancerForm.hetzner_load_balancer_id = String(configValue("hetzner_load_balancer_id") || ""); - loadBalancerForm.public_gateway_host = String(configValue("public_gateway_host") || "lb.truckwash.io"); + loadBalancerForm.public_gateway_host = String(configValue("public_gateway_host") || "api-v2.truckwash.io"); loadBalancerForm.hetzner_cloud_api_token = ""; } @@ -91,7 +95,7 @@ async function saveLoadBalancerSettings() { lb_automation_enabled: Boolean(loadBalancerForm.lb_automation_enabled), lb_automation_mode: loadBalancerForm.lb_automation_mode, hetzner_load_balancer_id: String(loadBalancerForm.hetzner_load_balancer_id || "").trim(), - public_gateway_host: String(loadBalancerForm.public_gateway_host || "").trim() || "lb.truckwash.io", + public_gateway_host: String(loadBalancerForm.public_gateway_host || "").trim() || "api-v2.truckwash.io", }; const token = String(loadBalancerForm.hetzner_cloud_api_token || "").trim(); if (token !== "") { @@ -109,6 +113,29 @@ async function runLoadBalancerReconcile(dryRun) { }); } +async function runGatewayRouteDeploy(dryRun) { + await run(dryRun ? "lb:routes:dry-run" : "lb:routes:deploy", async () => { + const response = await deployCoolifyGatewayRoutes({ dry_run: dryRun, enforce: !dryRun }); + gatewayRouteResult.value = response?.data?.data || response?.data || null; + await load(); + }); +} + +async function runGatewayApiCodeDeploy() { + await run("lb:api-code:deploy", async () => { + const response = await deployCoolifyGatewayApiCode({ + dry_run: false, + enforce: true, + deploy_routes: true, + }); + gatewayCodeDeployResult.value = response?.data?.data || response?.data || null; + if (gatewayCodeDeployResult.value?.route_deploy) { + gatewayRouteResult.value = gatewayCodeDeployResult.value.route_deploy; + } + await load(); + }); +} + async function runGatewayTest(gateway) { await run(`gateway:${gateway.id}:test`, async () => { await testCoolifyGateway(gateway.id); @@ -206,6 +233,10 @@ function driftActionLabel(action) { return action.type || "planned change"; } +function countItems(value) { + return Array.isArray(value) ? value.length : 0; +} + onMounted(load); @@ -244,7 +275,7 @@ onMounted(load); class="mt-2" module="Coolify" title="Gateway load balancer" - description="Managed public gateway for lb.truckwash.io and edge broker traffic." + description="Managed public gateway for api-v2.truckwash.io and edge broker traffic." icon="fas fa-route" >
@@ -323,6 +354,69 @@ onMounted(load); > Enforce reconcile + + + +
+ +
+ {{ gatewayCodeDeployResult.dry_run ? "API code plan" : "API code deploy" }} + Planned {{ countItems(gatewayCodeDeployResult.planned) }} + Applied {{ countItems(gatewayCodeDeployResult.applied) }} + Skipped {{ countItems(gatewayCodeDeployResult.skipped) }} + Errors {{ countItems(gatewayCodeDeployResult.errors) }} + + Route applied {{ countItems(gatewayCodeDeployResult.route_deploy.applied) }} + + + Warnings {{ countItems(gatewayCodeDeployResult.warnings) }} + + +
+ +
+ {{ gatewayRouteResult.dry_run ? "Route plan" : "Route deploy" }} + Planned {{ countItems(gatewayRouteResult.planned) }} + Applied {{ countItems(gatewayRouteResult.applied) }} + Skipped {{ countItems(gatewayRouteResult.skipped) }} + Errors {{ countItems(gatewayRouteResult.errors) }} + + Warnings {{ countItems(gatewayRouteResult.warnings) }} + +
@@ -547,6 +641,19 @@ onMounted(load); padding-left: 1.25rem; } +.coolify-route-result { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; +} + +.coolify-route-result ul { + flex-basis: 100%; + margin: 0; + padding-left: 1.25rem; +} + .coolify-muted { color: #64748b; font-size: 0.86rem; diff --git a/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue b/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue index d0c7fe1e..5da97881 100644 --- a/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue +++ b/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue @@ -49,6 +49,7 @@ const trFallback = (key, fallback, params = {}) => { return translated === fullKey ? fallback : translated; }; const DEFAULT_RELEASE_BRANCH = "master"; +const RELEASE_STATUS_SERVICES = ["frontend", "api", "database", "redis", "minio"]; const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || "")); const readableValue = (value) => String(value || "").replace(/_/g, " "); const statusLabel = (value) => trFallback(`values.status.${value}`, readableValue(value)); @@ -112,6 +113,7 @@ const summary = ref({ timeline: {}, module_health: [], suggestions: {}, + status_overview: null, }); const errors = ref([]); const busy = ref(null); @@ -144,6 +146,9 @@ const channelBundleSelection = reactive({ channel: null, bundle_id: null, }); +const selectedStatusChannelId = ref(null); +const selectedStatusIssue = ref(null); +const selectedStatusServiceKey = ref(""); const releaseConfigForm = reactive({ github_token: "", @@ -221,6 +226,8 @@ const bundleForm = reactive({ api_image: "", version_label: "", }); +const bundleVersionLabelAutoManaged = ref(true); +const bundleGeneratedVersionLabel = ref(""); const canView = computed( () => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_release_manager_view") @@ -467,6 +474,84 @@ const stats = computed(() => [ { label: trFallback("stats.bundles", "Bundles"), value: bundles.value.length }, { label: tr("stats.timeline_events"), value: timelineSummary.value.events || 0 }, ]); +const statusOverview = computed(() => + normalizeReleaseStatusOverview(summary.value?.status_overview || buildReleaseStatusOverviewFallback()) +); +const statusChannels = computed(() => statusOverview.value.channels); +const statusIssues = computed(() => statusOverview.value.issues); +const statusTotals = computed(() => statusOverview.value.totals); +const statusSummaryCards = computed(() => [ + { + key: "critical", + label: trFallback("status.critical", "Critical"), + value: statusTotals.value.critical || 0, + severity: "critical", + }, + { + key: "warning", + label: trFallback("status.warnings", "Warnings"), + value: statusTotals.value.warning || 0, + severity: "warning", + }, + { + key: "affected", + label: trFallback("status.affected_channels", "Affected channels"), + value: statusTotals.value.affected_channels || 0, + severity: (statusTotals.value.affected_channels || 0) > 0 ? "warning" : "ok", + }, + { + key: "services", + label: trFallback("status.unhealthy_services", "Unhealthy services"), + value: `${statusTotals.value.unhealthy_services || 0}/${statusTotals.value.services || 0}`, + severity: (statusTotals.value.unhealthy_services || 0) > 0 ? "warning" : "ok", + }, + { + key: "missing", + label: trFallback("status.missing_values", "Missing values"), + value: statusTotals.value.missing_values || 0, + severity: (statusTotals.value.missing_values || 0) > 0 ? "critical" : "ok", + }, + { + key: "generated", + label: trFallback("status.generated", "Generated"), + value: releaseTimestamp(statusOverview.value.generated_at || summary.value?.generated_at || ""), + severity: "ok", + }, +]); +const statusDrawerChannel = computed(() => { + const issue = selectedStatusIssue.value; + const channelId = selectedStatusChannelId.value || issue?.channel_id || null; + const channelSlug = issue?.channel_slug || ""; + + return ( + statusChannels.value.find((channel) => Number(channel.channel_id) === Number(channelId)) || + statusChannels.value.find((channel) => String(channel.channel_slug || "") === String(channelSlug)) || + null + ); +}); +const statusDrawerIssues = computed(() => { + const channelIssues = Array.isArray(statusDrawerChannel.value?.issues) ? statusDrawerChannel.value.issues : []; + if (!selectedStatusIssue.value) { + return channelIssues; + } + + const selectedKey = releaseStatusIssueKey(selectedStatusIssue.value); + return [ + selectedStatusIssue.value, + ...channelIssues.filter((issue) => releaseStatusIssueKey(issue) !== selectedKey), + ]; +}); +const statusDrawerService = computed(() => { + if (!selectedStatusServiceKey.value || !statusDrawerChannel.value) { + return null; + } + + return ( + (Array.isArray(statusDrawerChannel.value.services) ? statusDrawerChannel.value.services : []).find( + (service) => service.service_key === selectedStatusServiceKey.value + ) || null + ); +}); const activeTabMeta = computed(() => tabs.value.find((tab) => tab.key === activeTab.value) || tabs.value[0]); const normalizedGithubApiUrl = computed(() => String(releaseConfigForm.github_api_url || "").trim()); const isGithubApiUrlValid = computed(() => /^https?:\/\/[^\s]+$/i.test(normalizedGithubApiUrl.value)); @@ -513,6 +598,334 @@ const settingsGuideCanContinue = computed(() => { return currentStep ? currentStep.done : false; }); +function normalizeReleaseStatusOverview(overview) { + const source = overview && typeof overview === "object" ? overview : {}; + const channelsList = Array.isArray(source.channels) ? source.channels : []; + const issuesList = Array.isArray(source.issues) ? source.issues : []; + const totals = source.totals && typeof source.totals === "object" ? source.totals : {}; + + return { + generated_at: source.generated_at || summary.value?.generated_at || "", + state: source.state || (issuesList.length > 0 ? "attention" : "ready"), + totals: { + channels: Number(totals.channels ?? channelsList.length) || 0, + ready_channels: Number(totals.ready_channels ?? 0) || 0, + affected_channels: Number(totals.affected_channels ?? 0) || 0, + issues: Number(totals.issues ?? issuesList.length) || 0, + critical: Number(totals.critical ?? issuesList.filter((issue) => issue.severity === "critical").length) || 0, + warning: Number(totals.warning ?? issuesList.filter((issue) => issue.severity === "warning").length) || 0, + services: + Number(totals.services ?? channelsList.reduce((count, channel) => count + (channel.services?.length || 0), 0)) || + 0, + unhealthy_services: Number(totals.unhealthy_services ?? 0) || 0, + missing_values: Number(totals.missing_values ?? 0) || 0, + }, + issues: issuesList.map(normalizeReleaseStatusIssue).sort(releaseStatusIssueSort), + channels: channelsList.map(normalizeReleaseStatusChannel), + }; +} + +function normalizeReleaseStatusChannel(channel) { + const issues = Array.isArray(channel?.issues) ? channel.issues.map(normalizeReleaseStatusIssue) : []; + const services = Array.isArray(channel?.services) + ? channel.services.map(normalizeReleaseStatusService) + : RELEASE_STATUS_SERVICES.map((service) => normalizeReleaseStatusService({ service_key: service })); + + return { + channel_id: channel?.channel_id ?? channel?.id ?? null, + channel_slug: channel?.channel_slug || channel?.slug || "", + channel_name: channel?.channel_name || channel?.name || channel?.slug || "--", + default_channel: Boolean(channel?.default_channel), + enabled: channel?.enabled !== false, + severity: channel?.severity || releaseStatusMaxSeverity(issues.map((issue) => issue.severity)), + readiness: channel?.readiness || (issues.length > 0 ? "attention" : "ready"), + message: channel?.message || "", + availability: channel?.availability || {}, + missing_values: Array.isArray(channel?.missing_values) ? channel.missing_values : [], + services, + versions: channel?.versions || {}, + replay: channel?.replay || { + enabled: Boolean(channel?.replay_enabled), + capture_level: channel?.capture_level || "metadata", + }, + latest_deployments: Array.isArray(channel?.latest_deployments) ? channel.latest_deployments : [], + issues, + }; +} + +function normalizeReleaseStatusService(service) { + const key = service?.service_key || service?.key || ""; + return { + service_key: key, + label: service?.label || releaseStatusServiceLabel(key), + status: service?.status || service?.state || "ready", + state: service?.state || service?.status || "ready", + severity: service?.severity || "ok", + message: service?.message || trFallback("status.service_ready", "Release service is ready."), + next_action: service?.next_action || "", + target_tab: service?.target_tab || "overview", + target_id: service?.target_id ?? null, + deployment_id: service?.deployment_id ?? null, + coolify_target_id: service?.coolify_target_id ?? null, + missing_key: service?.missing_key || null, + issue_type: service?.issue_type || null, + version_label: service?.version_label || null, + repository: service?.repository || null, + branch: service?.branch || null, + health_url: service?.health_url || null, + resource_name: service?.resource_name || null, + }; +} + +function normalizeReleaseStatusIssue(issue) { + return { + severity: issue?.severity || "warning", + type: issue?.type || "stale_unknown", + channel_id: issue?.channel_id ?? null, + channel_slug: issue?.channel_slug || "", + service_key: issue?.service_key || null, + label: issue?.label || releaseStatusServiceLabel(issue?.service_key), + message: issue?.message || "", + next_action: issue?.next_action || "", + target_tab: issue?.target_tab || "overview", + target_id: issue?.target_id ?? null, + deployment_id: issue?.deployment_id ?? null, + coolify_target_id: issue?.coolify_target_id ?? null, + missing_key: issue?.missing_key || null, + }; +} + +function buildReleaseStatusOverviewFallback() { + const fallbackChannels = channels.value.map((channel) => { + const channelDeployments = deployments.value.filter((deployment) => Number(deployment.channel_id) === Number(channel.id)); + const latestByApp = Object.fromEntries( + ["frontend", "api"].map((app) => [ + app, + channelDeployments.find((deployment) => deployment.app === app) || null, + ]) + ); + const availability = channel.availability || {}; + const missingKeys = Array.isArray(availability.missing) ? availability.missing : []; + const missingValues = missingKeys.map((key) => ({ + key, + label: releaseStatusMissingLabel(key), + service_key: releaseStatusMissingServiceKey(key), + target_tab: releaseStatusMissingTargetTab(key), + })); + const services = RELEASE_STATUS_SERVICES.map((service) => + normalizeReleaseStatusService( + ["frontend", "api"].includes(service) + ? fallbackReleaseAppService(service, channel, latestByApp[service], missingKeys) + : fallbackReleaseDataService(service, channel) + ) + ); + const issues = [ + ...missingValues.map((missing) => + normalizeReleaseStatusIssue({ + severity: "critical", + type: "missing_value", + channel_id: channel.id, + channel_slug: channel.slug, + service_key: missing.service_key, + label: missing.label, + message: `${channel.name || channel.slug} is missing ${missing.label}.`, + next_action: releaseStatusMissingNextAction(missing.key), + target_tab: missing.target_tab, + missing_key: missing.key, + }) + ), + ...services + .filter((service) => releaseStatusSeverityRank(service.severity) > 0 && service.issue_type) + .map((service) => + normalizeReleaseStatusIssue({ + severity: service.severity, + type: service.issue_type, + channel_id: channel.id, + channel_slug: channel.slug, + service_key: service.service_key, + label: service.label, + message: service.message, + next_action: service.next_action, + target_tab: service.target_tab, + deployment_id: service.deployment_id, + missing_key: service.missing_key, + }) + ), + ].sort(releaseStatusIssueSort); + const severity = releaseStatusMaxSeverity(issues.map((issue) => issue.severity)); + + return { + channel_id: channel.id, + channel_slug: channel.slug, + channel_name: channel.name || channel.slug, + default_channel: Boolean(channel.default_channel), + enabled: channel.enabled !== false, + severity, + readiness: severity === "critical" ? "blocked" : severity === "warning" ? "attention" : "ready", + message: issues.length + ? `${issues.length} ${issues.length === 1 ? "issue" : "issues"} need attention.` + : trFallback("status.channel_ready", "All release services are ready."), + availability, + missing_values: missingValues, + services, + versions: channel.versions || {}, + replay: { + enabled: Boolean(channel.replay_enabled), + capture_level: channel.capture_level || "metadata", + }, + latest_deployments: channelDeployments.slice(0, 5), + issues, + }; + }); + const issues = fallbackChannels.flatMap((channel) => channel.issues).sort(releaseStatusIssueSort); + const critical = issues.filter((issue) => issue.severity === "critical").length; + const warning = issues.filter((issue) => issue.severity === "warning").length; + const affected = new Set(issues.map((issue) => issue.channel_slug).filter(Boolean)); + const unhealthyServices = fallbackChannels.reduce( + (count, channel) => + count + channel.services.filter((service) => releaseStatusSeverityRank(service.severity) > 0).length, + 0 + ); + + return { + generated_at: summary.value?.generated_at || "", + state: critical > 0 ? "blocked" : warning > 0 ? "attention" : "ready", + totals: { + channels: fallbackChannels.length, + ready_channels: fallbackChannels.filter((channel) => channel.readiness === "ready").length, + affected_channels: affected.size, + issues: issues.length, + critical, + warning, + services: fallbackChannels.length * RELEASE_STATUS_SERVICES.length, + unhealthy_services: unhealthyServices, + missing_values: issues.filter((issue) => issue.type === "missing_value").length, + }, + issues, + channels: fallbackChannels, + }; +} + +function fallbackReleaseAppService(service, channel, deployment, missingKeys) { + const missingKey = missingKeys.find((key) => releaseStatusMissingServiceKey(key) === service) || null; + const deploymentStatus = String(deployment?.status || "").toLowerCase(); + if (["failed", "error"].includes(deploymentStatus)) { + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: deploymentStatus, + state: "failed", + severity: "critical", + issue_type: "failed_deployment", + message: deploymentFailureCause(deployment) || `${releaseStatusServiceLabel(service)} deployment failed.`, + next_action: + deploymentFailureNextAction(deployment) || + trFallback("status.fix_failed_deployment", "Open deployment details and fix the failed release."), + target_tab: "deployments", + deployment_id: deployment?.id ?? null, + }; + } + + if (["queued", "running", "deploying", "building", "pending"].includes(deploymentStatus)) { + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: deploymentStatus, + state: "deployment_in_progress", + severity: "warning", + issue_type: "deployment_in_progress", + message: `${releaseStatusServiceLabel(service)} deployment is still in progress.`, + next_action: trFallback("status.wait_for_deployment", "Wait for deployment to finish, then refresh."), + target_tab: "deployments", + deployment_id: deployment?.id ?? null, + }; + } + + if (missingKey) { + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: "missing", + state: "missing_value", + severity: "critical", + issue_type: "missing_value", + message: `${releaseStatusServiceLabel(service)} is missing ${releaseStatusMissingLabel(missingKey)}.`, + next_action: releaseStatusMissingNextAction(missingKey), + target_tab: releaseStatusMissingTargetTab(missingKey), + missing_key: missingKey, + }; + } + + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: deploymentStatus || channel?.versions?.[service]?.status || "ready", + state: "ready", + severity: "ok", + version_label: channel?.versions?.[service]?.version_label || null, + deployment_id: deployment?.id ?? null, + }; +} + +function fallbackReleaseDataService(service, channel) { + const serviceSet = + channel?.versions?.service_set || + serviceSets.value.find((set) => Number(set.channel_id) === Number(channel?.id)) || + null; + const stackService = serviceSet?.stack?.[service] || serviceSet?.data_services?.[service] || null; + if (!serviceSet) { + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: "not_configured", + state: "stale_unknown", + severity: channel?.default_channel ? "ok" : "warning", + issue_type: channel?.default_channel ? null : "stale_unknown", + message: trFallback("status.no_service_set", "No service set is selected for this channel."), + next_action: trFallback("status.assign_service_set", "Create or assign a release bundle with a service set."), + target_tab: "bundles", + }; + } + + if (!stackService) { + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: "missing", + state: "missing_value", + severity: serviceSet.mode === "isolated_stack" ? "critical" : "warning", + issue_type: "missing_value", + message: `${releaseStatusServiceLabel(service)} is not assigned to this service set.`, + next_action: trFallback("status.add_data_service", "Add the missing data service to the service set."), + target_tab: "bundles", + missing_key: `${service}_service`, + }; + } + + const state = stackService.deployment_status || stackService.availability_state || stackService.replication?.status || "ready"; + const degraded = ["failed", "reconcile_failed", "restart_failed", "provision_blocked", "degraded"].includes( + String(state).toLowerCase() + ); + + return { + service_key: service, + label: releaseStatusServiceLabel(service), + status: state, + state: degraded ? "service_unhealthy" : "ready", + severity: degraded ? "critical" : "ok", + issue_type: degraded ? "service_unhealthy" : null, + message: degraded + ? `${releaseStatusServiceLabel(service)} Coolify target is unhealthy.` + : `${releaseStatusServiceLabel(service)} release service is ready.`, + next_action: degraded + ? trFallback("status.inspect_coolify", "Inspect the Coolify target before promotion.") + : "", + target_tab: "bundles", + coolify_target_id: stackService.id ?? null, + resource_name: stackService.resource_name || stackService.label || null, + }; +} + function responseData(response, fallback) { return response?.data?.data ?? fallback; } @@ -1103,13 +1516,11 @@ function fillDeploymentFromTarget(target = selectedTarget.value) { deploymentForm.channel_id = target.channel_id; deploymentForm.app = target.app; deploymentForm.repository = target.repository; - deploymentForm.branch = target.branch; + deploymentForm.branch = target.branch || DEFAULT_RELEASE_BRANCH; deploymentForm.commit_mode = "latest"; deploymentForm.commit_sha = ""; if (!deploymentForm.version_label) { - deploymentForm.version_label = `${target.channel_slug || "release"}-${target.app}-${new Date() - .toISOString() - .slice(0, 10)}`; + deploymentForm.version_label = deploymentVersionLabelFromTarget(target); } } @@ -1119,6 +1530,23 @@ function applyQuickDeployTarget(target) { activeTab.value = "deployments"; } +function deploymentVersionLabelFromTarget(target) { + return `${target?.channel_slug || "release"}-${target?.app || "app"}-${new Date().toISOString().slice(0, 10)}`; +} + +function deploymentPayloadFromTarget(target) { + return { + target_id: target.id, + channel_id: target.channel_id, + app: target.app, + repository: target.repository, + branch: target.branch || DEFAULT_RELEASE_BRANCH, + commit_mode: "latest", + commit_sha: "", + version_label: deploymentVersionLabelFromTarget(target), + }; +} + function applyBundleDefaults() { if (!isIsolatedStackMode.value && !bundleForm.source_service_set_id && serviceSets.value[0]) { bundleForm.source_service_set_id = serviceSets.value[0].id; @@ -1144,11 +1572,45 @@ function applyBundleDefaults() { if (!bundleForm.api_branch) { bundleForm.api_branch = selectedServiceSet.value?.targets?.api?.branch || DEFAULT_RELEASE_BRANCH; } - if (!bundleForm.version_label && bundleForm.channel_id) { - const channelSlug = - channels.value.find((channel) => Number(channel.id) === Number(bundleForm.channel_id))?.slug || "release"; - bundleForm.version_label = `${channelSlug}-bundle-${new Date().toISOString().slice(0, 10)}`; + applyBundleVersionLabelDefault(); +} + +function bundleVersionLabelForChannel(channelId = bundleForm.channel_id) { + const channelSlug = + channels.value.find((channel) => Number(channel.id) === Number(channelId))?.slug || + selectedBundleChannel()?.slug || + "release"; + return `${channelSlug}-bundle-${new Date().toISOString().slice(0, 10)}`; +} + +function applyBundleVersionLabelDefault({ force = false } = {}) { + if (!bundleForm.channel_id) { + return; } + const currentLabel = String(bundleForm.version_label || "").trim(); + const previousGeneratedLabel = String(bundleGeneratedVersionLabel.value || "").trim(); + const shouldApply = + force || + bundleVersionLabelAutoManaged.value || + currentLabel === "" || + (previousGeneratedLabel !== "" && currentLabel === previousGeneratedLabel); + if (!shouldApply) { + return; + } + const nextLabel = bundleVersionLabelForChannel(); + bundleForm.version_label = nextLabel; + bundleGeneratedVersionLabel.value = nextLabel; + bundleVersionLabelAutoManaged.value = true; +} + +function handleBundleVersionLabelInput(value) { + const label = String(value || "").trim(); + if (label === "") { + bundleVersionLabelAutoManaged.value = true; + applyBundleVersionLabelDefault({ force: true }); + return; + } + bundleVersionLabelAutoManaged.value = label === String(bundleGeneratedVersionLabel.value || "").trim(); } async function load() { @@ -1968,18 +2430,7 @@ async function saveTarget() { }); const savedTarget = responseData(savedResponse, {}); if (targetForm.coolify_deploy_now && savedTarget?.id) { - await startReleaseDeployment({ - target_id: savedTarget.id, - channel_id: savedTarget.channel_id, - app: savedTarget.app, - repository: savedTarget.repository, - branch: savedTarget.branch, - commit_mode: "latest", - commit_sha: "", - version_label: `${savedTarget.channel_slug || "release"}-${savedTarget.app}-${new Date() - .toISOString() - .slice(0, 10)}`, - }); + await startReleaseDeployment(deploymentPayloadFromTarget(savedTarget)); } Object.assign(targetForm, { id: null, @@ -2017,6 +2468,17 @@ function useTargetForDeployment(target) { activeTab.value = "deployments"; } +async function redeployTarget(target) { + if (!target?.id) { + return; + } + + await run(`target:${target.id}:redeploy`, async () => { + await startReleaseDeployment(deploymentPayloadFromTarget(target)); + await load(); + }); +} + async function triggerDeployment() { await run("deployment:start", async () => { deploymentAccessResult.value = await checkGithubAccess(deploymentForm); @@ -2076,27 +2538,308 @@ async function run(key, callback) { } function statusClass(status) { + const normalizedStatus = String(status || "").toLowerCase(); return { - "is-ok": ["ok", "active", "deployed", "promoted", "ready", "stable", "enabled", "isolated_stack"].includes(status), + "is-ok": [ + "ok", + "active", + "deployed", + "promoted", + "ready", + "stable", + "enabled", + "isolated_stack", + "healthy", + "protected", + ].includes(normalizedStatus), "is-degraded": [ "queued", + "running", + "building", + "pending", "deploying", "draft", "metadata", "not_configured", + "missing", "warning", + "attention", + "deployment_in_progress", + "stale_unknown", + "unconfigured", "needs_configuration", "needs_clone_targets", "needs_isolated_targets", "isolated_empty", "provisioning", + "waiting_for_coolify", + "restarting", "superseded", "removed", - ].includes(status), - "is-down": ["failed", "down", "error"].includes(status), + ].includes(normalizedStatus), + "is-down": [ + "critical", + "blocked", + "failed", + "down", + "error", + "service_unhealthy", + "reconcile_failed", + "restart_failed", + "provision_blocked", + "degraded", + "failover_blocked", + "destructive_action_required", + ].includes(normalizedStatus), }; } +function releaseStatusSeverityRank(severity) { + if (severity === "critical") { + return 2; + } + if (severity === "warning") { + return 1; + } + return 0; +} + +function releaseStatusIssueSort(a, b) { + const rank = releaseStatusSeverityRank(b?.severity) - releaseStatusSeverityRank(a?.severity); + if (rank !== 0) { + return rank; + } + const typeRank = releaseStatusIssueTypeRank(b?.type) - releaseStatusIssueTypeRank(a?.type); + if (typeRank !== 0) { + return typeRank; + } + return String(a?.channel_slug || "").localeCompare(String(b?.channel_slug || "")); +} + +function releaseStatusIssueTypeRank(type) { + return ( + { + failed_deployment: 5, + service_unhealthy: 4, + missing_value: 3, + deployment_in_progress: 2, + stale_unknown: 1, + }[type] || 0 + ); +} + +function releaseStatusMaxSeverity(severities) { + return (Array.isArray(severities) ? severities : []).reduce( + (current, severity) => (releaseStatusSeverityRank(severity) > releaseStatusSeverityRank(current) ? severity : current), + "ok" + ); +} + +function releaseStatusIssueKey(issue) { + return [ + issue?.type || "", + issue?.channel_id || issue?.channel_slug || "", + issue?.service_key || "", + issue?.missing_key || "", + issue?.deployment_id || "", + issue?.coolify_target_id || "", + ].join(":"); +} + +function releaseStatusServiceLabel(service) { + const labels = { + frontend: tr("tables.frontend"), + api: tr("tables.api"), + database: trFallback("values.stack.database", "Database"), + redis: trFallback("values.stack.redis", "Redis"), + minio: trFallback("values.stack.minio", "MinIO"), + }; + return labels[service] || readableValue(service); +} + +function releaseStatusServiceIcon(service) { + return ( + { + frontend: "fa-desktop", + api: "fa-code", + database: "fa-database", + redis: "fa-bolt", + minio: "fa-archive", + }[service] || "fa-cube" + ); +} + +function releaseStatusSeverityLabel(severity) { + return ( + { + critical: trFallback("status.severity.critical", "Critical"), + warning: trFallback("status.severity.warning", "Warning"), + ok: trFallback("status.severity.ok", "Ready"), + }[severity] || statusLabel(severity) + ); +} + +function releaseStatusReadinessLabel(readiness) { + return ( + { + blocked: trFallback("status.readiness.blocked", "Blocked"), + attention: trFallback("status.readiness.attention", "Needs attention"), + ready: trFallback("status.readiness.ready", "Ready"), + }[readiness] || statusLabel(readiness) + ); +} + +function releaseStatusSeverityClass(severity) { + return statusClass(severity === "ok" ? "ready" : severity); +} + +function releaseStatusMissingLabel(missing) { + const key = typeof missing === "string" ? missing : missing?.key || missing?.missing_key || ""; + const labels = { + release_bundle: trFallback("status.missing.release_bundle", "Release bundle"), + frontend_version: trFallback("status.missing.frontend_version", "Frontend version"), + frontend_base_url: trFallback("status.missing.frontend_base_url", "Frontend URL"), + frontend_coolify_service_uuid: trFallback("status.missing.frontend_coolify_service_uuid", "Frontend Coolify service"), + api_version: trFallback("status.missing.api_version", "API version"), + api_base_url: trFallback("status.missing.api_base_url", "API URL"), + api_coolify_service_uuid: trFallback("status.missing.api_coolify_service_uuid", "API Coolify service"), + database_service: trFallback("status.missing.database_service", "Database service"), + redis_service: trFallback("status.missing.redis_service", "Redis service"), + minio_service: trFallback("status.missing.minio_service", "MinIO service"), + }; + + return (typeof missing === "object" && missing?.label) || labels[key] || readableValue(key); +} + +function releaseStatusMissingServiceKey(key) { + return ( + { + frontend_version: "frontend", + frontend_base_url: "frontend", + frontend_coolify_service_uuid: "frontend", + api_version: "api", + api_base_url: "api", + api_coolify_service_uuid: "api", + database_service: "database", + redis_service: "redis", + minio_service: "minio", + }[key] || null + ); +} + +function releaseStatusMissingTargetTab(key) { + return ( + { + release_bundle: "bundles", + frontend_version: "deployments", + api_version: "deployments", + frontend_base_url: "integrations", + api_base_url: "integrations", + frontend_coolify_service_uuid: "integrations", + api_coolify_service_uuid: "integrations", + database_service: "bundles", + redis_service: "bundles", + minio_service: "bundles", + }[key] || "overview" + ); +} + +function releaseStatusMissingNextAction(key) { + return ( + { + release_bundle: trFallback("status.actions.create_bundle", "Create or deploy a release bundle, then attach it."), + frontend_version: trFallback("status.actions.deploy_frontend", "Deploy the missing frontend version."), + api_version: trFallback("status.actions.deploy_api", "Deploy the missing API version."), + frontend_base_url: trFallback("status.actions.set_frontend_url", "Set the frontend public release URL."), + api_base_url: trFallback("status.actions.set_api_url", "Set the API public release URL."), + database_service: trFallback("status.actions.add_database", "Add the missing database service."), + redis_service: trFallback("status.actions.add_redis", "Add the missing Redis service."), + minio_service: trFallback("status.actions.add_minio", "Add the missing MinIO service."), + }[key] || trFallback("status.actions.complete_missing", "Complete the missing release value.") + ); +} + +function releaseStatusIssueTypeLabel(type) { + return ( + { + missing_value: trFallback("status.issue_types.missing_value", "Missing value"), + failed_deployment: trFallback("status.issue_types.failed_deployment", "Failed deployment"), + service_unhealthy: trFallback("status.issue_types.service_unhealthy", "Service unhealthy"), + deployment_in_progress: trFallback("status.issue_types.deployment_in_progress", "Deployment in progress"), + stale_unknown: trFallback("status.issue_types.stale_unknown", "Unknown or stale"), + }[type] || statusLabel(type) + ); +} + +function releaseStatusServiceStatusLabel(service) { + const status = service?.state && service.state !== "ready" ? service.state : service?.status || "ready"; + return statusLabel(status); +} + +function releaseStatusVersionLabel(channel, app) { + return channel?.versions?.[app]?.version_label || channel?.versions?.[app]?.commit_sha || "--"; +} + +function releaseStatusBundleLabel(channel) { + return channel?.versions?.bundle_id ? `#${channel.versions.bundle_id}` : "--"; +} + +function releaseStatusReplayLabel(channel) { + return channel?.replay?.enabled + ? valueLabel("capture_level", channel.replay.capture_level) + : tr("common.off"); +} + +function releaseStatusLatestDeploymentText(channel) { + const deployment = Array.isArray(channel?.latest_deployments) ? channel.latest_deployments[0] : null; + if (!deployment) { + return "--"; + } + return `${releaseStatusServiceLabel(deployment.app)}: ${statusLabel(deployment.status)}`; +} + +function releaseStatusSourceChannel(statusChannel) { + return ( + channels.value.find((channel) => Number(channel.id) === Number(statusChannel?.channel_id)) || + channels.value.find((channel) => String(channel.slug || "") === String(statusChannel?.channel_slug || "")) || { + id: statusChannel?.channel_id, + slug: statusChannel?.channel_slug, + name: statusChannel?.channel_name, + versions: statusChannel?.versions || {}, + } + ); +} + +function openReleaseStatusDetails(channel, issue = null, serviceKey = "") { + selectedStatusChannelId.value = channel?.channel_id || channel?.id || issue?.channel_id || null; + selectedStatusIssue.value = issue; + selectedStatusServiceKey.value = serviceKey || issue?.service_key || ""; +} + +function openReleaseStatusIssue(issue) { + const channel = statusChannels.value.find( + (item) => + Number(item.channel_id) === Number(issue?.channel_id) || + String(item.channel_slug || "") === String(issue?.channel_slug || "") + ); + openReleaseStatusDetails(channel, issue, issue?.service_key || ""); +} + +function openReleaseStatusService(channel, service) { + openReleaseStatusDetails(channel, null, service?.service_key || ""); +} + +function closeReleaseStatusDetails() { + selectedStatusChannelId.value = null; + selectedStatusIssue.value = null; + selectedStatusServiceKey.value = ""; +} + +function goToReleaseStatusTab(tab) { + activeTab.value = tab; + closeReleaseStatusDetails(); +} + function normalizedBundleStatus(bundle) { return String(bundle?.status || "").toLowerCase(); } @@ -2219,6 +2962,13 @@ watch( } ); +watch( + () => bundleForm.channel_id, + () => { + applyBundleVersionLabelDefault(); + } +); + watch( () => bundleForm.dataset_mode, (mode) => { @@ -2281,6 +3031,63 @@ onMounted(async () => { @@ -4294,6 +5467,420 @@ onMounted(async () => { background: #f7fafc; } +.release-status-dashboard { + display: grid; + gap: 0.875rem; + margin-bottom: 1rem; +} + +.release-status-dashboard--first { + align-items: start; + grid-template-columns: minmax(0, 1fr) minmax(17rem, 0.9fr); + margin-top: 0.85rem; +} + +.release-status-dashboard--first .release-status-summary { + grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); +} + +.release-status-dashboard--first .release-status-issues { + max-height: 17rem; + position: static; +} + +.release-status-dashboard--first .release-status-summary-card { + min-height: 3.35rem; + padding: 0.55rem 0.65rem; +} + +.release-status-dashboard--first .release-status-summary-card strong { + font-size: 1.15rem; +} + +.release-status-dashboard--channels .release-status-summary, +.release-status-dashboard--channels .release-status-issues { + display: none; +} + +.release-status-dashboard--channels .release-status-layout { + grid-template-columns: minmax(0, 1fr); +} + +.release-status-summary { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(auto-fit, minmax(138px, 1fr)); +} + +.release-status-summary-card { + background: #ffffff; + border: 1px solid #d8dee8; + border-left-width: 4px; + border-radius: 6px; + min-height: 4.4rem; + padding: 0.75rem; +} + +.release-status-summary-card span { + color: #536173; + display: block; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +.release-status-summary-card strong { + color: #172033; + display: block; + font-size: 1.45rem; + line-height: 1.2; + margin-top: 0.2rem; + overflow-wrap: anywhere; +} + +.release-status-summary-card.is-ok { + border-left-color: #2f855a; +} + +.release-status-summary-card.is-degraded { + border-left-color: #b7791f; +} + +.release-status-summary-card.is-down { + border-left-color: #c53030; +} + +.release-status-layout { + align-items: start; + display: grid; + gap: 0.875rem; + grid-template-columns: minmax(17rem, 0.85fr) minmax(0, 2fr); +} + +.release-status-issues, +.release-status-channel-card { + background: #ffffff; + border: 1px solid #d8dee8; + border-radius: 6px; +} + +.release-status-issues { + align-self: start; + display: grid; + gap: 0.6rem; + max-height: calc(100vh - 8rem); + overflow: auto; + padding: 0.75rem; + position: sticky; + top: 0.75rem; +} + +.release-status-issues > header, +.release-status-channel-card > header, +.release-status-drawer-section > header, +.release-status-drawer__header, +.release-status-drawer-actions, +.release-status-issue__top { + align-items: center; + display: flex; + gap: 0.6rem; + justify-content: space-between; +} + +.release-status-issues > header p, +.release-status-channel-card header span, +.release-status-channel-message, +.release-status-issue small, +.release-status-drawer__header p, +.release-status-drawer-section header span, +.release-status-drawer-issue small, +.release-status-drawer-list span, +.release-status-service small { + color: #667085; +} + +.release-status-issues > header p, +.release-status-channel-message, +.release-status-drawer__header p { + margin: 0; +} + +.release-status-issue { + background: #fbfcfe; + border: 1px solid #d8dee8; + border-left-width: 4px; + border-radius: 6px; + color: #172033; + cursor: pointer; + display: grid; + gap: 0.35rem; + padding: 0.65rem; + text-align: left; + width: 100%; +} + +.release-status-issue:hover, +.release-status-service:hover, +.release-status-missing-values button:hover { + border-color: #6d7b90; +} + +.release-status-empty { + align-items: start; + background: #f7fafc; + border: 1px dashed #bdc7d4; + border-radius: 6px; + color: #334155; + display: grid; + gap: 0.3rem; + justify-items: start; + padding: 0.75rem; +} + +.release-status-channels { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(285px, 1fr)); +} + +.release-status-channel-card { + border-left-width: 4px; + display: grid; + gap: 0.75rem; + min-width: 0; + padding: 0.8rem; +} + +.release-status-channel-card.is-ok, +.release-status-issue.is-ok, +.release-status-service.is-ok, +.release-status-drawer-issue.is-ok { + border-left-color: #2f855a; +} + +.release-status-channel-card.is-degraded, +.release-status-issue.is-degraded, +.release-status-service.is-degraded, +.release-status-drawer-issue.is-degraded { + border-left-color: #b7791f; +} + +.release-status-channel-card.is-down, +.release-status-issue.is-down, +.release-status-service.is-down, +.release-status-drawer-issue.is-down { + border-left-color: #c53030; +} + +.release-status-card-tags, +.release-status-card-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + justify-content: flex-end; +} + +.release-status-card-meta, +.release-status-drawer-list { + display: grid; + gap: 0.45rem; +} + +.release-status-card-meta { + grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); +} + +.release-status-card-meta span, +.release-status-drawer-list > div { + background: #f7fafc; + border: 1px solid #e3e8ef; + border-radius: 6px; + min-width: 0; + padding: 0.55rem; +} + +.release-status-card-meta strong, +.release-status-drawer-list strong { + color: #172033; + display: block; + font-size: 0.78rem; +} + +.release-status-missing-values { + background: #fff8e6; + border: 1px solid #f2d184; + border-radius: 6px; + display: grid; + gap: 0.45rem; + padding: 0.6rem; +} + +.release-status-missing-values > div, +.release-status-services, +.release-status-drawer-services { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.release-status-missing-values button { + background: #ffffff; + border: 1px solid #ddb756; + border-radius: 999px; + color: #6f4700; + cursor: pointer; + font-size: 0.82rem; + padding: 0.22rem 0.55rem; +} + +.release-status-service { + align-items: center; + background: #ffffff; + border: 1px solid #d8dee8; + border-left-width: 4px; + border-radius: 6px; + color: #172033; + cursor: pointer; + display: inline-flex; + gap: 0.45rem; + min-height: 3rem; + min-width: 8.25rem; + padding: 0.45rem 0.55rem; + text-align: left; +} + +.release-status-service i { + color: #334155; + width: 1rem; +} + +.release-status-service span { + display: grid; + line-height: 1.15; + min-width: 0; +} + +.release-status-service strong, +.release-status-service small, +.release-status-card-meta span, +.release-status-drawer-list span { + overflow-wrap: anywhere; +} + +.release-status-service.is-selected { + box-shadow: 0 0 0 2px rgba(7, 71, 166, 0.18); +} + +.release-status-drawer-backdrop { + align-items: stretch; + background: rgba(15, 23, 42, 0.32); + bottom: 0; + display: flex; + justify-content: flex-end; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1000; +} + +.release-status-drawer { + background: #ffffff; + box-shadow: -12px 0 30px rgba(15, 23, 42, 0.18); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + max-width: 100%; + width: min(46rem, 92vw); +} + +.release-status-drawer__header, +.release-status-drawer-actions { + border-bottom: 1px solid #d8dee8; + padding: 0.95rem 1rem; +} + +.release-status-drawer__header h3 { + color: #172033; + font-size: 1.2rem; + line-height: 1.25; + margin: 0.15rem 0; +} + +.release-status-drawer__body { + display: grid; + gap: 0.85rem; + overflow: auto; + padding: 1rem; +} + +.release-status-drawer-section { + border: 1px solid #d8dee8; + border-radius: 6px; + display: grid; + gap: 0.65rem; + padding: 0.75rem; +} + +.release-status-drawer-issue { + align-items: start; + border: 1px solid #d8dee8; + border-left-width: 4px; + border-radius: 6px; + display: grid; + gap: 0.65rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: 0.65rem; +} + +.release-status-drawer-issue div { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.release-status-drawer-actions { + border-bottom: 0; + border-top: 1px solid #d8dee8; + flex-wrap: wrap; + justify-content: flex-start; +} + +@media (max-width: 768px) { + .release-status-dashboard--first { + grid-template-columns: minmax(0, 1fr); + } + + .release-status-layout { + grid-template-columns: minmax(0, 1fr); + } + + .release-status-issues { + max-height: none; + position: static; + } + + .release-status-dashboard--first .release-status-issues { + order: -1; + } + + .release-status-channels { + grid-template-columns: minmax(0, 1fr); + } + + .release-status-card-actions { + justify-content: flex-start; + } + + .release-status-drawer { + width: 100vw; + } + + .release-status-drawer-issue { + grid-template-columns: minmax(0, 1fr); + } +} + .release-control-plane { display: grid; grid-template-columns: minmax(12rem, 1fr) minmax(18rem, 2fr); @@ -4758,6 +6345,13 @@ onMounted(async () => { white-space: nowrap; } +.release-action-buttons { + align-items: center; + display: inline-flex; + gap: 0.35rem; + justify-content: flex-end; +} + .release-action-menu { display: inline-flex; } diff --git a/tests/e2e/coolify-infrastructure.spec.js b/tests/e2e/coolify-infrastructure.spec.js index 9c21bb0b..6c40abe0 100644 --- a/tests/e2e/coolify-infrastructure.spec.js +++ b/tests/e2e/coolify-infrastructure.spec.js @@ -18,6 +18,12 @@ function json(body, status = 200) { return { status, contentType: "application/json", + headers: { + "access-control-allow-origin": "*", + "access-control-allow-headers": + "Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + }, body: JSON.stringify(body), }; } @@ -86,7 +92,7 @@ function createCoolifyState() { lb_automation_mode: "report_only", hetzner_load_balancer_id: "123456", hetzner_cloud_api_token: "[redacted]", - public_gateway_host: "lb.truckwash.io", + public_gateway_host: "api-v2.truckwash.io", token_set: true, }, loadBalancer: { @@ -96,7 +102,7 @@ function createCoolifyState() { automation_enabled: false, automation_mode: "report_only", load_balancer_id: "123456", - public_gateway_host: "lb.truckwash.io", + public_gateway_host: "api-v2.truckwash.io", token_set: true, token_source: "config", required_services: [ @@ -198,6 +204,10 @@ function createCoolifyState() { removedHosts: [], reconciledTargets: [], lbReconciles: [], + routeDeploys: [], + apiCodeDeploys: [], + routePlans: [], + apiCodePlans: [], placement: { generated_at: "2026-05-18T08:00:00.000Z", servers: [ @@ -360,6 +370,117 @@ async function installCoolifyMocks(page, state) { ); }); + await page.route(/\/superuser\/coolify\/load-balancer\/routes\/deploy$/, async (route) => { + const payload = route.request().postDataJSON?.() || {}; + const dryRun = payload.dry_run !== false; + state.routeDeploys.push({ dry_run: dryRun }); + const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`; + const planned = [ + { + type: "deploy_gateway_route", + target_id: 13, + channel_slug: "internal", + app: "api", + resource_uuid: "api-application-uuid", + resource_type: "application", + public_url: `${gatewayBaseUrl}/internal/api`, + frontend_public_url: `${gatewayBaseUrl}/internal/frontend`, + target_ip: "65.21.214.30", + }, + ]; + state.routePlans.push(planned); + const warnings = [ + "No managed Coolify API application route was found for gateway targets: 94.130.142.41, 23.88.23.183.", + ]; + + await route.fulfill( + json({ + data: { + ok: true, + dry_run: dryRun, + mutated: !dryRun, + public_host: state.loadBalancer.config.public_gateway_host, + public_url: `${gatewayBaseUrl}/internal/api`, + planned, + applied: dryRun ? [] : planned, + skipped: [], + errors: [], + warnings, + coverage: { + enabled_gateway_ips: ["94.130.142.41", "65.21.214.30", "23.88.23.183"], + covered_target_ips: ["65.21.214.30"], + uncovered_gateway_ips: ["94.130.142.41", "23.88.23.183"], + }, + gateways: state.loadBalancer.gateways, + }, + }) + ); + }); + + await page.route(/\/superuser\/coolify\/load-balancer\/api\/deploy$/, async (route) => { + const payload = route.request().postDataJSON?.() || {}; + const dryRun = payload.dry_run !== false; + const deployRoutes = payload.deploy_routes !== false; + state.apiCodeDeploys.push({ dry_run: dryRun, deploy_routes: deployRoutes }); + const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`; + const planned = [ + { + type: "deploy_gateway_api_code", + target_id: 13, + channel_slug: "internal", + app: "api", + repository: "copenhagentruckwash/api", + branch: "master", + resource_uuid: "api-application-uuid", + public_url: `${gatewayBaseUrl}/internal/api`, + commit_mode: "latest", + }, + ]; + state.apiCodePlans.push(planned); + const routeDeploy = deployRoutes + ? { + ok: true, + dry_run: false, + mutated: true, + planned, + applied: planned, + skipped: [], + errors: [], + warnings: [], + } + : null; + + await route.fulfill( + json({ + data: { + ok: true, + dry_run: dryRun, + mutated: !dryRun, + deploy_routes: deployRoutes, + public_host: state.loadBalancer.config.public_gateway_host, + public_url: `${gatewayBaseUrl}/internal/api`, + planned, + applied: dryRun ? [] : planned, + skipped: [], + errors: [], + warnings: [], + deployment_wait: { + ok: true, + results: [ + { + deployment_uuid: "deployment-uuid", + status: "finished_or_not_running", + }, + ], + pending: [], + }, + route_deploy: routeDeploy, + gateways: state.loadBalancer.gateways, + }, + }) + ); + }); + await page.route(/\/superuser\/coolify\/gateways$/, async (route) => { await route.fulfill(json({ data: state.loadBalancer.gateways })); }); @@ -650,7 +771,7 @@ test.describe("Coolify infrastructure management", () => { await page.locator(".card-header").filter({ hasText: "Gateway load balancer" }).click(); await page.locator(".card-header").filter({ hasText: "Instances" }).click(); await page.locator(".card-header").filter({ hasText: "Managed targets" }).click(); - await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("lb.truckwash.io"); + await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("api-v2.truckwash.io"); await expect(page.getByTestId("coolify-gateways-table")).toContainText("65.21.214.30"); await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("Add target 65.21.214.30"); await page.getByRole("button", { name: "Dry-run reconcile" }).click(); @@ -659,6 +780,42 @@ test.describe("Coolify infrastructure management", () => { expect(state.lbReconciles.at(-1)).toEqual({ dry_run: false }); await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("ok"); await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("No planned changes."); + await page.getByRole("button", { name: "Dry-run route deploy" }).click(); + expect(state.routeDeploys.at(-1)).toEqual({ dry_run: true }); + expect(state.routePlans.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + public_url: "https://api-v2.truckwash.io/internal/api", + frontend_public_url: "https://api-v2.truckwash.io/internal/frontend", + }), + ]) + ); + await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route plan"); + await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Warnings 1"); + await page.getByRole("button", { name: "Deploy api-v2 route" }).click(); + expect(state.routeDeploys.at(-1)).toEqual({ dry_run: false }); + expect(state.routePlans.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + public_url: "https://api-v2.truckwash.io/internal/api", + frontend_public_url: "https://api-v2.truckwash.io/internal/frontend", + }), + ]) + ); + await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route deploy"); + await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Applied 1"); + await page.getByRole("button", { name: "Deploy latest API code" }).click(); + expect(state.apiCodeDeploys.at(-1)).toEqual({ dry_run: false, deploy_routes: true }); + expect(state.apiCodePlans.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + public_url: "https://api-v2.truckwash.io/internal/api", + }), + ]) + ); + await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("API code deploy"); + await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Applied 1"); + await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Route applied 1"); await expect(page.getByTestId("coolify-instances-table")).toContainText("Production Coolify"); await expect(page.getByTestId("coolify-targets-table")).toContainText("failover_ready"); @@ -692,7 +849,7 @@ test.describe("Coolify infrastructure management", () => { const state = await boot(page); await page.goto("/superuser/system/replication"); - await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 }); await createDatabaseReplica(page); await createRedisReplica(page); @@ -782,7 +939,7 @@ test.describe("Coolify infrastructure management", () => { await boot(page, state); await page.goto("/superuser/system/replication"); - await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 }); await expect.poll(() => state.provisionRequests.includes(2)).toBeTruthy(); await expect(page.getByTestId("replication-host-database-2")).toContainText( "Coolify: provisioned / failover_ready" @@ -848,6 +1005,7 @@ test.describe("Coolify infrastructure management", () => { await boot(page, state); await page.goto("/superuser/system/replication"); + await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 }); const row = page.getByTestId("replication-host-minio-22"); await expect(row).toContainText("Coolify: deploying / failover_blocked"); await expect(row.getByTestId("replication-host-progress")).toBeVisible(); diff --git a/tests/e2e/release-bootstrap.spec.js b/tests/e2e/release-bootstrap.spec.js index 27ecb849..61e2babd 100644 --- a/tests/e2e/release-bootstrap.spec.js +++ b/tests/e2e/release-bootstrap.spec.js @@ -5,12 +5,16 @@ const json = (body, status = 200) => ({ contentType: "application/json", headers: { "access-control-allow-origin": "*", + "access-control-allow-headers": + "Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", }, body: JSON.stringify(body), }); -test("non-default release frontend loads from lb.truckwash.io without redirecting", async ({ page }) => { +test("non-default release frontend loads from api-v2.truckwash.io without redirecting", async ({ page }) => { const releaseApiRequests = []; + const releaseEntryRequests = []; const runtime = { generated_at: "2026-05-20T10:00:00.000Z", trace_id: "trace-release-bootstrap", @@ -21,13 +25,13 @@ test("non-default release frontend loads from lb.truckwash.io without redirectin default_channel: false, }, versions: { - frontend: { version_label: "frontend-canary", deployed_url: "https://lb.truckwash.io/canary/frontend" }, - api: { version_label: "api-canary", deployed_url: "https://lb.truckwash.io/canary/api" }, + frontend: { version_label: "frontend-canary", deployed_url: "https://api-v2.truckwash.io/canary/frontend" }, + api: { version_label: "api-canary", deployed_url: "https://api-v2.truckwash.io/canary/api" }, bundle_id: 31, }, urls: { - frontend_base_url: "https://lb.truckwash.io/canary/frontend", - api_base_url: "https://lb.truckwash.io/canary/api", + frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", + api_base_url: "https://api-v2.truckwash.io/canary/api", }, availability: { configured: true, @@ -45,7 +49,8 @@ test("non-default release frontend loads from lb.truckwash.io without redirectin await page.route("**/release/runtime**", async (route) => { await route.fulfill(json({ data: runtime })); }); - await page.route("https://lb.truckwash.io/canary/frontend/release-entry.json", async (route) => { + await page.route("https://api-v2.truckwash.io/canary/frontend/release-entry.json", async (route) => { + releaseEntryRequests.push(route.request().url()); await route.fulfill( json({ entry: "assets/release-canary.js", @@ -53,7 +58,7 @@ test("non-default release frontend loads from lb.truckwash.io without redirectin }) ); }); - await page.route("https://lb.truckwash.io/canary/frontend/assets/release-canary.css", async (route) => { + await page.route("https://api-v2.truckwash.io/canary/frontend/assets/release-canary.css", async (route) => { await route.fulfill({ status: 200, contentType: "text/css", @@ -61,7 +66,7 @@ test("non-default release frontend loads from lb.truckwash.io without redirectin body: "body::before { content: ''; }", }); }); - await page.route("https://lb.truckwash.io/canary/frontend/assets/release-canary.js", async (route) => { + await page.route("https://api-v2.truckwash.io/canary/frontend/assets/release-canary.js", async (route) => { await route.fulfill({ status: 200, contentType: "application/javascript", @@ -69,11 +74,11 @@ test("non-default release frontend loads from lb.truckwash.io without redirectin body: ` document.body.dataset.releaseFrontend = 'canary'; document.body.dataset.releaseOrigin = window.location.origin; - fetch('https://lb.truckwash.io/canary/api/ping').catch(() => {}); + fetch('https://api-v2.truckwash.io/canary/api/ping').catch(() => {}); `, }); }); - await page.route("https://lb.truckwash.io/canary/api/ping", async (route) => { + await page.route("https://api-v2.truckwash.io/canary/api/ping", async (route) => { releaseApiRequests.push(route.request().url()); await route.fulfill(json({ data: { ok: true } })); }); @@ -83,7 +88,9 @@ test("non-default release frontend loads from lb.truckwash.io without redirectin const currentOrigin = await page.evaluate(() => window.location.origin); expect(page.url()).toContain("/shared/passkey-safe-link"); - expect(page.url()).not.toContain("lb.truckwash.io"); + expect(page.url()).not.toContain("api-v2.truckwash.io"); + expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]); await expect.poll(() => releaseApiRequests.length).toBe(1); + expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping"); await expect(page.locator("body")).toHaveAttribute("data-release-origin", currentOrigin); }); diff --git a/tests/e2e/release-channel-unavailable.spec.js b/tests/e2e/release-channel-unavailable.spec.js index 70ff2bdd..61136aca 100644 --- a/tests/e2e/release-channel-unavailable.spec.js +++ b/tests/e2e/release-channel-unavailable.spec.js @@ -4,8 +4,15 @@ import { mockApi, seedAuthenticatedState } from "./support/network.js"; const json = (body, status = 200) => ({ status, contentType: "application/json", + headers: { + "access-control-allow-origin": "*", + "access-control-allow-headers": + "Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + }, body: JSON.stringify(body), }); +const GUARD_TIMEOUT_MS = 30_000; const unavailableRuntime = { generated_at: "2026-05-19T09:00:00.000Z", @@ -141,7 +148,7 @@ test("users assigned to an unconfigured release channel can ignore the guard tem await page.goto("/user", { waitUntil: "domcontentloaded" }); const guard = page.getByTestId("release-channel-unavailable-page"); - await expect(guard).toBeVisible(); + await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); await expect(guard).toContainText("Release channel is not ready"); await expect(guard).toContainText("Canary"); await expect(page.getByTestId("release-channel-missing")).toContainText("Release bundle"); @@ -154,6 +161,101 @@ test("users assigned to an unconfigured release channel can ignore the guard tem await page.evaluate(() => window.localStorage.removeItem("release_channel_unavailable_ignore_until")); }); +test("invalid release runtime JSON shows the unavailable guard instead of crashing bootstrap", async ({ page }) => { + const internalRuntime = { + ...unavailableRuntime, + channel: { + ...unavailableRuntime.channel, + slug: "internal", + name: "Internal", + default_channel: false, + }, + versions: { frontend: null, api: null, bundle_id: null }, + availability: { + configured: false, + missing: ["release_runtime"], + status: "unconfigured", + }, + }; + const consoleErrors = []; + + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + await boot(page, internalRuntime); + await page.addInitScript(() => { + window.localStorage.setItem("release_channel_selected_slug", "internal"); + }); + await page.route("**/release/runtime**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/html", + headers: { + "access-control-allow-origin": "*", + "access-control-allow-headers": + "Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + }, + body: '
Warning Composer autoload warning {"success":true}', + }); + }); + + await page.goto("/user", { waitUntil: "domcontentloaded" }); + const guard = page.getByTestId("release-channel-unavailable-page"); + await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); + await expect(guard).toContainText("Internal"); + await expect(page.getByTestId("release-channel-missing")).toContainText("Release runtime"); + expect(consoleErrors.join("\n")).not.toContain("Cannot read properties of null"); +}); + +test("selected channel auth session 404 shows the release channel guard", async ({ page }) => { + const internalRuntime = { + ...availableRuntime(), + channel: { + ...unavailableRuntime.channel, + slug: "internal", + name: "Internal", + default_channel: false, + }, + frontend_base_url: null, + api_base_url: "https://api-v2.truckwash.io/internal/api", + urls: { + frontend_base_url: null, + api_base_url: "https://api-v2.truckwash.io/internal/api", + }, + availability: { + configured: true, + missing: [], + status: "ready", + }, + }; + const channelApiRequests = []; + + await boot(page, internalRuntime); + await page.addInitScript(() => { + window.localStorage.setItem("release_channel_selected_slug", "internal"); + }); + await page.route("**/release/runtime**", async (route) => { + await route.fulfill(json({ data: internalRuntime })); + }); + await page.route("https://api-v2.truckwash.io/internal/api/auth/session**", async (route) => { + channelApiRequests.push(route.request().url()); + await route.fulfill(json({ data: { message: "Not found" } }, 404)); + }); + + await page.goto("/user", { waitUntil: "domcontentloaded" }); + + const guard = page.getByTestId("release-channel-unavailable-page"); + await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); + await expect(guard).toContainText("Internal"); + await expect(page.getByTestId("release-channel-missing")).toContainText("API URL"); + await expect(page.locator(".swal2-popup")).toHaveCount(0); + await expect.poll(() => channelApiRequests.length).toBeGreaterThan(0); + expect(channelApiRequests.every((url) => url.startsWith("https://api-v2.truckwash.io/internal/api/"))).toBe(true); +}); + test("release channel choices show git commit and release time when available", async ({ page }) => { const runtime = runtimeWithSelectableReleaseDetails(); await boot(page, runtime); @@ -163,7 +265,7 @@ test("release channel choices show git commit and release time when available", await page.goto("/user", { waitUntil: "domcontentloaded" }); const guard = page.getByTestId("release-channel-unavailable-page"); - await expect(guard).toBeVisible(); + await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); const canary = page.getByTestId("release-channel-option-canary"); await expect(canary).toBeVisible(); @@ -190,6 +292,28 @@ test("predefined release channel text is localized on the guard page", async ({ name: "Internal", description: "Internal staff and superuser validation channel.", }, + versions: { + frontend: { + version_label: "frontend-internal", + commit_sha: "130cc2fc106a1111222233334444555566667777", + deployed_at: "2026-05-20T09:32:00.000Z", + }, + api: { + version_label: "api-internal", + commit_sha: "24ac681365511111222233334444555566667777", + deployed_at: "2026-05-20T09:32:00.000Z", + }, + bundle_id: 10, + bundle: { + id: 10, + promoted_at: "2026-05-20T09:33:00.000Z", + }, + }, + availability: { + configured: false, + missing: ["frontend_base_url"], + status: "unconfigured", + }, }; await boot(page, internalRuntime, "da"); @@ -199,10 +323,12 @@ test("predefined release channel text is localized on the guard page", async ({ await page.goto("/user", { waitUntil: "domcontentloaded" }); const guard = page.getByTestId("release-channel-unavailable-page"); - await expect(guard).toBeVisible(); + await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); await expect(guard).toContainText("Release-kanalen er ikke klar"); await expect(guard).toContainText("Intern"); await expect(guard).toContainText("Intern kanal til medarbejdere"); + await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend-URL"); + await expect(page.getByTestId("release-channel-missing")).not.toContainText("frontend_base_url"); await expect(guard).not.toContainText("Internal staff"); await expect(guard).not.toContainText(/\binternal\b/); await expect(guard).not.toContainText(/\p{L}\?\p{L}|\?\p{L}/u); @@ -218,7 +344,7 @@ test("users assigned to an unconfigured release channel can check again when it await page.goto("/user", { waitUntil: "domcontentloaded" }); const guard = page.getByTestId("release-channel-unavailable-page"); - await expect(guard).toBeVisible(); + await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); const currentUrl = page.url(); releaseRuntime = availableRuntime(); diff --git a/tests/e2e/release-manager.spec.js b/tests/e2e/release-manager.spec.js index 876e3223..a6e29d61 100644 --- a/tests/e2e/release-manager.spec.js +++ b/tests/e2e/release-manager.spec.js @@ -33,6 +33,8 @@ function createReleaseState() { nextBundleId: 1, nextCoolifyTargetId: 20, assignmentPayloads: [], + bundlePayloads: [], + deploymentPayloads: [], channels: [ { id: 1, @@ -250,8 +252,205 @@ function createReleaseState() { }; } +function releaseStatusService(label, service_key, overrides = {}) { + return { + service_key, + label, + status: "ready", + state: "ready", + severity: "ok", + message: `${label} release service is ready.`, + next_action: "", + target_tab: "overview", + issue_type: null, + ...overrides, + }; +} + +function releaseStatusIssue(channel, service, overrides = {}) { + return { + severity: "critical", + type: "missing_value", + channel_id: channel.id, + channel_slug: channel.slug, + service_key: service, + label: service ? releaseStatusServiceLabel(service) : "Release bundle", + message: "", + next_action: "", + target_tab: "overview", + target_id: null, + deployment_id: null, + coolify_target_id: null, + missing_key: null, + ...overrides, + }; +} + +function releaseStatusServiceLabel(service) { + return ( + { + frontend: "Frontend", + api: "API", + database: "Database", + redis: "Redis", + minio: "MinIO", + }[service] || service + ); +} + +function releaseStatusOverviewFixture(state) { + const channels = state.channels.map((channel) => { + const latestDeployments = state.deployments.filter( + (deployment) => Number(deployment.channel_id) === Number(channel.id) + ); + const failedApi = latestDeployments.find( + (deployment) => deployment.app === "api" && deployment.status === "failed" + ); + const missingValues = + channel.slug === "beta" + ? [ + { key: "release_bundle", label: "Release bundle", service_key: null, target_tab: "bundles" }, + { key: "frontend_version", label: "Frontend version", service_key: "frontend", target_tab: "deployments" }, + { key: "frontend_base_url", label: "Frontend URL", service_key: "frontend", target_tab: "integrations" }, + { key: "api_version", label: "API version", service_key: "api", target_tab: "deployments" }, + { key: "api_base_url", label: "API URL", service_key: "api", target_tab: "integrations" }, + ] + : []; + const services = [ + releaseStatusService("Frontend", "frontend", { + version_label: channel.versions?.frontend?.version_label || null, + deployment_id: latestDeployments.find((deployment) => deployment.app === "frontend")?.id || null, + }), + failedApi + ? releaseStatusService("API", "api", { + status: "failed", + state: "failed", + severity: "critical", + issue_type: "failed_deployment", + message: failedApi.failure_summary.root_cause, + next_action: failedApi.failure_summary.next_action, + target_tab: "deployments", + deployment_id: failedApi.id, + }) + : releaseStatusService("API", "api", { + version_label: channel.versions?.api?.version_label || null, + deployment_id: latestDeployments.find((deployment) => deployment.app === "api")?.id || null, + }), + releaseStatusService("Database", "database"), + releaseStatusService("Redis", "redis"), + releaseStatusService("MinIO", "minio"), + ]; + if (channel.slug === "beta") { + services[0] = releaseStatusService("Frontend", "frontend", { + status: "missing", + state: "missing_value", + severity: "critical", + issue_type: "missing_value", + message: "Frontend is missing Frontend version.", + next_action: "Deploy the missing frontend version.", + target_tab: "deployments", + missing_key: "frontend_version", + }); + services[1] = releaseStatusService("API", "api", { + status: "missing", + state: "missing_value", + severity: "critical", + issue_type: "missing_value", + message: "API is missing API version.", + next_action: "Deploy the missing API version.", + target_tab: "deployments", + missing_key: "api_version", + }); + } + const serviceIssues = services + .filter((service) => service.severity !== "ok" && service.issue_type) + .map((service) => + releaseStatusIssue(channel, service.service_key, { + severity: service.severity, + type: service.issue_type, + label: service.label, + message: service.message, + next_action: service.next_action, + target_tab: service.target_tab, + deployment_id: service.deployment_id, + missing_key: service.missing_key, + }) + ); + const missingIssues = missingValues.map((missing) => + releaseStatusIssue(channel, missing.service_key, { + type: "missing_value", + label: missing.label, + message: `${channel.name} is missing ${missing.label}.`, + next_action: `Complete ${missing.label}.`, + target_tab: missing.target_tab, + missing_key: missing.key, + }) + ); + const issues = [...missingIssues, ...serviceIssues]; + const critical = issues.some((issue) => issue.severity === "critical"); + + return { + channel_id: channel.id, + channel_slug: channel.slug, + channel_name: channel.name, + default_channel: Boolean(channel.default_channel), + enabled: channel.enabled !== false, + severity: critical ? "critical" : "ok", + readiness: critical ? "blocked" : "ready", + message: critical + ? `${issues.length} issues need attention before promotion.` + : "All release services are ready.", + availability: { + configured: missingValues.length === 0, + missing: missingValues.map((missing) => missing.key), + status: missingValues.length === 0 ? "ready" : "unconfigured", + }, + missing_values: missingValues, + services, + versions: { + ...channel.versions, + bundle_id: channel.slug === "stable" ? 1 : channel.versions?.bundle_id, + }, + replay: { + enabled: Boolean(channel.replay_enabled), + capture_level: channel.capture_level, + }, + latest_deployments: latestDeployments, + issues, + }; + }); + const issues = channels + .flatMap((channel) => channel.issues) + .sort((a, b) => + a.severity === b.severity ? a.channel_slug.localeCompare(b.channel_slug) : a.severity === "critical" ? -1 : 1 + ); + const affectedChannels = new Set(issues.map((issue) => issue.channel_slug)); + + return { + generated_at: "2026-05-20T10:00:00.000Z", + state: issues.length > 0 ? "blocked" : "ready", + totals: { + channels: channels.length, + ready_channels: channels.filter((channel) => channel.readiness === "ready").length, + affected_channels: affectedChannels.size, + issues: issues.length, + critical: issues.filter((issue) => issue.severity === "critical").length, + warning: issues.filter((issue) => issue.severity === "warning").length, + services: channels.length * 5, + unhealthy_services: channels.reduce( + (count, channel) => count + channel.services.filter((service) => service.severity !== "ok").length, + 0 + ), + missing_values: issues.filter((issue) => issue.type === "missing_value").length, + }, + issues, + channels, + }; +} + function summary(state) { return { + generated_at: "2026-05-20T10:00:00.000Z", channels: state.channels, assignments: state.assignments, deployment_targets: state.targets, @@ -294,7 +493,7 @@ function summary(state) { "https://canary.example.test/health", "https://api-canary.example.test/ping", ], - load_balancer_domains: ["lb.truckwash.io", "release-api.truckwash.io"], + load_balancer_domains: ["api-v2.truckwash.io", "release-api.truckwash.io"], coolify_instances: [{ id: 3, label: "Production Coolify", status: "ok" }], coolify_projects: [ { @@ -359,6 +558,7 @@ function summary(state) { }, ], }, + status_overview: releaseStatusOverviewFixture(state), }; } @@ -366,6 +566,24 @@ function channelSlug(state, id) { return state.channels.find((channel) => Number(channel.id) === Number(id))?.slug || "stable"; } +function addInternalChannel(state) { + const channel = { + id: state.nextChannelId++, + slug: "internal", + name: "Internal", + description: "Staff-only channel", + enabled: true, + default_channel: false, + rollout_percent: 0, + replay_enabled: false, + capture_level: "metadata", + retention_days: 14, + versions: {}, + }; + state.channels.push(channel); + return channel; +} + function addDeployedBundle(state, overrides = {}) { const serviceSet = state.serviceSets[0]; const channelId = Number(overrides.channel_id || serviceSet?.channel_id || 2); @@ -926,6 +1144,7 @@ async function installReleaseMocks(page, state) { if (pathname.endsWith("/superuser/releases/bundles") && method === "POST") { const payload = request.postDataJSON?.() || {}; + state.bundlePayloads.push(payload); const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.service_set_id)); const frontendCommit = { sha: @@ -1027,6 +1246,7 @@ async function installReleaseMocks(page, state) { if (pathname.endsWith("/superuser/releases/deployments") && method === "POST") { const payload = request.postDataJSON?.() || {}; + state.deploymentPayloads.push(payload); const target = state.targets.find((entry) => Number(entry.id) === Number(payload.target_id)); const channelId = Number(payload.channel_id || target?.channel_id || 1); const deployment = { @@ -1140,6 +1360,68 @@ async function selectReleaseTab(page, name) { .click(); } +test("overview status dashboard prioritizes release issues and opens channel details", async ({ page }) => { + test.setTimeout(180_000); + const state = createReleaseState(); + await boot(page, state); + if (test.info().project.name.includes("mobile")) { + await page.waitForTimeout(5_000); + } + await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" }); + + const dashboard = page.getByTestId("release-status-dashboard"); + await expect(dashboard).toBeVisible({ timeout: 90_000 }); + await expect(dashboard.getByTestId("release-status-summary")).toContainText("Critical"); + + const issuePanel = dashboard.getByTestId("release-status-issues"); + const failedDeploymentIssue = issuePanel.getByRole("button").filter({ hasText: "Composer install failed" }).first(); + await expect(failedDeploymentIssue).toBeVisible(); + const viewport = page.viewportSize(); + const issueBox = await failedDeploymentIssue.boundingBox(); + expect(issueBox).toBeTruthy(); + expect(issueBox.y).toBeLessThan((viewport?.height || 900) - 24); + + const canaryCard = page.getByTestId("release-status-channel-card").filter({ hasText: "Canary" }).first(); + await expect(canaryCard).toContainText("Frontend"); + await expect(canaryCard).toContainText("API"); + await expect(canaryCard).toContainText("Database"); + await expect(canaryCard).toContainText("Redis"); + await expect(canaryCard).toContainText("MinIO"); + await canaryCard.getByTestId("release-status-details").click(); + + const drawer = page.getByTestId("release-status-drawer"); + await expect(drawer).toBeVisible(); + await expect(drawer).toContainText("Canary"); + await expect(drawer).toContainText("Run the PHP test suite in the php1 container"); + await expect(drawer.getByTestId("release-status-service")).toHaveCount(5); + await expect(drawer).toContainText("Frontend"); + await expect(drawer).toContainText("API"); + await expect(drawer).toContainText("Database"); + await expect(drawer).toContainText("Redis"); + await expect(drawer).toContainText("MinIO"); + + const drawerBox = await drawer.boundingBox(); + expect(drawerBox).toBeTruthy(); + expect(drawerBox.x).toBeGreaterThanOrEqual(0); + expect(drawerBox.width).toBeLessThanOrEqual((viewport?.width || 1440) + 1); + + await page.locator(".release-status-drawer .delete").click(); + const betaCard = page.getByTestId("release-status-channel-card").filter({ hasText: "Beta" }).first(); + await betaCard.getByTestId("release-status-details").click(); + await expect(drawer).toBeVisible(); + await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("Release bundle"); + await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("Frontend version"); + await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("Frontend URL"); + await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("API version"); + await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("API URL"); + + await drawer + .getByRole("button", { name: /Deployments/ }) + .first() + .click(); + await expect(page.getByTestId("release-bundle-flow")).toBeVisible(); +}); + test("channel bundle picker keeps summary, field, and actions aligned", async ({ page }) => { const state = createReleaseState(); addDeployedBundle(state, { channel_id: 2 }); @@ -1152,8 +1434,9 @@ test("channel bundle picker keeps summary, field, and actions aligned", async ({ .locator("tbody tr") .filter({ hasText: "Canary" }) .first(); - await channelRow.locator('[data-testid^="release-channel-actions-"] button').first().click(); - await page.getByRole("button", { name: "Set bundle" }).click(); + const channelActions = channelRow.locator('[data-testid^="release-channel-actions-"]'); + await channelActions.locator("button").first().click(); + await channelActions.getByRole("button", { name: "Set bundle" }).click(); const channelBundlePicker = page.getByTestId("release-channel-bundle-picker"); await expect(channelBundlePicker).toBeVisible(); @@ -1184,12 +1467,81 @@ test("channel bundle picker keeps summary, field, and actions aligned", async ({ } }); +test("bundle auto label follows the selected release channel", async ({ page }) => { + const state = createReleaseState(); + const internalChannel = addInternalChannel(state); + await boot(page, state); + await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" }); + + await selectReleaseTab(page, "Deployments"); + await expandActiveReleaseCategory(page); + + const today = new Date().toISOString().slice(0, 10); + const bundleFlow = page.getByTestId("release-bundle-flow"); + const bundleChannel = bundleFlow.getByTestId("release-bundle-channel"); + await expect(bundleChannel).toHaveValue("1"); + + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await bundleFlow.getByRole("button", { name: "Next" }).click(); + const versionLabel = bundleFlow.getByTestId("release-bundle-version-label"); + await expect(versionLabel).toHaveValue(`stable-bundle-${today}`); + + await bundleFlow.getByRole("button", { name: "Back" }).click(); + await bundleFlow.getByRole("button", { name: "Back" }).click(); + await bundleChannel.selectOption(String(internalChannel.id)); + await expect(bundleChannel).toHaveValue(String(internalChannel.id)); + + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await expect(versionLabel).toHaveValue(`internal-bundle-${today}`); + + await page.getByTestId("release-bundle-deploy-submit").click(); + await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed"); + expect(state.bundlePayloads[state.bundlePayloads.length - 1]).toMatchObject({ + channel_id: internalChannel.id, + version_label: `internal-bundle-${today}`, + }); + await expect(page.getByTestId("release-bundles-table")).toContainText(`internal-bundle-${today}`); +}); + +test("custom bundle label is preserved until cleared", async ({ page }) => { + const state = createReleaseState(); + const internalChannel = addInternalChannel(state); + await boot(page, state); + await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" }); + + await selectReleaseTab(page, "Deployments"); + await expandActiveReleaseCategory(page); + + const today = new Date().toISOString().slice(0, 10); + const bundleFlow = page.getByTestId("release-bundle-flow"); + const bundleChannel = bundleFlow.getByTestId("release-bundle-channel"); + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await bundleFlow.getByRole("button", { name: "Next" }).click(); + const versionLabel = bundleFlow.getByTestId("release-bundle-version-label"); + await expect(versionLabel).toHaveValue(`stable-bundle-${today}`); + + await versionLabel.fill("ops-smoke-test"); + await bundleFlow.getByRole("button", { name: "Back" }).click(); + await bundleFlow.getByRole("button", { name: "Back" }).click(); + await bundleChannel.selectOption(String(internalChannel.id)); + + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await expect(versionLabel).toHaveValue("ops-smoke-test"); + + await versionLabel.fill(""); + await expect(versionLabel).toHaveValue(`internal-bundle-${today}`); +}); + test("superusers manage release channels, assignments, deployments, and replay", async ({ page }) => { const state = await boot(page); await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole("link", { name: /Release Manager/i }).first()).toBeVisible(); + if ((page.viewportSize()?.width || 0) > 768) { + await expect(page.getByRole("link", { name: /Release Manager/i }).first()).toBeVisible(); + } await expect(page.locator(".tabs").getByText("Release Manager", { exact: true })).toBeVisible(); await expect(page.getByTestId("release-control-api")).toContainText("https://api.truckwash.io:4433"); await expect(page.getByTestId("release-guided-setup")).toContainText("Guided setup"); @@ -1275,12 +1627,15 @@ test("superusers manage release channels, assignments, deployments, and replay", ); const loadBalancerDomainInput = page.getByTestId("release-target-form").getByPlaceholder("Load balancer domain"); await loadBalancerDomainInput.click(); - await expect(page.getByRole("button", { name: /lb\.truckwash\.io/ })).toBeVisible(); - const domainOptions = page.locator(".autocomplete .dropdown-content").filter({ hasText: "lb.truckwash.io" }).last(); + await expect(page.getByRole("button", { name: /api-v2\.truckwash\.io/ })).toBeVisible(); + const domainOptions = page + .locator(".autocomplete .dropdown-content") + .filter({ hasText: "api-v2.truckwash.io" }) + .last(); await expect(domainOptions.getByText("http://localhost:5173")).toHaveCount(0); await expect(domainOptions.getByText("http://api.truckwash.io:4433")).toHaveCount(0); - await loadBalancerDomainInput.fill("lb.truckwash.io"); - await page.getByTestId("release-target-public-url").fill("https://lb.truckwash.io/release/canary/api"); + await loadBalancerDomainInput.fill("api-v2.truckwash.io"); + await page.getByTestId("release-target-public-url").fill("https://api-v2.truckwash.io/release/canary/api"); await expect(page.getByTestId("release-target-form").getByLabel("Coolify SSL")).toBeChecked(); await page.getByTestId("release-target-form").locator("select").nth(3).selectOption("project-internal"); await page.getByTestId("release-target-form").getByRole("button", { name: "Test access" }).click(); @@ -1290,9 +1645,32 @@ test("superusers manage release channels, assignments, deployments, and replay", await expect( page.getByTestId("release-targets-table").locator('[data-testid^="release-target-actions-"]').first() ).toBeVisible(); + const apiTargetRow = page + .getByTestId("release-targets-table") + .locator("tbody tr") + .filter({ hasText: "truckwash/backend-php#release/canary" }) + .first(); + await apiTargetRow.getByRole("button", { name: "Redeploy" }).click(); + const redeployDate = new Date().toISOString().slice(0, 10); + const savedApiTarget = state.targets.find((target) => target.repository === "truckwash/backend-php"); + const redeployChannelSlug = channelSlug(state, savedApiTarget?.channel_id); + expect(state.deploymentPayloads[state.deploymentPayloads.length - 1]).toMatchObject({ + target_id: savedApiTarget?.id, + channel_id: savedApiTarget?.channel_id, + app: "api", + repository: "truckwash/backend-php", + branch: "release/canary", + commit_mode: "latest", + commit_sha: "", + version_label: `${redeployChannelSlug}-api-${redeployDate}`, + }); await selectReleaseTab(page, "Deployments"); await expandActiveReleaseCategory(page); + const redeployRow = page.getByTestId("release-deployments-table").locator("tbody tr").first(); + await expect(redeployRow).toContainText("truckwash/backend-php"); + await expect(redeployRow).toContainText("release/canary"); + await expect(redeployRow).toContainText("latestcommit"); await expect(page.getByTestId("release-service-set-cards")).toContainText("Canary shared stack"); const bundleFlow = page.getByTestId("release-bundle-flow"); await bundleFlow.getByRole("button", { name: "Next" }).click(); @@ -1369,8 +1747,9 @@ test("superusers manage release channels, assignments, deployments, and replay", .locator("tbody tr") .filter({ hasText: createdBundleChannel.name }) .first(); - await channelRow.locator('[data-testid^="release-channel-actions-"] button').first().click(); - await page.getByRole("button", { name: "Set bundle" }).click(); + const createdChannelActions = channelRow.locator('[data-testid^="release-channel-actions-"]'); + await createdChannelActions.locator("button").first().click(); + await createdChannelActions.getByRole("button", { name: "Set bundle" }).click(); await expect(page.getByTestId("release-channel-bundle-picker")).toBeVisible(); await page.getByTestId("release-channel-bundle-select").click(); await page diff --git a/tests/e2e/release/release.live-smoke.spec.ts b/tests/e2e/release/release.live-smoke.spec.ts index 05827a77..d1d5617b 100644 --- a/tests/e2e/release/release.live-smoke.spec.ts +++ b/tests/e2e/release/release.live-smoke.spec.ts @@ -29,6 +29,19 @@ function getLiveSettings() { test.describe.configure({ mode: "serial" }); +test("api-v2 gateway ping serves a trusted TLS API response", async ({ request }) => { + const response = await request.get("https://api-v2.truckwash.io/ping"); + const body = await response.json(); + + expect(response.ok()).toBeTruthy(); + expect(body).toMatchObject({ + success: true, + data: { + message: "pong", + }, + }); +}); + test.describe("Live smoke release gate", () => { test.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate."); diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index a21c3121..d84fe928 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -13,6 +13,12 @@ function json(body, status = 200) { return { status, contentType: "application/json", + headers: { + "access-control-allow-origin": "*", + "access-control-allow-headers": + "Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + }, body: JSON.stringify(body), }; } diff --git a/tests/unit/axios-request-queue.spec.js b/tests/unit/axios-request-queue.spec.js index 1b5f6b0e..271a6bb8 100644 --- a/tests/unit/axios-request-queue.spec.js +++ b/tests/unit/axios-request-queue.spec.js @@ -104,28 +104,33 @@ describe("axios request queue interceptor", () => { configureReleaseRuntime({ channel: { slug: "canary" }, versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 }, - api_base_url: "https://lb.truckwash.io/canary/api", + api_base_url: "https://api-v2.truckwash.io/canary/api", }); let dispatchedUrl = ""; + let dispatchedHeaders = {}; const response = await axios({ url: `${API_URL}/orders`, method: "GET", adapter: async (config) => { dispatchedUrl = config.url; + dispatchedHeaders = config.headers || {}; return createResponse({ ok: true }); }, }); expect(response.data).toEqual({ ok: true }); - expect(dispatchedUrl).toBe("https://lb.truckwash.io/canary/api/orders"); + expect(dispatchedUrl).toBe("https://api-v2.truckwash.io/canary/api/orders"); + expect(dispatchedHeaders["X-Release-Trace"]).toBe("test-trace"); + expect(dispatchedHeaders["X-Release-Channel"]).toBe("canary"); + expect(dispatchedHeaders["X-Frontend-Version"]).toEqual(expect.any(String)); }); it("does not rewrite requests that opt out for release manager control calls", async () => { configureReleaseRuntime({ channel: { slug: "canary" }, versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 }, - api_base_url: "https://lb.truckwash.io/canary/api", + api_base_url: "https://api-v2.truckwash.io/canary/api", }); let dispatchedUrl = ""; diff --git a/tests/unit/release-bootstrap.spec.js b/tests/unit/release-bootstrap.spec.js index d1025e68..22af7402 100644 --- a/tests/unit/release-bootstrap.spec.js +++ b/tests/unit/release-bootstrap.spec.js @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RELEASE_RUNTIME_GLOBAL_KEY, bootstrapReleaseApp, + fetchReleaseRuntime, loadRemoteReleaseEntry, runtimeApiUrl, shouldLoadRemoteRelease, @@ -29,6 +30,24 @@ describe("release bootstrap", () => { ); }); + it("attaches release trace, channel, and frontend headers to runtime requests", async () => { + localStorage.setItem("release_trace_id", "trace-runtime"); + localStorage.setItem("release_channel_selected_slug", "Internal"); + const fetchFn = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: { channel: { slug: "internal" } } }), + })); + + await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api-v2.truckwash.io" }); + + const [, options] = fetchFn.mock.calls[0]; + expect(options.headers).toMatchObject({ + "X-Release-Trace": "trace-runtime", + "X-Release-Channel": "internal", + "X-Frontend-Version": expect.any(String), + }); + }); + it("loads the local app for the default channel", async () => { const loadLocalApp = vi.fn(async () => ({ local: true })); const fetchFn = vi.fn(async () => ({ @@ -47,13 +66,36 @@ describe("release bootstrap", () => { expect(window[RELEASE_RUNTIME_GLOBAL_KEY].channel.slug).toBe("stable"); }); + it("loads the local unavailable app state when selected release runtime returns invalid JSON", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + localStorage.setItem("release_channel_selected_slug", "internal"); + const loadLocalApp = vi.fn(async () => ({ local: true })); + const fetchFn = vi.fn(async () => ({ + ok: true, + text: async () => "
WarningComposer autoload warning", + })); + + await bootstrapReleaseApp({ loadLocalApp, fetchFn }); + + expect(loadLocalApp).toHaveBeenCalledTimes(1); + expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({ + channel: { slug: "internal", default_channel: false }, + availability: { + configured: false, + missing: ["release_runtime"], + status: "unconfigured", + }, + }); + expect(shouldLoadRemoteRelease(null)).toBe(false); + }); + it("loads a non-default release entry without changing the browser URL", async () => { const runtime = { channel: { slug: "canary", default_channel: false }, availability: { configured: true }, urls: { - frontend_base_url: "https://lb.truckwash.io/canary/frontend", - api_base_url: "https://lb.truckwash.io/canary/api", + frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", + api_base_url: "https://api-v2.truckwash.io/canary/api", }, }; const fetchFn = vi @@ -80,9 +122,9 @@ describe("release bootstrap", () => { }); expect(shouldLoadRemoteRelease(runtime)).toBe(true); - expect(importModule).toHaveBeenCalledWith("https://lb.truckwash.io/canary/frontend/assets/index-canary.js"); + expect(importModule).toHaveBeenCalledWith("https://api-v2.truckwash.io/canary/frontend/assets/index-canary.js"); expect(document.querySelector("link")?.href).toBe( - "https://lb.truckwash.io/canary/frontend/assets/index-canary.css" + "https://api-v2.truckwash.io/canary/frontend/assets/index-canary.css" ); expect(window.location.href).toBe(originalHref); }); @@ -93,7 +135,7 @@ describe("release bootstrap", () => { const runtime = { channel: { slug: "canary", default_channel: false }, availability: { configured: true }, - frontend_base_url: "https://lb.truckwash.io/canary/frontend", + frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", }; const fetchFn = vi .fn() @@ -126,7 +168,7 @@ describe("release bootstrap", () => { })); const importModule = vi.fn(async () => ({})); const runtime = { - frontend_base_url: "https://lb.truckwash.io/canary/frontend", + frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", }; await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document }); diff --git a/tests/unit/release-channel-availability.spec.js b/tests/unit/release-channel-availability.spec.js index fa5b4e38..50060d5b 100644 --- a/tests/unit/release-channel-availability.spec.js +++ b/tests/unit/release-channel-availability.spec.js @@ -8,6 +8,8 @@ import { getReleaseChannelUnavailableStatus, hasSelectableReleaseChannels, ignoreUnavailableReleaseChannel, + isReleaseChannelApiAvailabilityError, + markReleaseChannelApiUnavailable, reconcileSelectedReleaseChannel, RELEASE_CHANNEL_IGNORE_MS, RELEASE_CHANNEL_IGNORE_STORAGE_KEY, @@ -17,6 +19,11 @@ import { selectReleaseChannel, __resetReleaseChannelAvailabilityForTests, } from "@/services/releaseChannelAvailability.js"; +import { + __resetReleaseTimelineForTests, + configureReleaseRuntime, + releaseRuntimeState, +} from "@/services/releaseTimeline.js"; const canaryRuntime = { channel: { @@ -55,6 +62,7 @@ const configuredCanaryRuntime = { describe("release channel availability", () => { afterEach(() => { __resetReleaseChannelAvailabilityForTests(); + __resetReleaseTimelineForTests(); }); it("blocks a non-default assigned channel when targets are missing", () => { @@ -246,4 +254,35 @@ describe("release channel availability", () => { expect(redirectUrl).toBeNull(); }); + + it("classifies selected channel auth session 404s as release API availability failures", () => { + configureReleaseRuntime({ + trace_id: "trace-channel-api", + channel: { slug: "internal", name: "Intern", default_channel: false }, + versions: { + frontend: { version_label: "frontend-internal" }, + api: { version_label: "api-internal" }, + bundle_id: 42, + }, + api_base_url: "https://api-v2.truckwash.io/internal/api", + frontend_base_url: "https://api-v2.truckwash.io/internal/frontend", + }); + + const error = { + response: { status: 404 }, + config: { url: "https://api-v2.truckwash.io/internal/api/auth/session" }, + }; + + expect(isReleaseChannelApiAvailabilityError(error)).toBe(true); + + const unavailableRuntime = markReleaseChannelApiUnavailable(); + + expect(unavailableRuntime.availability).toMatchObject({ + configured: false, + status: "unconfigured", + explicit: true, + }); + expect(unavailableRuntime.availability.missing).toContain("api_base_url"); + expect(releaseRuntimeState.availability.configured).toBe(false); + }); }); diff --git a/tests/unit/release-manager-i18n.spec.js b/tests/unit/release-manager-i18n.spec.js index 20045e13..ebdc58ed 100644 --- a/tests/unit/release-manager-i18n.spec.js +++ b/tests/unit/release-manager-i18n.spec.js @@ -5,6 +5,66 @@ import { readJsonFile } from "./helpers/readJsonFile"; const root = process.cwd(); const activeLocales = ["da", "en", "sv", "de", "no"]; const channelKeys = ["stable", "canary", "internal"]; +const requiredReleaseAvailabilityLabels = { + channel_unavailable: [ + "release_bundle", + "frontend_version", + "api_version", + "frontend_base_url", + "api_base_url", + "frontend_entry", + "release_runtime", + ], + channel_selector: [ + "release_bundle", + "frontend_version", + "api_version", + "frontend_base_url", + "api_base_url", + "frontend_entry", + "release_runtime", + "bundle", + "frontend", + "api", + ], +}; +const expectedNewReleaseLabels = { + da: { + frontend_base_url: "Frontend-URL", + api_base_url: "API-URL", + frontend_entry: "Frontend-entry", + release_runtime: "Release-runtime", + }, + en: { + frontend_base_url: "Frontend URL", + api_base_url: "API URL", + frontend_entry: "Frontend entry", + release_runtime: "Release runtime", + }, + sv: { + frontend_base_url: "Frontend-URL", + api_base_url: "API-URL", + frontend_entry: "Frontend-entry", + release_runtime: "Release-runtime", + }, + de: { + frontend_base_url: "Frontend-URL", + api_base_url: "API-URL", + frontend_entry: "Frontend-Einstiegspunkt", + release_runtime: "Release-Laufzeit", + }, + no: { + frontend_base_url: "Frontend-URL", + api_base_url: "API-URL", + frontend_entry: "Frontend-entry", + release_runtime: "Release-runtime", + }, +}; +const sharedReleaseDetailLabels = { + bundle: "Bundle", + frontend: "Frontend", + api: "API", +}; const suspiciousTranslationArtifact = /(?:\p{L}\?\p{L}|(?:^|[\s([{])\?\p{L}|\p{L}\?@:\{|\u00c3|\u00c2|\ufffd|\u00ef\u00bf\u00bd)/u; @@ -89,6 +149,26 @@ describe("release manager i18n", () => { } }); + it("defines release channel readiness labels for all runtime catalogs", () => { + for (const [name, releaseManager] of loadReleaseManagerCatalogs()) { + const locale = name.split(" ")[0]; + + for (const [group, keys] of Object.entries(requiredReleaseAvailabilityLabels)) { + for (const key of keys) { + const value = releaseManager[group]?.[key]; + expect(value, `${name} ${group}.${key}`).toEqual(expect.any(String)); + expect(value, `${name} ${group}.${key}`).not.toBe(""); + expect(value, `${name} ${group}.${key}`).not.toBe(key); + + const expectedLabel = expectedNewReleaseLabels[locale]?.[key] ?? sharedReleaseDetailLabels[key] ?? null; + if (expectedLabel && !value.startsWith("@:")) { + expect(value, `${name} ${group}.${key}`).toBe(expectedLabel); + } + } + } + } + }); + it("uses Danish copy for the internal channel guard", () => { const danishReleaseManager = readJsonFile(join(root, "src/i18n/locales/da.json")).configuration.release_manager; diff --git a/tests/unit/release-timeline.spec.js b/tests/unit/release-timeline.spec.js index 77816ffa..cff70ead 100644 --- a/tests/unit/release-timeline.spec.js +++ b/tests/unit/release-timeline.spec.js @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __resetReleaseTimelineForTests, __setReleaseTimelineTransportForTests, + buildCurrentReleaseHeaders, buildReleaseTimelineContext, configureReleaseRuntime, flushReleaseTimelineEvents, @@ -54,15 +55,33 @@ describe("release timeline runtime", () => { bundle_id: 31, }, urls: { - frontend_base_url: "https://lb.truckwash.io/canary/frontend/", - api_base_url: "https://lb.truckwash.io/canary/api/", + frontend_base_url: "https://api-v2.truckwash.io/canary/frontend/", + api_base_url: "https://api-v2.truckwash.io/canary/api/", }, }); - expect(releaseRuntimeState.frontendBaseUrl).toBe("https://lb.truckwash.io/canary/frontend"); - expect(releaseRuntimeState.apiBaseUrl).toBe("https://lb.truckwash.io/canary/api"); - expect(getReleaseRuntimeApiBaseUrl()).toBe("https://lb.truckwash.io/canary/api"); - expect(resolveReleaseApiUrl("/orders")).toBe("https://lb.truckwash.io/canary/api/orders"); + expect(releaseRuntimeState.frontendBaseUrl).toBe("https://api-v2.truckwash.io/canary/frontend"); + expect(releaseRuntimeState.apiBaseUrl).toBe("https://api-v2.truckwash.io/canary/api"); + expect(getReleaseRuntimeApiBaseUrl()).toBe("https://api-v2.truckwash.io/canary/api"); + expect(resolveReleaseApiUrl("/orders")).toBe("https://api-v2.truckwash.io/canary/api/orders"); + }); + + it("builds release headers from the active runtime state", () => { + configureReleaseRuntime({ + trace_id: "trace-headers", + channel: { slug: "internal", name: "Intern" }, + versions: { + frontend: { version_label: "frontend-internal", commit_sha: "130cc2fc106a" }, + api: { version_label: "api-internal" }, + bundle_id: 42, + }, + }); + + expect(buildCurrentReleaseHeaders()).toMatchObject({ + "X-Release-Trace": "trace-headers", + "X-Release-Channel": "internal", + "X-Frontend-Version": "130cc2fc106a", + }); }); it("redacts sensitive payload keys recursively", () => { diff --git a/tests/unit/superuser-releases.spec.js b/tests/unit/superuser-releases.spec.js index 8d444928..50c96685 100644 --- a/tests/unit/superuser-releases.spec.js +++ b/tests/unit/superuser-releases.spec.js @@ -18,6 +18,7 @@ import { releaseManagerControlApiCandidates, setReleaseManagerControlApiUrl, } from "@/services/superuserReleases.js"; +import { deployCoolifyGatewayRoutes } from "@/services/superuserCoolify.js"; import { __configureRequestQueueForTests, __resetRequestQueueForTests } from "@/services/requestQueue.js"; describe("superuser release manager service", () => { @@ -71,4 +72,20 @@ describe("superuser release manager service", () => { expect(localStorage.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY)).toBeNull(); expect(releaseManagerControlApiCandidates()).toContain("https://api.truckwash.io:4433"); }); + + it("keeps Coolify route deployment on the release manager control API", async () => { + setReleaseManagerControlApiUrl("https://control.example.test"); + axiosMock.mockResolvedValueOnce({ status: 200, data: { success: true } }); + + await deployCoolifyGatewayRoutes({ dry_run: true }); + + expect(axiosMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://control.example.test/superuser/coolify/load-balancer/routes/deploy", + method: "POST", + data: { dry_run: true }, + __skipReleaseApiRewrite: true, + }) + ); + }); });