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.
This commit is contained in:
Jeppe Bundgaard
2026-05-20 17:52:54 +02:00
parent 130cc2fc10
commit ea8db593c5
43 changed files with 3191 additions and 171 deletions
@@ -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;
};
@@ -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;
})
);
@@ -1,7 +1,7 @@
<script>
import axios from 'axios'
import { enqueueRequest } from "@/services/requestQueue.js";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
/**
* Get the selected customer number for X-Customer-Number header (used by subusers)
@@ -19,7 +19,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
}
// Build headers
const headers = {};
const headers = {
...buildCurrentReleaseHeaders(),
};
if (token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
@@ -70,7 +72,8 @@ export const unauthenticatedRequest = (url, method, data, catchCallable = null,
return axios({
method,
url: resolveReleaseApiUrl(url),
data
data,
headers: buildCurrentReleaseHeaders(),
}).catch((error) => {
if (catchCallable) {
// Call the catch callable
@@ -94,6 +97,7 @@ export const paginatedGetRequest = (url, currentPage, itemsPerPage) => {
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
Authorization: `Bearer ${token}`
};
@@ -49,6 +49,8 @@ import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/Su
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
isReleaseChannelApiAvailabilityError,
markReleaseChannelApiUnavailable,
reconcileSelectedReleaseChannel,
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
@@ -262,6 +264,12 @@ export const getSubuserSessionData = async () => {
SessionUser.initiated.value = true;
})
.catch((error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
@@ -332,6 +340,12 @@ export const getSessionData = async () => {
SessionUser.initiated.value = true;
})
.catch((error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
@@ -1,13 +1,14 @@
<script>
import { ref } from 'vue'
import axios from 'axios'
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
export const unauthenticatedRequest = (url, method, data) => {
return axios({
method,
url: resolveReleaseApiUrl(url),
data,
headers: buildCurrentReleaseHeaders(),
});
};
</script>
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+13 -1
View File
@@ -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'}",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
+15 -3
View File
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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'}",
@@ -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",
@@ -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",
+7 -1
View File
@@ -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;
+45 -3
View File
@@ -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)) {
+76 -1
View File
@@ -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 || ""
+61
View File
@@ -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;
};
+14 -1
View File
@@ -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) {
+25 -19
View File
@@ -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);
+1 -1
View File
@@ -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();
@@ -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);
</script>
@@ -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"
>
<div class="coolify-lb" data-testid="coolify-load-balancer-card">
@@ -323,6 +354,69 @@ onMounted(load);
>
Enforce reconcile
</button>
<button
class="button"
type="button"
:class="{ 'is-loading': busy === 'lb:routes:dry-run' }"
@click="runGatewayRouteDeploy(true)"
>
Dry-run route deploy
</button>
<button
class="button is-warning"
type="button"
:class="{ 'is-loading': busy === 'lb:routes:deploy' }"
@click="runGatewayRouteDeploy(false)"
>
Deploy api-v2 route
</button>
<button
class="button is-info"
type="button"
:class="{ 'is-loading': busy === 'lb:api-code:deploy' }"
@click="runGatewayApiCodeDeploy"
>
Deploy latest API code
</button>
</div>
<div
v-if="gatewayCodeDeployResult"
class="coolify-route-result mt-2"
data-testid="coolify-gateway-code-deploy-result"
>
<strong>{{ gatewayCodeDeployResult.dry_run ? "API code plan" : "API code deploy" }}</strong>
<span>Planned {{ countItems(gatewayCodeDeployResult.planned) }}</span>
<span>Applied {{ countItems(gatewayCodeDeployResult.applied) }}</span>
<span>Skipped {{ countItems(gatewayCodeDeployResult.skipped) }}</span>
<span>Errors {{ countItems(gatewayCodeDeployResult.errors) }}</span>
<span v-if="gatewayCodeDeployResult.route_deploy">
Route applied {{ countItems(gatewayCodeDeployResult.route_deploy.applied) }}
</span>
<span v-if="countItems(gatewayCodeDeployResult.warnings) > 0">
Warnings {{ countItems(gatewayCodeDeployResult.warnings) }}
</span>
<ul v-if="countItems(gatewayCodeDeployResult.warnings) > 0">
<li v-for="warning in gatewayCodeDeployResult.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<div
v-if="gatewayRouteResult"
class="coolify-route-result mt-2"
data-testid="coolify-gateway-route-result"
>
<strong>{{ gatewayRouteResult.dry_run ? "Route plan" : "Route deploy" }}</strong>
<span>Planned {{ countItems(gatewayRouteResult.planned) }}</span>
<span>Applied {{ countItems(gatewayRouteResult.applied) }}</span>
<span>Skipped {{ countItems(gatewayRouteResult.skipped) }}</span>
<span>Errors {{ countItems(gatewayRouteResult.errors) }}</span>
<span v-if="countItems(gatewayRouteResult.warnings) > 0">
Warnings {{ countItems(gatewayRouteResult.warnings) }}
</span>
<ul v-if="countItems(gatewayRouteResult.warnings) > 0">
<li v-for="warning in gatewayRouteResult.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<div class="table-container mt-3">
@@ -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;
File diff suppressed because it is too large Load Diff
+163 -5
View File
@@ -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();
+18 -11
View File
@@ -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);
});
+130 -4
View File
@@ -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: '<br /><b>Warning</b> 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();
+389 -10
View File
@@ -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
@@ -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.");
+6
View File
@@ -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),
};
}
+8 -3
View File
@@ -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 = "";
+48 -6
View File
@@ -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 () => "<br /><b>Warning</b>Composer 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 });
@@ -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);
});
});
+80
View File
@@ -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;
+25 -6
View File
@@ -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", () => {
+17
View File
@@ -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,
})
);
});
});