Add endpoint mode options, release channel rollback handling, and runtime refresh improvements

- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
This commit is contained in:
Jeppe Bundgaard
2026-05-21 14:47:46 +02:00
parent b2ddf0db5c
commit ebcd52a019
30 changed files with 1234 additions and 157 deletions
+3
View File
@@ -760,6 +760,7 @@
.pos-step-one-insights {
display: grid;
align-items: stretch;
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
@@ -903,6 +904,7 @@
.pos-step-one-insight-card__section {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.55rem;
min-width: 0;
@@ -970,6 +972,7 @@
.pos-step-one-empty-state,
.pos-step-one-loading-state {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 4rem;
@@ -660,7 +660,9 @@ const handleDesktopLastWashCopy = async (payload = {}) => {
return;
}
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || []);
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
sourceReference: payload?.order?.reference,
});
if (!didCopy) {
return;
}
@@ -190,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
</div>
@@ -248,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
</div>
@@ -4,7 +4,6 @@ import { useI18n } from "vue-i18n";
import {
releaseChannelKey,
releaseChannelOptions,
selectedReleaseChannelSlug,
} from "@/services/releaseChannelAvailability.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
@@ -31,7 +30,7 @@ const tr = (key, fallback, params = {}) => {
return te(path) ? t(path, params) : fallback;
};
const activeSlug = computed(() => selectedReleaseChannelSlug.value || releaseChannelKey(releaseRuntimeState.channel));
const activeSlug = computed(() => releaseChannelKey(releaseRuntimeState.channel));
const isSidebar = computed(() => props.variant === "sidebar");
const selectorTitle = computed(() =>
isSidebar.value ? tr("sidebar_title", "Release") : tr("title", "Release channel")
@@ -1,23 +1,32 @@
<script setup>
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import {
releaseChannelSelectorVisible,
selectReleaseChannel,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
const switchingSlug = ref("");
const switchError = ref("");
const { t, te } = useI18n({ useScope: "global" });
const tr = (key, fallback) => {
const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path) : fallback;
};
const switchChannel = async (option) => {
const slug = selectReleaseChannel(option.channel);
if (!slug) {
if (switchingSlug.value) {
return;
}
switchingSlug.value = slug;
switchingSlug.value = option.channelSlug || option.channel?.slug || "";
switchError.value = "";
try {
await SessionUser.refreshReleaseRuntime();
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
} catch (error) {
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
} finally {
switchingSlug.value = "";
}
@@ -25,10 +34,25 @@ const switchChannel = async (option) => {
</script>
<template>
<ReleaseChannelSelector
v-if="releaseChannelSelectorVisible"
variant="sidebar"
:switching-slug="switchingSlug"
@select="switchChannel"
/>
<div v-if="releaseChannelSelectorVisible" class="release-channel-sidebar-selector">
<ReleaseChannelSelector
variant="sidebar"
:switching-slug="switchingSlug"
:disabled="Boolean(switchingSlug)"
@select="switchChannel"
/>
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
{{ switchError }}
</p>
</div>
</template>
<style scoped>
.release-channel-sidebar-selector__error {
margin: 0 16px 14px;
color: #b42318;
font-size: 0.78rem;
font-weight: 700;
line-height: 1.35;
}
</style>
@@ -9,7 +9,7 @@ import {
releaseChannelSelectorVisible,
releaseChannelUnavailableStatus,
RELEASE_CHANNEL_CHECK_INTERVAL_MS,
selectReleaseChannel,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
const router = useRouter();
@@ -71,15 +71,14 @@ const ignoreForFiveMinutes = () => {
};
const switchChannel = async (option) => {
const slug = selectReleaseChannel(option.channel);
if (!slug || switchingSlug.value) {
if (switchingSlug.value) {
return;
}
switchingSlug.value = slug;
switchingSlug.value = option.channelSlug || option.channel?.slug || "";
checkError.value = "";
try {
await SessionUser.refreshReleaseRuntime();
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
} catch (error) {
checkError.value = tr("refresh_error");
} finally {
+12 -8
View File
@@ -128,14 +128,18 @@ const applyReleaseRuntimeConfig = (runtime) => {
};
};
export const refreshReleaseRuntime = async () => {
return fetchReleaseRuntime()
.then((runtime) => {
applyReleaseRuntimeConfig(runtime || {});
})
.catch((error) => {
console.warn("Could not refresh release runtime", error);
});
export const refreshReleaseRuntime = async ({ throwOnError = false } = {}) => {
try {
const runtime = await fetchReleaseRuntime();
applyReleaseRuntimeConfig(runtime || {});
return runtime || {};
} catch (error) {
console.warn("Could not refresh release runtime", error);
if (throwOnError) {
throw error;
}
return null;
}
};
/**
@@ -1234,6 +1234,20 @@ const getOrderItemNotes = (item) => {
return notesValue === "" ? null : notesValue;
};
const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
const normalizedSourceReference = String(sourceReference ?? "").trim();
if (!normalizedSourceReference || !isBlankPosMetadataValue(reference.value)) {
return;
}
reference.value = normalizedSourceReference;
const normalizedOrderId = toPositiveInteger(order_id.value);
if (normalizedOrderId) {
await SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedSourceReference);
}
};
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
const productId = getOrderItemProductId(sourceItem);
const quantity = getOrderItemQuantity(sourceItem);
@@ -1262,6 +1276,8 @@ export const copyLastWashItemsToCurrentOrder = async (sourceItems = [], options
return false;
}
await copyLastWashReferenceToEmptyCurrentOrder(options.sourceReference ?? options.sourceOrder?.reference);
const didEnsureOrder = await createOrder({
...options,
isMobile: false,
+31 -1
View File
@@ -2516,6 +2516,21 @@
"add_suggested_channel": "Tilføj foreslået kanal",
"module_health_empty": "Modul-health snapshots vises, når probes er blevet registreret."
},
"status": {
"confirm_issue_action": "Koer denne Release Manager-handling? Den kan aendre deployment-tilstand og bliver auditeret.",
"choose_bundle_prompt": "Indtast bundle-id'et, der skal saettes for denne kanal.",
"impact": "Konsekvens",
"cause": "Aarsag",
"automated_fix": "Automatisk rettelse",
"manual_fallback": "Manuel fallback",
"related_deployment": "Relateret deployment",
"recent_result": "Seneste resultat",
"no_automated_action": "Ingen automatisk handling tilgaengelig.",
"deployment": "Deployment",
"target": "Maal",
"coolify_target": "Coolify-maal",
"service_set": "Service set"
},
"settings": {
"title": "Release Manager-indstillinger",
"subtitle": "Konfigurer GitHub-tokenet, der bruges til private repositories og branch-opslag.",
@@ -2616,7 +2631,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"switch_error": "Release-kanalen kunne ikke skiftes. Den forrige kanal er stadig aktiv."
},
"common": {
"yes": "ja",
@@ -2628,6 +2644,7 @@
"rollback": "Rul tilbage",
"update": "Opdater",
"create": "Opret",
"publish_release": "Publicer release",
"clear": "Ryd",
"assign": "Tildel",
"remove": "Fjern",
@@ -2880,6 +2897,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
+13
View File
@@ -2990,6 +2990,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto-Deploy",
"auto_deploy_tooltip": "Automatisch Deployments erstellen, wenn sich dieses Ziel ändert.",
"coolify_ssl": "Coolify SSL",
+31 -1
View File
@@ -2350,6 +2350,21 @@
"add_suggested_channel": "Add suggested channel",
"module_health_empty": "Module health snapshots appear after probes have been recorded."
},
"status": {
"confirm_issue_action": "Run this Release Manager action? It can change deployment state and will be audited.",
"choose_bundle_prompt": "Enter the bundle id to set for this channel.",
"impact": "Impact",
"cause": "Cause",
"automated_fix": "Automated fix",
"manual_fallback": "Manual fallback",
"related_deployment": "Related deployment",
"recent_result": "Recent result",
"no_automated_action": "No automated action available.",
"deployment": "Deployment",
"target": "Target",
"coolify_target": "Coolify target",
"service_set": "Service set"
},
"settings": {
"title": "Release Manager Settings",
"subtitle": "Configure the GitHub token used for private repositories and branch lookups.",
@@ -2450,7 +2465,8 @@
"release_runtime": "Release runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"switch_error": "Release channel could not be switched. The previous channel is still active."
},
"common": {
"yes": "yes",
@@ -2462,6 +2478,7 @@
"rollback": "Rollback",
"update": "Update",
"create": "Create",
"publish_release": "Publish release",
"clear": "Clear",
"assign": "Assign",
"remove": "Remove",
@@ -2714,6 +2731,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Automatically create deployments when this target changes.",
"coolify_ssl": "Coolify SSL",
+13
View File
@@ -1939,6 +1939,19 @@
"ssl_domain_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.ssl_domain_message'}",
"ssl_domain_placeholder": "@:{'templates.generated.compat.configuration.release_manager.integrations.ssl_domain_placeholder'}",
"https_domain_for_coolify": "@:{'templates.generated.compat.configuration.release_manager.integrations.https_domain_for_coolify'}",
"endpoint_mode": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode'}",
"endpoint_mode_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_message'}",
"endpoint_mode_auto": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_auto'}",
"endpoint_mode_manual": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_manual'}",
"manual_endpoint_host": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host'}",
"manual_endpoint_host_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host_message'}",
"manual_endpoint_host_placeholder": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host_placeholder'}",
"manual_endpoint_port": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_port'}",
"manual_endpoint_port_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_port_message'}",
"app_port": "@:{'templates.generated.compat.configuration.release_manager.integrations.app_port'}",
"app_port_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.app_port_message'}",
"auto_gateway_endpoint": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_gateway_endpoint'}",
"pending_automatic_endpoint": "@:{'templates.generated.compat.configuration.release_manager.integrations.pending_automatic_endpoint'}",
"auto_deploy": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_deploy'}",
"auto_deploy_tooltip": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_deploy_tooltip'}",
"coolify_ssl": "@:{'templates.generated.compat.configuration.release_manager.integrations.coolify_ssl'}",
+13
View File
@@ -2991,6 +2991,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
+13
View File
@@ -3041,6 +3041,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
@@ -377,6 +377,21 @@
"add_suggested_channel": "Tilføj foreslået kanal",
"module_health_empty": "Modul-health snapshots vises, når probes er blevet registreret."
},
"status": {
"confirm_issue_action": "Koer denne Release Manager-handling? Den kan aendre deployment-tilstand og bliver auditeret.",
"choose_bundle_prompt": "Indtast bundle-id'et, der skal saettes for denne kanal.",
"impact": "Konsekvens",
"cause": "Aarsag",
"automated_fix": "Automatisk rettelse",
"manual_fallback": "Manuel fallback",
"related_deployment": "Relateret deployment",
"recent_result": "Seneste resultat",
"no_automated_action": "Ingen automatisk handling tilgaengelig.",
"deployment": "Deployment",
"target": "Maal",
"coolify_target": "Coolify-maal",
"service_set": "Service set"
},
"settings": {
"title": "Release Manager-indstillinger",
"subtitle": "Konfigurer GitHub-tokenet, der bruges til private repositories og branch-opslag.",
@@ -477,7 +492,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"switch_error": "Release-kanalen kunne ikke skiftes. Den forrige kanal er stadig aktiv."
},
"common": {
"yes": "ja",
@@ -489,6 +505,7 @@
"rollback": "Rul tilbage",
"update": "Opdater",
"create": "Opret",
"publish_release": "Publicer release",
"clear": "Ryd",
"assign": "Tildel",
"remove": "Fjern",
@@ -741,6 +758,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
@@ -741,6 +741,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto-Deploy",
"auto_deploy_tooltip": "Automatisch Deployments erstellen, wenn sich dieses Ziel ändert.",
"coolify_ssl": "Coolify SSL",
@@ -377,6 +377,21 @@
"add_suggested_channel": "Add suggested channel",
"module_health_empty": "Module health snapshots appear after probes have been recorded."
},
"status": {
"confirm_issue_action": "Run this Release Manager action? It can change deployment state and will be audited.",
"choose_bundle_prompt": "Enter the bundle id to set for this channel.",
"impact": "Impact",
"cause": "Cause",
"automated_fix": "Automated fix",
"manual_fallback": "Manual fallback",
"related_deployment": "Related deployment",
"recent_result": "Recent result",
"no_automated_action": "No automated action available.",
"deployment": "Deployment",
"target": "Target",
"coolify_target": "Coolify target",
"service_set": "Service set"
},
"settings": {
"title": "Release Manager Settings",
"subtitle": "Configure the GitHub token used for private repositories and branch lookups.",
@@ -477,7 +492,8 @@
"release_runtime": "Release runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"switch_error": "Release channel could not be switched. The previous channel is still active."
},
"common": {
"yes": "yes",
@@ -489,6 +505,7 @@
"rollback": "Rollback",
"update": "Update",
"create": "Create",
"publish_release": "Publish release",
"clear": "Clear",
"assign": "Assign",
"remove": "Remove",
@@ -741,6 +758,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Automatically create deployments when this target changes.",
"coolify_ssl": "Coolify SSL",
@@ -794,6 +794,19 @@
"ssl_domain_message": "@:{'phrases.compat.configuration.release_manager.integrations.ssl_domain_message'}",
"ssl_domain_placeholder": "@:{'phrases.compat.configuration.release_manager.integrations.ssl_domain_placeholder'}",
"https_domain_for_coolify": "@:{'phrases.compat.configuration.release_manager.integrations.https_domain_for_coolify'}",
"endpoint_mode": "@:{'phrases.compat.configuration.release_manager.integrations.endpoint_mode'}",
"endpoint_mode_message": "@:{'phrases.compat.configuration.release_manager.integrations.endpoint_mode_message'}",
"endpoint_mode_auto": "@:{'phrases.compat.configuration.release_manager.integrations.endpoint_mode_auto'}",
"endpoint_mode_manual": "@:{'phrases.compat.configuration.release_manager.integrations.endpoint_mode_manual'}",
"manual_endpoint_host": "@:{'phrases.compat.configuration.release_manager.integrations.manual_endpoint_host'}",
"manual_endpoint_host_message": "@:{'phrases.compat.configuration.release_manager.integrations.manual_endpoint_host_message'}",
"manual_endpoint_host_placeholder": "@:{'phrases.compat.configuration.release_manager.integrations.manual_endpoint_host_placeholder'}",
"manual_endpoint_port": "@:{'phrases.compat.configuration.release_manager.integrations.manual_endpoint_port'}",
"manual_endpoint_port_message": "@:{'phrases.compat.configuration.release_manager.integrations.manual_endpoint_port_message'}",
"app_port": "@:{'phrases.compat.configuration.release_manager.integrations.app_port'}",
"app_port_message": "@:{'phrases.compat.configuration.release_manager.integrations.app_port_message'}",
"auto_gateway_endpoint": "@:{'phrases.compat.configuration.release_manager.integrations.auto_gateway_endpoint'}",
"pending_automatic_endpoint": "@:{'phrases.compat.configuration.release_manager.integrations.pending_automatic_endpoint'}",
"auto_deploy": "@:{'phrases.compat.configuration.release_manager.integrations.auto_deploy'}",
"auto_deploy_tooltip": "@:{'phrases.compat.configuration.release_manager.integrations.auto_deploy_tooltip'}",
"coolify_ssl": "@:{'phrases.compat.configuration.release_manager.integrations.coolify_ssl'}",
@@ -741,6 +741,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
@@ -741,6 +741,19 @@
"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",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
@@ -391,6 +391,35 @@ export const selectReleaseChannel = (channelOrSlug) => {
return slug;
};
export const switchSelectedReleaseChannel = async (channelOrSlug, refreshRuntime) => {
if (typeof refreshRuntime !== "function") {
throw new Error("Release runtime refresh is not available.");
}
const previousSlug = getSelectedReleaseChannelSlug();
const targetSlug = selectReleaseChannel(channelOrSlug);
if (!targetSlug) {
return "";
}
try {
const runtime = (await refreshRuntime({ throwOnError: true })) || releaseRuntimeState;
const confirmedSlug = releaseChannelKey(runtime?.channel || releaseRuntimeState.channel);
if (confirmedSlug !== targetSlug) {
throw new Error(`Release runtime switched to ${confirmedSlug || "unknown"} instead of ${targetSlug}.`);
}
return targetSlug;
} catch (error) {
selectReleaseChannel(previousSlug);
try {
await refreshRuntime({ throwOnError: true });
} catch (restoreError) {
console.warn("Could not restore the previous release runtime after a failed switch.", restoreError);
}
throw error;
}
};
export const clearSelectedReleaseChannel = () => {
state.selectedChannelSlug = "";
writeSelectedChannelSlug("");
+3
View File
@@ -236,6 +236,9 @@ export const startReleaseDeployment = (payload) =>
export const promoteReleaseDeployment = (id) =>
requestReleaseManager(`/superuser/releases/deployments/${id}/promote`, "POST", {});
export const runReleaseIssueAction = (payload) =>
requestReleaseManager("/superuser/releases/issues/actions", "POST", payload);
export const setReleaseReplayTarget = (payload) =>
requestReleaseManager("/superuser/releases/replay-targets", "POST", payload);
@@ -30,6 +30,7 @@ import {
listReleaseGithubRepositories,
promoteReleaseBundle,
promoteReleaseDeployment,
requestReleaseManager,
rollbackReleaseChannel,
saveReleaseDeploymentTarget,
releaseManagerControlApiCandidates,
@@ -52,6 +53,8 @@ const DEFAULT_RELEASE_BRANCH = "master";
const RELEASE_STATUS_SERVICES = ["frontend", "api", "database", "redis", "minio"];
const RELEASE_DATA_SERVICES = ["database", "redis", "minio"];
const COOLIFY_PENDING_ENDPOINT = "created by Coolify; host/port assigned after deployment";
const runReleaseIssueAction = (payload) =>
requestReleaseManager("/superuser/releases/issues/actions", "POST", payload);
const SERVICE_CONFIGURATION_PENDING_ENDPOINT = "host/port assigned after service is configured";
const REPLICA_PENDING_ENDPOINT = "host/port assigned after replica provisioning";
const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || ""));
@@ -143,6 +146,7 @@ const bundleAccessResult = ref({
frontend: null,
api: null,
});
const releaseIssueActionResult = ref(null);
const releaseFlowStep = ref(0);
const showAdvancedDeployment = ref(false);
const createdBundle = ref(null);
@@ -200,6 +204,10 @@ const targetForm = reactive({
coolify_domain: "",
coolify_public_url: "",
coolify_deploy_now: false,
endpoint_mode: "auto",
manual_endpoint_host: "",
manual_endpoint_port: "",
coolify_ports_exposes: "80",
});
const deploymentForm = reactive({
@@ -329,41 +337,31 @@ const setupSteps = computed(() => [
key: "channels",
label: tr("setup.channels"),
done: channels.value.length > 0,
action: () => {
activeTab.value = "channels";
},
action: () => scrollToSection("channels"),
},
{
key: "targets",
label: tr("setup.targets"),
done: targets.value.length > 0,
action: () => {
activeTab.value = "integrations";
},
action: () => scrollToSection("integrations"),
},
{
key: "assignments",
label: tr("setup.assignments"),
done: assignments.value.length > 0,
action: () => {
activeTab.value = "assignments";
},
action: () => scrollToSection("assignments"),
},
{
key: "deployments",
label: tr("setup.deployments"),
done: deployments.value.length > 0,
action: () => {
activeTab.value = "deployments";
},
action: () => scrollToSection("deployments"),
},
{
key: "replay",
label: tr("setup.replay"),
done: channels.value.some((channel) => channel.replay_enabled) || timelineSummary.value.events > 0,
action: () => {
activeTab.value = "replay";
},
action: () => scrollToSection("replay"),
},
]);
const controlApiCandidates = computed(() => releaseManagerControlApiCandidates());
@@ -692,8 +690,20 @@ const statusDrawerService = computed(() => {
) || null
);
});
const activeTabMeta = computed(() => tabs.value.find((tab) => tab.key === activeTab.value) || tabs.value[0]);
const releasePrimaryIssue = computed(() => statusIssues.value[0] || null);
function scrollToSection(section) {
const key = normalizeReleaseTargetTab(section);
activeTab.value = key;
if (typeof document === "undefined") {
return;
}
requestAnimationFrame(() => {
const target = document.querySelector(`[data-release-section="${key}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
const releaseCockpitSeverity = computed(() => {
if ((statusTotals.value.critical || 0) > 0) {
return "critical";
@@ -733,7 +743,7 @@ const releaseCockpitSubtitle = computed(() => {
const releaseCockpitPrimaryAction = computed(() =>
releasePrimaryIssue.value
? releaseIssueActionLabel(releasePrimaryIssue.value)
: trFallback("actions.create_release", "Create release")
: trFallback("actions.publish_release", "Publish release")
);
const normalizedGithubApiUrl = computed(() => String(releaseConfigForm.github_api_url || "").trim());
const isGithubApiUrlValid = computed(() => /^https?:\/\/[^\s]+$/i.test(normalizedGithubApiUrl.value));
@@ -861,7 +871,8 @@ function normalizeReleaseStatusService(service) {
}
function normalizeReleaseStatusIssue(issue) {
return {
const normalized = {
key: issue?.key || "",
severity: issue?.severity || "warning",
type: issue?.type || "stale_unknown",
channel_id: issue?.channel_id ?? null,
@@ -874,7 +885,26 @@ function normalizeReleaseStatusIssue(issue) {
target_id: issue?.target_id ?? null,
deployment_id: issue?.deployment_id ?? null,
coolify_target_id: issue?.coolify_target_id ?? null,
service_set_id: issue?.service_set_id ?? null,
missing_key: issue?.missing_key || null,
impact: issue?.impact || "",
resolution_state: issue?.resolution_state || "open",
actions: Array.isArray(issue?.actions) ? issue.actions.map(normalizeReleaseStatusAction) : [],
};
normalized.key = normalized.key || releaseStatusIssueKey(normalized);
return normalized;
}
function normalizeReleaseStatusAction(action) {
return {
id: action?.id || "",
label: action?.label || readableValue(action?.id),
kind: action?.kind || "mutation",
requires_confirmation: Boolean(action?.requires_confirmation),
requires_input: Boolean(action?.requires_input),
disabled_reason: action?.disabled_reason || "",
permission: action?.permission || "superuser_release_manager_deploy",
bundle_choices: Array.isArray(action?.bundle_choices) ? action.bundle_choices : [],
};
}
@@ -1121,7 +1151,7 @@ function normalizeSettingsGuideStep(step) {
}
function openGithubSettings(step = 0) {
activeTab.value = "settings";
scrollToSection("settings");
settingsGuideStep.value = normalizeSettingsGuideStep(step);
}
@@ -1595,6 +1625,7 @@ function selectCoolifyService(service) {
targetForm.coolify_public_url = publicUrl;
targetForm.health_url = healthUrlForBaseUrl(targetForm.app, publicUrl);
}
targetForm.endpoint_mode = "auto";
targetForm.coolify_enable_ssl = true;
}
@@ -1626,8 +1657,12 @@ function prepareCoolifySslTarget(app, url) {
coolify_domain: coolifyDomain,
coolify_public_url: baseUrl,
coolify_deploy_now: true,
endpoint_mode: "auto",
manual_endpoint_host: "",
manual_endpoint_port: "",
coolify_ports_exposes: targetForm.coolify_ports_exposes || "80",
});
activeTab.value = "integrations";
scrollToSection("integrations");
}
function suggestedHealthUrl(app = targetForm.app) {
@@ -1651,7 +1686,7 @@ function applyChannelPreset(preset) {
capture_level: preset.capture_level || "metadata",
retention_days: Number(preset.retention_days || 14),
});
activeTab.value = "channels";
scrollToSection("channels");
}
function applyTargetPreset(preset) {
@@ -1680,8 +1715,12 @@ function applyTargetPreset(preset) {
coolify_domain: loadBalancerDomainValue(preset.coolify_domain),
coolify_public_url: preset.coolify_public_url || preset.public_url || "",
coolify_deploy_now: Boolean(preset.coolify_deploy_now),
endpoint_mode: preset.endpoint_mode === "manual" ? "manual" : "auto",
manual_endpoint_host: preset.manual_endpoint_host || "",
manual_endpoint_port: preset.manual_endpoint_port || "",
coolify_ports_exposes: String(preset.coolify_ports_exposes || preset.ports_exposes || targetForm.coolify_ports_exposes || "80"),
});
activeTab.value = "integrations";
scrollToSection("integrations");
}
function applyUrlSuggestion(kind, value) {
@@ -1708,7 +1747,7 @@ function fillDeploymentFromTarget(target = selectedTarget.value) {
function applyQuickDeployTarget(target) {
deploymentForm.target_id = target.id;
fillDeploymentFromTarget(target);
activeTab.value = "deployments";
scrollToSection("deployments");
}
function deploymentVersionLabelFromTarget(target) {
@@ -2105,8 +2144,31 @@ function targetContext(target) {
return fullDeploymentTarget(target)?.deploy_context || {};
}
function endpointDisplayValue(endpoint) {
if (!endpoint || typeof endpoint !== "object") {
return "";
}
const urlEndpoint = endpointFromUrl(endpoint.url);
if (urlEndpoint) {
return urlEndpoint;
}
return endpointFromHostPort(endpoint.host, endpoint.port) || String(endpoint.message || "").trim();
}
function targetEndpointPending(target) {
const endpoint = fullDeploymentTarget(target)?.endpoint;
if (!endpoint || typeof endpoint !== "object") {
return false;
}
return endpoint.status !== "resolved";
}
function targetPublicEndpoint(target) {
const fullTarget = fullDeploymentTarget(target);
const resolvedEndpoint = endpointDisplayValue(fullTarget?.endpoint);
if (resolvedEndpoint) {
return resolvedEndpoint;
}
const context = targetContext(fullTarget);
const directUrl = firstText(context.coolify_public_url, fullTarget?.health_url, context.health_url);
const directEndpoint = endpointFromUrl(directUrl);
@@ -2168,7 +2230,7 @@ function dataServiceName(service, fallback) {
}
function dataServiceEndpoint(service) {
return endpointFromHostPort(service?.replication?.host, service?.replication?.port);
return endpointDisplayValue(service?.endpoint) || endpointFromHostPort(service?.replication?.host, service?.replication?.port);
}
function dataServiceLocation(service) {
@@ -2181,6 +2243,10 @@ function dataServiceLocation(service) {
]);
}
function automaticGatewayEndpoint() {
return endpointFromHostPort(firstAvailable(loadBalancerDomainSuggestions.value, "api-v2.truckwash.io"), "443");
}
function isolatedPlacementLocation(app) {
const sourceTarget = isolatedStackSourceTarget(app === "api" ? "api" : "frontend");
const instanceId = Number(sourceTarget?.coolify_instance_id || coolifyInstances.value[0]?.id || 0);
@@ -2215,7 +2281,7 @@ function previewAppRow(app) {
type: "is-success is-light",
source: targetName(sourceTarget, trFallback("bundles.preview.source_defaults", "source target defaults")),
target: isolatedStackServiceName(app),
endpoint: COOLIFY_PENDING_ENDPOINT,
endpoint: automaticGatewayEndpoint() || trFallback("integrations.pending_automatic_endpoint", "automatic endpoint resolution pending"),
endpointPending: true,
location: isolatedPlacementLocation(app),
dataPolicy: trFallback("bundles.preview.code_target_only", "code target only"),
@@ -2230,8 +2296,8 @@ function previewAppRow(app) {
type: "is-info is-light",
source: targetName(sourceTarget, trFallback("bundles.preview.no_source_target", "no source target")),
target: targetName(target, trFallback("bundles.preview.target_needed", "target needed")),
endpoint: endpoint || trFallback("bundles.preview.endpoint_not_configured", "endpoint not configured"),
endpointPending: false,
endpoint: endpoint || trFallback("integrations.pending_automatic_endpoint", "automatic endpoint resolution pending"),
endpointPending: targetEndpointPending(target) || !endpoint,
location: targetLocation(target),
dataPolicy: trFallback("bundles.preview.code_target_only", "code target only"),
};
@@ -2592,6 +2658,13 @@ async function createIsolatedStackDeploymentTarget(app) {
coolify_domain: "",
coolify_public_url: "",
coolify_deploy_now: true,
endpoint_mode: "auto",
manual_endpoint_host: "",
manual_endpoint_port: "",
coolify_ports_exposes:
sourceTarget?.deploy_context?.coolify_ports_exposes ||
sourceTarget?.deploy_context?.ports_exposes ||
(app === "api" ? "80" : "80"),
coolify_project_uuid: isolatedStackProjectUuid(instanceId, sourceTarget),
coolify_environment_uuid: "",
coolify_environment_name: releaseCoolifyEnvironmentName(selectedBundleChannel()?.slug || "", branch),
@@ -2800,7 +2873,7 @@ function editChannel(channel) {
capture_level: channel.capture_level || "metadata",
retention_days: Number(channel.retention_days || 14),
});
activeTab.value = "channels";
scrollToSection("channels");
}
function resetChannelForm() {
@@ -2857,15 +2930,35 @@ async function saveTarget() {
const coolifyDomain = domainFromValue(targetForm.coolify_domain);
const coolifyPublicUrl =
publicBaseUrlFromValue(targetForm.coolify_public_url) || (coolifyDomain ? `https://${coolifyDomain}` : "");
const endpointMode = targetForm.endpoint_mode === "manual" ? "manual" : "auto";
const manualEndpointHost = String(targetForm.manual_endpoint_host || "").trim();
const manualEndpointPort = String(targetForm.manual_endpoint_port || "").trim();
const coolifyPortsExposes = String(targetForm.coolify_ports_exposes || "80").trim() || "80";
if (endpointMode === "manual" && !manualEndpointHost) {
throw new Error(trFallback("errors.manual_endpoint_host_required", "Manual endpoint mode requires a public host."));
}
if (
manualEndpointPort &&
(!/^\d+$/.test(manualEndpointPort) || Number(manualEndpointPort) < 1 || Number(manualEndpointPort) > 65535)
) {
throw new Error(trFallback("errors.manual_endpoint_port_invalid", "Manual endpoint port must be between 1 and 65535."));
}
const deployContext = {
...defaultCoolifyApplicationContext(targetForm.app, { coolify_build_pack: targetForm.coolify_build_pack }),
...defaultCoolifyApplicationContext(targetForm.app, {
coolify_build_pack: targetForm.coolify_build_pack,
coolify_ports_exposes: coolifyPortsExposes,
}),
coolify_auto_create: Boolean(targetForm.coolify_auto_create),
coolify_enable_ssl: Boolean(targetForm.coolify_enable_ssl && (coolifyPublicUrl || coolifyDomain)),
coolify_enable_ssl: Boolean(targetForm.coolify_enable_ssl),
coolify_domain: coolifyDomain,
coolify_public_url: coolifyPublicUrl,
coolify_deploy_now: Boolean(targetForm.coolify_deploy_now),
coolify_project_uuid: targetForm.coolify_project_uuid || "",
coolify_github_app_uuid: String(targetForm.coolify_github_app_uuid || "").trim(),
endpoint_mode: endpointMode,
manual_endpoint_host: endpointMode === "manual" ? manualEndpointHost : "",
manual_endpoint_port: endpointMode === "manual" ? manualEndpointPort : "",
coolify_ports_exposes: coolifyPortsExposes,
};
const savedResponse = await saveReleaseDeploymentTarget({
...targetForm,
@@ -2893,6 +2986,10 @@ async function saveTarget() {
coolify_domain: "",
coolify_public_url: "",
coolify_deploy_now: false,
endpoint_mode: "auto",
manual_endpoint_host: "",
manual_endpoint_port: "",
coolify_ports_exposes: "80",
});
targetAccessResult.value = null;
await load();
@@ -2919,7 +3016,7 @@ async function removeTarget(target) {
function useTargetForDeployment(target) {
deploymentForm.target_id = target.id;
fillDeploymentFromTarget(target);
activeTab.value = "deployments";
scrollToSection("deployments");
}
async function redeployTarget(target) {
@@ -3091,6 +3188,9 @@ function releaseStatusMaxSeverity(severities) {
}
function releaseStatusIssueKey(issue) {
if (issue?.key) {
return String(issue.key);
}
return [
issue?.type || "",
issue?.channel_id || issue?.channel_slug || "",
@@ -3229,6 +3329,23 @@ function releaseStatusIssueTypeLabel(type) {
);
}
function releaseStatusIssueRelationLabel(issue) {
const parts = [];
if (issue?.deployment_id) {
parts.push(`${trFallback("status.deployment", "Deployment")} #${issue.deployment_id}`);
}
if (issue?.target_id) {
parts.push(`${trFallback("status.target", "Target")} #${issue.target_id}`);
}
if (issue?.coolify_target_id) {
parts.push(`${trFallback("status.coolify_target", "Coolify target")} #${issue.coolify_target_id}`);
}
if (issue?.service_set_id) {
parts.push(`${trFallback("status.service_set", "Service set")} #${issue.service_set_id}`);
}
return parts.join(" / ");
}
function releaseStatusServiceStatusLabel(service) {
const status = service?.state && service.state !== "ready" ? service.state : service?.status || "ready";
return statusLabel(status);
@@ -3306,6 +3423,7 @@ function openReleaseStatusDetails(channel, issue = null, serviceKey = "") {
selectedStatusChannelId.value = channel?.channel_id || channel?.id || issue?.channel_id || null;
selectedStatusIssue.value = issue;
selectedStatusServiceKey.value = serviceKey || issue?.service_key || "";
releaseIssueActionResult.value = null;
}
function openReleaseStatusIssue(issue) {
@@ -3325,15 +3443,16 @@ function closeReleaseStatusDetails() {
selectedStatusChannelId.value = null;
selectedStatusIssue.value = null;
selectedStatusServiceKey.value = "";
releaseIssueActionResult.value = null;
}
function goToReleaseStatusTab(tab) {
activeTab.value = normalizeReleaseTargetTab(tab);
scrollToSection(tab);
closeReleaseStatusDetails();
}
function focusReleaseCreation() {
activeTab.value = "deployments";
scrollToSection("deployments");
closeReleaseStatusDetails();
}
@@ -3343,10 +3462,58 @@ function handleReleaseIssueAction(issue = releasePrimaryIssue.value) {
return;
}
activeTab.value = normalizeReleaseTargetTab(issue.target_tab);
scrollToSection(issue.target_tab);
openReleaseStatusIssue(issue);
}
async function executeReleaseIssueAction(issue, action) {
if (!issue || !action || action.disabled_reason) {
return;
}
const inputs = {};
if (action.id === "set_bundle" && action.bundle_choices.length > 1) {
const selected = window.prompt(
trFallback("status.choose_bundle_prompt", "Enter the bundle id to set for this channel."),
String(action.bundle_choices[0]?.id || "")
);
if (!selected) {
return;
}
inputs.bundle_id = selected;
}
const confirmed =
!action.requires_confirmation ||
window.confirm(
trFallback(
"status.confirm_issue_action",
"Run this Release Manager action? It can change deployment state and will be audited."
)
);
if (!confirmed) {
return;
}
await run(`issue:${releaseStatusIssueKey(issue)}:${action.id}`, async () => {
const result = responseData(
await runReleaseIssueAction({
issue_key: releaseStatusIssueKey(issue),
action_id: action.id,
inputs,
confirm: confirmed,
}),
{}
);
releaseIssueActionResult.value = result;
if (result.summary) {
summary.value = result.summary;
} else {
await load();
}
});
}
function normalizedBundleStatus(bundle) {
return String(bundle?.status || "").toLowerCase();
}
@@ -3565,7 +3732,7 @@ onMounted(async () => {
data-testid="release-cockpit-create-release"
@click="focusReleaseCreation"
>
{{ trFallback("actions.create_release", "Create release") }}
{{ trFallback("actions.publish_release", "Publish release") }}
</b-button>
<b-button
v-if="releasePrimaryIssue"
@@ -3600,26 +3767,41 @@ onMounted(async () => {
</span>
<small>{{ trFallback("status.issue_panel_hint", "Highest severity first.") }}</small>
</header>
<button
<article
v-for="issue in statusIssues.slice(0, 4)"
:key="releaseStatusIssueKey(issue)"
type="button"
class="release-status-issue"
:class="releaseStatusSeverityClass(issue.severity)"
@click="handleReleaseIssueAction(issue)"
>
<span class="release-status-issue__top">
<strong>{{ issue.label || releaseStatusServiceLabel(issue.service_key) }}</strong>
<b-tag :class="releaseStatusSeverityClass(issue.severity)">
{{ releaseStatusSeverityLabel(issue.severity) }}
</b-tag>
</span>
<span>{{ issue.message || releaseStatusIssueTypeLabel(issue.type) }}</span>
<small>
{{ issue.channel_slug || "--" }}
<span v-if="issue.next_action">/ {{ issue.next_action }}</span>
</small>
</button>
<button type="button" class="release-status-issue__summary" @click="handleReleaseIssueAction(issue)">
<span class="release-status-issue__top">
<strong>{{ issue.label || releaseStatusServiceLabel(issue.service_key) }}</strong>
<b-tag :class="releaseStatusSeverityClass(issue.severity)">
{{ releaseStatusSeverityLabel(issue.severity) }}
</b-tag>
</span>
<span>{{ issue.message || releaseStatusIssueTypeLabel(issue.type) }}</span>
<small>
{{ issue.channel_slug || "--" }}
<span v-if="issue.next_action">/ {{ issue.next_action }}</span>
</small>
</button>
<div v-if="issue.actions.length > 0" class="release-status-issue-actions">
<b-button
v-for="action in issue.actions.slice(0, 2)"
:key="action.id"
size="is-small"
:type="action.kind === 'refresh' ? 'is-light' : 'is-dark'"
icon-left="wrench"
icon-pack="fas"
:disabled="Boolean(action.disabled_reason) || !canDeploy"
:loading="busy === `issue:${releaseStatusIssueKey(issue)}:${action.id}`"
@click.stop="executeReleaseIssueAction(issue, action)"
>
{{ action.label }}
</b-button>
</div>
</article>
<div v-if="statusIssues.length === 0" class="release-status-empty">
<i class="fas fa-check-circle" aria-hidden="true"></i>
<strong>{{ trFallback("status.no_issues", "No release blockers") }}</strong>
@@ -3764,31 +3946,25 @@ onMounted(async () => {
<ConfigurationError v-if="errors.length > 0" :errors="errors" />
<b-tabs
v-model="activeTab"
class="release-tabs"
data-testid="release-section-tabs"
type="is-boxed"
size="is-small"
expanded
:animated="false"
>
<b-tab-item
<nav class="release-management-nav" data-testid="release-management-interface" aria-label="Release Manager">
<button
v-for="tab in tabs"
:key="tab.key"
:value="tab.key"
:label="tab.label"
:icon="tab.icon"
icon-pack="fas"
/>
</b-tabs>
<b-message class="release-tab-help" type="is-info" has-icon icon-pack="fas" icon="info-circle">
{{ activeTabMeta.description }}
</b-message>
type="button"
class="release-management-nav__item"
:class="{ 'release-management-nav__item--active': activeTab === tab.key }"
:data-testid="`release-section-link-${tab.key}`"
@click="scrollToSection(tab.key)"
>
<i :class="`fas fa-${tab.icon}`" aria-hidden="true"></i>
<span>{{ tab.label }}</span>
</button>
</nav>
<ConfigurationCategory
v-if="activeTab === 'overview'"
class="mt-2"
data-release-section="overview"
data-testid="release-section-overview"
module="ReleaseManager"
:title="tr('overview.title')"
:subtitle="tr('overview.subtitle')"
@@ -3874,26 +4050,41 @@ onMounted(async () => {
}}
</b-tag>
</header>
<button
<article
v-for="issue in statusIssues.slice(0, 8)"
:key="releaseStatusIssueKey(issue)"
type="button"
class="release-status-issue"
:class="releaseStatusSeverityClass(issue.severity)"
@click="openReleaseStatusIssue(issue)"
>
<span class="release-status-issue__top">
<strong>{{ issue.label || releaseStatusServiceLabel(issue.service_key) }}</strong>
<b-tag :class="releaseStatusSeverityClass(issue.severity)">
{{ releaseStatusSeverityLabel(issue.severity) }}
</b-tag>
</span>
<span>{{ issue.message || releaseStatusIssueTypeLabel(issue.type) }}</span>
<small>
{{ issue.channel_slug || "--" }}
<span v-if="issue.next_action">/ {{ issue.next_action }}</span>
</small>
</button>
<button type="button" class="release-status-issue__summary" @click="openReleaseStatusIssue(issue)">
<span class="release-status-issue__top">
<strong>{{ issue.label || releaseStatusServiceLabel(issue.service_key) }}</strong>
<b-tag :class="releaseStatusSeverityClass(issue.severity)">
{{ releaseStatusSeverityLabel(issue.severity) }}
</b-tag>
</span>
<span>{{ issue.message || releaseStatusIssueTypeLabel(issue.type) }}</span>
<small>
{{ issue.channel_slug || "--" }}
<span v-if="issue.next_action">/ {{ issue.next_action }}</span>
</small>
</button>
<div v-if="issue.actions.length > 0" class="release-status-issue-actions">
<b-button
v-for="action in issue.actions.slice(0, 2)"
:key="action.id"
size="is-small"
:type="action.kind === 'refresh' ? 'is-light' : 'is-dark'"
icon-left="wrench"
icon-pack="fas"
:disabled="Boolean(action.disabled_reason) || !canDeploy"
:loading="busy === `issue:${releaseStatusIssueKey(issue)}:${action.id}`"
@click.stop="executeReleaseIssueAction(issue, action)"
>
{{ action.label }}
</b-button>
</div>
</article>
<div v-if="statusIssues.length === 0" class="release-status-empty">
<i class="fas fa-check-circle" aria-hidden="true"></i>
<strong>{{ trFallback("status.no_issues", "No release blockers") }}</strong>
@@ -4217,8 +4408,9 @@ onMounted(async () => {
</ConfigurationCategory>
<ConfigurationCategory
v-if="activeTab === 'channels'"
class="mt-2"
data-release-section="channels"
data-testid="release-section-channels"
module="ReleaseManager"
:title="tr('channels.title')"
:subtitle="tr('channels.subtitle')"
@@ -4292,8 +4484,9 @@ onMounted(async () => {
</ConfigurationCategory>
<ConfigurationCategory
v-if="activeTab === 'assignments'"
class="mt-2"
data-release-section="assignments"
data-testid="release-section-assignments"
module="ReleaseManager"
:title="tr('assignments.title')"
:subtitle="tr('assignments.subtitle')"
@@ -4387,8 +4580,9 @@ onMounted(async () => {
</ConfigurationCategory>
<ConfigurationCategory
v-if="activeTab === 'deployments'"
class="mt-2"
data-release-section="deployments"
data-testid="release-section-deployments"
module="ReleaseManager"
:title="tr('deployments.title')"
:subtitle="tr('deployments.subtitle')"
@@ -5259,8 +5453,9 @@ onMounted(async () => {
</ConfigurationCategory>
<ConfigurationCategory
v-if="activeTab === 'replay'"
class="mt-2"
data-release-section="replay"
data-testid="release-section-replay"
module="ReleaseManager"
:title="tr('replay.title')"
:subtitle="tr('replay.subtitle')"
@@ -5276,8 +5471,9 @@ onMounted(async () => {
</ConfigurationCategory>
<ConfigurationCategory
v-if="activeTab === 'integrations'"
class="mt-2"
data-release-section="integrations"
data-testid="release-section-integrations"
module="ReleaseManager"
:title="tr('integrations.title')"
:subtitle="tr('integrations.subtitle')"
@@ -5559,6 +5755,43 @@ onMounted(async () => {
</template>
</BAutocomplete>
</b-field>
<b-field :label="tr('integrations.endpoint_mode')" :message="tr('integrations.endpoint_mode_message')">
<b-select v-model="targetForm.endpoint_mode" expanded data-testid="release-target-endpoint-mode">
<option value="auto">{{ tr("integrations.endpoint_mode_auto") }}</option>
<option value="manual">{{ tr("integrations.endpoint_mode_manual") }}</option>
</b-select>
</b-field>
<b-field
v-if="targetForm.endpoint_mode === 'manual'"
:label="tr('integrations.manual_endpoint_host')"
:message="tr('integrations.manual_endpoint_host_message')"
>
<b-input
v-model="targetForm.manual_endpoint_host"
:placeholder="tr('integrations.manual_endpoint_host_placeholder')"
data-testid="release-target-manual-endpoint-host"
/>
</b-field>
<b-field
v-if="targetForm.endpoint_mode === 'manual'"
:label="tr('integrations.manual_endpoint_port')"
:message="tr('integrations.manual_endpoint_port_message')"
>
<b-input
v-model="targetForm.manual_endpoint_port"
type="number"
min="1"
max="65535"
data-testid="release-target-manual-endpoint-port"
/>
</b-field>
<b-field :label="tr('integrations.app_port')" :message="tr('integrations.app_port_message')">
<b-input
v-model="targetForm.coolify_ports_exposes"
placeholder="80"
data-testid="release-target-app-port"
/>
</b-field>
<b-field :label="tr('integrations.ssl_domain')" :message="tr('integrations.ssl_domain_message')">
<BAutocomplete
v-model="targetForm.coolify_domain"
@@ -5706,8 +5939,9 @@ onMounted(async () => {
</ConfigurationCategory>
<ConfigurationCategory
v-if="activeTab === 'settings'"
class="mt-2"
data-release-section="settings"
data-testid="release-section-settings"
module="ReleaseManager"
:title="tr('settings.title')"
:subtitle="tr('settings.subtitle')"
@@ -5951,7 +6185,7 @@ onMounted(async () => {
>
{{ tr("settings.save") }}
</b-button>
<b-button native-type="button" icon-left="plug" icon-pack="fas" @click="activeTab = 'integrations'">
<b-button native-type="button" icon-left="plug" icon-pack="fas" @click="scrollToSection('integrations')">
{{ tr("settings.back_to_integrations") }}
</b-button>
</div>
@@ -5983,6 +6217,24 @@ onMounted(async () => {
</header>
<div class="release-status-drawer__body">
<b-message
v-if="releaseIssueActionResult"
:type="
releaseIssueActionResult.status === 'failed'
? 'is-danger'
: releaseIssueActionResult.status === 'needs_input'
? 'is-warning'
: 'is-success'
"
has-icon
icon-pack="fas"
:icon="releaseIssueActionResult.status === 'failed' ? 'exclamation-circle' : 'check-circle'"
data-testid="release-issue-action-result"
>
<strong>{{ trFallback("status.recent_result", "Recent result") }}:</strong>
{{ releaseIssueActionResult.message }}
</b-message>
<section v-if="statusDrawerIssues.length > 0" class="release-status-drawer-section">
<header>
<strong>{{ trFallback("status.issues", "Issues") }}</strong>
@@ -5998,18 +6250,59 @@ onMounted(async () => {
>
<div>
<strong>{{ issue.label || releaseStatusIssueTypeLabel(issue.type) }}</strong>
<span>{{ issue.message }}</span>
<small v-if="issue.next_action">{{ issue.next_action }}</small>
<small>{{ releaseStatusReadinessLabel(issue.resolution_state) }}</small>
<dl class="release-status-resolution-panel">
<div>
<dt>{{ trFallback("status.cause", "Cause") }}</dt>
<dd>{{ issue.message || releaseStatusIssueTypeLabel(issue.type) }}</dd>
</div>
<div v-if="issue.impact">
<dt>{{ trFallback("status.impact", "Impact") }}</dt>
<dd>{{ issue.impact }}</dd>
</div>
<div>
<dt>{{ trFallback("status.automated_fix", "Automated fix") }}</dt>
<dd>
<span v-if="issue.actions.length === 0">
{{ trFallback("status.no_automated_action", "No automated action available.") }}
</span>
<span v-else>{{ issue.actions.map((action) => action.label).join(", ") }}</span>
</dd>
</div>
<div v-if="issue.next_action">
<dt>{{ trFallback("status.manual_fallback", "Manual fallback") }}</dt>
<dd>{{ issue.next_action }}</dd>
</div>
<div v-if="releaseStatusIssueRelationLabel(issue)">
<dt>{{ trFallback("status.related_deployment", "Related deployment") }}</dt>
<dd>{{ releaseStatusIssueRelationLabel(issue) }}</dd>
</div>
</dl>
</div>
<div class="release-status-drawer-issue__actions">
<b-button
v-for="action in issue.actions"
:key="action.id"
size="is-small"
:type="action.kind === 'refresh' ? 'is-light' : 'is-dark'"
icon-left="wrench"
icon-pack="fas"
:disabled="Boolean(action.disabled_reason) || !canDeploy"
:loading="busy === `issue:${releaseStatusIssueKey(issue)}:${action.id}`"
@click="executeReleaseIssueAction(issue, action)"
>
{{ action.label }}
</b-button>
<b-button
v-if="issue.target_tab"
size="is-small"
icon-left="external-link-alt"
icon-pack="fas"
@click="goToReleaseStatusTab(issue.target_tab)"
>
{{ tabs.find((tab) => tab.key === issue.target_tab)?.label || issue.target_tab }}
</b-button>
</div>
<b-button
v-if="issue.target_tab"
size="is-small"
icon-left="external-link-alt"
icon-pack="fas"
@click="goToReleaseStatusTab(issue.target_tab)"
>
{{ tabs.find((tab) => tab.key === issue.target_tab)?.label || issue.target_tab }}
</b-button>
</article>
</section>
@@ -6154,12 +6447,34 @@ onMounted(async () => {
</template>
<style scoped>
.release-tabs {
margin-bottom: 0.75rem;
.release-management-nav {
align-items: center;
border: 1px solid #d8dee8;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.85rem;
padding: 0.35rem;
}
.release-tab-help {
margin-bottom: 0.85rem;
.release-management-nav__item {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: #334155;
cursor: pointer;
display: inline-flex;
gap: 0.4rem;
min-height: 2rem;
padding: 0.35rem 0.55rem;
}
.release-management-nav__item--active {
background: #f0f6ff;
border-color: #9ec5fe;
color: #0747a6;
}
.release-stats {
@@ -6477,7 +6792,6 @@ onMounted(async () => {
border-left-width: 4px;
border-radius: 6px;
color: #172033;
cursor: pointer;
display: grid;
gap: 0.35rem;
padding: 0.65rem;
@@ -6485,6 +6799,25 @@ onMounted(async () => {
width: 100%;
}
.release-status-issue__summary {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: grid;
gap: 0.35rem;
padding: 0;
text-align: left;
width: 100%;
}
.release-status-issue-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.release-status-issue:hover,
.release-status-service:hover,
.release-status-missing-values button:hover {
@@ -6705,6 +7038,39 @@ onMounted(async () => {
min-width: 0;
}
.release-status-resolution-panel {
display: grid;
gap: 0.45rem;
margin: 0.25rem 0 0;
}
.release-status-resolution-panel div {
background: #f7fafc;
border: 1px solid #e3e8ef;
border-radius: 6px;
padding: 0.45rem;
}
.release-status-resolution-panel dt {
color: #172033;
font-size: 0.76rem;
font-weight: 700;
}
.release-status-resolution-panel dd {
color: #475467;
margin: 0.1rem 0 0;
overflow-wrap: anywhere;
}
.release-status-drawer-issue__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
}
.release-status-drawer-actions {
border-bottom: 0;
border-top: 1px solid #d8dee8;
+2 -2
View File
@@ -2361,13 +2361,13 @@ test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership"
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("BOOKPO1");
await selectStepOneCustomer(page, 12345679);
await expect(page.locator(".pos-selected-customer__title")).toContainText("(TEST) Pleno Vognmandsforretning");
const createOrderRequest = waitForOrderMutation(
page,
"POST",
"/orders",
(body) => Number(body.booking_id) === 8891
(body) => Number(body.booking_id) === 8891 && Number(body.customer_id) === 12345679
);
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
const capturedCreateOrderRequest = await createOrderRequest;
+6
View File
@@ -1201,9 +1201,14 @@ test.describe("POS flow", () => {
{
...fixture.vehicles[0],
reg: "AB12345",
reference: "",
last_order_id: 9201,
},
];
fixture.ordersById[9201] = {
...fixture.ordersById[9201],
reference: "LAST-WASH-REF-9201",
};
fixture.orderItemsByOrderId[9201] = [
{
id: 92011,
@@ -1251,6 +1256,7 @@ test.describe("POS flow", () => {
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => (fixture.orderItemsByOrderId[9300] || []).length, { timeout: 10_000 }).toBe(3);
expect(fixture.ordersById[9300].department_id).toBe(1);
expect(fixture.ordersById[9300].reference).toBe("LAST-WASH-REF-9201");
const copiedItems = fixture.orderItemsByOrderId[9300] || [];
const primaryItem = copiedItems.find((item) => Number(item.product_id) === 53);
+62
View File
@@ -194,6 +194,29 @@ async function getHorizontalBounds(locator) {
});
}
async function expectVehicleEmptyStateMatchesLastWashContentHeight(page) {
const vehicleEmptyState = page.getByTestId("pos-desktop-vehicle-empty-state");
const lastWashList = page.getByTestId("pos-desktop-last-wash-section").locator(".pos-step-one-insight-list");
const lastWashCopyButton = page.getByTestId("pos-desktop-last-wash-copy");
await expect(vehicleEmptyState).toBeVisible();
await expect(lastWashList).toBeVisible();
await expect(lastWashCopyButton).toBeVisible();
const [emptyStateBox, lastWashListBox, lastWashCopyButtonBox] = await Promise.all([
vehicleEmptyState.boundingBox(),
lastWashList.boundingBox(),
lastWashCopyButton.boundingBox(),
]);
expect(emptyStateBox).not.toBeNull();
expect(lastWashListBox).not.toBeNull();
expect(lastWashCopyButtonBox).not.toBeNull();
const lastWashContentHeight = lastWashCopyButtonBox.y + lastWashCopyButtonBox.height - lastWashListBox.y;
expect(Math.abs(emptyStateBox.height - lastWashContentHeight)).toBeLessThanOrEqual(3);
}
async function expectPrimaryActionAboveClearAll(primaryAction, clearAllAction) {
const primaryBounds = await getHorizontalBounds(primaryAction);
const clearAllBounds = await getHorizontalBounds(clearAllAction);
@@ -425,6 +448,45 @@ test.describe("POS visuals", () => {
});
});
test("desktop step 1 stretches vehicle empty state beside last wash content", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Layout assertion is covered on chromium desktop.");
await page.setViewportSize({ width: 1920, height: 1080 });
const posFixture = createPosFixture();
posFixture.vehicles = posFixture.vehicles.map((vehicle) =>
vehicle.reg === "EC21235"
? {
...vehicle,
type: null,
wash_subscription: false,
addons: { enabled: 0, available: 0, list: [] },
last_order_id: 54518,
}
: vehicle
);
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: posFixture,
});
await primeSession(page, "pos-visual-desktop-empty-state-height-token");
await page.goto("/admin/12/modules/pos");
const stepOne = page.getByTestId("pos-step-1");
await expect(stepOne).toBeVisible({ timeout: POS_STEP_TIMEOUT });
await page.locator("#reg_1").fill("EC21235");
await expect(page.getByTestId("pos-desktop-vehicle-summary")).toBeVisible();
await expect(page.getByTestId("pos-desktop-last-wash")).toBeVisible();
await expect(page.getByTestId("pos-desktop-vehicle-empty-state")).toContainText(
"Ingen abonnement- eller tilvalgsdata"
);
await expectVehicleEmptyStateMatchesLastWashContentHeight(page);
});
test("desktop step 1 required reference warning snapshot", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Covered on chromium desktop.");
@@ -122,6 +122,61 @@ const runtimeWithSelectableReleaseDetails = () => ({
],
});
const runtimeWithFailingBetaSwitch = () => ({
generated_at: "2026-05-19T09:10:00.000Z",
trace_id: "trace-stable-channel",
channel: {
id: 1,
slug: "stable",
name: "Stable",
description: "Standard production channel.",
enabled: true,
default_channel: true,
},
versions: {
frontend: { version_label: "frontend-stable", commit_sha: "abc123" },
api: { version_label: "api-stable", commit_sha: "def456" },
bundle_id: 31,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
available_channels: [
{
channel: {
id: 1,
slug: "stable",
name: "Stable",
description: "Standard production channel.",
enabled: true,
default_channel: true,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: {
id: 3,
slug: "beta",
name: "Beta",
description: "Beta validation channel.",
enabled: true,
default_channel: false,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
],
});
async function boot(page, runtime = unavailableRuntime, locale = "en") {
await page.addInitScript((selectedLocale) => {
window.localStorage.setItem("locale", selectedLocale);
@@ -283,6 +338,40 @@ test("release channel choices show git commit and release time when available",
);
});
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
const runtime = runtimeWithFailingBetaSwitch();
await boot(page, runtime);
const runtimeRequests = [];
await page.route("**/release/runtime**", async (route) => {
const url = new URL(route.request().url());
const selectedChannel = url.searchParams.get("release_channel") || "";
runtimeRequests.push(selectedChannel || "default");
if (selectedChannel === "beta") {
await route.fulfill(json({ data: { message: "Beta runtime unavailable" } }, 502));
return;
}
await route.fulfill(json({ data: runtime }));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const stable = page.getByTestId("release-channel-option-stable");
const beta = page.getByTestId("release-channel-option-beta");
await expect(stable).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(stable).toHaveAttribute("aria-pressed", "true");
await expect(beta).toHaveAttribute("aria-pressed", "false");
await beta.click();
await expect(stable).toHaveAttribute("aria-pressed", "true");
await expect(beta).toHaveAttribute("aria-pressed", "false");
await expect(page.getByRole("alert")).toContainText("previous channel is still active");
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"))).toBeNull();
expect(runtimeRequests).toContain("beta");
});
test("predefined release channel text is localized on the guard page", async ({ page }) => {
const internalRuntime = {
...unavailableRuntime,
+221 -14
View File
@@ -34,6 +34,7 @@ function createReleaseState() {
nextCoolifyTargetId: 20,
assignmentPayloads: [],
bundlePayloads: [],
targetPayloads: [],
deploymentPayloads: [],
channels: [
{
@@ -318,7 +319,7 @@ function releaseStatusService(label, service_key, overrides = {}) {
}
function releaseStatusIssue(channel, service, overrides = {}) {
return {
const issue = {
severity: "critical",
type: "missing_value",
channel_id: channel.id,
@@ -334,6 +335,56 @@ function releaseStatusIssue(channel, service, overrides = {}) {
missing_key: null,
...overrides,
};
issue.key = releaseStatusIssueKey(issue);
issue.impact =
issue.impact ||
(issue.type === "failed_deployment"
? "This channel cannot be promoted until the failed deployment is replaced by a successful one."
: "This channel is incomplete and cannot receive traffic safely.");
issue.resolution_state = issue.resolution_state || "blocked";
issue.actions = Array.isArray(issue.actions) ? issue.actions : releaseStatusIssueActions(issue);
return issue;
}
function releaseStatusIssueKey(issue) {
return [
issue.type || "",
issue.channel_id || issue.channel_slug || "",
issue.service_key || "",
issue.missing_key || "",
issue.deployment_id || "",
issue.coolify_target_id || "",
].join(":");
}
function releaseStatusIssueActions(issue) {
if (issue.type === "failed_deployment") {
return [
{
id: "retry_deployment",
label: "Retry deployment",
kind: "mutation",
requires_confirmation: true,
requires_input: false,
disabled_reason: "",
permission: "superuser_release_manager_deploy",
},
];
}
if (issue.type === "missing_value" && ["frontend_version", "api_version"].includes(issue.missing_key)) {
return [
{
id: "deploy_missing_version",
label: "Deploy missing version",
kind: "mutation",
requires_confirmation: true,
requires_input: false,
disabled_reason: "",
permission: "superuser_release_manager_deploy",
},
];
}
return [];
}
function releaseStatusServiceLabel(service) {
@@ -353,9 +404,8 @@ function releaseStatusOverviewFixture(state) {
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 latestApi = latestDeployments.find((deployment) => deployment.app === "api");
const failedApi = latestApi?.status === "failed" ? latestApi : null;
const missingValues =
channel.slug === "beta"
? [
@@ -616,6 +666,45 @@ function channelSlug(state, id) {
return state.channels.find((channel) => Number(channel.id) === Number(id))?.slug || "stable";
}
function endpointForTargetFixture(target, slug = target.channel_slug || "stable") {
const context = target.deploy_context || {};
if (context.endpoint_mode === "manual") {
const host = String(context.manual_endpoint_host || "").trim();
const port = Number(context.manual_endpoint_port || 0) || null;
return {
mode: "manual",
status: host ? "resolved" : "pending",
host: host || null,
port,
url: host ? `https://${host}${port && port !== 443 ? `:${port}` : ""}` : null,
source: "manual",
message: host ? "Manual endpoint override is configured." : "Manual endpoint mode needs a public host.",
};
}
const publicUrl = String(context.coolify_public_url || target.health_url || "").trim();
if (publicUrl) {
const parsed = new URL(publicUrl, "https://fallback.test");
return {
mode: "auto",
status: "resolved",
host: parsed.hostname,
port: Number(parsed.port || (parsed.protocol === "http:" ? 80 : 443)),
url: publicUrl.replace(/\/(health|ping)$/i, "").replace(/\/$/, ""),
source: context.coolify_public_url ? "coolify_public_url" : "health_url",
message: "Automatic endpoint resolved from configured URL.",
};
}
return {
mode: "auto",
status: "pending",
host: "api-v2.truckwash.io",
port: 443,
url: `https://api-v2.truckwash.io/${slug}/${target.app}`,
source: "auto_gateway",
message: "Automatic gateway endpoint will be used when Coolify routing is ready.",
};
}
function addInternalChannel(state) {
const channel = {
id: state.nextChannelId++,
@@ -1092,6 +1181,7 @@ async function installReleaseMocks(page, state) {
if (pathname.endsWith("/superuser/releases/targets") && method === "POST") {
const payload = request.postDataJSON?.() || {};
state.targetPayloads.push(payload);
if (!["project-internal", "project-main"].includes(payload.deploy_context?.coolify_project_uuid)) {
await route.fulfill(json({ message: "Coolify project was not selected." }, 400));
return;
@@ -1103,6 +1193,7 @@ async function installReleaseMocks(page, state) {
coolify_instance_label: payload.coolify_instance_id ? "Production Coolify" : null,
deploy_context: payload.deploy_context || {},
};
target.endpoint = endpointForTargetFixture(target, target.channel_slug);
state.targets.unshift(target);
if (payload.app === "api" && !payload.deploy_context?.isolated_stack && state.serviceSets[0]) {
state.serviceSets[0].targets.api = target;
@@ -1308,6 +1399,66 @@ async function installReleaseMocks(page, state) {
return;
}
if (pathname.endsWith("/superuser/releases/issues/actions") && method === "POST") {
const payload = request.postDataJSON?.() || {};
const currentSummary = summary(state);
const issue = currentSummary.status_overview.issues.find((entry) => entry.key === payload.issue_key);
if (!issue) {
await route.fulfill(
json({
data: {
status: "failed",
message: "This release issue is no longer active.",
result: null,
summary: currentSummary,
},
})
);
return;
}
if (payload.action_id === "retry_deployment") {
const original = state.deployments.find((entry) => Number(entry.id) === Number(issue.deployment_id));
const deployment = {
...(original || {}),
id: state.nextDeploymentId++,
status: "deploying",
error_message: null,
failure_summary: null,
promotable: false,
started_at: "2026-05-19T08:30:00.000Z",
};
state.deployments.unshift(deployment);
await route.fulfill(
json({
data: {
status: "queued",
message: "Deployment retry started.",
issue,
action: issue.actions.find((action) => action.id === payload.action_id),
result: { deployment },
summary: summary(state),
},
})
);
return;
}
await route.fulfill(
json({
data: {
status: "needs_input",
message: "Select the missing deployment inputs.",
issue,
action: issue.actions.find((action) => action.id === payload.action_id),
result: { required_inputs: ["target_id"] },
summary: currentSummary,
},
})
);
return;
}
if (pathname.endsWith("/superuser/releases/deployments") && method === "POST") {
const payload = request.postDataJSON?.() || {};
state.deploymentPayloads.push(payload);
@@ -1413,15 +1564,29 @@ async function installReleaseMocks(page, state) {
});
}
async function expandActiveReleaseCategory(page) {
await expect(page.getByTestId("release-manager-page")).toBeVisible();
async function expectReleaseManagerReady(page) {
await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 90_000 });
await expect(page.getByTestId("release-management-interface")).toBeVisible({ timeout: 90_000 });
}
async function expandActiveReleaseCategory(page) {
await expectReleaseManagerReady(page);
}
const releaseSectionNames = {
Overview: "overview",
Channels: "channels",
Assignments: "assignments",
Deployments: "deployments",
Replay: "replay",
Integrations: "integrations",
Settings: "settings",
};
async function selectReleaseTab(page, name) {
await page
.getByTestId("release-section-tabs")
.getByRole("tab", { name: new RegExp(name) })
.click();
await expectReleaseManagerReady(page);
const key = releaseSectionNames[name] || String(name || "").toLowerCase();
await page.getByTestId("release-management-interface").getByTestId(`release-section-link-${key}`).click();
}
test("overview status dashboard prioritizes release issues and opens channel details", async ({ page }) => {
@@ -1433,6 +1598,11 @@ test("overview status dashboard prioritizes release issues and opens channel det
}
await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" });
await expectReleaseManagerReady(page);
await expect(page.getByTestId("release-section-link-integrations")).toBeVisible();
await expect(page.getByTestId("release-section-link-deployments")).toBeVisible();
await expect(page.getByTestId("release-manager-page")).not.toContainText("endpoint not configured");
const dashboard = page.getByTestId("release-status-dashboard");
await expect(dashboard).toBeVisible({ timeout: 90_000 });
await expect(dashboard).toContainText("Release promotion is blocked");
@@ -1459,6 +1629,16 @@ test("overview status dashboard prioritizes release issues and opens channel det
await page.getByTestId("release-cockpit-primary-action").click();
await expect(page.getByTestId("release-bundle-flow")).toBeVisible();
await expect(page.getByTestId("release-status-drawer")).toBeVisible();
await expect(
page.getByTestId("release-status-drawer").getByRole("button", { name: /Retry deployment/i })
).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page
.getByTestId("release-status-drawer")
.getByRole("button", { name: /Retry deployment/i })
.click();
await expect(page.getByTestId("release-issue-action-result")).toContainText("Deployment retry started");
await expect(issuePanel).not.toContainText("Composer install failed");
await page.locator(".release-status-drawer .delete").click();
await selectReleaseTab(page, "Overview");
@@ -1473,7 +1653,8 @@ test("overview status dashboard prioritizes release issues and opens channel det
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).toContainText("All release services are ready");
await expect(drawer).not.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");
@@ -1509,7 +1690,7 @@ test("channel bundle picker keeps summary, field, and actions aligned", async ({
await boot(page, state);
await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 30_000 });
await expectReleaseManagerReady(page);
const channelRow = page
.getByTestId("release-channels-overview")
.locator("tbody tr")
@@ -1619,7 +1800,7 @@ test("superusers manage release channels, assignments, deployments, and replay",
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 expectReleaseManagerReady(page);
if ((page.viewportSize()?.width || 0) > 768) {
await expect(page.getByRole("link", { name: /Release Manager/i }).first()).toBeVisible();
}
@@ -1717,11 +1898,33 @@ test("superusers manage release channels, assignments, deployments, and replay",
await expect(domainOptions.getByText("http://api.truckwash.io:4433")).toHaveCount(0);
await loadBalancerDomainInput.fill("api-v2.truckwash.io");
await page.getByTestId("release-target-public-url").fill("https://api-v2.truckwash.io/release/canary/api");
await page
.locator('[data-testid="release-target-endpoint-mode"] select, select[data-testid="release-target-endpoint-mode"]')
.selectOption("manual");
await page
.locator(
'[data-testid="release-target-manual-endpoint-host"] input, input[data-testid="release-target-manual-endpoint-host"]'
)
.fill("manual-api.truckwash.io");
await page
.locator(
'[data-testid="release-target-manual-endpoint-port"] input, input[data-testid="release-target-manual-endpoint-port"]'
)
.fill("8443");
await page
.locator('[data-testid="release-target-app-port"] input, input[data-testid="release-target-app-port"]')
.fill("8080");
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();
await expect(page.getByTestId("release-target-form")).toContainText("accessible");
await page.getByTestId("release-target-form").getByRole("button", { name: "Save target" }).click();
expect(state.targetPayloads[state.targetPayloads.length - 1].deploy_context).toMatchObject({
endpoint_mode: "manual",
manual_endpoint_host: "manual-api.truckwash.io",
manual_endpoint_port: "8443",
coolify_ports_exposes: "8080",
});
await expect(page.getByTestId("release-targets-table")).toContainText("truckwash/backend-php#release/canary");
await expect(
page.getByTestId("release-targets-table").locator('[data-testid^="release-target-actions-"]').first()
@@ -1771,7 +1974,8 @@ test("superusers manage release channels, assignments, deployments, and replay",
await expect(serviceActionTable).toContainText("PHP API");
await expect(serviceActionTable).toContainText("MariaDB");
await expect(serviceActionTable).toContainText("canary.example.test:443");
await expect(serviceActionTable).toContainText("api-v2.truckwash.io:443");
await expect(serviceActionTable).toContainText("manual-api.truckwash.io:8443");
await expect(serviceActionTable).not.toContainText("endpoint not configured");
await expect(serviceActionTable).toContainText("db-canary.example.test:3306");
await expect(serviceActionTable).toContainText("redis-canary.example.test:6379");
await expect(serviceActionTable).toContainText("minio-canary.example.test:9000");
@@ -1944,6 +2148,8 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
"release-internal-safe-stack-database"
);
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("api-v2.truckwash.io:443");
await expect(bundleFlow.getByTestId("release-service-action-table")).not.toContainText("endpoint not configured");
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
"created by Coolify; host/port assigned after deployment"
);
@@ -1967,6 +2173,7 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
expect(target.coolify_service_uuid || "").toBe("");
expect(target.deploy_context.coolify_auto_create).toBe(true);
expect(target.deploy_context.coolify_enable_ssl).toBe(false);
expect(target.deploy_context.endpoint_mode).toBe("auto");
expect(target.deploy_context.production_data_attached).toBe(false);
expect(target.deploy_context.coolify_github_app_uuid).toBe("github-app-copenhagentruckwash-github");
expect(target.deploy_context.coolify_build_pack).toBe(target.app === "api" ? "dockerfile" : "static");
@@ -17,6 +17,7 @@ import {
RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY,
releaseChannelRuntimeRequestParams,
selectReleaseChannel,
switchSelectedReleaseChannel,
__resetReleaseChannelAvailabilityForTests,
} from "@/services/releaseChannelAvailability.js";
import {
@@ -151,6 +152,49 @@ describe("release channel availability", () => {
expect(releaseChannelRuntimeRequestParams()).toEqual({ release_channel: "canary" });
});
it("rolls back a selected channel when the runtime refresh fails", async () => {
let refreshCalls = 0;
await expect(
switchSelectedReleaseChannel({ slug: "canary" }, async () => {
refreshCalls += 1;
if (refreshCalls > 1) {
return { channel: { slug: "stable", name: "Stable", default_channel: true } };
}
throw new Error("runtime unavailable");
})
).rejects.toThrow("runtime unavailable");
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
expect(releaseChannelRuntimeRequestParams()).toEqual({});
expect(refreshCalls).toBe(2);
});
it("rejects and rolls back when the refreshed runtime confirms a different channel", async () => {
configureReleaseRuntime({
channel: { slug: "stable", name: "Stable", default_channel: true },
availableChannels: [
{ channel: { slug: "stable", name: "Stable", default_channel: true } },
{ channel: configuredCanaryRuntime.channel },
],
});
await expect(
switchSelectedReleaseChannel({ slug: "canary" }, async () => ({
channel: { slug: "stable", name: "Stable", default_channel: true },
}))
).rejects.toThrow("instead of canary");
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
});
it("keeps a selected channel only after the refreshed runtime confirms it", async () => {
const selectedSlug = await switchSelectedReleaseChannel({ slug: "canary" }, async () => configuredCanaryRuntime);
expect(selectedSlug).toBe("canary");
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBe("canary");
});
it("clears a stored selection when the runtime no longer offers that channel", () => {
selectReleaseChannel("canary");