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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 || ""
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1639
-45
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user