From 20274cf8ebd4877d6519bf8caeb4b88123b4ba85 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 19 May 2026 13:11:17 +0200 Subject: [PATCH] Update i18n phrases for `ssl_domain` fields to clarify load balancer routing details and add new methods for release service sets and bundles in SuperUser Release Manager. --- src/i18n/generated/da-v2.json | 8 +- src/i18n/generated/de-v2.json | 8 +- src/i18n/generated/en-v2.json | 8 +- src/i18n/generated/no-v2.json | 8 +- src/i18n/generated/sv-v2.json | 8 +- src/i18n/locales/da.json | 8 +- src/i18n/locales/de.json | 8 +- src/i18n/locales/en.json | 8 +- src/i18n/locales/no.json | 8 +- src/i18n/locales/sv.json | 8 +- .../phrases/compat/configuration/index.json | 8 +- .../phrases/compat/configuration/index.json | 8 +- .../phrases/compat/configuration/index.json | 8 +- .../phrases/compat/configuration/index.json | 8 +- .../phrases/compat/configuration/index.json | 8 +- src/services/superuserReleases.js | 17 + .../ConfigurationReleaseManager.vue | 1239 +++++++++++++++-- tests/e2e/release-manager.spec.js | 353 ++++- 18 files changed, 1569 insertions(+), 160 deletions(-) diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index a0eee0b5..ee6a3313 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -2829,10 +2829,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domæne", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domæne", - "https_domain_for_coolify": "HTTPS-domæne til Coolify", + "ssl_domain": "Load balancer-domæne", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 25b0a80c..d666316a 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -2939,10 +2939,10 @@ "health_url": "Health URL", "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", - "ssl_domain": "SSL-Domain", - "ssl_domain_message": "Example: canary.example.test", - "ssl_domain_placeholder": "SSL-Domain", - "https_domain_for_coolify": "HTTPS-Domain für Coolify", + "ssl_domain": "Load-Balancer-Domain", + "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", "auto_deploy_tooltip": "Automatisch Deployments erstellen, wenn sich dieses Ziel ändert.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 954ebb2c..d5f0f765 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -2663,10 +2663,10 @@ "health_url": "Health URL", "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", - "ssl_domain": "SSL domain", - "ssl_domain_message": "Example: canary.example.test", - "ssl_domain_placeholder": "SSL domain", - "https_domain_for_coolify": "HTTPS domain for Coolify", + "ssl_domain": "Load balancer domain", + "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", "auto_deploy_tooltip": "Automatically create deployments when this target changes.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index a46f8885..9126720e 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -2940,10 +2940,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domene", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domene", - "https_domain_for_coolify": "HTTPS-domæne til Coolify", + "ssl_domain": "Load balancer-domene", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index 64f517a0..7f1673bd 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -2990,10 +2990,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domän", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domän", - "https_domain_for_coolify": "HTTPS-domän för Coolify", + "ssl_domain": "Load balancer-domän", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index 19c21495..d3556706 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -1887,10 +1887,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domæne", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domæne", - "https_domain_for_coolify": "HTTPS-domæne til Coolify", + "ssl_domain": "Load balancer-domæne", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 90d10984..5374f5b3 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1887,10 +1887,10 @@ "health_url": "Health URL", "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", - "ssl_domain": "SSL-Domain", - "ssl_domain_message": "Example: canary.example.test", - "ssl_domain_placeholder": "SSL-Domain", - "https_domain_for_coolify": "HTTPS-Domain für Coolify", + "ssl_domain": "Load-Balancer-Domain", + "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", "auto_deploy_tooltip": "Automatisch Deployments erstellen, wenn sich dieses Ziel ändert.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0f34a2a6..69b0b5e8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1887,10 +1887,10 @@ "health_url": "Health URL", "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", - "ssl_domain": "SSL domain", - "ssl_domain_message": "Example: canary.example.test", - "ssl_domain_placeholder": "SSL domain", - "https_domain_for_coolify": "HTTPS domain for Coolify", + "ssl_domain": "Load balancer domain", + "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", "auto_deploy_tooltip": "Automatically create deployments when this target changes.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/locales/no.json b/src/i18n/locales/no.json index d6a2d0aa..723ae783 100644 --- a/src/i18n/locales/no.json +++ b/src/i18n/locales/no.json @@ -1887,10 +1887,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domene", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domene", - "https_domain_for_coolify": "HTTPS-domæne til Coolify", + "ssl_domain": "Load balancer-domene", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index e2a591b2..9ed9f947 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -1887,10 +1887,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domän", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domän", - "https_domain_for_coolify": "HTTPS-domän för Coolify", + "ssl_domain": "Load balancer-domän", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/source/da/phrases/compat/configuration/index.json b/src/i18n/source/da/phrases/compat/configuration/index.json index 36da9fde..9caf65f5 100644 --- a/src/i18n/source/da/phrases/compat/configuration/index.json +++ b/src/i18n/source/da/phrases/compat/configuration/index.json @@ -690,10 +690,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domæne", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domæne", - "https_domain_for_coolify": "HTTPS-domæne til Coolify", + "ssl_domain": "Load balancer-domæne", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/source/de/phrases/compat/configuration/index.json b/src/i18n/source/de/phrases/compat/configuration/index.json index ed01441d..ce1165ea 100644 --- a/src/i18n/source/de/phrases/compat/configuration/index.json +++ b/src/i18n/source/de/phrases/compat/configuration/index.json @@ -690,10 +690,10 @@ "health_url": "Health URL", "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", - "ssl_domain": "SSL-Domain", - "ssl_domain_message": "Example: canary.example.test", - "ssl_domain_placeholder": "SSL-Domain", - "https_domain_for_coolify": "HTTPS-Domain für Coolify", + "ssl_domain": "Load-Balancer-Domain", + "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", "auto_deploy_tooltip": "Automatisch Deployments erstellen, wenn sich dieses Ziel ändert.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/source/en/phrases/compat/configuration/index.json b/src/i18n/source/en/phrases/compat/configuration/index.json index c5b6194a..97e5cbc2 100644 --- a/src/i18n/source/en/phrases/compat/configuration/index.json +++ b/src/i18n/source/en/phrases/compat/configuration/index.json @@ -690,10 +690,10 @@ "health_url": "Health URL", "health_url_message": "Example: https://api-canary.example.test/ping", "health_url_placeholder": "Health URL", - "ssl_domain": "SSL domain", - "ssl_domain_message": "Example: canary.example.test", - "ssl_domain_placeholder": "SSL domain", - "https_domain_for_coolify": "HTTPS domain for Coolify", + "ssl_domain": "Load balancer domain", + "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", "auto_deploy_tooltip": "Automatically create deployments when this target changes.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/source/no/phrases/compat/configuration/index.json b/src/i18n/source/no/phrases/compat/configuration/index.json index 3eb40b40..786a9bbb 100644 --- a/src/i18n/source/no/phrases/compat/configuration/index.json +++ b/src/i18n/source/no/phrases/compat/configuration/index.json @@ -690,10 +690,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domene", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domene", - "https_domain_for_coolify": "HTTPS-domæne til Coolify", + "ssl_domain": "Load balancer-domene", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/i18n/source/sv/phrases/compat/configuration/index.json b/src/i18n/source/sv/phrases/compat/configuration/index.json index 4dc47b20..6f2abba8 100644 --- a/src/i18n/source/sv/phrases/compat/configuration/index.json +++ b/src/i18n/source/sv/phrases/compat/configuration/index.json @@ -690,10 +690,10 @@ "health_url": "Health-URL", "health_url_message": "Eksempel: https://api-canary.example.test/ping", "health_url_placeholder": "Health-URL", - "ssl_domain": "SSL-domän", - "ssl_domain_message": "Eksempel: canary.example.test", - "ssl_domain_placeholder": "SSL-domän", - "https_domain_for_coolify": "HTTPS-domän för Coolify", + "ssl_domain": "Load balancer-domän", + "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", "auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.", "coolify_ssl": "Coolify SSL", diff --git a/src/services/superuserReleases.js b/src/services/superuserReleases.js index c8dabee4..3b2436c5 100644 --- a/src/services/superuserReleases.js +++ b/src/services/superuserReleases.js @@ -185,6 +185,23 @@ export const saveReleaseDeploymentTarget = (payload) => export const deleteReleaseDeploymentTarget = (id) => requestReleaseManager(`/superuser/releases/targets/${id}`, "DELETE", {}); +export const listReleaseServiceSets = () => requestReleaseManager("/superuser/releases/service-sets", "GET", {}); + +export const createReleaseServiceSet = (payload) => + requestReleaseManager("/superuser/releases/service-sets", "POST", payload); + +export const listReleaseBundles = (limit = 50) => + requestReleaseManager("/superuser/releases/bundles", "GET", { limit }); + +export const createReleaseBundle = (payload) => + requestReleaseManager("/superuser/releases/bundles", "POST", payload); + +export const deployReleaseBundle = (id) => + requestReleaseManager(`/superuser/releases/bundles/${id}/deploy`, "POST", {}); + +export const promoteReleaseBundle = (id) => + requestReleaseManager(`/superuser/releases/bundles/${id}/promote`, "POST", {}); + export const listReleaseGithubRepositories = (params = {}) => requestReleaseManager("/superuser/releases/github/repositories", "GET", params); diff --git a/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue b/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue index f6cbf267..2a631868 100644 --- a/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue +++ b/src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue @@ -2,6 +2,8 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; import { BAutocomplete } from "buefy"; import { useI18n } from "vue-i18n"; +import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue"; +import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue"; import PageTitle from "@/components/global/PageTitle.vue"; import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue"; import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue"; @@ -15,15 +17,19 @@ import { import { clearReleaseManagerControlApiUrl, createReleaseAssignment, + createReleaseBundle, createReleaseChannel, + createReleaseServiceSet, deleteReleaseAssignment, deleteReleaseDeploymentTarget, + deployReleaseBundle, getReleaseConfig, getReleaseManagerControlApiUrl, getReleaseSummary, listReleaseGithubBranches, listReleaseGithubCommits, listReleaseGithubRepositories, + promoteReleaseBundle, promoteReleaseDeployment, rollbackReleaseChannel, saveReleaseDeploymentTarget, @@ -45,6 +51,9 @@ const trFallback = (key, fallback, params = {}) => { return translated === fullKey ? fallback : translated; }; const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || "")); +const readableValue = (value) => String(value || "").replace(/_/g, " "); +const statusLabel = (value) => trFallback(`values.status.${value}`, readableValue(value)); +const modeLabel = (value) => trFallback(`values.service_set_mode.${value}`, readableValue(value)); const tabs = computed(() => [ { @@ -98,6 +107,8 @@ const summary = ref({ channels: [], assignments: [], deployment_targets: [], + service_sets: [], + bundles: [], deployments: [], timeline: {}, module_health: [], @@ -122,6 +133,13 @@ const githubCommitsSource = ref({ }); const targetAccessResult = ref(null); const deploymentAccessResult = ref(null); +const bundleAccessResult = ref({ + frontend: null, + api: null, +}); +const releaseFlowStep = ref(0); +const showAdvancedDeployment = ref(false); +const createdBundle = ref(null); const releaseConfigForm = reactive({ github_token: "", @@ -162,6 +180,7 @@ const targetForm = reactive({ repository: "", branch: "main", coolify_instance_id: "", + coolify_project_uuid: "", coolify_service_uuid: "", health_url: "", auto_deploy: true, @@ -182,6 +201,22 @@ const deploymentForm = reactive({ version_label: "", }); +const bundleForm = reactive({ + source_service_set_id: null, + dataset_mode: "attach_existing", + service_set_name: "", + channel_id: null, + frontend_repository: "", + frontend_branch: "main", + frontend_commit_mode: "latest", + frontend_commit_sha: "", + api_repository: "", + api_branch: "main", + api_commit_mode: "latest", + api_commit_sha: "", + version_label: "", +}); + const replayForm = reactive({ target_type: "user", target_id: "", @@ -219,6 +254,8 @@ const targets = computed(() => Array.isArray(summary.value?.deployment_targets) ? summary.value.deployment_targets : [] ); const deployments = computed(() => (Array.isArray(summary.value?.deployments) ? summary.value.deployments : [])); +const serviceSets = computed(() => (Array.isArray(summary.value?.service_sets) ? summary.value.service_sets : [])); +const bundles = computed(() => (Array.isArray(summary.value?.bundles) ? summary.value.bundles : [])); const moduleHealth = computed(() => (Array.isArray(summary.value?.module_health) ? summary.value.module_health : [])); const timelineSummary = computed(() => summary.value?.timeline || {}); const suggestions = computed(() => summary.value?.suggestions || {}); @@ -245,6 +282,13 @@ const apiUrlSuggestions = computed(() => const healthUrlSuggestions = computed(() => Array.isArray(suggestions.value.health_urls) ? suggestions.value.health_urls : [] ); +const loadBalancerDomainSuggestions = computed(() => + uniqueStrings( + (Array.isArray(suggestions.value.load_balancer_domains) ? suggestions.value.load_balancer_domains : []) + .map(domainFromValue) + .filter(Boolean) + ) +); const serviceUuidSuggestions = computed(() => Array.isArray(suggestions.value.coolify_service_uuids) ? suggestions.value.coolify_service_uuids : [] ); @@ -254,9 +298,19 @@ const moduleKeySuggestions = computed(() => const coolifyInstances = computed(() => Array.isArray(suggestions.value.coolify_instances) ? suggestions.value.coolify_instances : [] ); +const coolifyProjects = computed(() => + Array.isArray(suggestions.value.coolify_projects) ? suggestions.value.coolify_projects : [] +); const coolifyServices = computed(() => Array.isArray(suggestions.value.coolify_services) ? suggestions.value.coolify_services : [] ); +const selectedCoolifyInstance = computed(() => + coolifyInstances.value.find((instance) => Number(instance.id) === Number(targetForm.coolify_instance_id)) || null +); +const coolifyProjectsForSelectedInstance = computed(() => { + const instanceId = Number(targetForm.coolify_instance_id || selectedCoolifyInstance.value?.id || 0); + return coolifyProjects.value.filter((project) => !instanceId || Number(project.instance_id) === instanceId); +}); const channelPresets = computed(() => Array.isArray(suggestions.value.channel_presets) ? suggestions.value.channel_presets : [] ); @@ -314,6 +368,95 @@ const defaultChannel = computed( const selectedTarget = computed( () => targets.value.find((target) => Number(target.id) === Number(deploymentForm.target_id)) || null ); +const selectedServiceSet = computed( + () => serviceSets.value.find((set) => Number(set.id) === Number(bundleForm.source_service_set_id)) || null +); +const isIsolatedStackMode = computed(() => bundleForm.dataset_mode === "isolated_stack"); +const frontendBundleSourceTarget = computed( + () => selectedServiceSet.value?.targets?.frontend || targets.value.find((target) => target.app === "frontend") || null +); +const apiBundleSourceTarget = computed( + () => selectedServiceSet.value?.targets?.api || targets.value.find((target) => target.app === "api") || null +); +const frontendBundleTarget = computed( + () => (isIsolatedStackMode.value ? null : frontendBundleSourceTarget.value) +); +const apiBundleTarget = computed( + () => (isIsolatedStackMode.value ? null : apiBundleSourceTarget.value) +); +const datasetModeMessage = computed(() => + isIsolatedStackMode.value + ? trFallback( + "bundles.isolated_stack_message", + "Creates new Coolify frontend and API services and leaves data services unattached so production data is not used." + ) + : trFallback( + "bundles.dataset_mode_message", + "Reuse services, clone them through replicas, or start isolated empty data services." + ) +); +const bundleReviewStack = computed(() => [ + { + key: "frontend", + label: trFallback("bundles.stack.frontend", "Frontend"), + value: bundleForm.frontend_repository || frontendBundleSourceTarget.value?.repository || "--", + detail: `${bundleForm.frontend_branch || frontendBundleSourceTarget.value?.branch || "main"} ${ + bundleForm.frontend_commit_mode === "specific" ? bundleForm.frontend_commit_sha : trFallback("bundles.latest", "latest") + }`, + status: isIsolatedStackMode.value + ? trFallback("bundles.new_isolated_service", "new isolated service") + : frontendBundleTarget.value + ? trFallback("bundles.ready", "ready") + : trFallback("bundles.needs_target", "target needed"), + }, + { + key: "api", + label: trFallback("bundles.stack.api", "PHP backend"), + value: bundleForm.api_repository || apiBundleSourceTarget.value?.repository || "--", + detail: `${bundleForm.api_branch || apiBundleSourceTarget.value?.branch || "main"} ${ + bundleForm.api_commit_mode === "specific" ? bundleForm.api_commit_sha : trFallback("bundles.latest", "latest") + }`, + status: isIsolatedStackMode.value + ? trFallback("bundles.new_isolated_service", "new isolated service") + : apiBundleTarget.value + ? trFallback("bundles.ready", "ready") + : trFallback("bundles.needs_target", "target needed"), + }, + ...["database", "redis", "minio"].map((kind) => { + const service = isIsolatedStackMode.value + ? null + : selectedServiceSet.value?.data_services?.[kind] || selectedServiceSet.value?.stack?.[kind] || null; + return { + key: kind, + label: trFallback(`values.stack.${kind}`, kind.charAt(0).toUpperCase() + kind.slice(1)), + value: isIsolatedStackMode.value + ? trFallback("bundles.new_empty_data_service", "New empty service") + : service?.label || service?.resource_name || trFallback("bundles.unassigned_data_service", "Not assigned yet"), + detail: service?.replication?.label || service?.replication?.host || service?.availability_state || "", + status: isIsolatedStackMode.value + ? trFallback("bundles.production_data_not_attached", "production data not attached") + : service?.replication?.status || service?.deployment_status || trFallback("bundles.needs_configuration", "needs configuration"), + }; + }), +]); +const canContinueReleaseFlow = computed(() => { + if (releaseFlowStep.value === 0) { + return Boolean(bundleForm.channel_id || defaultChannel.value?.id); + } + if (releaseFlowStep.value === 1) { + return ( + String(bundleForm.frontend_repository || frontendBundleTarget.value?.repository || "").includes("/") && + String(bundleForm.api_repository || apiBundleTarget.value?.repository || "").includes("/") && + (!bundleForm.frontend_commit_mode || + bundleForm.frontend_commit_mode === "latest" || + String(bundleForm.frontend_commit_sha || "").trim() !== "") && + (!bundleForm.api_commit_mode || + bundleForm.api_commit_mode === "latest" || + String(bundleForm.api_commit_sha || "").trim() !== "") + ); + } + return true; +}); const isCustomerAssignmentSubject = computed(() => assignmentForm.subject_type === "customer"); const canSubmitAssignment = computed(() => { if (!canManage.value) { @@ -330,7 +473,8 @@ const canSubmitAssignment = computed(() => { const stats = computed(() => [ { label: tr("stats.channels"), value: channels.value.length }, { label: tr("stats.targets"), value: targets.value.length }, - { label: tr("stats.deployments"), value: deployments.value.length }, + { label: trFallback("stats.service_sets", "Service sets"), value: serviceSets.value.length }, + { label: trFallback("stats.bundles", "Bundles"), value: bundles.value.length }, { label: tr("stats.timeline_events"), value: timelineSummary.value.events || 0 }, ]); const activeTabMeta = computed(() => tabs.value.find((tab) => tab.key === activeTab.value) || tabs.value[0]); @@ -571,14 +715,37 @@ function baseUrlFromValue(value) { function domainFromValue(value) { try { - return new URL(baseUrlFromValue(value)).host; + const hostname = new URL(baseUrlFromValue(value)).hostname.toLowerCase(); + return isLocalDomain(hostname) ? "" : hostname; } catch { - return String(value || "") + const domain = String(value || "") .replace(/^https?:\/\//i, "") - .replace(/\/.*$/, ""); + .replace(/:\d+$/, "") + .replace(/\/.*$/, "") + .trim() + .toLowerCase(); + return isLocalDomain(domain) ? "" : domain; } } +function isLocalDomain(domain) { + const value = String(domain || "").toLowerCase(); + return value === "localhost" || value.endsWith(".localhost") || value === "127.0.0.1" || value === "::1"; +} + +function loadBalancerDomainOptions(query) { + return autocompleteOptions(loadBalancerDomainSuggestions.value, query, { + category: tr("integrations.ssl_domain"), + description: tr("integrations.https_domain_for_coolify"), + icon: "fas fa-route", + }); +} + +function loadBalancerDomainValue(value) { + const domain = domainFromValue(value); + return loadBalancerDomainSuggestions.value.includes(domain) ? domain : ""; +} + function healthUrlForBaseUrl(app, value) { const baseUrl = baseUrlFromValue(value); if (!baseUrl) { @@ -596,7 +763,8 @@ function channelUrlOptions(kind) { icon: kind === "api" ? "fas fa-server" : "fas fa-window-maximize", }); const deployUrl = baseUrlFromValue(query || options[0]?.value || ""); - if (deployUrl && coolifyInstances.value.length > 0 && canDeploy.value) { + const deployDomain = loadBalancerDomainValue(deployUrl); + if (deployUrl && deployDomain && coolifyInstances.value.length > 0 && canDeploy.value) { options.unshift({ value: deployUrl, title: tr("autocomplete.deploy_here_with_coolify", { app: valueLabel("app", app) }), @@ -678,6 +846,36 @@ function serviceUuidOptions(query) { ].slice(0, 10); } +function defaultCoolifyProjectUuid(instanceId = targetForm.coolify_instance_id) { + const id = Number(instanceId || 0); + const instance = coolifyInstances.value.find((entry) => Number(entry.id) === id); + const defaultProject = String(instance?.default_project_uuid || "").trim(); + if (defaultProject) { + return defaultProject; + } + return ( + coolifyProjects.value.find((project) => Number(project.instance_id) === id && project.default)?.uuid || + coolifyProjects.value.find((project) => Number(project.instance_id) === id)?.uuid || + "" + ); +} + +function ensureCoolifyProjectForInstance() { + const instanceId = Number(targetForm.coolify_instance_id || 0); + if (!instanceId) { + targetForm.coolify_project_uuid = ""; + return; + } + + const selectedProjectMatches = coolifyProjects.value.some( + (project) => + Number(project.instance_id) === instanceId && String(project.uuid) === String(targetForm.coolify_project_uuid) + ); + if (!selectedProjectMatches) { + targetForm.coolify_project_uuid = defaultCoolifyProjectUuid(instanceId); + } +} + function controlApiOptions() { return autocompleteOptions(controlApiCandidates.value, controlApiInput.value, { category: tr("autocomplete.control_api"), @@ -718,7 +916,7 @@ function selectTargetField(field, option) { selectCoolifyService(option.service); return; } - targetForm[field] = value; + targetForm[field] = field === "coolify_domain" ? domainFromValue(value) : value; if (field === "repository") { targetAccessResult.value = null; void loadGithubBranches(value); @@ -759,9 +957,10 @@ function selectCoolifyService(service) { } targetForm.coolify_service_uuid = service.uuid || ""; targetForm.coolify_instance_id = service.instance_id || targetForm.coolify_instance_id; + ensureCoolifyProjectForInstance(); const publicUrl = baseUrlFromValue(service.urls?.[0] || ""); if (publicUrl) { - targetForm.coolify_domain = domainFromValue(publicUrl); + targetForm.coolify_domain = loadBalancerDomainValue(publicUrl) || targetForm.coolify_domain; targetForm.health_url = healthUrlForBaseUrl(targetForm.app, publicUrl); } targetForm.coolify_enable_ssl = true; @@ -769,6 +968,7 @@ function selectCoolifyService(service) { function prepareCoolifySslTarget(app, url) { const baseUrl = baseUrlFromValue(url); + const coolifyDomain = loadBalancerDomainValue(baseUrl) || firstAvailable(loadBalancerDomainSuggestions.value); Object.assign(targetForm, { channel_id: targetForm.channel_id || channelForm.id || defaultChannel.value?.id || null, app, @@ -784,11 +984,12 @@ function prepareCoolifySslTarget(app, url) { ), branch: targetForm.branch || firstAvailable(branchSuggestions.value, "main"), coolify_instance_id: targetForm.coolify_instance_id || coolifyInstances.value[0]?.id || "", + coolify_project_uuid: targetForm.coolify_project_uuid || defaultCoolifyProjectUuid(targetForm.coolify_instance_id || coolifyInstances.value[0]?.id), health_url: healthUrlForBaseUrl(app, baseUrl), auto_deploy: true, coolify_auto_create: false, coolify_enable_ssl: true, - coolify_domain: domainFromValue(baseUrl), + coolify_domain: coolifyDomain, coolify_deploy_now: true, }); activeTab.value = "integrations"; @@ -828,13 +1029,13 @@ function applyTargetPreset(preset) { repository: preset.repository || firstAvailable(repositorySuggestions.value), branch: preset.branch || firstAvailable(branchSuggestions.value, "main"), coolify_instance_id: targetForm.coolify_instance_id || coolifyInstances.value[0]?.id || "", + coolify_project_uuid: preset.coolify_project_uuid || targetForm.coolify_project_uuid || defaultCoolifyProjectUuid(targetForm.coolify_instance_id || coolifyInstances.value[0]?.id), coolify_service_uuid: preset.coolify_service_uuid || firstAvailable(serviceUuidSuggestions.value), health_url: preset.health_url || suggestedHealthUrl(preset.app || "frontend"), auto_deploy: preset.auto_deploy !== false, coolify_auto_create: Boolean(preset.coolify_auto_create), coolify_enable_ssl: preset.coolify_enable_ssl !== false, - coolify_domain: - preset.coolify_domain || domainFromValue(preset.health_url || suggestedHealthUrl(preset.app || "frontend")), + coolify_domain: loadBalancerDomainValue(preset.coolify_domain), coolify_deploy_now: Boolean(preset.coolify_deploy_now), }); activeTab.value = "integrations"; @@ -873,6 +1074,37 @@ function applyQuickDeployTarget(target) { activeTab.value = "deployments"; } +function applyBundleDefaults() { + if (!isIsolatedStackMode.value && !bundleForm.source_service_set_id && serviceSets.value[0]) { + bundleForm.source_service_set_id = serviceSets.value[0].id; + } + if (!bundleForm.channel_id) { + bundleForm.channel_id = selectedServiceSet.value?.channel_id || defaultChannel.value?.id || null; + } + if (!bundleForm.frontend_repository) { + bundleForm.frontend_repository = + selectedServiceSet.value?.targets?.frontend?.repository || + targets.value.find((target) => target.app === "frontend")?.repository || + firstAvailable(repositorySuggestions.value.filter((repository) => /front|vue|web/i.test(repository))); + } + if (!bundleForm.api_repository) { + bundleForm.api_repository = + selectedServiceSet.value?.targets?.api?.repository || + targets.value.find((target) => target.app === "api")?.repository || + firstAvailable(repositorySuggestions.value.filter((repository) => /api|backend|php/i.test(repository))); + } + if (!bundleForm.frontend_branch) { + bundleForm.frontend_branch = selectedServiceSet.value?.targets?.frontend?.branch || "main"; + } + if (!bundleForm.api_branch) { + bundleForm.api_branch = selectedServiceSet.value?.targets?.api?.branch || "main"; + } + if (!bundleForm.version_label && bundleForm.channel_id) { + const channelSlug = channels.value.find((channel) => Number(channel.id) === Number(bundleForm.channel_id))?.slug || "release"; + bundleForm.version_label = `${channelSlug}-bundle-${new Date().toISOString().slice(0, 10)}`; + } +} + async function load() { errors.value = []; busy.value = "load"; @@ -884,9 +1116,14 @@ async function load() { if (!targetForm.channel_id && channels.value[0]) { targetForm.channel_id = channels.value[0].id; } + ensureCoolifyProjectForInstance(); if (!deploymentForm.channel_id && channels.value[0]) { deploymentForm.channel_id = channels.value[0].id; } + if (!bundleForm.channel_id && channels.value[0]) { + bundleForm.channel_id = channels.value[0].id; + } + applyBundleDefaults(); if (!replayForm.channel_id && channels.value[0]) { replayForm.channel_id = channels.value[0].id; } @@ -1028,6 +1265,220 @@ async function testDeploymentAccess() { }); } +function bundleAppForm(app) { + return { + repository: bundleForm[`${app}_repository`], + branch: bundleForm[`${app}_branch`], + commit_mode: bundleForm[`${app}_commit_mode`], + commit_sha: bundleForm[`${app}_commit_sha`], + }; +} + +function serviceSetStackItem(set, item) { + return set?.stack?.[item] || null; +} + +function bundleApp(bundle, app) { + return bundle?.apps?.[app] || {}; +} + +function selectedBundleChannel() { + return ( + channels.value.find((channel) => Number(channel.id) === Number(bundleForm.channel_id)) || + selectedServiceSet.value?.channel || + defaultChannel.value || + channels.value[0] || + null + ); +} + +function bundleChannelId() { + return bundleForm.channel_id || selectedServiceSet.value?.channel_id || selectedBundleChannel()?.id || null; +} + +function safeStackSlug(value) { + const slug = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return (slug || `isolated-stack-${new Date().toISOString().slice(0, 10)}`).slice(0, 48); +} + +function isolatedStackServiceName(app) { + const channelSlug = selectedBundleChannel()?.slug || "release"; + const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`); + return `release-${stackName}-${app}`.slice(0, 64); +} + +function isolatedStackSourceTarget(app) { + return app === "api" ? apiBundleSourceTarget.value : frontendBundleSourceTarget.value; +} + +function isolatedStackProjectUuid(instanceId, sourceTarget) { + return sourceTarget?.deploy_context?.coolify_project_uuid || defaultCoolifyProjectUuid(instanceId); +} + +async function createIsolatedStackDeploymentTarget(app) { + const sourceTarget = isolatedStackSourceTarget(app); + const instanceId = Number(sourceTarget?.coolify_instance_id || coolifyInstances.value[0]?.id || 0) || null; + const repository = bundleForm[`${app}_repository`] || sourceTarget?.repository || ""; + const branch = bundleForm[`${app}_branch`] || sourceTarget?.branch || "main"; + if (!instanceId) { + throw new Error( + trFallback("errors.isolated_stack_coolify_required", "Select or configure a Coolify instance before creating an isolated stack.") + ); + } + if (!String(repository).includes("/")) { + throw new Error( + trFallback("errors.isolated_stack_repository_required", "Select frontend and API repositories before creating an isolated stack.") + ); + } + + const savedTarget = responseData( + await saveReleaseDeploymentTarget({ + channel_id: bundleChannelId(), + app, + repository, + branch, + coolify_instance_id: instanceId, + coolify_service_uuid: "", + health_url: "", + auto_deploy: true, + deploy_context: { + coolify_auto_create: true, + coolify_enable_ssl: false, + coolify_domain: "", + coolify_public_url: "", + coolify_deploy_now: true, + coolify_project_uuid: isolatedStackProjectUuid(instanceId, sourceTarget), + coolify_service_name: isolatedStackServiceName(app), + isolated_stack: true, + production_data_attached: false, + }, + }), + {} + ); + if (!savedTarget?.id) { + throw new Error(trFallback("errors.isolated_stack_target_failed", "Release Manager could not create the isolated Coolify target.")); + } + return savedTarget; +} + +function selectBundleField(app, field, option) { + if (!option) { + return; + } + bundleForm[`${app}_${field}`] = optionValue(option); + bundleAccessResult.value[app] = null; + if (field === "repository") { + void loadGithubBranches(bundleForm[`${app}_repository`]); + } +} + +async function testBundleAccess() { + await run("github:bundle:test", async () => { + await verifyBundleAccess(); + }); +} + +async function verifyBundleAccess() { + const frontendResult = await checkGithubAccess(bundleAppForm("frontend")); + const apiResult = await checkGithubAccess(bundleAppForm("api")); + bundleAccessResult.value = { + frontend: frontendResult, + api: apiResult, + }; +} + +function previousReleaseFlowStep() { + releaseFlowStep.value = Math.max(0, releaseFlowStep.value - 1); +} + +function nextReleaseFlowStep() { + if (!canContinueReleaseFlow.value) { + return; + } + releaseFlowStep.value = Math.min(3, releaseFlowStep.value + 1); +} + +async function serviceSetIdForBundle() { + const sourceId = isIsolatedStackMode.value ? null : Number(bundleForm.source_service_set_id || 0) || null; + if (bundleForm.dataset_mode === "attach_existing" && sourceId) { + return sourceId; + } + + const isolatedTargets = isIsolatedStackMode.value + ? { + frontend: await createIsolatedStackDeploymentTarget("frontend"), + api: await createIsolatedStackDeploymentTarget("api"), + } + : null; + const serviceSetPayload = { + mode: bundleForm.dataset_mode, + source_service_set_id: sourceId, + channel_id: bundleChannelId(), + name: + bundleForm.service_set_name || + `${selectedBundleChannel()?.slug || "release"} ${ + bundleForm.dataset_mode === "isolated_stack" + ? "isolated stack" + : bundleForm.dataset_mode === "fresh_empty" + ? "fresh data" + : "service set" + }`, + frontend_target_id: isolatedTargets?.frontend?.id || frontendBundleTarget.value?.id || null, + api_target_id: isolatedTargets?.api?.id || apiBundleTarget.value?.id || null, + metadata: isIsolatedStackMode.value + ? { + isolated_stack: true, + production_data_attached: false, + production_code_targets_attached: false, + } + : {}, + }; + const serviceSet = responseData(await createReleaseServiceSet(serviceSetPayload), {}); + return serviceSet?.id; +} + +async function createAndDeployBundle() { + await run("bundle:deploy", async () => { + await verifyBundleAccess(); + const serviceSetId = await serviceSetIdForBundle(); + if (!serviceSetId) { + throw new Error(trFallback("errors.service_set_required", "A service set is required before deploying a bundle.")); + } + const bundle = responseData( + await createReleaseBundle({ + service_set_id: serviceSetId, + channel_id: bundleForm.channel_id, + version_label: bundleForm.version_label, + frontend: bundleAppForm("frontend"), + api: bundleAppForm("api"), + }), + {} + ); + createdBundle.value = responseData(await deployReleaseBundle(bundle.id), bundle); + releaseFlowStep.value = 3; + await load(); + }); +} + +async function deployExistingBundle(bundle) { + await run(`bundle:${bundle.id}:deploy`, async () => { + createdBundle.value = responseData(await deployReleaseBundle(bundle.id), bundle); + await load(); + }); +} + +async function promoteBundle(bundle) { + await run(`bundle:${bundle.id}:promote`, async () => { + await promoteReleaseBundle(bundle.id); + await load(); + }); +} + async function applyControlApiUrl(url) { const normalized = setReleaseManagerControlApiUrl(url); if (!normalized) { @@ -1114,12 +1565,14 @@ async function removeAssignment(assignment) { async function saveTarget() { await run("target:save", async () => { targetAccessResult.value = await checkGithubAccess({ ...targetForm, commit_mode: "latest", commit_sha: "" }); + const coolifyDomain = domainFromValue(targetForm.coolify_domain); const deployContext = { coolify_auto_create: Boolean(targetForm.coolify_auto_create), - coolify_enable_ssl: Boolean(targetForm.coolify_enable_ssl), - coolify_domain: targetForm.coolify_domain || domainFromValue(targetForm.health_url), - coolify_public_url: baseUrlFromValue(targetForm.coolify_domain || targetForm.health_url), + coolify_enable_ssl: Boolean(targetForm.coolify_enable_ssl && coolifyDomain), + coolify_domain: coolifyDomain, + coolify_public_url: coolifyDomain ? `https://${coolifyDomain}` : "", coolify_deploy_now: Boolean(targetForm.coolify_deploy_now), + coolify_project_uuid: targetForm.coolify_project_uuid || "", }; const savedResponse = await saveReleaseDeploymentTarget({ ...targetForm, @@ -1147,6 +1600,7 @@ async function saveTarget() { repository: "", branch: "main", coolify_instance_id: "", + coolify_project_uuid: "", coolify_service_uuid: "", health_url: "", auto_deploy: true, @@ -1233,12 +1687,70 @@ async function run(key, callback) { function statusClass(status) { return { - "is-ok": ["ok", "active", "deployed", "stable", "enabled"].includes(status), - "is-degraded": ["queued", "deploying", "metadata", "not_configured", "warning"].includes(status), + "is-ok": ["ok", "active", "deployed", "promoted", "ready", "stable", "enabled", "isolated_stack"].includes(status), + "is-degraded": [ + "queued", + "deploying", + "draft", + "metadata", + "not_configured", + "warning", + "needs_configuration", + "needs_clone_targets", + "needs_isolated_targets", + "isolated_empty", + "provisioning", + ].includes(status), "is-down": ["failed", "down", "error"].includes(status), }; } +function deploymentFailureSummary(deployment) { + const summary = deployment?.failure_summary || deployment?.result?.failure_summary; + return summary && typeof summary === "object" ? summary : null; +} + +function deploymentFailureCause(deployment) { + return String(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "").trim(); +} + +function deploymentFailureNextAction(deployment) { + return String(deploymentFailureSummary(deployment)?.next_action || "").trim(); +} + +function canPromoteDeployment(deployment) { + if (!canDeploy.value) { + return false; + } + + if (typeof deployment?.promotable === "boolean") { + return deployment.promotable; + } + + return String(deployment?.status || "").toLowerCase() === "deployed"; +} + +function promotionBlockedReason(deployment) { + if (canPromoteDeployment(deployment)) { + return tr("actions.promote"); + } + + return ( + deployment?.promotion_blocked_reason || + trFallback( + "deployments.promotion_blocked", + "Only successfully deployed release deployments can be promoted." + ) + ); +} + +watch( + () => [targetForm.coolify_instance_id, coolifyProjects.value.length], + () => { + ensureCoolifyProjectForInstance(); + } +); + watch( () => assignmentForm.subject_type, (subjectType) => { @@ -1301,6 +1813,51 @@ watch( } ); +watch( + () => bundleForm.source_service_set_id, + () => { + applyBundleDefaults(); + } +); + +watch( + () => bundleForm.dataset_mode, + (mode) => { + if (mode === "isolated_stack") { + bundleForm.source_service_set_id = null; + } + if (mode !== "attach_existing" && !bundleForm.service_set_name) { + const channelSlug = channels.value.find((channel) => Number(channel.id) === Number(bundleForm.channel_id))?.slug || "release"; + bundleForm.service_set_name = `${channelSlug}-${mode.replace(/_/g, "-")}-${new Date().toISOString().slice(0, 10)}`; + } + } +); + +["frontend", "api"].forEach((app) => { + watch( + () => bundleForm[`${app}_repository`], + (repository) => { + bundleAccessResult.value[app] = null; + void loadGithubBranches(repository); + } + ); + watch( + () => bundleForm[`${app}_branch`], + () => { + bundleAccessResult.value[app] = null; + } + ); + watch( + () => bundleForm[`${app}_commit_mode`], + (mode) => { + bundleAccessResult.value[app] = null; + if (mode === "latest") { + bundleForm[`${app}_commit_sha`] = ""; + } + } + ); +}); + onBeforeUnmount(() => { searchCustomer(null); }); @@ -1494,7 +2051,7 @@ onMounted(async () => { {{ tr("tables.frontend") }} {{ tr("tables.api") }} {{ tr("tables.replay") }} - {{ tr("tables.actions") }} + {{ tr("tables.actions") }} @@ -1511,21 +2068,25 @@ onMounted(async () => { {{ channel.replay_enabled ? valueLabel("capture_level", channel.capture_level) : tr("common.off") }} - - - {{ tr("actions.edit") }} - - + - {{ tr("actions.rollback") }} - + + @@ -1817,7 +2378,7 @@ onMounted(async () => { {{ tr("tables.channel") }} {{ tr("tables.reason") }} {{ tr("tables.expires") }} - {{ tr("tables.actions") }} + {{ tr("tables.actions") }} @@ -1826,17 +2387,21 @@ onMounted(async () => { {{ assignment.channel_slug }} {{ assignment.reason || "--" }} {{ assignment.expires_at || "--" }} - - + - {{ tr("actions.remove") }} - + + @@ -1853,6 +2418,310 @@ onMounted(async () => { icon="fas fa-rocket" default-expanded > +
+
+
+ {{ set.name }} + {{ statusLabel(set.status) }} +
+

{{ set.channel_slug || "--" }} / {{ modeLabel(set.mode) }}

+
+ + + {{ trFallback(`values.stack.${item}`, item) }} + +
+
+ + {{ trFallback("bundles.reuse", "Reuse") }} + + + {{ trFallback("bundles.clone", "Clone") }} + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+

{{ trFallback("bundles.frontend_code", "Frontend code") }}

+ + + + + + + + + + + + + + + +
+ +
+

{{ trFallback("bundles.api_code", "PHP backend code") }}

+ + + + + + + + + + + + + + + +
+
+
+ + + + + +
+
+ + + {{ item.label }} + {{ item.value }} + {{ item.detail }} + + {{ statusLabel(item.status) }} +
+
+
+ + + + {{ trFallback("bundles.deploy_summary", "Deploying creates a bundle for frontend, PHP, MariaDB, Redis, and MinIO. Promotion only attaches the selected code and service set; replica failover stays in the replica system.") }} + + + {{ trFallback("bundles.github_access_ok", "GitHub access checked for frontend and PHP backend.") }} + + + {{ trFallback("bundles.created", "Bundle deployed") }} #{{ createdBundle.id }} + + +
+ +
+ + {{ tr("actions.back") }} + + + {{ tr("actions.next") }} + + + + {{ tr("actions.test_access") }} + + + + {{ trFallback("bundles.deploy_bundle", "Deploy bundle") }} + +
+
+ +
+ + {{ showAdvancedDeployment ? trFallback("deployments.hide_advanced", "Hide advanced") : trFallback("deployments.show_advanced", "Advanced single app deployment") }} + +
+ +
{
+
+ +
+ + + + + + + + + + + + + + + + + + + +
{{ trFallback("tables.bundle", "Bundle") }}{{ trFallback("tables.service_set", "Service set") }}{{ tr("tables.source") }}{{ tr("tables.status") }}{{ tr("tables.actions") }}
#{{ bundle.id }} {{ bundle.version_label || "--" }}{{ bundle.service_set_name || bundle.service_set_slug || bundle.service_set_id }} + {{ bundleApp(bundle, "frontend").repository || "--" }} +
{{ bundleApp(bundle, "frontend").branch || "--" }} {{ bundleApp(bundle, "frontend").commit_sha || "latest" }}
+ {{ bundleApp(bundle, "api").repository || "--" }} +
{{ bundleApp(bundle, "api").branch || "--" }} {{ bundleApp(bundle, "api").commit_sha || "latest" }}
+
{{ statusLabel(bundle.status) }} + + + +
+
@@ -2027,7 +2945,7 @@ onMounted(async () => { - + @@ -2040,19 +2958,34 @@ onMounted(async () => { - @@ -2325,6 +3258,32 @@ onMounted(async () => { :placeholder="tr('integrations.coolify_instance_placeholder')" /> + + + + + + + { - + { - + @@ -2483,26 +3435,26 @@ onMounted(async () => { - @@ -2856,6 +3808,99 @@ onMounted(async () => { overflow-wrap: anywhere; } +.release-service-set-grid, +.release-bundle-code-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + margin-bottom: 1rem; +} + +.release-service-set-card, +.release-bundle-code-card, +.release-bundle-flow { + background: #ffffff; + border: 1px solid #d8dee8; + border-radius: 6px; + padding: 0.875rem; +} + +.release-service-set-card { + display: grid; + gap: 0.65rem; +} + +.release-service-set-card header { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: space-between; +} + +.release-stack-mini, +.release-stack-review { + display: grid; + gap: 0.5rem; +} + +.release-stack-mini { + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); +} + +.release-stack-mini span { + align-items: center; + display: flex; + gap: 0.35rem; + min-width: 0; +} + +.release-bundle-flow { + margin-bottom: 1rem; +} + +.release-form--inside-step { + margin-top: 0.75rem; +} + +.release-bundle-code-card { + display: grid; + gap: 0.75rem; +} + +.release-bundle-code-card h4 { + color: #172033; + font-size: 0.95rem; + font-weight: 700; + margin: 0; +} + +.release-stack-review article { + align-items: center; + border: 1px solid #d8dee8; + border-radius: 6px; + display: grid; + gap: 0.75rem; + grid-template-columns: auto minmax(0, 1fr) auto; + min-height: 3.4rem; + padding: 0.7rem 0.8rem; +} + +.release-stack-review strong, +.release-stack-review small { + display: block; + overflow-wrap: anywhere; +} + +.release-stack-review small { + color: #667085; +} + +.release-advanced-deployment { + border-top: 1px solid #d8dee8; + margin-top: 1rem; + padding-top: 1rem; +} + .release-github-status__content { align-items: center; display: flex; @@ -2975,6 +4020,19 @@ onMounted(async () => { font-size: 0.85rem; } +.release-failure-note { + color: #9f1f17; + font-size: 0.82rem; + line-height: 1.35; + margin-top: 0.45rem; + max-width: 28rem; + overflow-wrap: anywhere; +} + +.release-failure-note strong { + color: #7a1610; +} + .release-stat strong { display: block; color: #172033; @@ -3025,6 +4083,19 @@ onMounted(async () => { align-self: end; } +.release-action-cell { + text-align: right; + white-space: nowrap; +} + +.release-action-menu { + display: inline-flex; +} + +.release-action-menu :deep(.dropdown-menu) { + text-align: left; +} + .release-form :deep(.field) { margin-bottom: 0; } diff --git a/tests/e2e/release-manager.spec.js b/tests/e2e/release-manager.spec.js index 6e61e4d6..6d71a5ef 100644 --- a/tests/e2e/release-manager.spec.js +++ b/tests/e2e/release-manager.spec.js @@ -24,7 +24,9 @@ function createReleaseState() { nextChannelId: 4, nextAssignmentId: 1, nextTargetId: 2, - nextDeploymentId: 2, + nextDeploymentId: 3, + nextServiceSetId: 2, + nextBundleId: 1, channels: [ { id: 1, @@ -91,7 +93,78 @@ function createReleaseState() { auto_deploy: true, }, ], + serviceSets: [ + { + id: 1, + channel_id: 2, + channel_slug: "canary", + name: "Canary shared stack", + slug: "canary-shared-stack", + mode: "attach_existing", + status: "ready", + targets: { + frontend: { + id: 1, + channel_id: 2, + channel_slug: "canary", + app: "frontend", + repository: "truckwash/front-end-vue", + branch: "main", + }, + api: null, + }, + data_services: { + database: { + id: 10, + kind: "database", + label: "Canary MariaDB", + deployment_status: "running", + replication: { id: 100, label: "mariadb-canary", status: "ok", role: "replica" }, + }, + redis: { + id: 11, + kind: "redis", + label: "Canary Redis", + deployment_status: "running", + replication: { id: 101, label: "redis-canary", status: "ok", role: "replica" }, + }, + minio: { + id: 12, + kind: "minio", + label: "Canary MinIO", + deployment_status: "running", + replication: { id: 102, label: "minio-canary", status: "ok", role: "replica" }, + }, + }, + attached_bundles: [], + }, + ], + bundles: [], deployments: [ + { + id: 2, + target_id: 1, + channel_id: 2, + channel_slug: "canary", + app: "api", + repository: "copenhagentruckwash/api", + branch: "master", + commit_sha: "ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3", + version_label: "internal-api-2026-05-19", + status: "failed", + error_message: "Composer install failed in the Coolify build.", + failure_summary: { + category: "configuration", + stage: "provider_configuration", + root_cause: "Composer install failed in the Coolify build.", + next_action: "Run the PHP test suite in the php1 container before retrying the deployment.", + promotion_blocked: true, + }, + promotable: false, + promotion_blocked_reason: + "Deployment failed and cannot be promoted. Cause: Composer install failed in the Coolify build.", + started_at: "2026-05-19T08:05:00.000Z", + }, { id: 1, target_id: 1, @@ -103,6 +176,7 @@ function createReleaseState() { commit_sha: "c0ffee", version_label: "frontend-canary", status: "deployed", + promotable: true, started_at: "2026-05-19T08:00:00.000Z", }, ], @@ -128,6 +202,18 @@ function summary(state) { channels: state.channels, assignments: state.assignments, deployment_targets: state.targets, + service_sets: state.serviceSets.map((set) => ({ + ...set, + stack: { + frontend: set.targets?.frontend || null, + api: set.targets?.api || null, + database: set.data_services?.database || null, + redis: set.data_services?.redis || null, + minio: set.data_services?.minio || null, + }, + attached_bundle_count: state.bundles.filter((bundle) => Number(bundle.service_set_id) === Number(set.id)).length, + })), + bundles: state.bundles, deployments: state.deployments, replay_targets: state.replayTargets, module_health: [ @@ -146,10 +232,34 @@ function summary(state) { suggestions: { repositories: ["truckwash/front-end-vue", "truckwash/backend-php"], branches: ["main", "release/canary", "release/beta"], - frontend_base_urls: ["https://canary.example.test", "https://app.example.test"], - api_base_urls: ["https://api-canary.example.test", "https://api.example.test"], - health_urls: ["https://canary.example.test/health", "https://api-canary.example.test/ping"], + frontend_base_urls: ["http://localhost:5173", "https://canary.example.test", "https://app.example.test"], + api_base_urls: ["http://api.truckwash.io:4433", "https://api-canary.example.test", "https://api.example.test"], + health_urls: [ + "http://localhost:5173/health", + "http://api.truckwash.io:4433/ping", + "https://canary.example.test/health", + "https://api-canary.example.test/ping", + ], + load_balancer_domains: ["api-v2.truckwash.io", "release-api.truckwash.io"], coolify_instances: [{ id: 3, label: "Production Coolify", status: "ok" }], + coolify_projects: [ + { + instance_id: 3, + instance_label: "Production Coolify", + uuid: "project-main", + name: "Production Project", + description: "Default production project", + default: true, + }, + { + instance_id: 3, + instance_label: "Production Coolify", + uuid: "project-internal", + name: "Internal Project", + description: "Internal validation project", + default: false, + }, + ], coolify_services: [ { instance_id: 3, @@ -439,17 +549,152 @@ async function installReleaseMocks(page, state) { if (pathname.endsWith("/superuser/releases/targets") && method === "POST") { const payload = request.postDataJSON?.() || {}; + if (!["project-internal", "project-main"].includes(payload.deploy_context?.coolify_project_uuid)) { + await route.fulfill(json({ message: "Coolify project was not selected." }, 400)); + return; + } const target = { ...payload, id: state.nextTargetId++, channel_slug: channelSlug(state, payload.channel_id), coolify_instance_label: payload.coolify_instance_id ? "Production Coolify" : null, + deploy_context: payload.deploy_context || {}, }; state.targets.unshift(target); + if (payload.app === "api" && !payload.deploy_context?.isolated_stack && state.serviceSets[0]) { + state.serviceSets[0].targets.api = target; + } await route.fulfill(json({ data: target })); return; } + if (pathname.endsWith("/superuser/releases/service-sets") && method === "GET") { + await route.fulfill(json({ data: summary(state).service_sets })); + return; + } + + if (pathname.endsWith("/superuser/releases/service-sets") && method === "POST") { + const payload = request.postDataJSON?.() || {}; + const source = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.source_service_set_id)); + const serviceSet = { + id: state.nextServiceSetId++, + channel_id: payload.channel_id || source?.channel_id || 2, + channel_slug: channelSlug(state, payload.channel_id || source?.channel_id || 2), + name: payload.name || "Fresh release stack", + slug: (payload.name || "fresh-release-stack").toLowerCase().replace(/[^a-z0-9]+/g, "-"), + mode: payload.mode || "attach_existing", + source_service_set_id: payload.source_service_set_id || null, + status: + payload.mode === "clone_existing" + ? "provisioning" + : payload.mode === "fresh_empty" + ? "isolated_empty" + : payload.mode === "isolated_stack" + ? "isolated_stack" + : "ready", + targets: { + frontend: + state.targets.find((entry) => Number(entry.id) === Number(payload.frontend_target_id)) || + source?.targets?.frontend || + null, + api: + state.targets.find((entry) => Number(entry.id) === Number(payload.api_target_id)) || + source?.targets?.api || + null, + }, + data_services: ["fresh_empty", "isolated_stack"].includes(payload.mode) + ? { database: null, redis: null, minio: null } + : source?.data_services || { + database: null, + redis: null, + minio: null, + }, + attached_bundles: [], + }; + state.serviceSets.unshift(serviceSet); + await route.fulfill(json({ data: summary(state).service_sets.find((entry) => entry.id === serviceSet.id) })); + return; + } + + if (pathname.endsWith("/superuser/releases/bundles") && method === "GET") { + await route.fulfill(json({ data: state.bundles })); + return; + } + + if (pathname.endsWith("/superuser/releases/bundles") && method === "POST") { + const payload = request.postDataJSON?.() || {}; + const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.service_set_id)); + const bundle = { + id: state.nextBundleId++, + channel_id: payload.channel_id || serviceSet?.channel_id || 2, + channel_slug: channelSlug(state, payload.channel_id || serviceSet?.channel_id || 2), + service_set_id: payload.service_set_id, + service_set_name: serviceSet?.name || "Service set", + version_label: payload.version_label || `bundle-${state.nextBundleId}`, + status: "draft", + apps: { + frontend: { + version_id: state.nextBundleId * 10 + 1, + deployment_id: null, + repository: payload.frontend?.repository || "truckwash/front-end-vue", + branch: payload.frontend?.branch || "main", + commit_sha: payload.frontend?.commit_mode === "specific" ? payload.frontend?.commit_sha : "c0ffee", + }, + api: { + version_id: state.nextBundleId * 10 + 2, + deployment_id: null, + repository: payload.api?.repository || "truckwash/backend-php", + branch: payload.api?.branch || "release/canary", + commit_sha: payload.api?.commit_mode === "specific" ? payload.api?.commit_sha : "feedface", + }, + }, + }; + state.bundles.unshift(bundle); + await route.fulfill(json({ data: bundle }, 201)); + return; + } + + if (/\/superuser\/releases\/bundles\/\d+\/deploy$/.test(pathname) && method === "POST") { + const id = Number(pathname.match(/bundles\/(\d+)\/deploy/)?.[1] || 0); + const bundle = state.bundles.find((entry) => entry.id === id); + if (bundle) { + bundle.status = "deployed"; + for (const app of ["frontend", "api"]) { + const deployment = { + id: state.nextDeploymentId++, + target_id: null, + channel_id: bundle.channel_id, + channel_slug: bundle.channel_slug, + app, + repository: bundle.apps[app].repository, + branch: bundle.apps[app].branch, + commit_sha: bundle.apps[app].commit_sha, + version_label: bundle.version_label, + status: "deployed", + bundle_id: bundle.id, + service_set_id: bundle.service_set_id, + deployment_kind: "bundle_member", + promotable: true, + started_at: "2026-05-19T08:20:00.000Z", + }; + bundle.apps[app].deployment_id = deployment.id; + state.deployments.unshift(deployment); + } + } + await route.fulfill(json({ data: bundle || null }, 202)); + return; + } + + if (/\/superuser\/releases\/bundles\/\d+\/promote$/.test(pathname) && method === "POST") { + const id = Number(pathname.match(/bundles\/(\d+)\/promote/)?.[1] || 0); + const bundle = state.bundles.find((entry) => entry.id === id); + if (bundle) { + bundle.status = "promoted"; + } + await route.fulfill(json({ data: { bundle } })); + return; + } + if (pathname.endsWith("/superuser/releases/deployments") && method === "POST") { const payload = request.postDataJSON?.() || {}; const target = state.targets.find((entry) => Number(entry.id) === Number(payload.target_id)); @@ -529,6 +774,9 @@ test("superusers manage release channels, assignments, deployments, and replay", await expect(page.getByTestId("release-guided-setup")).toContainText("Guided setup"); await expect(page.getByTestId("release-guided-setup")).toContainText("Frontend target"); await expect(page.getByTestId("release-channels-overview")).toContainText("Canary"); + await expect( + page.getByTestId("release-channels-overview").locator('[data-testid^="release-channel-actions-"]').first() + ).toBeVisible(); await selectReleaseTab(page, "Settings"); await expect(page.getByTestId("release-settings-summary")).toContainText("ReleaseManager.github_token"); @@ -565,6 +813,9 @@ test("superusers manage release channels, assignments, deployments, and replay", await expect(page.getByTestId("release-assignment-customer-search")).toHaveValue("Acme Transport (424242)"); await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click(); await expect(page.getByTestId("release-assignments-table")).toContainText("customer:424242"); + await expect( + page.getByTestId("release-assignments-table").locator('[data-testid^="release-assignment-actions-"]').first() + ).toBeVisible(); await selectReleaseTab(page, "Integrations"); await expandActiveReleaseCategory(page); @@ -577,26 +828,56 @@ test("superusers manage release channels, assignments, deployments, and replay", await expect(page.getByTestId("release-target-form").getByPlaceholder("Coolify service UUID")).toHaveValue( "api-canary-service" ); - await expect(page.getByTestId("release-target-form").getByPlaceholder("SSL domain")).toHaveValue( - "api-canary.example.test" - ); + const loadBalancerDomainInput = page.getByTestId("release-target-form").getByPlaceholder("Load balancer domain"); + await loadBalancerDomainInput.click(); + 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("api-v2.truckwash.io"); 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(); 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() + ).toBeVisible(); - await page - .getByTestId("release-targets-table") - .getByRole("row", { name: /truckwash\/backend-php/i }) - .getByRole("button", { name: "Deploy" }) - .click(); + await selectReleaseTab(page, "Deployments"); await expandActiveReleaseCategory(page); - await page.getByTestId("release-deployment-form").locator("select").nth(3).selectOption("specific"); - await page.getByTestId("release-deployment-form").getByPlaceholder("commit sha").fill("feedface"); - await page.getByTestId("release-deployment-form").getByPlaceholder("version label").fill("api-feedface"); - await page.getByTestId("release-deployment-submit").click(); + 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(); + await expect(bundleFlow.getByPlaceholder("owner/frontend")).toHaveValue("truckwash/front-end-vue"); + await expect(bundleFlow.getByPlaceholder("owner/backend-php")).toHaveValue("truckwash/backend-php"); + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Frontend"); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("PHP backend"); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Database"); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Redis"); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Minio"); + await page.getByTestId("release-bundle-deploy-submit").click(); + await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed"); + await expect(page.getByTestId("release-bundles-table")).toContainText("truckwash/front-end-vue"); + await expect(page.getByTestId("release-bundles-table")).toContainText("truckwash/backend-php"); + await expect( + page.getByTestId("release-bundles-table").locator('[data-testid^="release-bundle-actions-"]').first() + ).toBeVisible(); await expect(page.getByTestId("release-deployments-table")).toContainText("feedface"); + await expect( + page.getByTestId("release-deployments-table").locator('[data-testid^="release-deployment-actions-"]').first() + ).toBeVisible(); + const failedDeploymentRow = page + .getByTestId("release-deployments-table") + .getByRole("row", { name: /Composer install failed in the Coolify build/i }); + await expect(failedDeploymentRow).toContainText("Run the PHP test suite in the php1 container"); + await failedDeploymentRow.locator('[data-testid^="release-deployment-actions-"] button').first().click(); + await expect(failedDeploymentRow.getByRole("button", { name: "Promote" })).toBeDisabled(); await selectReleaseTab(page, "Replay"); await expandActiveReleaseCategory(page); @@ -606,3 +887,43 @@ test("superusers manage release channels, assignments, deployments, and replay", await page.getByTestId("release-timeline-filter-form").getByRole("button", { name: "Search" }).click(); await expect(page.getByTestId("release-timeline-events")).toContainText("request_failed"); }); + +test("isolated stack mode creates fresh Coolify app targets without attaching production data", async ({ page }) => { + const state = await boot(page); + await page.goto("/superuser/configuration/releases"); + + await selectReleaseTab(page, "Deployments"); + await expandActiveReleaseCategory(page); + + const bundleFlow = page.getByTestId("release-bundle-flow"); + await bundleFlow.getByTestId("release-bundle-dataset-mode").selectOption("isolated_stack"); + await expect(bundleFlow).toContainText("production data is not used"); + await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toBeDisabled(); + await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack"); + + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await expect(bundleFlow.getByPlaceholder("owner/frontend")).toHaveValue("truckwash/front-end-vue"); + await expect(bundleFlow.getByPlaceholder("owner/backend-php")).toHaveValue("truckwash/backend-php"); + + await bundleFlow.getByRole("button", { name: "Next" }).click(); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("new isolated service"); + await expect(page.getByTestId("release-bundle-review-stack")).toContainText("production data not attached"); + + await page.getByTestId("release-bundle-deploy-submit").click(); + await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed"); + + const isolatedTargets = state.targets.filter((target) => target.deploy_context?.isolated_stack); + expect(isolatedTargets).toHaveLength(2); + expect(isolatedTargets.map((target) => target.app).sort()).toEqual(["api", "frontend"]); + for (const target of isolatedTargets) { + 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.production_data_attached).toBe(false); + expect(target.deploy_context.coolify_service_name).toMatch(/^release-internal-safe-stack-(api|frontend)$/); + } + + const isolatedSet = state.serviceSets.find((set) => set.mode === "isolated_stack"); + expect(isolatedSet.source_service_set_id).toBeNull(); + expect(isolatedSet.data_services).toEqual({ database: null, redis: null, minio: null }); +});
{{ tr("tables.source") }} {{ tr("tables.status") }} {{ tr("tables.started") }}{{ tr("tables.actions") }}{{ tr("tables.actions") }}
{{ valueLabel("status", deployment.status) }} +
+ {{ trFallback("deployments.failure_cause", "Cause") }}: + {{ deploymentFailureCause(deployment) }} +
+ {{ trFallback("deployments.next_action", "Next") }}: + {{ deploymentFailureNextAction(deployment) }} +
+
{{ deployment.started_at || deployment.created_at || "--" }} - + - {{ tr("actions.promote") }} - + +
{{ tr("tables.source") }} {{ tr("tables.coolify") }} {{ tr("tables.auto") }}{{ tr("tables.actions") }}{{ tr("tables.actions") }}
{{ target.repository }}#{{ target.branch }} {{ target.coolify_instance_label || target.coolify_instance_id || "--" }} {{ target.auto_deploy ? tr("common.yes") : tr("common.no") }} - + - {{ tr("actions.deploy") }} - - - {{ tr("actions.delete") }} - + +