Compare commits

...
Author SHA1 Message Date
Jeppe B a952dd2c61 Merge pull request #52
Add endpoint mode options and enhance runtime refresh handling
2026-05-21 14:48:44 +02:00
Jeppe Bundgaard ebcd52a019 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.
2026-05-21 14:47:46 +02:00
Jeppe Bundgaard b2ddf0db5c Add beta frontend debug display in build info 2026-05-21 11:45:20 +02:00
Jeppe Bundgaard eaf4b965d5 Add runtime support for service sets, release management enhancements, and new unit tests
- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs.
- Update the release session builder to include `service_set` handling.
- Introduce release service definitions, status tones, and enhanced runtime display text.
- Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`.
- Implement extensive utility methods for data service handling, target context, and deployment endpoints.
- Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
2026-05-21 11:41:50 +02:00
Jeppe Bundgaard 6ba1c754a1 Serve frontend releases under channel prefixes 2026-05-20 19:27:01 +02:00
Jeppe Bundgaard 468613ab68 Add Coolify frontend release Docker image 2026-05-20 18:53:17 +02:00
Jeppe Bundgaard 261d84bd01 Route selected release runtime through gateway 2026-05-20 18:32:21 +02:00
Jeppe Bundgaard ea8db593c5 Add release headers service, runtime-aware API header handling, and i18n updates for release configuration
- Implement `releaseHeaders.js` to manage release-related HTTP headers.
- Update requests to include runtime-based release headers.
- Extend i18n phrases across multiple locales for release runtime and configuration labels.
- Enhance unit tests for header building and API availability error handling.
2026-05-20 17:52:54 +02:00
Jeppe Bundgaard 130cc2fc10 Add release runtime bootstrap implementation with unit and E2E tests
- Create `releaseBootstrap.js` service to handle runtime configuration and remote frontend loading.
- Integrate runtime-aware API URL resolution across components and services.
- Update i18n phrases to clarify new domains for `ssl_domain_message`.
- Enhance unit and E2E tests to validate runtime-based frontend loading and fallback behavior.
- Refactor session-related API calls and incorporate runtime-configured base URLs.
2026-05-20 11:26:21 +02:00
Jeppe Bundgaard 3715da8fd3 Add release channel selector components, subject autocomplete, and related tests
- Implement components for `ReleaseChannelSelector`, including panel and sidebar variants.
- Add `ReleaseAssignmentSubjectAutocomplete` for user, subuser, and customer search functionality.
- Update i18n phrases for channel selector and subject assignment.
- Enhance unit and E2E tests for release channel availability, switching, and runtime configuration.
- Modify modules for channel-related state handling and runtime refresh mechanics.
2026-05-20 10:31:49 +02:00
Jeppe Bundgaard 100e477a95 Add "Delete" functionality for release service sets in isolated stacks
- Implement deletion of inactive isolated stacks via API and UI.
- Normalize build pack handling, defaulting frontend to "static".
- Update E2E tests to verify deletion and adjusted UI behavior.
- Refactor related release manager methods and validation logic.
2026-05-19 16:55:20 +02:00
Jeppe Bundgaard 1132c8c256 Merge remote-tracking branch 'origin/master' 2026-05-19 14:47:01 +02:00
Jeppe Bundgaard 42237fcc70 Add unit tests for release manager i18n validation and implement ReleaseReplayInspector Vue component for timeline inspection and replay functionality. 2026-05-19 14:46:42 +02:00
gold-dev ae8b3b6b64 Feat: Hvidovre only one toggler 2026-05-19 14:26:23 +03:00
Jeppe B 8378491b0b Merge pull request #51
Fix chromium mobile runner flakes
2026-05-19 13:12:11 +02:00
77 changed files with 13122 additions and 1743 deletions
+17
View File
@@ -0,0 +1,17 @@
.github
.idea
.vscode
coverage
dev-dist
dist
node_modules
node_modules.*
output
playwright-report
test-results
.ai
.ai-workflow
.claude
.codex
.gradle
gradle
+18
View File
@@ -0,0 +1,18 @@
FROM node:24-alpine AS build
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+1 -1
View File
@@ -16,6 +16,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/releaseBootstrap.js"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /release-entry.json {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /internal/frontend/release-entry.json {
add_header Cache-Control "no-store";
try_files /release-entry.json =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ^~ /internal/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
rewrite ^/internal/frontend/(.*)$ /$1 break;
try_files $uri =404;
}
location ^~ /internal/frontend/ {
rewrite ^/internal/frontend/(.*)$ /$1 break;
try_files $uri $uri/ /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+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;
@@ -9,7 +9,7 @@ import { ref } from "vue";
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { useI18n } from "vue-i18n";
import { API_URL } from "@/config.js";
import { getReleaseRuntimeApiBaseUrl } from "@/services/releaseTimeline.js";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
import {
buildCustomerAttributeTargetPayload,
@@ -992,7 +992,7 @@ const shouldRetryPreviewRequestWithAuth = (downloadLink) => {
try {
const previewUrl = new URL(downloadLink, window.location.origin);
const apiOrigin = new URL(API_URL, window.location.origin).origin;
const apiOrigin = new URL(getReleaseRuntimeApiBaseUrl(), window.location.origin).origin;
return previewUrl.origin === apiOrigin || previewUrl.origin === window.location.origin;
} catch {
return false;
@@ -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>
@@ -6,7 +6,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
import { API_URL } from "@/config";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
const { t: $t } = useI18n();
@@ -196,7 +196,7 @@ const dynamicImageUrl = computed(() => {
params.set("thumb_position", String(dynamicImageThumbPosition.value));
}
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
});
const displayedDynamicImageUrl = computed(() => (
+299 -57
View File
@@ -1,7 +1,12 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import { API_URL, REQUEST_QUEUE_CONFIG } from "@/config.js";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import {
buildReleaseSessionSummary,
getReleaseRuntimeApiBaseUrl,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearErrorRequests,
@@ -268,16 +273,19 @@ watch(recentRequests, (requests) => {
const resolvePingUrl = () => {
const configuredEndpoint = REQUEST_QUEUE_CONFIG.ping.endpoint;
if (!configuredEndpoint) {
return API_URL;
return getReleaseRuntimeApiBaseUrl();
}
if (/^https?:\/\//i.test(configuredEndpoint)) {
return configuredEndpoint;
}
return `${API_URL.replace(/\/+$/, "")}/${String(configuredEndpoint).replace(/^\/+/, "")}`;
return resolveReleaseApiUrl(configuredEndpoint);
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
pingLatencyMs.value = null;
@@ -404,7 +412,7 @@ const grantMissingPermission = async (permission) => {
setPermissionGrantStatus(normalizedPermission, "loading");
try {
await axios.post(
`${API_URL}/roles/permissions`,
resolveReleaseApiUrl("/roles/permissions"),
{
group_id: roleId,
permission_id: normalizedPermission,
@@ -638,60 +646,186 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Runtime</div>
<ul class="request-queue-progress__meta-list request-queue-progress__section-content request-queue-progress__section-content--meta">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="API_URL">{{ API_URL }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Environment</span>
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Commit</span>
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version</span>
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version time</span>
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
</li>
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
<span class="request-queue-progress__meta-label">i18n catalog</span>
<button
class="request-queue-progress__catalog-switch"
data-testid="request-queue-i18n-catalog-switch"
type="button"
@click="handleToggleI18nCatalogVersion"
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
class="request-queue-progress__meta-value"
:title="releaseSessionSummary.channelSlug || releaseSessionSummary.channelLabel"
>
{{ releaseSessionSummary.channelLabel }}
<span v-if="releaseSessionSummary.channelSlug">({{ releaseSessionSummary.channelSlug }})</span>
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Trace ID</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.traceId">
{{ releaseSessionSummary.traceId }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Generated</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.generatedAt">
{{ releaseSessionSummary.generatedAt }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Bundle</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.bundleLabel">
{{ releaseSessionSummary.bundleLabel }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Service set</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.serviceSetLabel">
{{ releaseSessionSummary.serviceSetLabel }}
</span>
</li>
<li
v-if="releaseSessionSummary.missingLabels.length > 0"
class="request-queue-progress__meta-item"
data-testid="request-queue-release-missing"
>
{{ i18nCatalogVersionLabel }}
</button>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing requests</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing responses</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
</li>
</ul>
<span class="request-queue-progress__meta-label">Missing</span>
<span
class="request-queue-progress__meta-value request-queue-progress__meta-value--warning"
:title="releaseSessionSummary.missingLabels.join(', ')"
>
{{ releaseSessionSummary.missingLabels.join(", ") }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">App releases</div>
<ul class="request-queue-progress__release-list">
<li
v-for="app in releaseSessionSummary.appRows"
:key="app.key"
class="request-queue-progress__release-row"
:data-testid="`request-queue-release-app-${app.key}`"
>
<div class="request-queue-progress__release-row-header">
<strong>{{ app.label }}</strong>
<span
class="request-queue-progress__status-pill"
:class="`request-queue-progress__status-pill--${app.tone}`"
>
{{ app.status }}
</span>
</div>
<span class="request-queue-progress__release-primary" :title="app.title">
{{ app.primaryText }}
</span>
<span
v-if="app.secondaryText"
class="request-queue-progress__release-secondary"
:title="app.secondaryText"
>
{{ app.secondaryText }}
</span>
<span
v-if="app.url"
class="request-queue-progress__release-url"
:title="app.url"
>
{{ app.url }}
</span>
<span
v-if="app.missingLabel"
class="request-queue-progress__release-secondary request-queue-progress__release-secondary--warning"
>
{{ app.missingLabel }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">Connected services</div>
<ul class="request-queue-progress__release-list">
<li
v-for="service in releaseSessionSummary.serviceRows"
:key="service.key"
class="request-queue-progress__release-row"
:data-testid="`request-queue-release-service-${service.key}`"
>
<div class="request-queue-progress__release-row-header">
<strong>{{ service.label }}</strong>
<span
class="request-queue-progress__status-pill"
:class="`request-queue-progress__status-pill--${service.tone}`"
>
{{ service.status }}
</span>
</div>
<span class="request-queue-progress__release-primary" :title="service.title">
{{ service.primaryText }}
</span>
<span
v-if="service.secondaryText"
class="request-queue-progress__release-secondary"
:title="service.secondaryText"
>
{{ service.secondaryText }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Environment</span>
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Commit</span>
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version</span>
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version time</span>
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
</li>
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
<span class="request-queue-progress__meta-label">i18n catalog</span>
<button
class="request-queue-progress__catalog-switch"
data-testid="request-queue-i18n-catalog-switch"
type="button"
@click="handleToggleI18nCatalogVersion"
>
{{ i18nCatalogVersionLabel }}
</button>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing requests</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing responses</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
</li>
</ul>
</div>
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--user" data-testid="request-queue-user-box">
@@ -928,6 +1062,12 @@ onBeforeUnmount(() => {
min-height: 56px;
}
.request-queue-progress__section-content--release {
display: flex;
flex-direction: column;
gap: 8px;
}
.request-queue-progress__section-title {
font-size: 12px;
font-weight: 600;
@@ -1167,6 +1307,108 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.request-queue-progress__meta-value--warning {
color: #fbbf24;
}
.request-queue-progress__subsection-title {
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
padding-top: 7px;
}
.request-queue-progress__release-list {
display: grid;
gap: 6px;
list-style: none;
margin: 0;
padding: 0;
}
.request-queue-progress__release-row {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
background: rgba(2, 6, 23, 0.28);
display: grid;
gap: 3px;
min-width: 0;
padding: 7px 8px;
}
.request-queue-progress__release-row-header {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.request-queue-progress__release-row-header strong {
font-size: 12px;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__status-pill {
border-radius: 999px;
display: inline-flex;
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
line-height: 1;
max-width: 120px;
overflow: hidden;
padding: 3px 7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__status-pill--ok {
background: rgba(94, 234, 212, 0.16);
color: #99f6e4;
}
.request-queue-progress__status-pill--warning {
background: rgba(251, 191, 36, 0.16);
color: #fde68a;
}
.request-queue-progress__status-pill--danger {
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
}
.request-queue-progress__release-primary,
.request-queue-progress__release-secondary,
.request-queue-progress__release-url {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__release-primary {
font-size: 12px;
line-height: 1.25;
}
.request-queue-progress__release-secondary,
.request-queue-progress__release-url {
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
line-height: 1.25;
}
.request-queue-progress__release-secondary--warning {
color: #fbbf24;
}
.request-queue-progress__catalog-switch {
border: 1px solid rgba(126, 249, 227, 0.5);
border-radius: 999px;
+3 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import MenuDefault from "@/components/menus/MenuDefault.vue";
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import {ref, computed} from "vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n';
@@ -70,9 +71,10 @@ const menu_items = computed(() => [
:show_icons="false"
/>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
</div>
</template>
<style scoped>
</style>
</style>
@@ -0,0 +1,404 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { BAutocomplete } from "buefy";
import { useI18n } from "vue-i18n";
import { searchReleaseAssignmentSubjects } from "@/services/superuserReleases.js";
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
limit: {
type: Number,
default: 5,
},
debounceMs: {
type: Number,
default: 250,
},
});
const emit = defineEmits(["update:modelValue", "select", "clear"]);
const { t } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.assignments.${key}`, params);
const valueLabel = (type) => {
const key = `configuration.release_manager.values.subject_type.${type}`;
const translated = t(key);
return translated === key ? type : translated;
};
const SUBJECT_TYPES = ["user", "subuser", "customer"];
const autocompleteRef = ref(null);
const typedSubject = ref("");
const remoteOptions = ref([]);
const selectedOption = ref(null);
const isFetching = ref(false);
let debounceTimer = null;
let requestSequence = 0;
const subjectIcon = (type) => {
if (type === "customer") {
return "fas fa-building";
}
if (type === "subuser") {
return "fas fa-id-badge";
}
return "fas fa-user";
};
const normalizeOption = (option) => {
if (!option || typeof option !== "object") {
return null;
}
const subjectType = String(option.subject_type || "").trim().toLowerCase();
const subjectId = String(option.subject_id || "").trim();
if (!SUBJECT_TYPES.includes(subjectType) || !subjectId) {
return null;
}
const title = String(option.title || `${valueLabel(subjectType)} #${subjectId}`).trim();
const description = String(option.description || "").trim();
return {
subject_type: subjectType,
subject_id: subjectId,
label: String(option.label || (description ? `${title} - ${description}` : title)).trim(),
title,
description,
icon: String(option.icon || subjectIcon(subjectType)).trim(),
source: String(option.source || subjectType).trim(),
group: option.group || subjectType,
manual: Boolean(option.manual),
};
};
const manualTitle = (type, id) => {
if (type === "customer") {
return tr("manual_customer", { id });
}
if (type === "subuser") {
return tr("manual_subuser", { id });
}
return tr("manual_user", { id });
};
const manualOptions = computed(() => {
const query = typedSubject.value.trim();
if (!query) {
return [];
}
const explicit = query.match(/^(user|subuser|customer):([a-zA-Z0-9_.:-]{1,64})$/i);
const subjects = explicit
? [{ type: explicit[1].toLowerCase(), id: explicit[2] }]
: /^\d{1,64}$/.test(query)
? SUBJECT_TYPES.map((type) => ({ type, id: query }))
: [];
return subjects
.map(({ type, id }) =>
normalizeOption({
subject_type: type,
subject_id: id,
title: manualTitle(type, id),
description: tr("manual_description", { subject: `${type}:${id}` }),
icon: subjectIcon(type),
source: "manual",
group: "manual",
manual: true,
})
)
.filter(Boolean);
});
const groupLabel = (group) => {
if (group === "manual") {
return tr("group_manual");
}
if (group === "customer") {
return tr("group_customers");
}
if (group === "subuser") {
return tr("group_subusers");
}
return tr("group_users");
};
const groupedOptions = computed(() => {
const seen = new Set();
const grouped = new Map();
const order = ["user", "subuser", "customer", "manual"];
[...remoteOptions.value, ...manualOptions.value].forEach((option) => {
const normalized = normalizeOption(option);
if (!normalized) {
return;
}
const key = `${normalized.subject_type}:${normalized.subject_id}`;
if (seen.has(key)) {
return;
}
seen.add(key);
const group = normalized.group || normalized.subject_type;
if (!grouped.has(group)) {
grouped.set(group, []);
}
grouped.get(group).push(normalized);
});
return order
.filter((group) => grouped.has(group))
.map((group) => ({
group: groupLabel(group),
key: group,
items: grouped.get(group),
}));
});
const syncInputAttributes = async () => {
await nextTick();
const input = autocompleteRef.value?.$el?.querySelector?.("input");
if (!input) {
return;
}
input.setAttribute("data-testid", "release-assignment-subject-search");
input.setAttribute("autocomplete", "off");
input.setAttribute("aria-label", tr("subject"));
};
const fetchOptions = async (query) => {
const search = String(query || "").trim();
if (!search) {
remoteOptions.value = [];
isFetching.value = false;
return;
}
const currentRequest = ++requestSequence;
isFetching.value = true;
try {
const response = await searchReleaseAssignmentSubjects({ search, limit: props.limit });
if (currentRequest !== requestSequence) {
return;
}
const options = Array.isArray(response?.data?.data) ? response.data.data : [];
remoteOptions.value = options.map(normalizeOption).filter(Boolean);
} catch {
if (currentRequest === requestSequence) {
remoteOptions.value = [];
}
} finally {
if (currentRequest === requestSequence) {
isFetching.value = false;
}
}
};
const scheduleFetch = (query) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (props.debounceMs <= 0) {
void fetchOptions(query);
return;
}
debounceTimer = window.setTimeout(() => {
debounceTimer = null;
void fetchOptions(query);
}, props.debounceMs);
};
const handleTyping = (value) => {
typedSubject.value = String(value || "");
if (selectedOption.value && typedSubject.value !== selectedOption.value.label) {
selectedOption.value = null;
emit("clear");
}
scheduleFetch(typedSubject.value);
void syncInputAttributes();
};
const handleSelect = (option) => {
const normalized = normalizeOption(option);
if (!normalized) {
return;
}
selectedOption.value = normalized;
typedSubject.value = normalized.label;
emit("update:modelValue", normalized);
emit("select", normalized);
void syncInputAttributes();
};
watch(
() => props.modelValue,
(nextValue) => {
const normalized = normalizeOption(nextValue);
selectedOption.value = normalized;
typedSubject.value = normalized?.label || "";
void syncInputAttributes();
},
{ immediate: true }
);
onMounted(() => {
void syncInputAttributes();
});
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
requestSequence++;
});
</script>
<template>
<div class="release-assignment-subject" data-testid="release-assignment-subject-autocomplete">
<BAutocomplete
ref="autocompleteRef"
v-model="typedSubject"
:data="groupedOptions"
field="label"
group-field="group"
group-options="items"
:placeholder="tr('subject_placeholder')"
icon="search"
icon-pack="fas"
:loading="isFetching"
open-on-focus
expanded
keep-first
max-height="320"
@typing="handleTyping"
@select="handleSelect"
@focus="syncInputAttributes"
>
<template #group="{ group, index }">
<span
class="release-assignment-subject__group"
:data-testid="`release-assignment-subject-group-${groupedOptions[index]?.key || 'other'}`"
>
{{ group }}
</span>
</template>
<template #default="slotProps">
<div
class="release-assignment-subject__option"
:data-testid="
slotProps.option.manual
? `release-assignment-subject-manual-${slotProps.option.subject_type}-${slotProps.option.subject_id}`
: `release-assignment-subject-option-${slotProps.option.subject_type}-${slotProps.option.subject_id}`
"
>
<span class="release-assignment-subject__icon">
<i :class="slotProps.option.icon" aria-hidden="true"></i>
</span>
<span class="release-assignment-subject__body">
<strong>{{ slotProps.option.title }}</strong>
<small>{{ slotProps.option.description }}</small>
</span>
<b-tag size="is-small">{{ valueLabel(slotProps.option.subject_type) }}</b-tag>
</div>
</template>
<template #empty>
<span class="release-assignment-subject__empty">{{ tr("subject_empty") }}</span>
</template>
</BAutocomplete>
</div>
</template>
<style scoped>
.release-assignment-subject {
min-width: 0;
width: 100%;
}
.release-assignment-subject :deep(.autocomplete),
.release-assignment-subject :deep(.control) {
width: 100%;
}
.release-assignment-subject :deep(.dropdown-content) {
border-radius: 6px;
box-shadow: 0 12px 28px rgba(31, 45, 61, 0.16);
max-width: min(760px, calc(100vw - 32px));
}
.release-assignment-subject :deep(.dropdown-item) {
padding: 0;
white-space: normal;
}
.release-assignment-subject__group {
background: #f7f9fc;
color: #5f6b7c;
display: block;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.2;
padding: 8px 12px 5px;
text-transform: uppercase;
}
.release-assignment-subject__option {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: 34px minmax(0, 1fr) auto;
padding: 10px 12px;
width: 100%;
}
.release-assignment-subject__icon {
align-items: center;
background: #eef3fb;
border-radius: 50%;
color: #243957;
display: inline-flex;
height: 30px;
justify-content: center;
width: 30px;
}
.release-assignment-subject__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.release-assignment-subject__body strong,
.release-assignment-subject__body small {
display: block;
overflow-wrap: anywhere;
}
.release-assignment-subject__body strong {
color: #253047;
line-height: 1.25;
}
.release-assignment-subject__body small,
.release-assignment-subject__empty {
color: #758195;
font-size: 0.78rem;
line-height: 1.25;
}
.release-assignment-subject__empty {
display: block;
padding: 10px 12px;
}
</style>
@@ -0,0 +1,438 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
releaseChannelKey,
releaseChannelOptions,
} from "@/services/releaseChannelAvailability.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const props = defineProps({
variant: {
type: String,
default: "panel",
},
switchingSlug: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select"]);
const { t, te, locale } = useI18n({ useScope: "global" });
const tr = (key, fallback, params = {}) => {
const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path, params) : fallback;
};
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")
);
const selectorSubtitle = computed(() =>
isSidebar.value ? "" : tr("subtitle", "Choose which assigned release channel this device should use.")
);
const optionStateLabel = (option) => {
if (option.defaultChannel) {
return tr("default", "Default");
}
return option.configured ? tr("ready", "Ready") : tr("unavailable", "Not ready");
};
const missingLabel = (key) => {
if (key === "release_bundle") return tr("release_bundle", "Release bundle");
if (key === "frontend_version") return tr("frontend_version", "Frontend version");
if (key === "api_version") return tr("api_version", "API version");
if (key === "frontend_base_url") return tr("frontend_base_url", "Frontend URL");
if (key === "api_base_url") return tr("api_base_url", "API URL");
if (key === "frontend_entry") return tr("frontend_entry", "Frontend entry");
if (key === "release_runtime") return tr("release_runtime", "Release runtime");
return key;
};
const textValue = (value) => String(value || "").trim();
const shortCommit = (value) => textValue(value).slice(0, 12);
const githubAccess = (version) =>
version?.metadata?.github_access && typeof version.metadata.github_access === "object"
? version.metadata.github_access
: {};
const githubCommit = (version) => {
const access = githubAccess(version);
return version?.commit && typeof version.commit === "object"
? version.commit
: access.commit || access.latest_commit || null;
};
const versionCommit = (version) => {
const commit = githubCommit(version);
return (
textValue(version?.commit_sha) ||
(typeof version?.commit === "string" ? textValue(version.commit) : "") ||
textValue(commit?.sha) ||
textValue(githubAccess(version)?.commit?.sha) ||
textValue(githubAccess(version)?.latest_commit?.sha)
);
};
const versionTimestamp = (version) => {
const commit = githubCommit(version);
return (
textValue(version?.deployed_at) ||
textValue(version?.released_at) ||
textValue(version?.promoted_at) ||
textValue(version?.created_at) ||
textValue(version?.commit_authored_at) ||
textValue(commit?.authored_at) ||
textValue(githubAccess(version)?.commit_authored_at)
);
};
const formatReleaseTime = (value) => {
const timestamp = textValue(value);
if (!timestamp) {
return "";
}
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) {
return timestamp;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
};
const appReleaseDetail = (option, app) => {
const version = option?.versions?.[app] || null;
if (!version) {
return null;
}
const commit = versionCommit(version);
const timestamp = versionTimestamp(version);
const value = shortCommit(commit) || textValue(version?.version_label) || textValue(version?.label);
if (!value && !timestamp) {
return null;
}
return {
key: app,
label: app === "api" ? tr("api", "API") : tr("frontend", "Frontend"),
value,
timestamp: formatReleaseTime(timestamp),
};
};
const bundleReleaseDetail = (option) => {
const bundle = option?.versions?.bundle || null;
const bundleId = textValue(bundle?.id || option?.versions?.bundle_id);
const timestamp = textValue(bundle?.promoted_at) || textValue(bundle?.deployed_at) || textValue(bundle?.created_at);
if (!bundleId && !timestamp) {
return null;
}
return {
key: "bundle",
label: tr("bundle", "Bundle"),
value: bundleId ? `#${bundleId}` : "",
timestamp: formatReleaseTime(timestamp),
};
};
const optionReleaseDetails = (option) =>
[bundleReleaseDetail(option), appReleaseDetail(option, "frontend"), appReleaseDetail(option, "api")].filter(Boolean);
const optionTestId = (option) => `release-channel-option-${option.channelSlug || "unknown"}`;
const selectOption = (option) => {
if (props.disabled || props.switchingSlug || option.channelSlug === activeSlug.value) {
return;
}
emit("select", option);
};
</script>
<template>
<section
class="release-channel-selector"
:class="[`release-channel-selector--${variant}`]"
data-testid="release-channel-selector"
>
<div class="release-channel-selector__header">
<p>{{ selectorTitle }}</p>
<span v-if="selectorSubtitle">{{ selectorSubtitle }}</span>
</div>
<div class="release-channel-selector__options">
<button
v-for="option in releaseChannelOptions"
:key="option.channelSlug"
type="button"
class="release-channel-option"
:class="{
'release-channel-option--active': option.channelSlug === activeSlug,
'release-channel-option--unavailable': !option.configured,
}"
:aria-pressed="option.channelSlug === activeSlug"
:disabled="disabled || Boolean(switchingSlug)"
:data-testid="optionTestId(option)"
@click="selectOption(option)"
>
<span class="release-channel-option__main">
<span class="release-channel-option__icon" aria-hidden="true">
<i class="fas" :class="option.defaultChannel ? 'fa-shield-alt' : 'fa-code-branch'"></i>
</span>
<span class="release-channel-option__copy">
<strong>{{ option.channelName }}</strong>
<small>{{ optionStateLabel(option) }}</small>
</span>
</span>
<span class="release-channel-option__meta">
<span v-if="switchingSlug === option.channelSlug" class="release-channel-option__spinner" aria-hidden="true">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
<span v-else-if="option.channelSlug === activeSlug" class="release-channel-option__check" aria-hidden="true">
<i class="fas fa-check"></i>
</span>
<span v-else class="release-channel-option__arrow" aria-hidden="true">
<i class="fas fa-chevron-right"></i>
</span>
</span>
<span v-if="!isSidebar && !option.configured && option.missing.length" class="release-channel-option__missing">
<span v-for="item in option.missing" :key="item">{{ missingLabel(item) }}</span>
</span>
<span v-if="!isSidebar && optionReleaseDetails(option).length" class="release-channel-option__release-details">
<span
v-for="detail in optionReleaseDetails(option)"
:key="detail.key"
class="release-channel-option__release-detail"
:data-testid="`${optionTestId(option)}-${detail.key}-release`"
>
<strong>{{ detail.label }}</strong>
<span v-if="detail.value">{{ detail.value }}</span>
<small v-if="detail.timestamp">{{ detail.timestamp }}</small>
</span>
</span>
</button>
</div>
</section>
</template>
<style scoped>
.release-channel-selector {
width: 100%;
}
.release-channel-selector--panel {
margin-top: 24px;
padding: 18px;
border: 1px solid #d9e4f2;
background: rgba(255, 255, 255, 0.72);
}
.release-channel-selector--sidebar {
padding: 0 16px 14px;
}
.release-channel-selector__header {
margin-bottom: 10px;
}
.release-channel-selector__header p {
margin: 0;
color: #10243f;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.release-channel-selector__header span {
display: block;
margin-top: 3px;
color: #64748b;
font-size: 0.88rem;
line-height: 1.35;
}
.release-channel-selector--sidebar .release-channel-selector__header {
margin-bottom: 8px;
}
.release-channel-selector__options {
display: grid;
gap: 8px;
}
.release-channel-option {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid #d7e2ef;
border-radius: 6px;
background: #ffffff;
color: #1f2937;
cursor: pointer;
text-align: left;
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.release-channel-option:hover:not(:disabled) {
border-color: #8fb1d6;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.1);
transform: translateY(-1px);
}
.release-channel-option:disabled {
cursor: default;
}
.release-channel-option--active {
border-color: #153554;
background: #f8fbff;
box-shadow: inset 3px 0 0 #153554;
}
.release-channel-option--unavailable:not(.release-channel-option--active) {
border-color: #f4cc7a;
background: #fffaf0;
}
.release-channel-option__main {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.release-channel-option__icon,
.release-channel-option__check,
.release-channel-option__arrow,
.release-channel-option__spinner {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.release-channel-option__icon {
width: 28px;
height: 28px;
border-radius: 50%;
background: #eaf2fb;
color: #005486;
}
.release-channel-option__copy {
min-width: 0;
display: grid;
gap: 2px;
}
.release-channel-option__copy strong,
.release-channel-option__copy small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-channel-option__copy strong {
color: #172033;
font-size: 0.95rem;
line-height: 1.2;
}
.release-channel-option__copy small {
color: #64748b;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
}
.release-channel-option__meta {
color: #153554;
}
.release-channel-option__missing {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-left: 38px;
}
.release-channel-option__missing span {
padding: 4px 7px;
border: 1px solid #f6c56b;
background: #fff7e6;
color: #704600;
font-size: 0.72rem;
font-weight: 700;
}
.release-channel-option__release-details {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
padding-left: 38px;
}
.release-channel-option__release-detail {
min-width: 0;
display: grid;
gap: 2px;
padding: 7px 8px;
border: 1px solid #d9e4f2;
background: #ffffff;
color: #334155;
overflow-wrap: anywhere;
}
.release-channel-option__release-detail strong {
color: #005486;
font-size: 0.68rem;
line-height: 1.1;
text-transform: uppercase;
}
.release-channel-option__release-detail span {
color: #172033;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.78rem;
font-weight: 700;
line-height: 1.25;
}
.release-channel-option__release-detail small {
color: #64748b;
font-size: 0.72rem;
line-height: 1.25;
}
.release-channel-selector--sidebar .release-channel-option {
padding: 9px 10px;
border-radius: 5px;
box-shadow: none;
}
.release-channel-selector--sidebar .release-channel-option__icon {
width: 24px;
height: 24px;
font-size: 0.72rem;
}
.release-channel-selector--sidebar .release-channel-option__copy strong {
font-size: 0.86rem;
}
.release-channel-selector--sidebar .release-channel-option__copy small {
font-size: 0.68rem;
}
</style>
@@ -0,0 +1,58 @@
<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,
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) => {
if (switchingSlug.value) {
return;
}
switchingSlug.value = option.channelSlug || option.channel?.slug || "";
switchError.value = "";
try {
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 = "";
}
};
</script>
<template>
<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>
@@ -3,17 +3,25 @@ import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
acknowledgeReleaseChannelSwitch,
redirectToConfiguredReleaseFrontend,
releaseChannelSwitchedStatus,
} from "@/services/releaseChannelAvailability.js";
const { t } = useI18n({ useScope: "global" });
const { t, te } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.channel_switched.${key}`, params);
const status = computed(() => releaseChannelSwitchedStatus.value);
const frontendLabel = computed(() => status.value.frontendBaseUrl || tr("current_app_image"));
const apiLabel = computed(() => status.value.apiBaseUrl || tr("current_api"));
const localizedChannelName = computed(() => {
const key = `configuration.release_manager.channel_names.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.channelName;
});
const localizedChannelDescription = computed(() => {
const key = `configuration.release_manager.channel_descriptions.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.description;
});
const frontendVersion = computed(() => status.value.versions?.frontend || null);
const apiVersion = computed(() => status.value.versions?.api || null);
const bundleLabel = computed(() =>
status.value.versions?.bundle_id ? `#${status.value.versions.bundle_id}` : tr("assigned_channel")
);
const versionLabel = (version, fallback) => {
return version?.version_label || version?.commit_sha || fallback;
@@ -21,7 +29,6 @@ const versionLabel = (version, fallback) => {
const continueToApp = () => {
acknowledgeReleaseChannelSwitch(status.value.channel, status.value.principalKey);
redirectToConfiguredReleaseFrontend();
};
</script>
@@ -42,25 +49,33 @@ const continueToApp = () => {
</div>
<div class="release-card release-card--channel">
<i class="fas fa-code-branch"></i>
<span>{{ status.channelSlug || "channel" }}</span>
<span>{{ localizedChannelName }}</span>
</div>
</div>
<div class="release-switch-copy">
<p class="release-kicker">{{ tr("kicker") }}</p>
<h1>{{ tr("title", { channel: status.channelName }) }}</h1>
<h1>{{ tr("title", { channel: localizedChannelName }) }}</h1>
<p class="release-summary">
{{ tr("summary_prefix") }} <strong>{{ status.channelName }}</strong> {{ tr("summary_suffix") }}
{{ tr("summary_prefix") }} <strong>{{ localizedChannelName }}</strong> {{ tr("summary_suffix") }}
</p>
<p v-if="status.description" class="release-description">
{{ status.description }}
<p v-if="localizedChannelDescription" class="release-description">
{{ localizedChannelDescription }}
</p>
<div class="release-details" data-testid="release-channel-switched-details">
<span><strong>{{ tr("frontend") }}</strong>{{ frontendLabel }}</span>
<span><strong>{{ tr("api") }}</strong>{{ apiLabel }}</span>
<span><strong>{{ tr("frontend_version") }}</strong>{{ versionLabel(frontendVersion, tr("assigned_channel")) }}</span>
<span><strong>{{ tr("api_version") }}</strong>{{ versionLabel(apiVersion, tr("assigned_channel")) }}</span>
<span
><strong>{{ tr("bundle") }}</strong
>{{ bundleLabel }}</span
>
<span
><strong>{{ tr("frontend_version") }}</strong
>{{ versionLabel(frontendVersion, tr("assigned_channel")) }}</span
>
<span
><strong>{{ tr("api_version") }}</strong
>{{ versionLabel(apiVersion, tr("assigned_channel")) }}</span
>
</div>
<div class="release-actions">
@@ -3,28 +3,46 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import {
ignoreUnavailableReleaseChannel,
redirectToConfiguredReleaseFrontend,
releaseChannelSelectorVisible,
releaseChannelUnavailableStatus,
RELEASE_CHANNEL_CHECK_INTERVAL_MS,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
const router = useRouter();
const { t } = useI18n({ useScope: "global" });
const { t, te } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.channel_unavailable.${key}`, params);
const checking = ref(false);
const checkError = ref("");
const secondsUntilNextCheck = ref(RELEASE_CHANNEL_CHECK_INTERVAL_MS / 1000);
const switchingSlug = ref("");
let pollTimer = null;
let countdownTimer = null;
const status = computed(() => releaseChannelUnavailableStatus.value);
const localizedChannelName = computed(() => {
const key = `configuration.release_manager.channel_names.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.channelName;
});
const localizedChannelDescription = computed(() => {
const key = `configuration.release_manager.channel_descriptions.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.description;
});
const missingLabels = computed(() =>
status.value.missing.map((key) =>
key === "frontend_base_url" ? tr("frontend_url") : key === "api_base_url" ? tr("api_url") : key
)
status.value.missing.map((key) => {
if (key === "release_bundle") return tr("release_bundle");
if (key === "frontend_version") return tr("frontend_version");
if (key === "api_version") return tr("api_version");
if (key === "frontend_base_url") return tr("frontend_base_url");
if (key === "api_base_url") return tr("api_base_url");
if (key === "frontend_entry") return tr("frontend_entry");
if (key === "release_runtime") return tr("release_runtime");
return key;
})
);
const resetCountdown = () => {
@@ -40,7 +58,6 @@ const checkAgain = async () => {
checkError.value = "";
try {
await SessionUser.refreshReleaseRuntime();
redirectToConfiguredReleaseFrontend();
} catch (error) {
checkError.value = tr("refresh_error");
} finally {
@@ -53,6 +70,23 @@ const ignoreForFiveMinutes = () => {
ignoreUnavailableReleaseChannel(status.value.channel);
};
const switchChannel = async (option) => {
if (switchingSlug.value) {
return;
}
switchingSlug.value = option.channelSlug || option.channel?.slug || "";
checkError.value = "";
try {
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
} catch (error) {
checkError.value = tr("refresh_error");
} finally {
switchingSlug.value = "";
resetCountdown();
}
};
const logout = async () => {
await SessionUser.auth.logout();
router.push({ name: "login" });
@@ -91,7 +125,7 @@ onBeforeUnmount(() => {
</div>
<div class="release-card release-card--channel">
<i class="fas fa-code-branch"></i>
<span>{{ status.channelSlug || "channel" }}</span>
<span>{{ localizedChannelName }}</span>
</div>
<div class="release-card release-card--base">
<i class="fas fa-layer-group"></i>
@@ -103,15 +137,20 @@ onBeforeUnmount(() => {
<p class="release-kicker">{{ tr("kicker") }}</p>
<h1>{{ tr("title") }}</h1>
<p class="release-summary">
{{ tr("summary_prefix") }} <strong>{{ status.channelName }}</strong
{{ tr("summary_prefix") }} <strong>{{ localizedChannelName }}</strong
>{{ tr("summary_suffix") }}
</p>
<p v-if="status.description" class="release-description">
{{ status.description }}
<p v-if="localizedChannelDescription" class="release-description">
{{ localizedChannelDescription }}
</p>
<div class="release-missing" data-testid="release-channel-missing">
<span v-for="label in missingLabels" :key="label">{{ label }}</span>
</div>
<ReleaseChannelSelector
v-if="releaseChannelSelectorVisible"
:switching-slug="switchingSlug"
@select="switchChannel"
/>
<p class="release-check-status" data-testid="release-channel-next-check">
{{ tr("checking_again", { seconds: secondsUntilNextCheck }) }}
</p>
@@ -122,9 +161,9 @@ onBeforeUnmount(() => {
type="is-dark"
icon-left="clock"
icon-pack="fas"
data-testid="release-channel-ignore"
@click="ignoreForFiveMinutes"
>
data-testid="release-channel-ignore"
@click="ignoreForFiveMinutes"
>
{{ tr("ignore") }}
</b-button>
<b-button
@@ -132,9 +171,9 @@ onBeforeUnmount(() => {
icon-left="sync-alt"
icon-pack="fas"
:loading="checking"
data-testid="release-channel-check-again"
@click="checkAgain"
>
data-testid="release-channel-check-again"
@click="checkAgain"
>
{{ tr("check_again") }}
</b-button>
<b-button type="is-danger is-light" icon-left="sign-out-alt" icon-pack="fas" @click="logout">
@@ -0,0 +1,751 @@
<script setup>
import { computed, onMounted, reactive, ref } 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 {
getReleaseTimelineSession,
listReleaseTimelineSessions,
searchReleaseTimeline,
setReleaseReplayTarget,
} from "@/services/superuserReleases.js";
const props = defineProps({
channels: {
type: Array,
default: () => [],
},
moduleKeys: {
type: Array,
default: () => [],
},
canReplay: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["refresh-summary"]);
const { t } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.${key}`, params);
const trFallback = (key, fallback, params = {}) => {
const fullKey = `configuration.release_manager.${key}`;
const translated = t(fullKey, params);
return translated === fullKey ? fallback : translated;
};
const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || ""));
const replayForm = reactive({
target_type: "user",
target_id: "",
channel_id: null,
capture_level: "full_redacted",
expires_at: "",
});
const timelineFilters = reactive({
trace_id: "",
channel_slug: "",
principal_type: "",
principal_id: "",
customer_number: "",
module_key: "",
severity: "",
event_type: "",
device_type: "",
frontend_version: "",
api_version: "",
date_from: "",
date_to: "",
has_error_report: false,
limit: 100,
});
const busy = ref("");
const errors = ref([]);
const timelineEvents = ref([]);
const timelineSessions = ref([]);
const selectedDetail = ref(null);
const selectedDetailTab = ref("events");
const responseData = (response, fallback) => response?.data?.data ?? fallback;
const parseError = (error) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || "Unknown error";
const channelOptions = computed(() => props.channels || []);
const moduleOptions = computed(() => {
const query = String(timelineFilters.module_key || "").toLowerCase();
return (props.moduleKeys || [])
.map((key) => ({
value: key,
title: key,
description: trFallback("autocomplete.timeline_module_key", "Timeline module"),
icon: "fas fa-puzzle-piece",
}))
.filter((option) => !query || option.value.toLowerCase().includes(query))
.slice(0, 10);
});
const sessionRows = computed(() => (Array.isArray(timelineSessions.value) ? timelineSessions.value : []));
const selectedSession = computed(() => selectedDetail.value?.session || null);
const detailEvents = computed(() =>
Array.isArray(selectedDetail.value?.events) ? selectedDetail.value.events : []
);
const detailErrorReports = computed(() =>
Array.isArray(selectedDetail.value?.error_reports) ? selectedDetail.value.error_reports : []
);
const selectedRelease = computed(() => selectedDetail.value?.release || selectedSession.value?.release || {});
const filterPayload = () => {
const payload = {};
for (const [key, value] of Object.entries(timelineFilters)) {
if (value === "" || value === null || value === false) {
continue;
}
payload[key] = value;
}
return payload;
};
const statusClass = (status) => ({
"is-ok": ["ok", "active", "deployed", "promoted", "ready", "stable", "enabled", "info"].includes(status),
"is-degraded": ["queued", "deploying", "draft", "metadata", "warning"].includes(status),
"is-down": ["failed", "down", "error"].includes(status),
});
const formatDate = (value) => {
if (!value) {
return "--";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const shortValue = (value) => {
const text = String(value || "");
return text.length > 16 ? `${text.slice(0, 12)}...` : text || "--";
};
const userLabel = (session) => {
const user = session?.user || {};
return [user.label, user.customer_number ? `Customer ${user.customer_number}` : null].filter(Boolean).join(" / ") || "--";
};
const releaseLabel = (session, app) => {
const release = session?.release?.[app] || {};
return release.version_label || release.commit_sha || "--";
};
const releaseApp = (app) => selectedRelease.value?.[app] || selectedSession.value?.release?.[app] || {};
const versionReferenceLabel = (app) => {
const version = releaseApp(app)?.version;
return version?.id ? `#${version.id} ${version.status || ""}`.trim() : "--";
};
const deploymentReferenceLabel = (app) => {
const deployment = releaseApp(app)?.deployment;
return deployment?.id ? `#${deployment.id} ${deployment.status || ""}`.trim() : "--";
};
const bundleReferenceLabel = () => {
const bundle = selectedRelease.value?.bundle;
return bundle?.id ? `#${bundle.id} ${bundle.version_label || bundle.status || ""}`.trim() : "--";
};
const deviceLabel = (session) => {
const device = session?.device || {};
return [device.type, device.browser_name, device.os_name].filter(Boolean).join(" / ") || "--";
};
const viewportLabel = (session) => {
const device = session?.device || {};
if (!device.viewport_width || !device.viewport_height) {
return "--";
}
return `${device.viewport_width}x${device.viewport_height} @ ${device.device_pixel_ratio || 1}`;
};
const jsonPreview = (value) => {
if (value === null || value === undefined || value === "") {
return "--";
}
return JSON.stringify(value, null, 2);
};
async function run(key, callback) {
busy.value = key;
errors.value = [];
try {
await callback();
} catch (error) {
errors.value.push(error);
} finally {
busy.value = "";
}
}
async function enableReplayTarget() {
await run("replay:target", async () => {
await setReleaseReplayTarget({ ...replayForm, enabled: true });
Object.assign(replayForm, { target_id: "", expires_at: "" });
emit("refresh-summary");
await loadReplayData();
});
}
async function loadReplayData() {
await run("timeline:search", async () => {
const filters = filterPayload();
const [sessionsResponse, eventsResponse] = await Promise.all([
listReleaseTimelineSessions(filters),
searchReleaseTimeline(filters),
]);
timelineSessions.value = responseData(sessionsResponse, []);
timelineEvents.value = responseData(eventsResponse, []);
if (selectedSession.value && !timelineSessions.value.some((session) => session.trace_id === selectedSession.value.trace_id)) {
selectedDetail.value = null;
}
});
}
async function inspectSession(session) {
if (!session?.trace_id) {
return;
}
await run(`session:${session.trace_id}`, async () => {
selectedDetail.value = responseData(await getReleaseTimelineSession(session.trace_id), null);
selectedDetailTab.value = "events";
});
}
function applyChannelTarget(channel) {
Object.assign(replayForm, { target_type: "channel", target_id: channel.slug, channel_id: channel.id });
}
function selectModule(option) {
timelineFilters.module_key = option?.value || option || "";
}
function closeDetailModal() {
selectedDetail.value = null;
selectedDetailTab.value = "events";
}
onMounted(loadReplayData);
</script>
<template>
<div class="release-replay-inspector">
<div v-if="errors.length" class="notification is-danger is-light">
<p v-for="(error, index) in errors" :key="index">{{ parseError(error) }}</p>
</div>
<div class="release-chip-row" data-testid="release-replay-target-suggestions">
<b-button
v-for="channel in channelOptions"
:key="channel.id"
size="is-small"
type="is-light"
icon-left="circle"
icon-pack="fas"
@click="applyChannelTarget(channel)"
>
{{ tr("replay.capture_channel", { channel: channel.slug }) }}
</b-button>
</div>
<form class="release-form" data-testid="release-replay-form" @submit.prevent="enableReplayTarget">
<b-field :label="tr('replay.target_type')">
<b-select v-model="replayForm.target_type" expanded>
<option value="user">{{ valueLabel("subject_type", "user") }}</option>
<option value="subuser">{{ valueLabel("subject_type", "subuser") }}</option>
<option value="customer">{{ valueLabel("subject_type", "customer") }}</option>
<option value="channel">{{ valueLabel("subject_type", "channel") }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.target')" :message="tr('replay.target_message')">
<b-input v-model="replayForm.target_id" :placeholder="tr('replay.target_placeholder')" />
</b-field>
<b-field :label="tr('replay.channel_scope')" :message="tr('replay.channel_scope_message')">
<b-select v-model.number="replayForm.channel_id" expanded>
<option :value="null">{{ tr("replay.no_channel_scope") }}</option>
<option v-for="channel in channelOptions" :key="channel.id" :value="channel.id">{{ channel.slug }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.capture_level')">
<b-select v-model="replayForm.capture_level" expanded>
<option value="full_redacted">{{ valueLabel("capture_level", "full_redacted") }}</option>
<option value="metadata">{{ valueLabel("capture_level", "metadata") }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.expires')" :message="tr('replay.expires_message')">
<b-input v-model="replayForm.expires_at" type="datetime-local" />
</b-field>
<div class="release-form-actions">
<b-button
type="is-dark"
native-type="submit"
icon-left="play"
icon-pack="fas"
:disabled="!props.canReplay"
:loading="busy === 'replay:target'"
>
{{ tr("actions.enable") }}
</b-button>
</div>
</form>
<form class="release-form mt-3" data-testid="release-timeline-filter-form" @submit.prevent="loadReplayData">
<b-field :label="tr('replay.trace_id')" :message="tr('replay.trace_id_message')">
<b-input v-model="timelineFilters.trace_id" :placeholder="tr('replay.trace_id_placeholder')" />
</b-field>
<b-field :label="tr('replay.channel')">
<b-input v-model="timelineFilters.channel_slug" :placeholder="tr('replay.channel_placeholder')" />
</b-field>
<b-field :label="tr('replay.principal_id')">
<b-input v-model="timelineFilters.principal_id" :placeholder="tr('replay.principal_id_placeholder')" />
</b-field>
<b-field :label="trFallback('replay.customer_number', 'Customer number')">
<b-input v-model="timelineFilters.customer_number" placeholder="Customer number" />
</b-field>
<b-field :label="tr('replay.module')">
<BAutocomplete
v-model="timelineFilters.module_key"
:data="moduleOptions"
field="value"
:placeholder="tr('replay.module_placeholder')"
open-on-focus
keep-first
expanded
@select="selectModule"
>
<template #default="slotProps">
<div class="release-autocomplete-option">
<i :class="slotProps.option.icon" aria-hidden="true"></i>
<span>
<strong>{{ slotProps.option.title }}</strong>
<small>{{ slotProps.option.description }}</small>
</span>
</div>
</template>
</BAutocomplete>
</b-field>
<b-field :label="tr('replay.severity')">
<b-select v-model="timelineFilters.severity" expanded>
<option value="">{{ tr("replay.any_severity") }}</option>
<option value="error">{{ valueLabel("severity", "error") }}</option>
<option value="warning">{{ valueLabel("severity", "warning") }}</option>
<option value="info">{{ valueLabel("severity", "info") }}</option>
</b-select>
</b-field>
<b-field :label="trFallback('replay.event_type', 'Event type')">
<b-input v-model="timelineFilters.event_type" placeholder="request_failed" />
</b-field>
<b-field :label="trFallback('replay.device_type', 'Device type')">
<b-select v-model="timelineFilters.device_type" expanded>
<option value="">{{ trFallback("replay.any_device", "Any device") }}</option>
<option value="desktop">desktop</option>
<option value="tablet">tablet</option>
<option value="mobile">mobile</option>
</b-select>
</b-field>
<b-field :label="trFallback('replay.frontend_release', 'Frontend release')">
<b-input v-model="timelineFilters.frontend_version" placeholder="frontend-2026.05.19" />
</b-field>
<b-field :label="trFallback('replay.api_release', 'API release')">
<b-input v-model="timelineFilters.api_version" placeholder="api-2026.05.19" />
</b-field>
<b-field :label="trFallback('replay.date_from', 'From')">
<b-input v-model="timelineFilters.date_from" type="datetime-local" />
</b-field>
<b-field :label="trFallback('replay.date_to', 'To')">
<b-input v-model="timelineFilters.date_to" type="datetime-local" />
</b-field>
<b-field :label="trFallback('replay.error_reports', 'Error reports')">
<b-checkbox v-model="timelineFilters.has_error_report">
{{ trFallback("replay.has_error_report", "Has error report") }}
</b-checkbox>
</b-field>
<div class="release-form-actions">
<b-button
native-type="submit"
icon-left="search"
icon-pack="fas"
:disabled="!props.canReplay"
:loading="busy === 'timeline:search'"
>
{{ tr("actions.search") }}
</b-button>
</div>
</form>
<div class="table-container mt-3">
<table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions">
<thead>
<tr>
<th>{{ tr("replay.trace_id") }}</th>
<th>{{ trFallback("replay.user", "User") }}</th>
<th>{{ trFallback("replay.device", "Device") }}</th>
<th>{{ tr("replay.channel") }}</th>
<th>{{ trFallback("replay.release", "Release") }}</th>
<th>{{ trFallback("replay.events", "Events") }}</th>
<th>{{ trFallback("replay.last_seen", "Last seen") }}</th>
<th>{{ trFallback("actions.inspect", "Inspect") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessionRows" :key="session.trace_id">
<td><code>{{ shortValue(session.trace_id) }}</code></td>
<td>{{ userLabel(session) }}</td>
<td>{{ deviceLabel(session) }}</td>
<td>{{ session.channel_slug || "--" }}</td>
<td>{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }}</td>
<td>
{{ session.event_count || 0 }}
<b-tag v-if="session.error_count" type="is-danger" size="is-small">{{ session.error_count }}</b-tag>
<b-tag v-if="session.error_report_count" type="is-warning" size="is-small">
{{ session.error_report_count }}
</b-tag>
</td>
<td>{{ formatDate(session.last_event_at || session.last_seen_at) }}</td>
<td class="release-action-cell">
<ActionSettingsWheelButton
class="release-session-actions"
:data-testid="`release-session-actions-${session.trace_id}`"
>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-search"
:label="trFallback('actions.inspect', 'Inspect')"
:click-action="() => inspectSession(session)"
:disabled="busy === `session:${session.trace_id}`"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<tr v-if="sessionRows.length === 0">
<td colspan="8">{{ tr("replay.no_timeline_events") }}</td>
</tr>
</tbody>
</table>
</div>
<Teleport to="body">
<div
v-if="selectedDetail"
class="modal is-active release-inspector-modal"
data-testid="release-timeline-session-modal"
>
<div class="modal-background release-inspector-modal__backdrop" @click="closeDetailModal"></div>
<section class="release-inspector-modal__card" data-testid="release-timeline-session-detail">
<header class="release-inspector-modal__header">
<div>
<p class="release-inspector-modal__eyebrow">{{ trFallback("replay.debug_inspection", "Debug inspection") }}</p>
<h3>{{ userLabel(selectedSession) }}</h3>
<p class="release-inspector-modal__trace"><code>{{ selectedSession.trace_id }}</code></p>
</div>
<div class="release-inspector-modal__meta">
<b-tag :class="statusClass(selectedSession.error_count ? 'error' : 'info')">
{{ selectedSession.error_count ? "errors" : "info" }}
</b-tag>
<button class="delete" type="button" aria-label="close" @click="closeDetailModal"></button>
</div>
</header>
<div class="release-inspector-modal__summary">
<span>{{ selectedSession.channel_slug || "--" }}</span>
<span>{{ deviceLabel(selectedSession) }}</span>
<span>{{ releaseLabel(selectedSession, "frontend") }} / {{ releaseLabel(selectedSession, "api") }}</span>
<span>{{ selectedSession.event_count || 0 }} {{ trFallback("replay.events", "Events") }}</span>
</div>
<div class="release-inspector-modal__tabs" role="tablist">
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'events' }" type="button" @click="selectedDetailTab = 'events'">
{{ trFallback("replay.events", "Events") }}
</button>
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'release' }" type="button" @click="selectedDetailTab = 'release'">
{{ trFallback("replay.release", "Release") }}
</button>
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'device' }" type="button" @click="selectedDetailTab = 'device'">
{{ trFallback("replay.device", "Device") }}
</button>
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'reports' }" type="button" @click="selectedDetailTab = 'reports'">
{{ trFallback("replay.error_reports", "Error reports") }}
</button>
<button class="button is-small" type="button" disabled>
{{ trFallback("replay.visual_replay", "Visual replay") }}
</button>
</div>
<div class="release-inspector-modal__body">
<div v-if="selectedDetailTab === 'events'" class="release-detail-panel">
<ol class="release-timeline" data-testid="release-timeline-events">
<li v-for="event in detailEvents" :key="event.id">
<time>{{ formatDate(event.occurred_at) }}</time>
<strong>{{ event.event_type }}</strong>
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
<div class="release-muted">
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
{{ event.route_path || event.component || "--" }}
</div>
<details>
<summary>{{ trFallback("replay.payload", "Payload") }}</summary>
<pre>{{ jsonPreview(event.payload) }}</pre>
</details>
</li>
<li v-if="detailEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
</ol>
</div>
<div v-else-if="selectedDetailTab === 'release'" class="release-detail-panel release-kv-grid">
<span>Channel</span><strong>{{ selectedSession.channel_slug || "--" }}</strong>
<span>Frontend</span><strong>{{ releaseApp("frontend").version_label || releaseLabel(selectedSession, "frontend") }}</strong>
<span>Frontend commit</span><strong>{{ releaseApp("frontend").commit_sha || "--" }}</strong>
<span>Frontend version ref</span><strong>{{ versionReferenceLabel("frontend") }}</strong>
<span>Frontend deployment</span><strong>{{ deploymentReferenceLabel("frontend") }}</strong>
<span>API</span><strong>{{ releaseApp("api").version_label || releaseLabel(selectedSession, "api") }}</strong>
<span>API commit</span><strong>{{ releaseApp("api").commit_sha || "--" }}</strong>
<span>API version ref</span><strong>{{ versionReferenceLabel("api") }}</strong>
<span>API deployment</span><strong>{{ deploymentReferenceLabel("api") }}</strong>
<span>Bundle</span><strong>{{ bundleReferenceLabel() }}</strong>
<span>Route</span><strong>{{ selectedSession.last_route_path || "--" }}</strong>
</div>
<div v-else-if="selectedDetailTab === 'device'" class="release-detail-panel release-kv-grid">
<span>Type</span><strong>{{ selectedSession.device?.type || "--" }}</strong>
<span>Browser</span><strong>{{ selectedSession.device?.browser_name || "--" }} {{ selectedSession.device?.browser_version || "" }}</strong>
<span>OS</span><strong>{{ selectedSession.device?.os_name || "--" }} {{ selectedSession.device?.os_version || "" }}</strong>
<span>Viewport</span><strong>{{ viewportLabel(selectedSession) }}</strong>
<span>User agent</span><strong class="release-break-word">{{ selectedSession.device?.user_agent || "--" }}</strong>
</div>
<div v-else-if="selectedDetailTab === 'reports'" class="release-detail-panel">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Reporter</th>
<th>Route</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr v-for="report in detailErrorReports" :key="report.id">
<td>
<router-link to="/superuser/error-reports">#{{ report.id }}</router-link>
</td>
<td>{{ report.status }}</td>
<td>{{ report.reporter?.name || report.reporter?.email || report.reporter?.type || "--" }}</td>
<td>{{ report.route_path || "--" }}</td>
<td>{{ formatDate(report.created_at) }}</td>
</tr>
<tr v-if="detailErrorReports.length === 0">
<td colspan="5">{{ trFallback("replay.no_error_reports", "No linked error reports.") }}</td>
</tr>
</tbody>
</table>
</div>
<p class="release-muted mt-2">
{{ trFallback("replay.visual_replay_disabled", "Visual replay requires a future visual_redacted capture mode.") }}
</p>
</div>
</section>
</div>
</Teleport>
<ol v-if="!selectedDetail" class="release-timeline" data-testid="release-timeline-events">
<li v-for="event in timelineEvents" :key="event.id">
<time>{{ formatDate(event.occurred_at) }}</time>
<strong>{{ event.event_type }}</strong>
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
<div class="release-muted">
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
{{ event.route_path || event.component || "--" }}
</div>
<details>
<summary>{{ trFallback("replay.payload", "Payload") }}</summary>
<pre>{{ jsonPreview(event.payload) }}</pre>
</details>
</li>
<li v-if="timelineEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
</ol>
</div>
</template>
<style scoped>
.release-replay-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.release-session-table td {
vertical-align: middle;
}
.release-action-cell {
text-align: right;
width: 1%;
white-space: nowrap;
}
.release-inspector-modal {
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 90;
}
.release-inspector-modal__backdrop {
background: rgba(15, 23, 42, 0.42);
}
.release-inspector-modal__card {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 10px;
box-shadow: 0 28px 90px rgba(15, 23, 42, 0.34);
color: #111827;
display: flex;
flex-direction: column;
max-height: min(86vh, 900px);
overflow: hidden;
position: relative;
width: min(1120px, calc(100vw - 2rem));
z-index: 1;
}
.release-inspector-modal__header {
align-items: flex-start;
background: #ffffff;
border-bottom: 1px solid #eef2f7;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
}
.release-inspector-modal__header h3 {
color: #111827;
font-size: 1.2rem;
font-weight: 700;
line-height: 1.25;
margin: 0;
}
.release-inspector-modal__eyebrow {
color: #6b7280;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.release-inspector-modal__trace {
margin: 0.4rem 0 0;
}
.release-inspector-modal__meta {
align-items: center;
display: flex;
gap: 0.75rem;
}
.release-inspector-modal__summary {
background: #f8fafc;
border-bottom: 1px solid #eef2f7;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
}
.release-inspector-modal__summary span {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 999px;
color: #374151;
font-size: 0.78rem;
font-weight: 600;
padding: 0.25rem 0.65rem;
}
.release-inspector-modal__tabs {
background: #ffffff;
border-bottom: 1px solid #eef2f7;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.9rem 1.5rem;
}
.release-inspector-modal__body {
background: #ffffff;
overflow: auto;
padding: 1.25rem 1.5rem 1.5rem;
}
.release-detail-panel {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 6px;
color: #111827;
padding: 1rem;
}
.release-kv-grid {
display: grid;
grid-template-columns: minmax(9rem, 12rem) minmax(0, 1fr);
gap: 0.5rem 1rem;
}
.release-kv-grid span {
color: #6b7280;
}
.release-break-word {
word-break: break-word;
}
.release-timeline pre {
background: #111827;
border-radius: 6px;
color: #f9fafb;
font-size: 0.78rem;
margin-top: 0.5rem;
max-height: 24rem;
overflow: auto;
padding: 0.75rem;
white-space: pre-wrap;
}
@media (max-width: 768px) {
.release-inspector-modal__card {
max-height: 92vh;
width: calc(100vw - 1rem);
}
.release-inspector-modal__header {
flex-direction: column;
padding: 1rem;
}
.release-inspector-modal__summary,
.release-inspector-modal__tabs,
.release-inspector-modal__body {
padding-left: 1rem;
padding-right: 1rem;
}
.release-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,7 +1,7 @@
<script>
import axios from 'axios'
import {API_URL} from "@/config.js";
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
/**
* Get the selected customer number for X-Customer-Number header (used by subusers)
@@ -19,7 +19,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
}
// Build headers
const headers = {};
const headers = {
...buildCurrentReleaseHeaders(),
};
if (token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
@@ -31,17 +33,18 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
headers['X-Customer-Number'] = selectedCustomerNumber;
}
const requestUrl = resolveReleaseApiUrl(url);
return enqueueRequest(
() => axios({
method,
url: API_URL + url,
url: requestUrl,
...(method === 'GET' ? { params: data } : { data }),
__skipRequestQueue: true,
headers
}),
{
method,
url: API_URL + url,
url: requestUrl,
requestData: {
params: method === 'GET' ? data : null,
data: method === 'GET' ? null : data,
@@ -68,8 +71,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
export const unauthenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
return axios({
method,
url: API_URL + url,
data
url: resolveReleaseApiUrl(url),
data,
headers: buildCurrentReleaseHeaders(),
}).catch((error) => {
if (catchCallable) {
// Call the catch callable
@@ -93,6 +97,7 @@ export const paginatedGetRequest = (url, currentPage, itemsPerPage) => {
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
Authorization: `Bearer ${token}`
};
@@ -103,7 +108,7 @@ export const paginatedGetRequest = (url, currentPage, itemsPerPage) => {
headers['X-Customer-Number'] = selectedCustomerNumber;
}
return axios.get(API_URL + url, {
return axios.get(resolveReleaseApiUrl(url), {
params: {
page: currentPage,
limit: itemsPerPage
+47 -17
View File
@@ -48,7 +48,14 @@ import Swal from "sweetalert2";
import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/SubuserGrants.vue";
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import { setReleaseChannelSwitchNoticePrincipal } from "@/services/releaseChannelAvailability.js";
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
import {
isReleaseChannelApiAvailabilityError,
markReleaseChannelApiUnavailable,
reconcileSelectedReleaseChannel,
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -92,16 +99,21 @@ const hydrateSessionFromStorage = () => {
const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime);
reconcileSelectedReleaseChannel(normalizedRuntime);
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
SessionUser.runtimeConfig.release.versions.value = normalizedRuntime.versions || { frontend: null, api: null };
SessionUser.runtimeConfig.release.availableChannels.value =
normalizedRuntime.available_channels || normalizedRuntime.availableChannels || [];
SessionUser.runtimeConfig.release.versions.value = normalizedRuntime.versions || {
frontend: null,
api: null,
bundle_id: null,
};
SessionUser.runtimeConfig.release.frontendBaseUrl.value =
normalizedRuntime.frontend_base_url ||
normalizedRuntime.frontendBaseUrl ||
normalizedRuntime?.channel?.frontend_base_url ||
null;
normalizedRuntime.frontend_base_url || runtimeUrls.frontend_base_url || null;
SessionUser.runtimeConfig.release.apiBaseUrl.value =
normalizedRuntime.api_base_url || normalizedRuntime.apiBaseUrl || normalizedRuntime?.channel?.api_base_url || null;
normalizedRuntime.api_base_url || runtimeUrls.api_base_url || null;
SessionUser.runtimeConfig.release.availability.value = normalizedRuntime.availability || {
configured: true,
missing: [],
@@ -116,14 +128,18 @@ const applyReleaseRuntimeConfig = (runtime) => {
};
};
export const refreshReleaseRuntime = async () => {
return authenticatedRequest("/release/runtime", "GET")
.then((response) => {
applyReleaseRuntimeConfig(response?.data?.data || response?.data || {});
})
.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;
}
};
/**
@@ -253,6 +269,12 @@ export const getSubuserSessionData = async () => {
SessionUser.initiated.value = true;
})
.catch((error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
@@ -272,7 +294,7 @@ export const getSubuserSessionData = async () => {
* @returns {Promise<void>}
*/
export const getSessionData = async () => {
return await authenticatedRequest("/auth/session", "GET")
return await authenticatedRequest("/auth/session", "GET", releaseChannelRuntimeRequestParams())
.then((response) => {
SessionUser.user.id.value = response.data.data.id;
SessionUser.user.customer_number.value = response.data.data.customer_number;
@@ -323,6 +345,12 @@ export const getSessionData = async () => {
SessionUser.initiated.value = true;
})
.catch((error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
@@ -487,6 +515,7 @@ export const SessionUser = {
release: {
traceId: ref(null),
channel: ref(null),
availableChannels: ref([]),
versions: ref({ frontend: null, api: null }),
frontendBaseUrl: ref(null),
apiBaseUrl: ref(null),
@@ -743,7 +772,8 @@ export const SessionUser = {
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
SessionUser.runtimeConfig.release.traceId.value = null;
SessionUser.runtimeConfig.release.channel.value = null;
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null };
SessionUser.runtimeConfig.release.availableChannels.value = [];
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null, bundle_id: null };
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
SessionUser.runtimeConfig.release.availability.value = {
@@ -1,13 +1,14 @@
<script>
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
export const unauthenticatedRequest = (url, method, data) => {
return axios({
method,
url: API_URL + url,
url: resolveReleaseApiUrl(url),
data,
headers: buildCurrentReleaseHeaders(),
});
};
</script>
+27 -7
View File
@@ -859,7 +859,11 @@ export const createOrder = async (options = { isMobile: false }) => {
parseError(response, "stepError");
return false;
}
order_id.value = parseInt(response.data.data.id);
const createdOrder = response.data.data || {};
order_id.value = parseInt(createdOrder.id);
if (!isBlankPosMetadataValue(createdOrder.po)) {
order_po.value = String(createdOrder.po);
}
if (selectedDepartmentId) {
department_id.value = selectedDepartmentId;
}
@@ -1230,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);
@@ -1258,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,
@@ -2078,11 +2098,6 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
try {
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
let booking = getSelectedPendingOrderBooking();
if (!booking || !Array.isArray(booking?.items)) {
booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId, {
@@ -2124,11 +2139,16 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
const bookingPo = String(booking?.po ?? "").trim();
if (bookingPo !== "" && bookingPo !== order_po.value) {
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
order_po.value = bookingPo;
}
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
if (bookingItems.length === 0) {
return true;
@@ -8,6 +8,7 @@ import { ADMIN_DEPARTMENT_SELECTION_CLASS } from "@/components/models/navigation
import { isDepartmentLabelValid, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isSmall } from "@/components/displays/pagination/PaginationDisplayIsSmall.vue";
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
@@ -452,6 +453,7 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
</BMenuList>
</BMenu>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
</div>
</template>
+2 -2
View File
@@ -1,5 +1,5 @@
import { computed, ref, watch } from "vue";
import { API_URL } from "@/config";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
export function useWashSessionActions(options) {
@@ -121,7 +121,7 @@ export function useWashSessionActions(options) {
params.set("thumb_position", String(thumbPosition));
}
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
});
const executeSelfServeCommand = async (
+5 -2
View File
@@ -21,14 +21,17 @@ export const IS_DEV = parseBoolean(import.meta.env.VITE_IS_DEV, import.meta.env.
// Development mode
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
export const API_URL = normalizeApiUrl(
import.meta.env.VITE_API_URL || (IS_DEV ? "https://api.truckwash.io:4433" : "https://api.truckwash.io")
import.meta.env.VITE_API_URL || (IS_DEV ? "/api" : "https://api.truckwash.io")
);
export const RELEASE_MANAGER_CONTROL_API_URL = normalizeApiUrl(
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL
);
export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl(
import.meta.env.VITE_RELEASE_PUBLIC_GATEWAY_API_URL || "https://api-v2.truckwash.io"
);
export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS ||
(IS_DEV ? "https://api.truckwash.io:4433,https://api.truckwash.io" : "")
(IS_DEV ? "https://api-v2.truckwash.io,https://api.truckwash.io" : "")
)
.split(",")
.map((url) => normalizeApiUrl(url.trim()))
+103 -26
View File
@@ -2441,14 +2441,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -2457,15 +2467,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -2473,7 +2483,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -2486,26 +2496,41 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilot-tildelinger",
"deployments": "F?rste udrulning",
"deployments": "Første udrulning",
"replay": "Replay-opsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Udrulninger",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehændelser"
},
"overview": {
"title": "Oversigt",
"subtitle": "Aktuel kanalstatus, release-aktivitet og modulstatus.",
"guided_setup": "Guidet ops?tning",
"next_step": "N?ste: {step}",
"guided_setup": "Guidet opsætning",
"next_step": "Næste: {step}",
"ready": "Release Manager er klar til daglig drift.",
"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.",
@@ -2515,14 +2540,14 @@
"github_webhook_secret": "GitHub webhook-hemmelighed",
"configured": "Konfigureret",
"not_configured": "Ikke konfigureret",
"loaded_from": "Indl?st fra {variable}",
"loaded_from": "Indlæst fra {variable}",
"set_below": "Angiv {variable} nedenfor",
"private_repositories_prefix": "Private repositories l?ses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories læses med serverens miljøvariabel",
"private_repositories_or": "eller modulets konfigurationsvariabel",
"github_token_message": "Eksempel: github_pat_... med adgang til de private repositories, Release Manager udruller.",
"github_token_placeholder": "Lad feltet v?re tomt for at beholde det eksisterende token",
"github_token_placeholder": "Lad feltet være tomt for at beholde det eksisterende token",
"github_api_url_message": "Konfigureret i ReleaseManager.github_api_url. Brug https://api.github.com medmindre GitHub Enterprise bruges.",
"webhook_secret_message": "Valgfrit; lad feltet v?re tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_message": "Valgfrit; lad feltet være tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighed",
"save": "Gem indstillinger",
"back_to_integrations": "Tilbage til integrationer",
@@ -2559,30 +2584,55 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Din konto er tildelt",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indl?se dens release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"summary_suffix": ", men kanalen mangler påkrævet release-konfiguration.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Tjekker igen om {seconds}s",
"refresh_error": "Release-status kunne ikke opdateres. Det n?ste automatiske tjek pr?ver igen.",
"ignore": "Ignorer de n?ste 5 minutter",
"refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.",
"ignore": "Ignorer de næste 5 minutter",
"check_again": "Tjek igen",
"logout": "Log ud",
"base_image": "Basis-image"
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er nu p? {channel}",
"title": "Du er nu på {channel}",
"summary_prefix": "Din konto er blevet tildelt release-kanalen",
"summary_suffix": "Denne enhed husker, at du har set denne besked.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"assigned_channel": "Tildelt kanal",
"current_app_image": "Nuv?rende app-image",
"current_api": "Nuv?rende API",
"current_app_image": "Nuværende app-image",
"current_api": "Nuværende API",
"base_image": "Basis-image",
"continue": "Forts?t"
"continue": "Fortsæt"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Vælg hvilken tildelt release-kanal denne enhed skal bruge.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Ikke klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"switch_error": "Release-kanalen kunne ikke skiftes. Den forrige kanal er stadig aktiv."
},
"common": {
"yes": "ja",
@@ -2594,11 +2644,13 @@
"rollback": "Rul tilbage",
"update": "Opdater",
"create": "Opret",
"publish_release": "Publicer release",
"clear": "Ryd",
"assign": "Tildel",
"remove": "Fjern",
"test_access": "Test adgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promover",
"enable": "Aktiver",
"search": "Søg",
@@ -2733,6 +2785,18 @@
"assignments": {
"title": "Tildelinger",
"subtitle": "Fastgør brugere, medarbejdere eller kunder til release-kanaler.",
"subject": "Emne",
"subject_message": "Søg efter brugere, medarbejdere eller kunder, eller skriv et manuelt emne som user:42.",
"subject_placeholder": "Søg eller skriv emne",
"subject_empty": "Ingen emner fundet",
"group_users": "Brugere",
"group_subusers": "Medarbejdere",
"group_customers": "Kunder",
"group_manual": "Manuel",
"manual_user": "Bruger #{id}",
"manual_subuser": "Medarbejder #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Brug indtastet værdi {subject}",
"subject_type": "Emnetype",
"subject_type_message": "Vælg identitetstypen, der skal fastgøres.",
"customer": "Kunde",
@@ -2762,7 +2826,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
@@ -2833,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",
+85 -25
View File
@@ -2551,27 +2551,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -2595,30 +2605,30 @@
}
},
"setup": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele",
"assignments": "Pilot-Zuweisungen",
"deployments": "Erste Bereitstellung",
"replay": "Replay-Erfassung"
},
"stats": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "Ziele",
"deployments": "Bereitstellungen",
"timeline_events": "Timeline-Ereignisse"
},
"overview": {
"title": "?bersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivit?t und Modulstatus.",
"guided_setup": "Gef?hrte Einrichtung",
"next_step": "N?chster Schritt: {step}",
"ready": "Release Manager ist f?r den t?glichen Betrieb bereit.",
"title": "Übersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivität und Modulstatus.",
"guided_setup": "Geführte Einrichtung",
"next_step": "Nächster Schritt: {step}",
"ready": "Release Manager ist für den täglichen Betrieb bereit.",
"add_suggested_channel": "Vorgeschlagenen Kanal hinzufügen",
"module_health_empty": "Modul-Health-Snapshots erscheinen, nachdem Probes aufgezeichnet wurden."
},
"settings": {
"title": "Release-Manager-Einstellungen",
"subtitle": "GitHub-Token f?r private Repositories und Branch-Abfragen konfigurieren.",
"subtitle": "GitHub-Token für private Repositories und Branch-Abfragen konfigurieren.",
"github_token": "GitHub-Token",
"github_api_url": "GitHub-API-URL",
"webhook_secret": "Webhook-Secret",
@@ -2635,7 +2645,7 @@
"webhook_secret_message": "Optional; leer lassen, um das vorhandene Secret zu behalten.",
"webhook_secret_placeholder": "Webhook-HMAC-Secret",
"save": "Einstellungen speichern",
"back_to_integrations": "Zur?ck zu Integrationen",
"back_to_integrations": "Zurück zu Integrationen",
"guide": {
"steps": {
"github_token": "GitHub-Token",
@@ -2669,13 +2679,18 @@
"kicker": "Release Manager",
"title": "Release-Kanal ist nicht bereit",
"summary_prefix": "Dein Konto ist zugewiesen zu",
"summary_suffix": ", aber diesem Kanal fehlt die Konfiguration, um sein Release-Image zu laden.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Erneute Pr?fung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die n?chste automatische Pr?fung versucht es erneut.",
"ignore": "N?chste 5 Minuten ignorieren",
"check_again": "Erneut pr?fen",
"summary_suffix": ", aber diesem Kanal fehlt erforderliche Release-Konfiguration.",
"release_bundle": "Release-Bundle",
"frontend_version": "Frontend-Version",
"api_version": "API-Version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-Einstiegspunkt",
"release_runtime": "Release-Laufzeit",
"checking_again": "Erneute Prüfung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.",
"ignore": "Nächste 5 Minuten ignorieren",
"check_again": "Erneut prüfen",
"logout": "Abmelden",
"base_image": "Basis-Image"
},
@@ -2683,7 +2698,8 @@
"kicker": "Release Manager",
"title": "Du bist jetzt auf {channel}",
"summary_prefix": "Dein Konto wurde dem Release-Kanal",
"summary_suffix": "zugewiesen. Dieses Ger?t merkt sich, dass du diesen Hinweis gesehen hast.",
"summary_suffix": "zugewiesen. Dieses Gerät merkt sich, dass du diesen Hinweis gesehen hast.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-Version",
@@ -2694,6 +2710,24 @@
"base_image": "Basis-Image",
"continue": "Fortfahren"
},
"channel_selector": {
"title": "Release-Kanal",
"subtitle": "Wählen Sie, welchen zugewiesenen Release-Kanal dieses Gerät verwenden soll.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Bereit",
"unavailable": "Nicht bereit",
"release_bundle": "Release-Bundle",
"frontend_version": "Frontend-Version",
"api_version": "API-Version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-Einstiegspunkt",
"release_runtime": "Release-Laufzeit",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
"no": "nein",
@@ -2709,6 +2743,7 @@
"remove": "Entfernen",
"test_access": "Zugriff testen",
"deploy": "Deployen",
"redeploy": "Erneut deployen",
"promote": "Promoten",
"enable": "Aktivieren",
"search": "Suchen",
@@ -2843,6 +2878,18 @@
"assignments": {
"title": "Zuweisungen",
"subtitle": "Benutzer, Mitarbeiter oder Kunden an Release-Kanäle binden.",
"subject": "Subjekt",
"subject_message": "Benutzer, Subuser oder Kunden suchen oder ein manuelles Subjekt wie user:42 eingeben.",
"subject_placeholder": "Subjekt suchen oder eingeben",
"subject_empty": "Keine Subjekte gefunden",
"group_users": "Benutzer",
"group_subusers": "Subuser",
"group_customers": "Kunden",
"group_manual": "Manuell",
"manual_user": "Benutzer #{id}",
"manual_subuser": "Subuser #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Eingegebenen Wert {subject} verwenden",
"subject_type": "Subjekttyp",
"subject_type_message": "Identitätstyp zum Binden auswählen.",
"customer": "Customer",
@@ -2872,7 +2919,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
@@ -2943,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",
+81 -4
View File
@@ -2275,6 +2275,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -2340,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.",
@@ -2393,9 +2418,14 @@
"kicker": "Release Manager",
"title": "Release channel is not ready",
"summary_prefix": "Your account is assigned to",
"summary_suffix": ", but that channel is missing the configuration needed to load its release image.",
"frontend_url": "Frontend URL",
"api_url": "API URL",
"summary_suffix": ", but that channel is missing required release configuration.",
"release_bundle": "Release bundle",
"frontend_version": "Frontend version",
"api_version": "API version",
"frontend_base_url": "Frontend URL",
"api_base_url": "API URL",
"frontend_entry": "Frontend entry",
"release_runtime": "Release runtime",
"checking_again": "Checking again in {seconds}s",
"refresh_error": "Release status could not be refreshed. The next automatic check will try again.",
"ignore": "Ignore next 5 minutes",
@@ -2408,6 +2438,7 @@
"title": "You are now on {channel}",
"summary_prefix": "Your account has been assigned to the",
"summary_suffix": "release channel. This device will remember that you have seen this notice.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend version",
@@ -2418,6 +2449,25 @@
"base_image": "Base image",
"continue": "Continue"
},
"channel_selector": {
"title": "Release channel",
"subtitle": "Choose which assigned release channel this device should use.",
"sidebar_title": "Release",
"default": "Default",
"ready": "Ready",
"unavailable": "Not ready",
"release_bundle": "Release bundle",
"frontend_version": "Frontend version",
"api_version": "API version",
"frontend_base_url": "Frontend URL",
"api_base_url": "API URL",
"frontend_entry": "Frontend entry",
"release_runtime": "Release runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"switch_error": "Release channel could not be switched. The previous channel is still active."
},
"common": {
"yes": "yes",
"no": "no",
@@ -2428,11 +2478,13 @@
"rollback": "Rollback",
"update": "Update",
"create": "Create",
"publish_release": "Publish release",
"clear": "Clear",
"assign": "Assign",
"remove": "Remove",
"test_access": "Test access",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promote",
"enable": "Enable",
"search": "Search",
@@ -2567,6 +2619,18 @@
"assignments": {
"title": "Assignments",
"subtitle": "Pin users, subusers, or customers to release channels.",
"subject": "Subject",
"subject_message": "Search users, subusers, or customers, or type a manual subject like user:42.",
"subject_placeholder": "Search or type subject",
"subject_empty": "No subjects found",
"group_users": "Users",
"group_subusers": "Subusers",
"group_customers": "Customers",
"group_manual": "Manual",
"manual_user": "User #{id}",
"manual_subuser": "Subuser #{id}",
"manual_customer": "Customer #{id}",
"manual_description": "Use typed value {subject}",
"subject_type": "Subject type",
"subject_type_message": "Choose the identity type to pin.",
"customer": "Customer",
@@ -2596,7 +2660,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit selection",
"commit_selection_message": "Latest resolves to the branch head with the configured GitHub token.",
@@ -2667,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",
+62 -2
View File
@@ -1500,6 +1500,16 @@
"release_manager": {
"title": "@:{'templates.generated.compat.configuration.release_manager.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.subtitle'}",
"channel_names": {
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_names.stable'}",
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_names.canary'}",
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_names.internal'}"
},
"channel_descriptions": {
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.stable'}",
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.canary'}",
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.internal'}"
},
"control_api": {
"title": "@:{'templates.generated.compat.configuration.release_manager.control_api.title'}",
"tooltip": "@:{'templates.generated.compat.configuration.release_manager.control_api.tooltip'}",
@@ -1619,8 +1629,13 @@
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.title'}",
"summary_prefix": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.summary_prefix'}",
"summary_suffix": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.summary_suffix'}",
"frontend_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_url'}",
"api_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_url'}",
"release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_bundle'}",
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_version'}",
"api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_version'}",
"frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_base_url'}",
"api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_base_url'}",
"frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_entry'}",
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_runtime'}",
"checking_again": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.checking_again'}",
"refresh_error": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.refresh_error'}",
"ignore": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.ignore'}",
@@ -1633,6 +1648,7 @@
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.title'}",
"summary_prefix": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.summary_prefix'}",
"summary_suffix": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.summary_suffix'}",
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.bundle'}",
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.frontend'}",
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.api'}",
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.frontend_version'}",
@@ -1643,6 +1659,24 @@
"base_image": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.base_image'}",
"continue": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.continue'}"
},
"channel_selector": {
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.subtitle'}",
"sidebar_title": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.sidebar_title'}",
"default": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.default'}",
"ready": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.ready'}",
"unavailable": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.unavailable'}",
"release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_bundle'}",
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_version'}",
"api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_version'}",
"frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_base_url'}",
"api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_base_url'}",
"frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_entry'}",
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_runtime'}",
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.bundle'}",
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend'}",
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api'}"
},
"common": {
"yes": "@:{'templates.generated.compat.configuration.release_manager.common.yes'}",
"no": "@:{'templates.generated.compat.configuration.release_manager.common.no'}",
@@ -1658,6 +1692,7 @@
"remove": "@:{'templates.generated.compat.configuration.release_manager.actions.remove'}",
"test_access": "@:{'templates.generated.compat.configuration.release_manager.actions.test_access'}",
"deploy": "@:{'templates.generated.compat.configuration.release_manager.actions.deploy'}",
"redeploy": "@:{'templates.generated.compat.configuration.release_manager.actions.redeploy'}",
"promote": "@:{'templates.generated.compat.configuration.release_manager.actions.promote'}",
"enable": "@:{'templates.generated.compat.configuration.release_manager.actions.enable'}",
"search": "@:{'templates.generated.compat.configuration.release_manager.actions.search'}",
@@ -1792,6 +1827,18 @@
"assignments": {
"title": "@:{'templates.generated.compat.configuration.release_manager.assignments.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.assignments.subtitle'}",
"subject": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject'}",
"subject_message": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_message'}",
"subject_placeholder": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_placeholder'}",
"subject_empty": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_empty'}",
"group_users": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_users'}",
"group_subusers": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_subusers'}",
"group_customers": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_customers'}",
"group_manual": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_manual'}",
"manual_user": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_user'}",
"manual_subuser": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_subuser'}",
"manual_customer": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_customer'}",
"manual_description": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_description'}",
"subject_type": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_type'}",
"subject_type_message": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_type_message'}",
"customer": "@:{'templates.generated.compat.configuration.release_manager.assignments.customer'}",
@@ -1892,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'}",
+78 -18
View File
@@ -2552,16 +2552,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -2572,7 +2582,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -2584,7 +2594,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -2597,14 +2607,14 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilottildelinger",
"deployments": "F?rste utrulling",
"deployments": "Første utrulling",
"replay": "Replay-innsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullinger",
"timeline_events": "Tidslinjehendelser"
},
@@ -2628,12 +2638,12 @@
"not_configured": "Ikke konfigurert",
"loaded_from": "Lastet fra {variable}",
"set_below": "Angi {variable} nedenfor",
"private_repositories_prefix": "Private repositories leses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories leses med serverens miljøvariabel",
"private_repositories_or": "eller modulens konfigurasjonsvariabel",
"github_token_message": "Eksempel: github_pat_... med tilgang til de private repositories Release Manager ruller ut.",
"github_token_placeholder": "La st? tomt for ? beholde eksisterende token",
"github_token_placeholder": "La stå tomt for å beholde eksisterende token",
"github_api_url_message": "Konfigurert i ReleaseManager.github_api_url. Bruk https://api.github.com med mindre GitHub Enterprise brukes.",
"webhook_secret_message": "Valgfritt; la st? tomt for ? beholde eksisterende hemmelighet.",
"webhook_secret_message": "Valgfritt; la stå tomt for å beholde eksisterende hemmelighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighet",
"save": "Lagre innstillinger",
"back_to_integrations": "Tilbake til integrasjoner",
@@ -2670,11 +2680,16 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Kontoen din er tildelt",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for ? laste release-imaget.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"summary_suffix": ", men kanalen mangler påkrevd release-konfigurasjon.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Sjekker igjen om {seconds}s",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk pr?ver igjen.",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.",
"ignore": "Ignorer de neste 5 minuttene",
"check_again": "Sjekk igjen",
"logout": "Logg ut",
@@ -2682,19 +2697,38 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er n? p? {channel}",
"title": "Du er nå på {channel}",
"summary_prefix": "Kontoen din er tildelt release-kanalen",
"summary_suffix": "Denne enheten husker at du har sett denne meldingen.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"assigned_channel": "Tildelt kanal",
"current_app_image": "N?v?rende app-image",
"current_api": "N?v?rende API",
"current_app_image": "Nåværende app-image",
"current_api": "Nåværende API",
"base_image": "Basis-image",
"continue": "Fortsett"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Velg hvilken tildelt release-kanal denne enheten skal bruke.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Ikke klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
"no": "nei",
@@ -2710,6 +2744,7 @@
"remove": "Fjern",
"test_access": "Test tilgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promoter",
"enable": "Aktiver",
"search": "Søk",
@@ -2844,6 +2879,18 @@
"assignments": {
"title": "Tildelinger",
"subtitle": "Fest brukere, medarbeidere eller kunder til release-kanaler.",
"subject": "Emne",
"subject_message": "Søk etter brukere, medarbeidere eller kunder, eller skriv et manuelt emne som user:42.",
"subject_placeholder": "Søk eller skriv emne",
"subject_empty": "Ingen emner funnet",
"group_users": "Brukere",
"group_subusers": "Medarbeidere",
"group_customers": "Kunder",
"group_manual": "Manuell",
"manual_user": "Bruker #{id}",
"manual_subuser": "Medarbeider #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Bruk inntastet verdi {subject}",
"subject_type": "Emnetype",
"subject_type_message": "Vælg identitetstypen, der skal fastgøres.",
"customer": "Kunde",
@@ -2873,7 +2920,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
@@ -2944,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",
+102 -42
View File
@@ -2602,31 +2602,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -2634,58 +2644,58 @@
},
"replay": {
"label": "Replay",
"description": "Aktivera riktad insamling och s?k i release-tidslinjeh?ndelser."
"description": "Aktivera riktad insamling och sök i release-tidslinjehändelser."
},
"integrations": {
"label": "Integrationer",
"description": "Anslut GitHub-repositories, branches och Coolify-tj?nster."
"description": "Anslut GitHub-repositories, branches och Coolify-tjänster."
},
"settings": {
"label": "Inst?llningar",
"description": "Konfigurera Release Managers GitHub-?tkomst och webhook-inst?llningar."
"label": "Inställningar",
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
}
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- och Coolify-m?l",
"targets": "GitHub- och Coolify-mål",
"assignments": "Pilottilldelningar",
"deployments": "F?rsta utrullningen",
"deployments": "Första utrullningen",
"replay": "Replay-insamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullningar",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehändelser"
},
"overview": {
"title": "?versikt",
"subtitle": "Aktuell kanalh?lsa, release-aktivitet och modulstatus.",
"title": "Översikt",
"subtitle": "Aktuell kanalhälsa, release-aktivitet och modulstatus.",
"guided_setup": "Guidad installation",
"next_step": "N?sta: {step}",
"ready": "Release Manager ?r klar f?r daglig drift.",
"next_step": "Nästa: {step}",
"ready": "Release Manager är klar för daglig drift.",
"add_suggested_channel": "Lägg till föreslagen kanal",
"module_health_empty": "Modulhälsosnapshots visas när probes har registrerats."
},
"settings": {
"title": "Release Manager-inst?llningar",
"subtitle": "Konfigurera GitHub-token som anv?nds f?r privata repositories och branch-uppslag.",
"title": "Release Manager-inställningar",
"subtitle": "Konfigurera GitHub-token som används för privata repositories och branch-uppslag.",
"github_token": "GitHub-token",
"github_api_url": "GitHub API-URL",
"webhook_secret": "Webhook-hemlighet",
"github_webhook_secret": "GitHub webhook-hemlighet",
"configured": "Konfigurerad",
"not_configured": "Inte konfigurerad",
"loaded_from": "Inl?st fr?n {variable}",
"loaded_from": "Inläst från {variable}",
"set_below": "Ange {variable} nedan",
"private_repositories_prefix": "Privata repositories l?ses med serverns milj?variabel",
"private_repositories_prefix": "Privata repositories läses med serverns miljövariabel",
"private_repositories_or": "eller modulens konfigurationsvariabel",
"github_token_message": "Exempel: github_pat_... med ?tkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "L?mna tomt f?r att beh?lla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Anv?nd https://api.github.com om du inte anv?nder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; l?mna tomt f?r att beh?lla befintlig hemlighet.",
"github_token_message": "Exempel: github_pat_... med åtkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "Lämna tomt för att behålla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Använd https://api.github.com om du inte använder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; lämna tomt för att behålla befintlig hemlighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemlighet",
"save": "Spara inst?llningar",
"save": "Spara inställningar",
"back_to_integrations": "Tillbaka till integrationer",
"guide": {
"steps": {
@@ -2718,13 +2728,18 @@
},
"channel_unavailable": {
"kicker": "Release Manager",
"title": "Release-kanalen ?r inte klar",
"summary_prefix": "Ditt konto ?r tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som beh?vs f?r att l?sa in dess release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"title": "Release-kanalen är inte klar",
"summary_prefix": "Ditt konto är tilldelat",
"summary_suffix": ", men kanalen saknar obligatorisk release-konfiguration.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Kontrollerar igen om {seconds}s",
"refresh_error": "Release-status kunde inte uppdateras. N?sta automatiska kontroll f?rs?ker igen.",
"refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.",
"ignore": "Ignorera de kommande 5 minuterna",
"check_again": "Kontrollera igen",
"logout": "Logga ut",
@@ -2732,9 +2747,10 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du ?r nu p? {channel}",
"title": "Du är nu på {channel}",
"summary_prefix": "Ditt konto har tilldelats release-kanalen",
"summary_suffix": "Den h?r enheten kommer ih?g att du har sett detta meddelande.",
"summary_suffix": "Den här enheten kommer ihåg att du har sett detta meddelande.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
@@ -2743,7 +2759,25 @@
"current_app_image": "Nuvarande app-image",
"current_api": "Nuvarande API",
"base_image": "Bas-image",
"continue": "Forts?tt"
"continue": "Fortsätt"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Välj vilken tilldelad release-kanal den här enheten ska använda.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Inte klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
@@ -2760,6 +2794,7 @@
"remove": "Ta bort",
"test_access": "Testa åtkomst",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promota",
"enable": "Aktivera",
"search": "Sök",
@@ -2894,6 +2929,18 @@
"assignments": {
"title": "Tilldelningar",
"subtitle": "Fäst användare, medarbetare eller kunder till release-kanaler.",
"subject": "Ämne",
"subject_message": "Sök efter användare, medarbetare eller kunder, eller skriv ett manuellt ämne som user:42.",
"subject_placeholder": "Sök eller skriv ämne",
"subject_empty": "Inga ämnen hittades",
"group_users": "Användare",
"group_subusers": "Medarbetare",
"group_customers": "Kunder",
"group_manual": "Manuell",
"manual_user": "Användare #{id}",
"manual_subuser": "Medarbetare #{id}",
"manual_customer": "Kund #{id}",
"manual_description": "Använd inmatat värde {subject}",
"subject_type": "Ämnestyp",
"subject_type_message": "Välj identitetstypen som ska fästas.",
"customer": "Kunde",
@@ -2923,7 +2970,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
@@ -2994,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",
+73 -26
View File
@@ -1499,14 +1499,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -1515,15 +1525,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -1531,7 +1541,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -1544,22 +1554,22 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilot-tildelinger",
"deployments": "F?rste udrulning",
"deployments": "Første udrulning",
"replay": "Replay-opsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Udrulninger",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehændelser"
},
"overview": {
"title": "Oversigt",
"subtitle": "Aktuel kanalstatus, release-aktivitet og modulstatus.",
"guided_setup": "Guidet ops?tning",
"next_step": "N?ste: {step}",
"guided_setup": "Guidet opsætning",
"next_step": "Næste: {step}",
"ready": "Release Manager er klar til daglig drift.",
"add_suggested_channel": "Tilføj foreslået kanal",
"module_health_empty": "Modul-health snapshots vises, når probes er blevet registreret."
@@ -1573,14 +1583,14 @@
"github_webhook_secret": "GitHub webhook-hemmelighed",
"configured": "Konfigureret",
"not_configured": "Ikke konfigureret",
"loaded_from": "Indl?st fra {variable}",
"loaded_from": "Indlæst fra {variable}",
"set_below": "Angiv {variable} nedenfor",
"private_repositories_prefix": "Private repositories l?ses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories læses med serverens miljøvariabel",
"private_repositories_or": "eller modulets konfigurationsvariabel",
"github_token_message": "Eksempel: github_pat_... med adgang til de private repositories, Release Manager udruller.",
"github_token_placeholder": "Lad feltet v?re tomt for at beholde det eksisterende token",
"github_token_placeholder": "Lad feltet være tomt for at beholde det eksisterende token",
"github_api_url_message": "Konfigureret i ReleaseManager.github_api_url. Brug https://api.github.com medmindre GitHub Enterprise bruges.",
"webhook_secret_message": "Valgfrit; lad feltet v?re tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_message": "Valgfrit; lad feltet være tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighed",
"save": "Gem indstillinger",
"back_to_integrations": "Tilbage til integrationer",
@@ -1617,30 +1627,54 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Din konto er tildelt",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indl?se dens release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"summary_suffix": ", men kanalen mangler påkrævet release-konfiguration.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Tjekker igen om {seconds}s",
"refresh_error": "Release-status kunne ikke opdateres. Det n?ste automatiske tjek pr?ver igen.",
"ignore": "Ignorer de n?ste 5 minutter",
"refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.",
"ignore": "Ignorer de næste 5 minutter",
"check_again": "Tjek igen",
"logout": "Log ud",
"base_image": "Basis-image"
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er nu p? {channel}",
"title": "Du er nu på {channel}",
"summary_prefix": "Din konto er blevet tildelt release-kanalen",
"summary_suffix": "Denne enhed husker, at du har set denne besked.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"assigned_channel": "Tildelt kanal",
"current_app_image": "Nuv?rende app-image",
"current_api": "Nuv?rende API",
"current_app_image": "Nuværende app-image",
"current_api": "Nuværende API",
"base_image": "Basis-image",
"continue": "Forts?t"
"continue": "Fortsæt"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Vælg hvilken tildelt release-kanal denne enhed skal bruge.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Ikke klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
@@ -1657,6 +1691,7 @@
"remove": "Fjern",
"test_access": "Test adgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promover",
"enable": "Aktiver",
"search": "Søg",
@@ -1791,6 +1826,18 @@
"assignments": {
"title": "Tildelinger",
"subtitle": "Fastgør brugere, medarbejdere eller kunder til release-kanaler.",
"subject": "Emne",
"subject_message": "Søg efter brugere, medarbejdere eller kunder, eller skriv et manuelt emne som user:42.",
"subject_placeholder": "Søg eller skriv emne",
"subject_empty": "Ingen emner fundet",
"group_users": "Brugere",
"group_subusers": "Medarbejdere",
"group_customers": "Kunder",
"group_manual": "Manuel",
"manual_user": "Bruger #{id}",
"manual_subuser": "Medarbejder #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Brug indtastet værdi {subject}",
"subject_type": "Emnetype",
"subject_type_message": "Vælg identitetstypen, der skal fastgøres.",
"customer": "Kunde",
@@ -1820,7 +1867,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
+72 -25
View File
@@ -1499,27 +1499,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -1543,30 +1553,30 @@
}
},
"setup": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele",
"assignments": "Pilot-Zuweisungen",
"deployments": "Erste Bereitstellung",
"replay": "Replay-Erfassung"
},
"stats": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "Ziele",
"deployments": "Bereitstellungen",
"timeline_events": "Timeline-Ereignisse"
},
"overview": {
"title": "?bersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivit?t und Modulstatus.",
"guided_setup": "Gef?hrte Einrichtung",
"next_step": "N?chster Schritt: {step}",
"ready": "Release Manager ist f?r den t?glichen Betrieb bereit.",
"title": "Übersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivität und Modulstatus.",
"guided_setup": "Geführte Einrichtung",
"next_step": "Nächster Schritt: {step}",
"ready": "Release Manager ist für den täglichen Betrieb bereit.",
"add_suggested_channel": "Vorgeschlagenen Kanal hinzufügen",
"module_health_empty": "Modul-Health-Snapshots erscheinen, nachdem Probes aufgezeichnet wurden."
},
"settings": {
"title": "Release-Manager-Einstellungen",
"subtitle": "GitHub-Token f?r private Repositories und Branch-Abfragen konfigurieren.",
"subtitle": "GitHub-Token für private Repositories und Branch-Abfragen konfigurieren.",
"github_token": "GitHub-Token",
"github_api_url": "GitHub-API-URL",
"webhook_secret": "Webhook-Secret",
@@ -1583,7 +1593,7 @@
"webhook_secret_message": "Optional; leer lassen, um das vorhandene Secret zu behalten.",
"webhook_secret_placeholder": "Webhook-HMAC-Secret",
"save": "Einstellungen speichern",
"back_to_integrations": "Zur?ck zu Integrationen",
"back_to_integrations": "Zurück zu Integrationen",
"guide": {
"steps": {
"github_token": "GitHub-Token",
@@ -1617,13 +1627,18 @@
"kicker": "Release Manager",
"title": "Release-Kanal ist nicht bereit",
"summary_prefix": "Dein Konto ist zugewiesen zu",
"summary_suffix": ", aber diesem Kanal fehlt die Konfiguration, um sein Release-Image zu laden.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Erneute Pr?fung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die n?chste automatische Pr?fung versucht es erneut.",
"ignore": "N?chste 5 Minuten ignorieren",
"check_again": "Erneut pr?fen",
"summary_suffix": ", aber diesem Kanal fehlt erforderliche Release-Konfiguration.",
"release_bundle": "Release-Bundle",
"frontend_version": "Frontend-Version",
"api_version": "API-Version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-Einstiegspunkt",
"release_runtime": "Release-Laufzeit",
"checking_again": "Erneute Prüfung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.",
"ignore": "Nächste 5 Minuten ignorieren",
"check_again": "Erneut prüfen",
"logout": "Abmelden",
"base_image": "Basis-Image"
},
@@ -1631,7 +1646,8 @@
"kicker": "Release Manager",
"title": "Du bist jetzt auf {channel}",
"summary_prefix": "Dein Konto wurde dem Release-Kanal",
"summary_suffix": "zugewiesen. Dieses Ger?t merkt sich, dass du diesen Hinweis gesehen hast.",
"summary_suffix": "zugewiesen. Dieses Gerät merkt sich, dass du diesen Hinweis gesehen hast.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-Version",
@@ -1642,6 +1658,24 @@
"base_image": "Basis-Image",
"continue": "Fortfahren"
},
"channel_selector": {
"title": "Release-Kanal",
"subtitle": "Wählen Sie, welchen zugewiesenen Release-Kanal dieses Gerät verwenden soll.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Bereit",
"unavailable": "Nicht bereit",
"release_bundle": "Release-Bundle",
"frontend_version": "Frontend-Version",
"api_version": "API-Version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-Einstiegspunkt",
"release_runtime": "Release-Laufzeit",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
"no": "nein",
@@ -1657,6 +1691,7 @@
"remove": "Entfernen",
"test_access": "Zugriff testen",
"deploy": "Deployen",
"redeploy": "Erneut deployen",
"promote": "Promoten",
"enable": "Aktivieren",
"search": "Suchen",
@@ -1791,6 +1826,18 @@
"assignments": {
"title": "Zuweisungen",
"subtitle": "Benutzer, Mitarbeiter oder Kunden an Release-Kanäle binden.",
"subject": "Subjekt",
"subject_message": "Benutzer, Subuser oder Kunden suchen oder ein manuelles Subjekt wie user:42 eingeben.",
"subject_placeholder": "Subjekt suchen oder eingeben",
"subject_empty": "Keine Subjekte gefunden",
"group_users": "Benutzer",
"group_subusers": "Subuser",
"group_customers": "Kunden",
"group_manual": "Manuell",
"manual_user": "Benutzer #{id}",
"manual_subuser": "Subuser #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Eingegebenen Wert {subject} verwenden",
"subject_type": "Subjekttyp",
"subject_type_message": "Identitätstyp zum Binden auswählen.",
"customer": "Customer",
@@ -1820,7 +1867,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
+51 -4
View File
@@ -1499,6 +1499,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -1617,9 +1627,14 @@
"kicker": "Release Manager",
"title": "Release channel is not ready",
"summary_prefix": "Your account is assigned to",
"summary_suffix": ", but that channel is missing the configuration needed to load its release image.",
"frontend_url": "Frontend URL",
"api_url": "API URL",
"summary_suffix": ", but that channel is missing required release configuration.",
"release_bundle": "Release bundle",
"frontend_version": "Frontend version",
"api_version": "API version",
"frontend_base_url": "Frontend URL",
"api_base_url": "API URL",
"frontend_entry": "Frontend entry",
"release_runtime": "Release runtime",
"checking_again": "Checking again in {seconds}s",
"refresh_error": "Release status could not be refreshed. The next automatic check will try again.",
"ignore": "Ignore next 5 minutes",
@@ -1632,6 +1647,7 @@
"title": "You are now on {channel}",
"summary_prefix": "Your account has been assigned to the",
"summary_suffix": "release channel. This device will remember that you have seen this notice.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend version",
@@ -1642,6 +1658,24 @@
"base_image": "Base image",
"continue": "Continue"
},
"channel_selector": {
"title": "Release channel",
"subtitle": "Choose which assigned release channel this device should use.",
"sidebar_title": "Release",
"default": "Default",
"ready": "Ready",
"unavailable": "Not ready",
"release_bundle": "Release bundle",
"frontend_version": "Frontend version",
"api_version": "API version",
"frontend_base_url": "Frontend URL",
"api_base_url": "API URL",
"frontend_entry": "Frontend entry",
"release_runtime": "Release runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "yes",
"no": "no",
@@ -1657,6 +1691,7 @@
"remove": "Remove",
"test_access": "Test access",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promote",
"enable": "Enable",
"search": "Search",
@@ -1791,6 +1826,18 @@
"assignments": {
"title": "Assignments",
"subtitle": "Pin users, subusers, or customers to release channels.",
"subject": "Subject",
"subject_message": "Search users, subusers, or customers, or type a manual subject like user:42.",
"subject_placeholder": "Search or type subject",
"subject_empty": "No subjects found",
"group_users": "Users",
"group_subusers": "Subusers",
"group_customers": "Customers",
"group_manual": "Manual",
"manual_user": "User #{id}",
"manual_subuser": "Subuser #{id}",
"manual_customer": "Customer #{id}",
"manual_description": "Use typed value {subject}",
"subject_type": "Subject type",
"subject_type_message": "Choose the identity type to pin.",
"customer": "Customer",
@@ -1820,7 +1867,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit selection",
"commit_selection_message": "Latest resolves to the branch head with the configured GitHub token.",
+65 -18
View File
@@ -1499,16 +1499,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -1519,7 +1529,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -1531,7 +1541,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -1544,14 +1554,14 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilottildelinger",
"deployments": "F?rste utrulling",
"deployments": "Første utrulling",
"replay": "Replay-innsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullinger",
"timeline_events": "Tidslinjehendelser"
},
@@ -1575,12 +1585,12 @@
"not_configured": "Ikke konfigurert",
"loaded_from": "Lastet fra {variable}",
"set_below": "Angi {variable} nedenfor",
"private_repositories_prefix": "Private repositories leses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories leses med serverens miljøvariabel",
"private_repositories_or": "eller modulens konfigurasjonsvariabel",
"github_token_message": "Eksempel: github_pat_... med tilgang til de private repositories Release Manager ruller ut.",
"github_token_placeholder": "La st? tomt for ? beholde eksisterende token",
"github_token_placeholder": "La stå tomt for å beholde eksisterende token",
"github_api_url_message": "Konfigurert i ReleaseManager.github_api_url. Bruk https://api.github.com med mindre GitHub Enterprise brukes.",
"webhook_secret_message": "Valgfritt; la st? tomt for ? beholde eksisterende hemmelighet.",
"webhook_secret_message": "Valgfritt; la stå tomt for å beholde eksisterende hemmelighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighet",
"save": "Lagre innstillinger",
"back_to_integrations": "Tilbake til integrasjoner",
@@ -1617,11 +1627,16 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Kontoen din er tildelt",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for ? laste release-imaget.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"summary_suffix": ", men kanalen mangler påkrevd release-konfigurasjon.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Sjekker igjen om {seconds}s",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk pr?ver igjen.",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.",
"ignore": "Ignorer de neste 5 minuttene",
"check_again": "Sjekk igjen",
"logout": "Logg ut",
@@ -1629,19 +1644,38 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er n? p? {channel}",
"title": "Du er nå på {channel}",
"summary_prefix": "Kontoen din er tildelt release-kanalen",
"summary_suffix": "Denne enheten husker at du har sett denne meldingen.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"assigned_channel": "Tildelt kanal",
"current_app_image": "N?v?rende app-image",
"current_api": "N?v?rende API",
"current_app_image": "Nåværende app-image",
"current_api": "Nåværende API",
"base_image": "Basis-image",
"continue": "Fortsett"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Velg hvilken tildelt release-kanal denne enheten skal bruke.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Ikke klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
"no": "nei",
@@ -1657,6 +1691,7 @@
"remove": "Fjern",
"test_access": "Test tilgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promoter",
"enable": "Aktiver",
"search": "Søk",
@@ -1791,6 +1826,18 @@
"assignments": {
"title": "Tildelinger",
"subtitle": "Fest brukere, medarbeidere eller kunder til release-kanaler.",
"subject": "Emne",
"subject_message": "Søk etter brukere, medarbeidere eller kunder, eller skriv et manuelt emne som user:42.",
"subject_placeholder": "Søk eller skriv emne",
"subject_empty": "Ingen emner funnet",
"group_users": "Brukere",
"group_subusers": "Medarbeidere",
"group_customers": "Kunder",
"group_manual": "Manuell",
"manual_user": "Bruker #{id}",
"manual_subuser": "Medarbeider #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Bruk inntastet verdi {subject}",
"subject_type": "Emnetype",
"subject_type_message": "Vælg identitetstypen, der skal fastgøres.",
"customer": "Kunde",
@@ -1820,7 +1867,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
+89 -42
View File
@@ -1499,31 +1499,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -1531,58 +1541,58 @@
},
"replay": {
"label": "Replay",
"description": "Aktivera riktad insamling och s?k i release-tidslinjeh?ndelser."
"description": "Aktivera riktad insamling och sök i release-tidslinjehändelser."
},
"integrations": {
"label": "Integrationer",
"description": "Anslut GitHub-repositories, branches och Coolify-tj?nster."
"description": "Anslut GitHub-repositories, branches och Coolify-tjänster."
},
"settings": {
"label": "Inst?llningar",
"description": "Konfigurera Release Managers GitHub-?tkomst och webhook-inst?llningar."
"label": "Inställningar",
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
}
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- och Coolify-m?l",
"targets": "GitHub- och Coolify-mål",
"assignments": "Pilottilldelningar",
"deployments": "F?rsta utrullningen",
"deployments": "Första utrullningen",
"replay": "Replay-insamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullningar",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehändelser"
},
"overview": {
"title": "?versikt",
"subtitle": "Aktuell kanalh?lsa, release-aktivitet och modulstatus.",
"title": "Översikt",
"subtitle": "Aktuell kanalhälsa, release-aktivitet och modulstatus.",
"guided_setup": "Guidad installation",
"next_step": "N?sta: {step}",
"ready": "Release Manager ?r klar f?r daglig drift.",
"next_step": "Nästa: {step}",
"ready": "Release Manager är klar för daglig drift.",
"add_suggested_channel": "Lägg till föreslagen kanal",
"module_health_empty": "Modulhälsosnapshots visas när probes har registrerats."
},
"settings": {
"title": "Release Manager-inst?llningar",
"subtitle": "Konfigurera GitHub-token som anv?nds f?r privata repositories och branch-uppslag.",
"title": "Release Manager-inställningar",
"subtitle": "Konfigurera GitHub-token som används för privata repositories och branch-uppslag.",
"github_token": "GitHub-token",
"github_api_url": "GitHub API-URL",
"webhook_secret": "Webhook-hemlighet",
"github_webhook_secret": "GitHub webhook-hemlighet",
"configured": "Konfigurerad",
"not_configured": "Inte konfigurerad",
"loaded_from": "Inl?st fr?n {variable}",
"loaded_from": "Inläst från {variable}",
"set_below": "Ange {variable} nedan",
"private_repositories_prefix": "Privata repositories l?ses med serverns milj?variabel",
"private_repositories_prefix": "Privata repositories läses med serverns miljövariabel",
"private_repositories_or": "eller modulens konfigurationsvariabel",
"github_token_message": "Exempel: github_pat_... med ?tkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "L?mna tomt f?r att beh?lla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Anv?nd https://api.github.com om du inte anv?nder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; l?mna tomt f?r att beh?lla befintlig hemlighet.",
"github_token_message": "Exempel: github_pat_... med åtkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "Lämna tomt för att behålla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Använd https://api.github.com om du inte använder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; lämna tomt för att behålla befintlig hemlighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemlighet",
"save": "Spara inst?llningar",
"save": "Spara inställningar",
"back_to_integrations": "Tillbaka till integrationer",
"guide": {
"steps": {
@@ -1615,13 +1625,18 @@
},
"channel_unavailable": {
"kicker": "Release Manager",
"title": "Release-kanalen ?r inte klar",
"summary_prefix": "Ditt konto ?r tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som beh?vs f?r att l?sa in dess release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"title": "Release-kanalen är inte klar",
"summary_prefix": "Ditt konto är tilldelat",
"summary_suffix": ", men kanalen saknar obligatorisk release-konfiguration.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Kontrollerar igen om {seconds}s",
"refresh_error": "Release-status kunde inte uppdateras. N?sta automatiska kontroll f?rs?ker igen.",
"refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.",
"ignore": "Ignorera de kommande 5 minuterna",
"check_again": "Kontrollera igen",
"logout": "Logga ut",
@@ -1629,9 +1644,10 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du ?r nu p? {channel}",
"title": "Du är nu på {channel}",
"summary_prefix": "Ditt konto har tilldelats release-kanalen",
"summary_suffix": "Den h?r enheten kommer ih?g att du har sett detta meddelande.",
"summary_suffix": "Den här enheten kommer ihåg att du har sett detta meddelande.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
@@ -1640,7 +1656,25 @@
"current_app_image": "Nuvarande app-image",
"current_api": "Nuvarande API",
"base_image": "Bas-image",
"continue": "Forts?tt"
"continue": "Fortsätt"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Välj vilken tilldelad release-kanal den här enheten ska använda.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Inte klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
@@ -1657,6 +1691,7 @@
"remove": "Ta bort",
"test_access": "Testa åtkomst",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promota",
"enable": "Aktivera",
"search": "Sök",
@@ -1791,6 +1826,18 @@
"assignments": {
"title": "Tilldelningar",
"subtitle": "Fäst användare, medarbetare eller kunder till release-kanaler.",
"subject": "Ämne",
"subject_message": "Sök efter användare, medarbetare eller kunder, eller skriv ett manuellt ämne som user:42.",
"subject_placeholder": "Sök eller skriv ämne",
"subject_empty": "Inga ämnen hittades",
"group_users": "Användare",
"group_subusers": "Medarbetare",
"group_customers": "Kunder",
"group_manual": "Manuell",
"manual_user": "Användare #{id}",
"manual_subuser": "Medarbetare #{id}",
"manual_customer": "Kund #{id}",
"manual_description": "Använd inmatat värde {subject}",
"subject_type": "Ämnestyp",
"subject_type_message": "Välj identitetstypen som ska fästas.",
"customer": "Kunde",
@@ -1820,7 +1867,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
@@ -302,14 +302,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -318,15 +328,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -334,7 +344,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -347,26 +357,41 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilot-tildelinger",
"deployments": "F?rste udrulning",
"deployments": "Første udrulning",
"replay": "Replay-opsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Udrulninger",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehændelser"
},
"overview": {
"title": "Oversigt",
"subtitle": "Aktuel kanalstatus, release-aktivitet og modulstatus.",
"guided_setup": "Guidet ops?tning",
"next_step": "N?ste: {step}",
"guided_setup": "Guidet opsætning",
"next_step": "Næste: {step}",
"ready": "Release Manager er klar til daglig drift.",
"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.",
@@ -376,14 +401,14 @@
"github_webhook_secret": "GitHub webhook-hemmelighed",
"configured": "Konfigureret",
"not_configured": "Ikke konfigureret",
"loaded_from": "Indl?st fra {variable}",
"loaded_from": "Indlæst fra {variable}",
"set_below": "Angiv {variable} nedenfor",
"private_repositories_prefix": "Private repositories l?ses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories læses med serverens miljøvariabel",
"private_repositories_or": "eller modulets konfigurationsvariabel",
"github_token_message": "Eksempel: github_pat_... med adgang til de private repositories, Release Manager udruller.",
"github_token_placeholder": "Lad feltet v?re tomt for at beholde det eksisterende token",
"github_token_placeholder": "Lad feltet være tomt for at beholde det eksisterende token",
"github_api_url_message": "Konfigureret i ReleaseManager.github_api_url. Brug https://api.github.com medmindre GitHub Enterprise bruges.",
"webhook_secret_message": "Valgfrit; lad feltet v?re tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_message": "Valgfrit; lad feltet være tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighed",
"save": "Gem indstillinger",
"back_to_integrations": "Tilbage til integrationer",
@@ -420,30 +445,55 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Din konto er tildelt",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indl?se dens release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"summary_suffix": ", men kanalen mangler påkrævet release-konfiguration.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Tjekker igen om {seconds}s",
"refresh_error": "Release-status kunne ikke opdateres. Det n?ste automatiske tjek pr?ver igen.",
"ignore": "Ignorer de n?ste 5 minutter",
"refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.",
"ignore": "Ignorer de næste 5 minutter",
"check_again": "Tjek igen",
"logout": "Log ud",
"base_image": "Basis-image"
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er nu p? {channel}",
"title": "Du er nu på {channel}",
"summary_prefix": "Din konto er blevet tildelt release-kanalen",
"summary_suffix": "Denne enhed husker, at du har set denne besked.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"assigned_channel": "Tildelt kanal",
"current_app_image": "Nuv?rende app-image",
"current_api": "Nuv?rende API",
"current_app_image": "Nuværende app-image",
"current_api": "Nuværende API",
"base_image": "Basis-image",
"continue": "Forts?t"
"continue": "Fortsæt"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Vælg hvilken tildelt release-kanal denne enhed skal bruge.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Ikke klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"switch_error": "Release-kanalen kunne ikke skiftes. Den forrige kanal er stadig aktiv."
},
"common": {
"yes": "ja",
@@ -455,11 +505,13 @@
"rollback": "Rul tilbage",
"update": "Opdater",
"create": "Opret",
"publish_release": "Publicer release",
"clear": "Ryd",
"assign": "Tildel",
"remove": "Fjern",
"test_access": "Test adgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promover",
"enable": "Aktiver",
"search": "Søg",
@@ -594,6 +646,18 @@
"assignments": {
"title": "Tildelinger",
"subtitle": "Fastgør brugere, medarbejdere eller kunder til release-kanaler.",
"subject": "Emne",
"subject_message": "Søg efter brugere, medarbejdere eller kunder, eller skriv et manuelt emne som user:42.",
"subject_placeholder": "Søg eller skriv emne",
"subject_empty": "Ingen emner fundet",
"group_users": "Brugere",
"group_subusers": "Medarbejdere",
"group_customers": "Kunder",
"group_manual": "Manuel",
"manual_user": "Bruger #{id}",
"manual_subuser": "Medarbejder #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Brug indtastet værdi {subject}",
"subject_type": "Emnetype",
"subject_type_message": "Vælg identitetstypen, der skal fastgøres.",
"customer": "Kunde",
@@ -623,7 +687,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
@@ -694,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",
@@ -302,27 +302,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -346,30 +356,30 @@
}
},
"setup": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele",
"assignments": "Pilot-Zuweisungen",
"deployments": "Erste Bereitstellung",
"replay": "Replay-Erfassung"
},
"stats": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "Ziele",
"deployments": "Bereitstellungen",
"timeline_events": "Timeline-Ereignisse"
},
"overview": {
"title": "?bersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivit?t und Modulstatus.",
"guided_setup": "Gef?hrte Einrichtung",
"next_step": "N?chster Schritt: {step}",
"ready": "Release Manager ist f?r den t?glichen Betrieb bereit.",
"title": "Übersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivität und Modulstatus.",
"guided_setup": "Geführte Einrichtung",
"next_step": "Nächster Schritt: {step}",
"ready": "Release Manager ist für den täglichen Betrieb bereit.",
"add_suggested_channel": "Vorgeschlagenen Kanal hinzufügen",
"module_health_empty": "Modul-Health-Snapshots erscheinen, nachdem Probes aufgezeichnet wurden."
},
"settings": {
"title": "Release-Manager-Einstellungen",
"subtitle": "GitHub-Token f?r private Repositories und Branch-Abfragen konfigurieren.",
"subtitle": "GitHub-Token für private Repositories und Branch-Abfragen konfigurieren.",
"github_token": "GitHub-Token",
"github_api_url": "GitHub-API-URL",
"webhook_secret": "Webhook-Secret",
@@ -386,7 +396,7 @@
"webhook_secret_message": "Optional; leer lassen, um das vorhandene Secret zu behalten.",
"webhook_secret_placeholder": "Webhook-HMAC-Secret",
"save": "Einstellungen speichern",
"back_to_integrations": "Zur?ck zu Integrationen",
"back_to_integrations": "Zurück zu Integrationen",
"guide": {
"steps": {
"github_token": "GitHub-Token",
@@ -420,13 +430,18 @@
"kicker": "Release Manager",
"title": "Release-Kanal ist nicht bereit",
"summary_prefix": "Dein Konto ist zugewiesen zu",
"summary_suffix": ", aber diesem Kanal fehlt die Konfiguration, um sein Release-Image zu laden.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Erneute Pr?fung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die n?chste automatische Pr?fung versucht es erneut.",
"ignore": "N?chste 5 Minuten ignorieren",
"check_again": "Erneut pr?fen",
"summary_suffix": ", aber diesem Kanal fehlt erforderliche Release-Konfiguration.",
"release_bundle": "Release-Bundle",
"frontend_version": "Frontend-Version",
"api_version": "API-Version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-Einstiegspunkt",
"release_runtime": "Release-Laufzeit",
"checking_again": "Erneute Prüfung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.",
"ignore": "Nächste 5 Minuten ignorieren",
"check_again": "Erneut prüfen",
"logout": "Abmelden",
"base_image": "Basis-Image"
},
@@ -434,7 +449,8 @@
"kicker": "Release Manager",
"title": "Du bist jetzt auf {channel}",
"summary_prefix": "Dein Konto wurde dem Release-Kanal",
"summary_suffix": "zugewiesen. Dieses Ger?t merkt sich, dass du diesen Hinweis gesehen hast.",
"summary_suffix": "zugewiesen. Dieses Gerät merkt sich, dass du diesen Hinweis gesehen hast.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-Version",
@@ -445,6 +461,24 @@
"base_image": "Basis-Image",
"continue": "Fortfahren"
},
"channel_selector": {
"title": "Release-Kanal",
"subtitle": "Wählen Sie, welchen zugewiesenen Release-Kanal dieses Gerät verwenden soll.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Bereit",
"unavailable": "Nicht bereit",
"release_bundle": "Release-Bundle",
"frontend_version": "Frontend-Version",
"api_version": "API-Version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-Einstiegspunkt",
"release_runtime": "Release-Laufzeit",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
"no": "nein",
@@ -460,6 +494,7 @@
"remove": "Entfernen",
"test_access": "Zugriff testen",
"deploy": "Deployen",
"redeploy": "Erneut deployen",
"promote": "Promoten",
"enable": "Aktivieren",
"search": "Suchen",
@@ -594,6 +629,18 @@
"assignments": {
"title": "Zuweisungen",
"subtitle": "Benutzer, Mitarbeiter oder Kunden an Release-Kanäle binden.",
"subject": "Subjekt",
"subject_message": "Benutzer, Subuser oder Kunden suchen oder ein manuelles Subjekt wie user:42 eingeben.",
"subject_placeholder": "Subjekt suchen oder eingeben",
"subject_empty": "Keine Subjekte gefunden",
"group_users": "Benutzer",
"group_subusers": "Subuser",
"group_customers": "Kunden",
"group_manual": "Manuell",
"manual_user": "Benutzer #{id}",
"manual_subuser": "Subuser #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Eingegebenen Wert {subject} verwenden",
"subject_type": "Subjekttyp",
"subject_type_message": "Identitätstyp zum Binden auswählen.",
"customer": "Customer",
@@ -623,7 +670,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
@@ -694,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",
@@ -302,6 +302,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -367,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.",
@@ -420,9 +445,14 @@
"kicker": "Release Manager",
"title": "Release channel is not ready",
"summary_prefix": "Your account is assigned to",
"summary_suffix": ", but that channel is missing the configuration needed to load its release image.",
"frontend_url": "Frontend URL",
"api_url": "API URL",
"summary_suffix": ", but that channel is missing required release configuration.",
"release_bundle": "Release bundle",
"frontend_version": "Frontend version",
"api_version": "API version",
"frontend_base_url": "Frontend URL",
"api_base_url": "API URL",
"frontend_entry": "Frontend entry",
"release_runtime": "Release runtime",
"checking_again": "Checking again in {seconds}s",
"refresh_error": "Release status could not be refreshed. The next automatic check will try again.",
"ignore": "Ignore next 5 minutes",
@@ -435,6 +465,7 @@
"title": "You are now on {channel}",
"summary_prefix": "Your account has been assigned to the",
"summary_suffix": "release channel. This device will remember that you have seen this notice.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend version",
@@ -445,6 +476,25 @@
"base_image": "Base image",
"continue": "Continue"
},
"channel_selector": {
"title": "Release channel",
"subtitle": "Choose which assigned release channel this device should use.",
"sidebar_title": "Release",
"default": "Default",
"ready": "Ready",
"unavailable": "Not ready",
"release_bundle": "Release bundle",
"frontend_version": "Frontend version",
"api_version": "API version",
"frontend_base_url": "Frontend URL",
"api_base_url": "API URL",
"frontend_entry": "Frontend entry",
"release_runtime": "Release runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"switch_error": "Release channel could not be switched. The previous channel is still active."
},
"common": {
"yes": "yes",
"no": "no",
@@ -455,11 +505,13 @@
"rollback": "Rollback",
"update": "Update",
"create": "Create",
"publish_release": "Publish release",
"clear": "Clear",
"assign": "Assign",
"remove": "Remove",
"test_access": "Test access",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promote",
"enable": "Enable",
"search": "Search",
@@ -594,6 +646,18 @@
"assignments": {
"title": "Assignments",
"subtitle": "Pin users, subusers, or customers to release channels.",
"subject": "Subject",
"subject_message": "Search users, subusers, or customers, or type a manual subject like user:42.",
"subject_placeholder": "Search or type subject",
"subject_empty": "No subjects found",
"group_users": "Users",
"group_subusers": "Subusers",
"group_customers": "Customers",
"group_manual": "Manual",
"manual_user": "User #{id}",
"manual_subuser": "Subuser #{id}",
"manual_customer": "Customer #{id}",
"manual_description": "Use typed value {subject}",
"subject_type": "Subject type",
"subject_type_message": "Choose the identity type to pin.",
"customer": "Customer",
@@ -623,7 +687,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit selection",
"commit_selection_message": "Latest resolves to the branch head with the configured GitHub token.",
@@ -694,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",
@@ -355,6 +355,16 @@
"release_manager": {
"title": "@:{'phrases.compat.configuration.release_manager.title'}",
"subtitle": "@:{'phrases.compat.configuration.release_manager.subtitle'}",
"channel_names": {
"stable": "@:{'phrases.compat.configuration.release_manager.channel_names.stable'}",
"canary": "@:{'phrases.compat.configuration.release_manager.channel_names.canary'}",
"internal": "@:{'phrases.compat.configuration.release_manager.channel_names.internal'}"
},
"channel_descriptions": {
"stable": "@:{'phrases.compat.configuration.release_manager.channel_descriptions.stable'}",
"canary": "@:{'phrases.compat.configuration.release_manager.channel_descriptions.canary'}",
"internal": "@:{'phrases.compat.configuration.release_manager.channel_descriptions.internal'}"
},
"control_api": {
"title": "@:{'phrases.compat.configuration.release_manager.control_api.title'}",
"tooltip": "@:{'phrases.compat.configuration.release_manager.control_api.tooltip'}",
@@ -474,8 +484,13 @@
"title": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.title'}",
"summary_prefix": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.summary_prefix'}",
"summary_suffix": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.summary_suffix'}",
"frontend_url": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_url'}",
"api_url": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.api_url'}",
"release_bundle": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.release_bundle'}",
"frontend_version": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_version'}",
"api_version": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.api_version'}",
"frontend_base_url": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_base_url'}",
"api_base_url": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.api_base_url'}",
"frontend_entry": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.frontend_entry'}",
"release_runtime": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.release_runtime'}",
"checking_again": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.checking_again'}",
"refresh_error": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.refresh_error'}",
"ignore": "@:{'phrases.compat.configuration.release_manager.channel_unavailable.ignore'}",
@@ -488,6 +503,7 @@
"title": "@:{'phrases.compat.configuration.release_manager.channel_switched.title'}",
"summary_prefix": "@:{'phrases.compat.configuration.release_manager.channel_switched.summary_prefix'}",
"summary_suffix": "@:{'phrases.compat.configuration.release_manager.channel_switched.summary_suffix'}",
"bundle": "@:{'phrases.compat.configuration.release_manager.channel_switched.bundle'}",
"frontend": "@:{'phrases.compat.configuration.release_manager.channel_switched.frontend'}",
"api": "@:{'phrases.compat.configuration.release_manager.channel_switched.api'}",
"frontend_version": "@:{'phrases.compat.configuration.release_manager.channel_switched.frontend_version'}",
@@ -498,6 +514,24 @@
"base_image": "@:{'phrases.compat.configuration.release_manager.channel_switched.base_image'}",
"continue": "@:{'phrases.compat.configuration.release_manager.channel_switched.continue'}"
},
"channel_selector": {
"title": "@:{'phrases.compat.configuration.release_manager.channel_selector.title'}",
"subtitle": "@:{'phrases.compat.configuration.release_manager.channel_selector.subtitle'}",
"sidebar_title": "@:{'phrases.compat.configuration.release_manager.channel_selector.sidebar_title'}",
"default": "@:{'phrases.compat.configuration.release_manager.channel_selector.default'}",
"ready": "@:{'phrases.compat.configuration.release_manager.channel_selector.ready'}",
"unavailable": "@:{'phrases.compat.configuration.release_manager.channel_selector.unavailable'}",
"release_bundle": "@:{'phrases.compat.configuration.release_manager.channel_selector.release_bundle'}",
"frontend_version": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend_version'}",
"api_version": "@:{'phrases.compat.configuration.release_manager.channel_selector.api_version'}",
"frontend_base_url": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend_base_url'}",
"api_base_url": "@:{'phrases.compat.configuration.release_manager.channel_selector.api_base_url'}",
"frontend_entry": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend_entry'}",
"release_runtime": "@:{'phrases.compat.configuration.release_manager.channel_selector.release_runtime'}",
"bundle": "@:{'phrases.compat.configuration.release_manager.channel_selector.bundle'}",
"frontend": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend'}",
"api": "@:{'phrases.compat.configuration.release_manager.channel_selector.api'}"
},
"common": {
"yes": "@:{'phrases.compat.configuration.release_manager.common.yes'}",
"no": "@:{'phrases.compat.configuration.release_manager.common.no'}",
@@ -513,6 +547,7 @@
"remove": "@:{'phrases.compat.configuration.release_manager.actions.remove'}",
"test_access": "@:{'phrases.compat.configuration.release_manager.actions.test_access'}",
"deploy": "@:{'phrases.compat.configuration.release_manager.actions.deploy'}",
"redeploy": "@:{'phrases.compat.configuration.release_manager.actions.redeploy'}",
"promote": "@:{'phrases.compat.configuration.release_manager.actions.promote'}",
"enable": "@:{'phrases.compat.configuration.release_manager.actions.enable'}",
"search": "@:{'phrases.compat.configuration.release_manager.actions.search'}",
@@ -647,6 +682,18 @@
"assignments": {
"title": "@:{'phrases.compat.configuration.release_manager.assignments.title'}",
"subtitle": "@:{'phrases.compat.configuration.release_manager.assignments.subtitle'}",
"subject": "@:{'phrases.compat.configuration.release_manager.assignments.subject'}",
"subject_message": "@:{'phrases.compat.configuration.release_manager.assignments.subject_message'}",
"subject_placeholder": "@:{'phrases.compat.configuration.release_manager.assignments.subject_placeholder'}",
"subject_empty": "@:{'phrases.compat.configuration.release_manager.assignments.subject_empty'}",
"group_users": "@:{'phrases.compat.configuration.release_manager.assignments.group_users'}",
"group_subusers": "@:{'phrases.compat.configuration.release_manager.assignments.group_subusers'}",
"group_customers": "@:{'phrases.compat.configuration.release_manager.assignments.group_customers'}",
"group_manual": "@:{'phrases.compat.configuration.release_manager.assignments.group_manual'}",
"manual_user": "@:{'phrases.compat.configuration.release_manager.assignments.manual_user'}",
"manual_subuser": "@:{'phrases.compat.configuration.release_manager.assignments.manual_subuser'}",
"manual_customer": "@:{'phrases.compat.configuration.release_manager.assignments.manual_customer'}",
"manual_description": "@:{'phrases.compat.configuration.release_manager.assignments.manual_description'}",
"subject_type": "@:{'phrases.compat.configuration.release_manager.assignments.subject_type'}",
"subject_type_message": "@:{'phrases.compat.configuration.release_manager.assignments.subject_type_message'}",
"customer": "@:{'phrases.compat.configuration.release_manager.assignments.customer'}",
@@ -747,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'}",
@@ -302,16 +302,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -322,7 +332,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -334,7 +344,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -347,14 +357,14 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilottildelinger",
"deployments": "F?rste utrulling",
"deployments": "Første utrulling",
"replay": "Replay-innsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullinger",
"timeline_events": "Tidslinjehendelser"
},
@@ -378,12 +388,12 @@
"not_configured": "Ikke konfigurert",
"loaded_from": "Lastet fra {variable}",
"set_below": "Angi {variable} nedenfor",
"private_repositories_prefix": "Private repositories leses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories leses med serverens miljøvariabel",
"private_repositories_or": "eller modulens konfigurasjonsvariabel",
"github_token_message": "Eksempel: github_pat_... med tilgang til de private repositories Release Manager ruller ut.",
"github_token_placeholder": "La st? tomt for ? beholde eksisterende token",
"github_token_placeholder": "La stå tomt for å beholde eksisterende token",
"github_api_url_message": "Konfigurert i ReleaseManager.github_api_url. Bruk https://api.github.com med mindre GitHub Enterprise brukes.",
"webhook_secret_message": "Valgfritt; la st? tomt for ? beholde eksisterende hemmelighet.",
"webhook_secret_message": "Valgfritt; la stå tomt for å beholde eksisterende hemmelighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighet",
"save": "Lagre innstillinger",
"back_to_integrations": "Tilbake til integrasjoner",
@@ -420,11 +430,16 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Kontoen din er tildelt",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for ? laste release-imaget.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"summary_suffix": ", men kanalen mangler påkrevd release-konfigurasjon.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Sjekker igjen om {seconds}s",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk pr?ver igjen.",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.",
"ignore": "Ignorer de neste 5 minuttene",
"check_again": "Sjekk igjen",
"logout": "Logg ut",
@@ -432,19 +447,38 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er n? p? {channel}",
"title": "Du er nå på {channel}",
"summary_prefix": "Kontoen din er tildelt release-kanalen",
"summary_suffix": "Denne enheten husker at du har sett denne meldingen.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"assigned_channel": "Tildelt kanal",
"current_app_image": "N?v?rende app-image",
"current_api": "N?v?rende API",
"current_app_image": "Nåværende app-image",
"current_api": "Nåværende API",
"base_image": "Basis-image",
"continue": "Fortsett"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Velg hvilken tildelt release-kanal denne enheten skal bruke.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Ikke klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
"no": "nei",
@@ -460,6 +494,7 @@
"remove": "Fjern",
"test_access": "Test tilgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promoter",
"enable": "Aktiver",
"search": "Søk",
@@ -594,6 +629,18 @@
"assignments": {
"title": "Tildelinger",
"subtitle": "Fest brukere, medarbeidere eller kunder til release-kanaler.",
"subject": "Emne",
"subject_message": "Søk etter brukere, medarbeidere eller kunder, eller skriv et manuelt emne som user:42.",
"subject_placeholder": "Søk eller skriv emne",
"subject_empty": "Ingen emner funnet",
"group_users": "Brukere",
"group_subusers": "Medarbeidere",
"group_customers": "Kunder",
"group_manual": "Manuell",
"manual_user": "Bruker #{id}",
"manual_subuser": "Medarbeider #{id}",
"manual_customer": "Kunde #{id}",
"manual_description": "Bruk inntastet verdi {subject}",
"subject_type": "Emnetype",
"subject_type_message": "Vælg identitetstypen, der skal fastgøres.",
"customer": "Kunde",
@@ -623,7 +670,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
@@ -694,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",
@@ -302,31 +302,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -334,58 +344,58 @@
},
"replay": {
"label": "Replay",
"description": "Aktivera riktad insamling och s?k i release-tidslinjeh?ndelser."
"description": "Aktivera riktad insamling och sök i release-tidslinjehändelser."
},
"integrations": {
"label": "Integrationer",
"description": "Anslut GitHub-repositories, branches och Coolify-tj?nster."
"description": "Anslut GitHub-repositories, branches och Coolify-tjänster."
},
"settings": {
"label": "Inst?llningar",
"description": "Konfigurera Release Managers GitHub-?tkomst och webhook-inst?llningar."
"label": "Inställningar",
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
}
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- och Coolify-m?l",
"targets": "GitHub- och Coolify-mål",
"assignments": "Pilottilldelningar",
"deployments": "F?rsta utrullningen",
"deployments": "Första utrullningen",
"replay": "Replay-insamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullningar",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehändelser"
},
"overview": {
"title": "?versikt",
"subtitle": "Aktuell kanalh?lsa, release-aktivitet och modulstatus.",
"title": "Översikt",
"subtitle": "Aktuell kanalhälsa, release-aktivitet och modulstatus.",
"guided_setup": "Guidad installation",
"next_step": "N?sta: {step}",
"ready": "Release Manager ?r klar f?r daglig drift.",
"next_step": "Nästa: {step}",
"ready": "Release Manager är klar för daglig drift.",
"add_suggested_channel": "Lägg till föreslagen kanal",
"module_health_empty": "Modulhälsosnapshots visas när probes har registrerats."
},
"settings": {
"title": "Release Manager-inst?llningar",
"subtitle": "Konfigurera GitHub-token som anv?nds f?r privata repositories och branch-uppslag.",
"title": "Release Manager-inställningar",
"subtitle": "Konfigurera GitHub-token som används för privata repositories och branch-uppslag.",
"github_token": "GitHub-token",
"github_api_url": "GitHub API-URL",
"webhook_secret": "Webhook-hemlighet",
"github_webhook_secret": "GitHub webhook-hemlighet",
"configured": "Konfigurerad",
"not_configured": "Inte konfigurerad",
"loaded_from": "Inl?st fr?n {variable}",
"loaded_from": "Inläst från {variable}",
"set_below": "Ange {variable} nedan",
"private_repositories_prefix": "Privata repositories l?ses med serverns milj?variabel",
"private_repositories_prefix": "Privata repositories läses med serverns miljövariabel",
"private_repositories_or": "eller modulens konfigurationsvariabel",
"github_token_message": "Exempel: github_pat_... med ?tkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "L?mna tomt f?r att beh?lla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Anv?nd https://api.github.com om du inte anv?nder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; l?mna tomt f?r att beh?lla befintlig hemlighet.",
"github_token_message": "Exempel: github_pat_... med åtkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "Lämna tomt för att behålla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Använd https://api.github.com om du inte använder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; lämna tomt för att behålla befintlig hemlighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemlighet",
"save": "Spara inst?llningar",
"save": "Spara inställningar",
"back_to_integrations": "Tillbaka till integrationer",
"guide": {
"steps": {
@@ -418,13 +428,18 @@
},
"channel_unavailable": {
"kicker": "Release Manager",
"title": "Release-kanalen ?r inte klar",
"summary_prefix": "Ditt konto ?r tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som beh?vs f?r att l?sa in dess release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"title": "Release-kanalen är inte klar",
"summary_prefix": "Ditt konto är tilldelat",
"summary_suffix": ", men kanalen saknar obligatorisk release-konfiguration.",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"checking_again": "Kontrollerar igen om {seconds}s",
"refresh_error": "Release-status kunde inte uppdateras. N?sta automatiska kontroll f?rs?ker igen.",
"refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.",
"ignore": "Ignorera de kommande 5 minuterna",
"check_again": "Kontrollera igen",
"logout": "Logga ut",
@@ -432,9 +447,10 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du ?r nu p? {channel}",
"title": "Du är nu på {channel}",
"summary_prefix": "Ditt konto har tilldelats release-kanalen",
"summary_suffix": "Den h?r enheten kommer ih?g att du har sett detta meddelande.",
"summary_suffix": "Den här enheten kommer ihåg att du har sett detta meddelande.",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
@@ -443,7 +459,25 @@
"current_app_image": "Nuvarande app-image",
"current_api": "Nuvarande API",
"base_image": "Bas-image",
"continue": "Forts?tt"
"continue": "Fortsätt"
},
"channel_selector": {
"title": "Release-kanal",
"subtitle": "Välj vilken tilldelad release-kanal den här enheten ska använda.",
"sidebar_title": "Release",
"default": "Standard",
"ready": "Klar",
"unavailable": "Inte klar",
"release_bundle": "Release-bundle",
"frontend_version": "Frontend-version",
"api_version": "API-version",
"frontend_base_url": "Frontend-URL",
"api_base_url": "API-URL",
"frontend_entry": "Frontend-entry",
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
},
"common": {
"yes": "ja",
@@ -460,6 +494,7 @@
"remove": "Ta bort",
"test_access": "Testa åtkomst",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promota",
"enable": "Aktivera",
"search": "Sök",
@@ -594,6 +629,18 @@
"assignments": {
"title": "Tilldelningar",
"subtitle": "Fäst användare, medarbetare eller kunder till release-kanaler.",
"subject": "Ämne",
"subject_message": "Sök efter användare, medarbetare eller kunder, eller skriv ett manuellt ämne som user:42.",
"subject_placeholder": "Sök eller skriv ämne",
"subject_empty": "Inga ämnen hittades",
"group_users": "Användare",
"group_subusers": "Medarbetare",
"group_customers": "Kunder",
"group_manual": "Manuell",
"manual_user": "Användare #{id}",
"manual_subuser": "Medarbetare #{id}",
"manual_customer": "Kund #{id}",
"manual_description": "Använd inmatat värde {subject}",
"subject_type": "Ämnestyp",
"subject_type_message": "Välj identitetstypen som ska fästas.",
"customer": "Kunde",
@@ -623,7 +670,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
@@ -694,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",
+15 -4
View File
@@ -17,13 +17,23 @@ import i18n from '@/i18n'
import VueApexCharts from "vue3-apexcharts";
import { initializeAutoTableExports } from '@/services/AutoTableExportService.js';
import { installAxiosRequestQueue } from '@/services/installAxiosRequestQueue.js';
import { installReleaseErrorInstrumentation } from '@/services/releaseTimeline.js';
import {
configureReleaseRuntime,
getReleaseRuntimeApiBaseUrl,
installReleaseErrorInstrumentation,
} from '@/services/releaseTimeline.js';
import { RELEASE_RUNTIME_GLOBAL_KEY } from '@/services/releaseBootstrap.js';
import { API_URL, IS_DEV } from './config';
import { IS_DEV } from './config';
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || '';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || '';
const LAST_VERSION_CHECK_STORAGE_KEY = 'lastVersionCheck';
const bootstrapRuntime = typeof window !== 'undefined' ? window[RELEASE_RUNTIME_GLOBAL_KEY] : null;
if (bootstrapRuntime && typeof bootstrapRuntime === 'object') {
configureReleaseRuntime(bootstrapRuntime);
}
void import('bulma/css/bulma.min.css');
void import('buefy/dist/css/buefy.css');
@@ -76,7 +86,8 @@ const logBuildBanner = () => {
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Last version check: ${lastVersionCheckDisplay}`,
`Remote API: ${API_URL}`,
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
`Debug: Is running beta FE.`,
].join('\n'));
};
@@ -121,7 +132,7 @@ const app = createApp(App)
.use(VueApexCharts)
.provide('Colors', Colors)
.provide('IS_DEV', IS_DEV)
.provide('API_URL', API_URL)
.provide('API_URL', getReleaseRuntimeApiBaseUrl())
installReleaseErrorInstrumentation(app, Router);
app.mount('#app');
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapReleaseApp } from "@/services/releaseBootstrap.js";
void bootstrapReleaseApp({
loadLocalApp: () => import("./main.js"),
});
+14
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, rewriteReleaseApiUrl } from "@/services/releaseTimeline.js";
let requestInterceptorId = null;
@@ -29,6 +30,19 @@ export const installAxiosRequestQueue = () => {
}
requestInterceptorId = axios.interceptors.request.use((config) => {
if (config?.__skipReleaseApiRewrite !== true && config?.url) {
config.url = rewriteReleaseApiUrl(config.url);
}
if (config?.__skipReleaseApiRewrite !== true && config?.baseURL) {
config.baseURL = rewriteReleaseApiUrl(config.baseURL);
}
if (config?.__skipReleaseApiRewrite !== true) {
config.headers = {
...buildCurrentReleaseHeaders(),
...(config.headers || {}),
};
}
if (isQueueBypassed(config) || config?.__queueAdapterWrapped) {
return config;
}
+259
View File
@@ -0,0 +1,259 @@
import { API_URL, RELEASE_PUBLIC_GATEWAY_API_URL } from "@/config.js";
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
const normalizeReleaseChannelSlug = (value) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, "");
const browserOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return "http://localhost";
};
const resolveRuntimeBaseUrl = (value) => {
const baseUrl = normalizeBaseUrl(value);
if (!baseUrl) {
return "";
}
if (/^https?:\/\//i.test(baseUrl)) {
return baseUrl;
}
return new URL(`${baseUrl.replace(/^\/+/, "")}/`, `${browserOrigin()}/`).href.replace(/\/+$/, "");
};
const browserStorage = () => {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage || null;
} catch {
return null;
}
};
const readSelectedReleaseChannel = () =>
normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
const slug = normalizeReleaseChannelSlug(channelSlug);
if (!slug || slug === "stable") {
return "";
}
const baseUrl = normalizeBaseUrl(gatewayBaseUrl);
if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) {
return "";
}
return new URL(`${slug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
};
const buildRuntimeHeaders = () => {
const storage = browserStorage();
const headers = {
Accept: "application/json",
...buildReleaseHeaders({ channelSlug: readSelectedReleaseChannel() }),
};
const token = storage?.getItem("token");
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const selectedCustomerNumber = storage?.getItem("selected_customer_number");
if (storage?.getItem("is_subuser") === "true" && selectedCustomerNumber) {
headers["X-Customer-Number"] = selectedCustomerNumber;
}
return headers;
};
export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
const selectedChannel = readSelectedReleaseChannel();
const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl);
const runtimeBaseUrl = selectedChannelApiBaseUrl || resolveRuntimeBaseUrl(apiBaseUrl);
const url = new URL("release/runtime", `${runtimeBaseUrl}/`);
if (selectedChannel) {
url.searchParams.set("release_channel", selectedChannel);
}
return url.href;
};
const parseJsonResponse = async (response, label) => {
if (typeof response?.text === "function") {
const body = await response.text();
try {
return JSON.parse(body);
} catch (error) {
const prefix = body.trim().slice(0, 120);
const details = prefix ? ` Body starts with: ${prefix}` : "";
throw new Error(`${label} returned invalid JSON.${details}`, { cause: error });
}
}
return response?.json?.();
};
export const fetchReleaseRuntime = async ({
fetchFn = globalThis.fetch,
apiBaseUrl = API_URL,
gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL,
} = {}) => {
if (typeof fetchFn !== "function") {
return null;
}
const response = await fetchFn(runtimeApiUrl(apiBaseUrl, gatewayBaseUrl), {
method: "GET",
headers: buildRuntimeHeaders(),
credentials: "omit",
cache: "no-store",
});
if (!response?.ok) {
throw new Error(`Release runtime request failed with HTTP ${response?.status || 0}.`);
}
const payload = await parseJsonResponse(response, "Release runtime");
return payload?.data || payload || null;
};
const runtimeFrontendBaseUrl = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
};
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
export const shouldLoadRemoteRelease = (runtime = {}) => {
const channel = runtimeChannel(runtime);
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
const isDefault = channel?.default_channel === true || channel?.default_channel === 1 || slug === "stable";
return !isDefault && runtime?.availability?.configured !== false && Boolean(runtimeFrontendBaseUrl(runtime));
};
export const releaseEntryUrl = (frontendBaseUrl) =>
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
const resolveReleaseAssetUrl = (frontendBaseUrl, value) =>
new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
export const loadRemoteReleaseEntry = async ({
runtime,
fetchFn = globalThis.fetch,
documentRef = globalThis.document,
importModule = (url) => import(/* @vite-ignore */ url),
} = {}) => {
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
method: "GET",
cache: "no-store",
mode: "cors",
});
if (!response?.ok) {
throw new Error(`Release entry request failed with HTTP ${response?.status || 0}.`);
}
const entry = await parseJsonResponse(response, "Release entry");
const entryModule = String(entry?.entry || "").trim();
if (!entryModule) {
throw new Error("Release entry is missing an app module.");
}
for (const cssFile of Array.isArray(entry?.css) ? entry.css : []) {
const href = resolveReleaseAssetUrl(frontendBaseUrl, cssFile);
if (documentRef?.querySelector?.(`link[data-release-entry-css="${href}"]`)) {
continue;
}
const link = documentRef.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.crossOrigin = "anonymous";
link.dataset.releaseEntryCss = href;
documentRef.head.appendChild(link);
}
return importModule(resolveReleaseAssetUrl(frontendBaseUrl, entryModule));
};
const unavailableRuntime = (runtime, missing) => ({
...(runtime || {}),
availability: {
...(runtime?.availability || {}),
configured: false,
missing: Array.from(new Set([...(runtime?.availability?.missing || []), missing])),
status: "unconfigured",
},
});
const unavailableSelectedRuntime = (missing) => {
const selectedChannel = readSelectedReleaseChannel();
if (!selectedChannel || selectedChannel === "stable") {
return null;
}
return unavailableRuntime(
{
channel: {
slug: selectedChannel,
name: selectedChannel,
default_channel: false,
},
availability: {
explicit: true,
},
},
missing
);
};
export const setReleaseRuntimeGlobal = (runtime) => {
if (typeof window !== "undefined") {
window[RELEASE_RUNTIME_GLOBAL_KEY] = runtime || null;
}
return runtime;
};
export const bootstrapReleaseApp = async ({
loadLocalApp,
fetchFn = globalThis.fetch,
importModule,
documentRef = globalThis.document,
} = {}) => {
if (typeof loadLocalApp !== "function") {
throw new Error("Release bootstrap requires a local app loader.");
}
let runtime = null;
try {
runtime = await fetchReleaseRuntime({ fetchFn });
setReleaseRuntimeGlobal(runtime);
} catch (error) {
console.warn("Could not resolve release runtime before app bootstrap.", error);
const unavailable = unavailableSelectedRuntime("release_runtime");
if (unavailable) {
setReleaseRuntimeGlobal(unavailable);
}
}
if (!shouldLoadRemoteRelease(runtime)) {
return loadLocalApp();
}
try {
return await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef });
} catch (error) {
console.error("Could not load release channel frontend entry.", error);
setReleaseRuntimeGlobal(unavailableRuntime(runtime, "frontend_entry"));
return loadLocalApp();
}
};
+298 -74
View File
@@ -1,8 +1,9 @@
import { computed, reactive, readonly } from "vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
import { configureReleaseRuntime, releaseRuntimeState } from "@/services/releaseTimeline.js";
export const RELEASE_CHANNEL_IGNORE_STORAGE_KEY = "release_channel_unavailable_ignore_until";
export const RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY = "release_channel_switch_notice_seen";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_CHANNEL_IGNORE_MS = 5 * 60 * 1000;
export const RELEASE_CHANNEL_CHECK_INTERVAL_MS = 10 * 1000;
@@ -56,11 +57,50 @@ const writeSwitchNoticeMap = (map) => {
}
};
const normalizeReleaseChannelSlug = (value) => {
const slug = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug.slice(0, 64);
};
const readSelectedChannelSlug = () => {
if (typeof window === "undefined") {
return "";
}
try {
return normalizeReleaseChannelSlug(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
} catch {
return "";
}
};
const writeSelectedChannelSlug = (slug) => {
if (typeof window === "undefined") {
return;
}
try {
if (slug) {
window.localStorage.setItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY, slug);
} else {
window.localStorage.removeItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY);
}
} catch {
// Storage is optional; the runtime endpoint will fall back to the assigned channel.
}
};
const state = reactive({
now: Date.now(),
ignoredUntilByChannel: readIgnoreMap(),
switchNoticeSeenByKey: readSwitchNoticeMap(),
switchNoticePrincipalKey: "",
selectedChannelSlug: readSelectedChannelSlug(),
});
let clockTimer = null;
@@ -72,7 +112,7 @@ export const setReleaseChannelSwitchNoticePrincipal = (principalKey) => {
};
export const releaseChannelKey = (channel) => {
const slug = String(channel?.slug || "").trim();
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
if (slug) {
return slug;
}
@@ -80,39 +120,16 @@ export const releaseChannelKey = (channel) => {
return id ? `id:${id}` : "";
};
export const normalizeReleaseUrl = (value) => {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
try {
const url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
return `${url.protocol}//${url.host}`;
} catch {
return raw.replace(/\/+$/, "");
}
};
const runtimeUrl = (runtime, key) => {
const camelKey = key === "frontend_base_url" ? "frontendBaseUrl" : "apiBaseUrl";
return normalizeReleaseUrl(runtime?.[camelKey] || runtime?.[key] || runtime?.channel?.[key]);
};
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
const hasExplicitRuntimeTargets = (runtime) => {
const hasExplicitReleaseRuntime = (runtime) => {
return (
hasOwn(runtime, "frontend_base_url") ||
hasOwn(runtime, "frontendBaseUrl") ||
hasOwn(runtime, "api_base_url") ||
hasOwn(runtime, "apiBaseUrl") ||
hasOwn(runtime?.channel, "frontend_base_url") ||
hasOwn(runtime?.channel, "api_base_url")
Boolean(runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false) ||
hasOwn(runtime, "versions")
);
};
const runtimeAvailability = (runtime, frontendBaseUrl, apiBaseUrl) => {
const runtimeAvailability = (runtime, channel) => {
if (runtime?.availability && typeof runtime.availability === "object") {
const missing = Array.isArray(runtime.availability.missing) ? runtime.availability.missing : [];
const configured =
@@ -126,7 +143,7 @@ const runtimeAvailability = (runtime, frontendBaseUrl, apiBaseUrl) => {
};
}
if (!hasExplicitRuntimeTargets(runtime)) {
if (!hasOwn(runtime, "versions")) {
return {
configured: true,
missing: [],
@@ -134,11 +151,29 @@ const runtimeAvailability = (runtime, frontendBaseUrl, apiBaseUrl) => {
};
}
const isDefaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
if (isDefaultChannel) {
return {
configured: true,
missing: [],
status: "ready",
};
}
const versions = runtime?.versions || {};
const missing = [];
if (!frontendBaseUrl) {
if (!versions.bundle_id) {
missing.push("release_bundle");
}
if (!versions.frontend) {
missing.push("frontend_version");
} else if (!(runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || runtime?.frontendBaseUrl)) {
missing.push("frontend_base_url");
}
if (!apiBaseUrl) {
if (!versions.api) {
missing.push("api_version");
} else if (!(runtime?.api_base_url || runtime?.urls?.api_base_url || runtime?.apiBaseUrl)) {
missing.push("api_base_url");
}
@@ -149,19 +184,89 @@ const runtimeAvailability = (runtime, frontendBaseUrl, apiBaseUrl) => {
};
};
const channelDisplayName = (channel, fallback = "Release channel") => {
const slug = String(channel?.slug || "").trim();
return String(channel?.name || slug || fallback).trim();
};
const normalizeReleaseChannelOption = (entry = {}, runtime = {}) => {
const channel = entry?.channel && typeof entry.channel === "object" ? entry.channel : entry;
const channelSlug = normalizeReleaseChannelSlug(channel?.slug || "");
const channelName = channelDisplayName(channel);
const defaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || channelSlug === "stable";
const versions =
entry?.versions ||
(releaseChannelKey(channel) === releaseChannelKey(runtime?.channel) ? runtime?.versions || {} : {});
const availability =
entry?.availability && typeof entry.availability === "object"
? entry.availability
: runtimeAvailability({ channel, versions }, channel);
return {
channel,
channelSlug,
channelName,
defaultChannel,
description: channel?.description || "",
versions,
availability,
configured: availability.configured !== false,
missing: Array.isArray(availability.missing) ? availability.missing : [],
status: availability.status || (availability.configured === false ? "unconfigured" : "ready"),
};
};
export const getReleaseChannelOptions = (runtime = {}) => {
const source = Array.isArray(runtime?.availableChannels)
? runtime.availableChannels
: Array.isArray(runtime?.available_channels)
? runtime.available_channels
: [];
const entries = source.length > 0 ? source : runtime?.channel ? [{ channel: runtime.channel }] : [];
const optionsByKey = new Map();
entries.forEach((entry) => {
const option = normalizeReleaseChannelOption(entry, runtime);
const key = releaseChannelKey(option.channel);
if (key && !optionsByKey.has(key)) {
optionsByKey.set(key, option);
}
});
if (runtime?.channel) {
const currentOption = normalizeReleaseChannelOption(
{
channel: runtime.channel,
versions: runtime.versions || {},
availability: runtime.availability || null,
},
runtime
);
const key = releaseChannelKey(currentOption.channel);
if (key && !optionsByKey.has(key)) {
optionsByKey.set(key, currentOption);
}
}
return [...optionsByKey.values()];
};
export const hasSelectableReleaseChannels = (runtime = {}) => {
const options = getReleaseChannelOptions(runtime);
const hasOnlyDefaultStable =
options.length === 1 && options[0].defaultChannel === true && options[0].channelSlug === "stable";
return options.length > 1 && !hasOnlyDefaultStable;
};
export const getReleaseChannelUnavailableStatus = (runtime = {}, now = Date.now(), ignoredUntil = 0) => {
const channel = runtime?.channel || null;
const channelSlug = String(channel?.slug || "").trim();
const channelName = String(channel?.name || channelSlug || "Release channel").trim();
const channelSlug = normalizeReleaseChannelSlug(channel?.slug || "");
const channelName = channelDisplayName(channel);
const isDefaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || channelSlug === "stable";
const frontendBaseUrl = runtimeUrl(runtime, "frontend_base_url");
const apiBaseUrl = runtimeUrl(runtime, "api_base_url");
const explicitAvailability =
runtime?.availability?.explicit === false
? false
: Boolean(runtime?.availability && typeof runtime.availability === "object") || hasExplicitRuntimeTargets(runtime);
const availability = runtimeAvailability(runtime, frontendBaseUrl, apiBaseUrl);
const explicitAvailability = hasExplicitReleaseRuntime(runtime);
const availability = runtimeAvailability(runtime, channel);
const ignored = Number(ignoredUntil || 0) > now;
const unavailable = Boolean(channelSlug) && !isDefaultChannel && availability.configured === false;
@@ -172,8 +277,6 @@ export const getReleaseChannelUnavailableStatus = (runtime = {}, now = Date.now(
defaultChannel: isDefaultChannel,
explicitAvailability,
description: channel?.description || "",
frontendBaseUrl,
apiBaseUrl,
missing: availability.missing,
configured: availability.configured,
unavailable,
@@ -191,6 +294,154 @@ export const releaseChannelUnavailableStatus = computed(() => {
return getReleaseChannelUnavailableStatus(releaseRuntimeState, state.now, ignoredUntil);
});
export const releaseChannelOptions = computed(() => getReleaseChannelOptions(releaseRuntimeState));
export const releaseChannelSelectorVisible = computed(() => hasSelectableReleaseChannels(releaseRuntimeState));
export const selectedReleaseChannelSlug = computed(() => state.selectedChannelSlug);
export const getSelectedReleaseChannelSlug = () => state.selectedChannelSlug || readSelectedChannelSlug();
export const releaseChannelRuntimeRequestParams = () => {
const slug = getSelectedReleaseChannelSlug();
return slug ? { release_channel: slug } : {};
};
const RELEASE_CHANNEL_API_FAILURE_STATUSES = new Set([404, 502, 503, 504]);
const releaseRuntimeApiBaseUrl = (runtime = {}) =>
String(runtime?.apiBaseUrl || runtime?.api_base_url || runtime?.urls?.api_base_url || "")
.trim()
.replace(/\/+$/, "");
const releaseRuntimeFrontendBaseUrl = (runtime = {}) =>
String(runtime?.frontendBaseUrl || runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || "")
.trim()
.replace(/\/+$/, "");
export const isReleaseChannelApiAvailabilityError = (error, runtime = releaseRuntimeState) => {
const status = Number(error?.response?.status || 0);
if (!RELEASE_CHANNEL_API_FAILURE_STATUSES.has(status)) {
return false;
}
const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug());
const isDefaultChannel =
runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable";
if (!channelSlug || isDefaultChannel) {
return false;
}
const requestUrl = String(error?.config?.url || error?.request?.responseURL || "");
if (!requestUrl.includes("/auth/session")) {
return false;
}
const apiBaseUrl = releaseRuntimeApiBaseUrl(runtime);
return !apiBaseUrl || requestUrl.startsWith(apiBaseUrl);
};
export const markReleaseChannelApiUnavailable = (
runtime = releaseRuntimeState,
missingKey = "api_base_url"
) => {
const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug());
const isDefaultChannel =
runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable";
if (!channelSlug || isDefaultChannel) {
return null;
}
const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {};
const missing = Array.from(new Set([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey]));
const nextRuntime = {
trace_id: runtime?.traceId || runtime?.trace_id || null,
channel: runtime?.channel || {
slug: channelSlug,
name: channelSlug,
default_channel: false,
},
available_channels: runtime?.availableChannels || runtime?.available_channels || [],
versions: runtime?.versions || {},
frontend_base_url: releaseRuntimeFrontendBaseUrl(runtime) || null,
api_base_url: releaseRuntimeApiBaseUrl(runtime) || null,
urls: {
frontend_base_url: releaseRuntimeFrontendBaseUrl(runtime) || null,
api_base_url: releaseRuntimeApiBaseUrl(runtime) || null,
},
availability: {
...availability,
configured: false,
explicit: true,
missing,
status: "unconfigured",
},
capture_policy: runtime?.capturePolicy || runtime?.capture_policy || {},
};
configureReleaseRuntime(nextRuntime);
return nextRuntime;
};
export const selectReleaseChannel = (channelOrSlug) => {
const slug = normalizeReleaseChannelSlug(
typeof channelOrSlug === "string" ? channelOrSlug : channelOrSlug?.slug || channelOrSlug?.channelSlug || ""
);
state.selectedChannelSlug = slug;
writeSelectedChannelSlug(slug);
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("");
};
export const reconcileSelectedReleaseChannel = (runtime = releaseRuntimeState) => {
const hasExplicitOptions =
Array.isArray(runtime?.availableChannels) || Array.isArray(runtime?.available_channels);
const selected = getSelectedReleaseChannelSlug();
if (!hasExplicitOptions || !selected) {
return selected;
}
const hasSelectedOption = getReleaseChannelOptions(runtime).some((option) => option.channelSlug === selected);
if (!hasSelectedOption) {
clearSelectedReleaseChannel();
return "";
}
return selected;
};
export const releaseChannelSwitchNoticeKey = (channel, principalKey = state.switchNoticePrincipalKey) => {
const channelKey = releaseChannelKey(channel);
const principal = String(principalKey || "").trim();
@@ -304,47 +555,20 @@ export const stopReleaseChannelAvailabilityClock = () => {
clockTimer = null;
};
export const buildReleaseFrontendRedirectUrl = (runtime = releaseRuntimeState, currentLocation = window.location) => {
const frontendBaseUrl = runtimeUrl(runtime, "frontend_base_url");
if (!frontendBaseUrl || !currentLocation) {
return null;
}
export const buildReleaseFrontendRedirectUrl = () => null;
const target = new URL(frontendBaseUrl);
const currentOrigin = currentLocation.origin || `${currentLocation.protocol}//${currentLocation.host}`;
if (target.origin === currentOrigin) {
return null;
}
target.pathname = currentLocation.pathname || "/";
target.search = currentLocation.search || "";
target.hash = currentLocation.hash || "";
return target.toString();
};
export const redirectToConfiguredReleaseFrontend = (runtime = releaseRuntimeState) => {
const status = getReleaseChannelUnavailableStatus(runtime, Date.now(), 0);
if (!status.configured) {
return false;
}
const redirectUrl = buildReleaseFrontendRedirectUrl(runtime);
if (!redirectUrl) {
return false;
}
window.location.assign(redirectUrl);
return true;
};
export const redirectToConfiguredReleaseFrontend = () => false;
export const __resetReleaseChannelAvailabilityForTests = () => {
state.now = Date.now();
state.ignoredUntilByChannel = {};
state.switchNoticeSeenByKey = {};
state.switchNoticePrincipalKey = "";
state.selectedChannelSlug = "";
stopReleaseChannelAvailabilityClock();
if (typeof window !== "undefined") {
window.localStorage.removeItem(RELEASE_CHANNEL_IGNORE_STORAGE_KEY);
window.localStorage.removeItem(RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY);
window.localStorage.removeItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY);
}
};
+61
View File
@@ -0,0 +1,61 @@
export const RELEASE_TRACE_STORAGE_KEY = "release_trace_id";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
const browserStorage = () => {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage || null;
} catch {
return null;
}
};
const normalizeIdentifier = (value, maxLength = 128) =>
String(value || "")
.trim()
.replace(/[^a-zA-Z0-9_.:-]/g, "")
.slice(0, Math.max(1, maxLength));
export const normalizeReleaseChannelSlug = (value) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
export const selectedReleaseChannelSlugFromStorage = () =>
normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
export const releaseTraceIdFromStorage = () =>
normalizeIdentifier(browserStorage()?.getItem(RELEASE_TRACE_STORAGE_KEY) || "", 64);
const fallbackFrontendVersion = () =>
normalizeIdentifier(import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown", 128);
export const buildReleaseHeaders = ({
traceId = "",
channelSlug = "",
frontendVersion = "",
} = {}) => {
const headers = {};
const normalizedTraceId = normalizeIdentifier(traceId || releaseTraceIdFromStorage(), 64);
const normalizedChannelSlug = normalizeReleaseChannelSlug(channelSlug || selectedReleaseChannelSlugFromStorage());
const normalizedFrontendVersion = normalizeIdentifier(frontendVersion || fallbackFrontendVersion(), 128);
if (normalizedTraceId) {
headers["X-Release-Trace"] = normalizedTraceId;
}
if (normalizedChannelSlug) {
headers["X-Release-Channel"] = normalizedChannelSlug;
}
if (normalizedFrontendVersion) {
headers["X-Frontend-Version"] = normalizedFrontendVersion;
}
return headers;
};
+528 -41
View File
@@ -1,7 +1,8 @@
import { reactive, readonly } from "vue";
import { API_URL } from "@/config.js";
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
const TRACE_STORAGE_KEY = "release_trace_id";
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
const MAX_QUEUE_SIZE = 50;
const MAX_FRONTEND_FAILURE_BUFFER_SIZE = 50;
const FRONTEND_FAILURE_EVENT_TYPES = new Set([
@@ -39,9 +40,13 @@ const readTraceId = () => {
const releaseRuntimeStateMutable = reactive({
traceId: readTraceId(),
channel: null,
availableChannels: [],
versions: {
frontend: null,
api: null,
service_set: null,
bundle_id: null,
bundle: null,
},
frontendBaseUrl: null,
apiBaseUrl: null,
@@ -69,17 +74,33 @@ export const releaseRuntimeState = readonly(releaseRuntimeStateMutable);
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
const hasExplicitRuntimeTargets = (runtime) => {
return (
hasOwn(runtime, "frontend_base_url") ||
hasOwn(runtime, "frontendBaseUrl") ||
hasOwn(runtime, "api_base_url") ||
hasOwn(runtime, "apiBaseUrl") ||
hasOwn(runtime?.channel, "frontend_base_url") ||
hasOwn(runtime?.channel, "api_base_url")
);
const normalizeRuntimeBaseUrl = (value) => {
const raw = String(value || "").trim().replace(/\/+$/, "");
if (!raw) {
return null;
}
if (raw.startsWith("/") && !raw.startsWith("//")) {
return raw;
}
if (/^https?:\/\//i.test(raw)) {
return raw;
}
return null;
};
const runtimeUrls = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return {
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url),
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
};
};
const hasExplicitReleaseRuntime = (runtime) =>
Boolean(
runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false
) || hasOwn(runtime, "versions");
export const configureReleaseRuntime = (runtime = {}) => {
if (!runtime || typeof runtime !== "object") {
return releaseRuntimeStateMutable;
@@ -95,40 +116,58 @@ export const configureReleaseRuntime = (runtime = {}) => {
}
releaseRuntimeStateMutable.channel = runtime.channel || null;
releaseRuntimeStateMutable.availableChannels = Array.isArray(runtime.available_channels)
? runtime.available_channels
: Array.isArray(runtime.availableChannels)
? runtime.availableChannels
: [];
releaseRuntimeStateMutable.versions = {
frontend: runtime?.versions?.frontend || null,
api: runtime?.versions?.api || null,
service_set: runtime?.versions?.service_set || null,
bundle_id: runtime?.versions?.bundle_id || null,
bundle: runtime?.versions?.bundle || null,
};
const frontendBaseUrl =
runtime.frontend_base_url || runtime.frontendBaseUrl || runtime?.channel?.frontend_base_url || null;
const apiBaseUrl = runtime.api_base_url || runtime.apiBaseUrl || runtime?.channel?.api_base_url || null;
const missingRuntimeTargets = [];
if (!frontendBaseUrl) {
missingRuntimeTargets.push("frontend_base_url");
const urls = runtimeUrls(runtime);
releaseRuntimeStateMutable.frontendBaseUrl = urls.frontendBaseUrl;
releaseRuntimeStateMutable.apiBaseUrl = urls.apiBaseUrl;
const channel = runtime.channel || null;
const isDefaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
const missingReleaseContent = [];
if (!isDefaultChannel && hasOwn(runtime, "versions")) {
if (!runtime?.versions?.bundle_id) {
missingReleaseContent.push("release_bundle");
}
if (!runtime?.versions?.frontend) {
missingReleaseContent.push("frontend_version");
} else if (!urls.frontendBaseUrl) {
missingReleaseContent.push("frontend_base_url");
}
if (!runtime?.versions?.api) {
missingReleaseContent.push("api_version");
} else if (!urls.apiBaseUrl) {
missingReleaseContent.push("api_base_url");
}
}
if (!apiBaseUrl) {
missingRuntimeTargets.push("api_base_url");
}
releaseRuntimeStateMutable.frontendBaseUrl = frontendBaseUrl;
releaseRuntimeStateMutable.apiBaseUrl = apiBaseUrl;
releaseRuntimeStateMutable.availability = runtime.availability
? {
...runtime.availability,
explicit: true,
}
: hasExplicitRuntimeTargets(runtime)
? {
configured: missingRuntimeTargets.length === 0,
missing: missingRuntimeTargets,
status: missingRuntimeTargets.length === 0 ? "ready" : "unconfigured",
explicit: true,
}
: {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
: hasExplicitReleaseRuntime(runtime)
? {
configured: missingReleaseContent.length === 0,
missing: missingReleaseContent,
status: missingReleaseContent.length === 0 ? "ready" : "unconfigured",
explicit: true,
}
: {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
releaseRuntimeStateMutable.capturePolicy = {
enabled: Boolean(runtime?.capture_policy?.enabled),
capture_level: runtime?.capture_policy?.capture_level || "metadata",
@@ -139,6 +178,36 @@ export const configureReleaseRuntime = (runtime = {}) => {
return releaseRuntimeStateMutable;
};
export const getReleaseRuntimeApiBaseUrl = () => releaseRuntimeStateMutable.apiBaseUrl || API_URL;
export const resolveReleaseApiUrl = (url = "") => {
const value = String(url || "");
if (/^https?:\/\//i.test(value)) {
return rewriteReleaseApiUrl(value);
}
return `${getReleaseRuntimeApiBaseUrl().replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`;
};
export const rewriteReleaseApiUrl = (url = "") => {
const value = String(url || "");
const runtimeApiUrl = releaseRuntimeStateMutable.apiBaseUrl;
const defaultApiUrl = API_URL.replace(/\/+$/, "");
if (!runtimeApiUrl || runtimeApiUrl === defaultApiUrl || !value) {
return value;
}
if (value === defaultApiUrl) {
return runtimeApiUrl;
}
if (value.startsWith(`${defaultApiUrl}/`)) {
return `${runtimeApiUrl}${value.slice(defaultApiUrl.length)}`;
}
return value;
};
export const redactReleasePayload = (value, depth = 0) => {
if (depth > 8) {
return "[depth-limit]";
@@ -221,6 +290,425 @@ export const getRecentFrontendFailureEvents = () =>
payload: redactReleasePayload(event.payload),
}));
const browserInfoFromUserAgent = (userAgent = "") => {
const ua = String(userAgent || "");
const matchers = [
["Edge", /Edg\/([\d.]+)/],
["Chrome", /Chrome\/([\d.]+)/],
["Firefox", /Firefox\/([\d.]+)/],
["Safari", /Version\/([\d.]+).*Safari/],
];
for (const [name, pattern] of matchers) {
const match = ua.match(pattern);
if (match) {
return { name, version: match[1] || null };
}
}
return { name: "Unknown", version: null };
};
const osInfoFromUserAgent = (userAgent = "") => {
const ua = String(userAgent || "");
const matchers = [
["Windows", /Windows NT ([\d.]+)/],
["Android", /Android ([\d.]+)/],
["iOS", /(?:iPhone|iPad).*OS ([\d_]+)/],
["macOS", /Mac OS X ([\d_]+)/],
["Linux", /Linux/],
];
for (const [name, pattern] of matchers) {
const match = ua.match(pattern);
if (match) {
return { name, version: match[1] ? String(match[1]).replace(/_/g, ".") : null };
}
}
return { name: "Unknown", version: null };
};
const currentDeviceType = () => {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth || 0);
if (width > 0 && width < 769) {
return "mobile";
}
if (width >= 769 && width < 1024) {
return "tablet";
}
return "desktop";
};
const versionLabel = (version) => version?.version_label || version?.label || null;
const commitSha = (version) => version?.commit_sha || version?.commit || null;
const RELEASE_SERVICE_DEFINITIONS = Object.freeze([
{ key: "frontend", label: "Frontend", kind: "app" },
{ key: "api", label: "API", kind: "app" },
{ key: "database", label: "Database", kind: "data" },
{ key: "redis", label: "Redis", kind: "data" },
{ key: "minio", label: "MinIO", kind: "data" },
]);
const RELEASE_MISSING_LABELS = Object.freeze({
release_bundle: "Release bundle",
frontend_version: "Frontend version",
frontend_base_url: "Frontend URL",
api_version: "API version",
api_base_url: "API URL",
database_service: "Database service",
redis_service: "Redis service",
minio_service: "MinIO service",
});
const isPlainRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const firstFilledString = (...values) => {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
};
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
return {
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url),
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
};
};
const releaseRuntimeTraceId = (runtime = {}) => firstFilledString(runtime.traceId, runtime.trace_id);
const releaseRuntimeGeneratedAt = (runtime = {}) => firstFilledString(runtime.generatedAt, runtime.generated_at);
const releaseShortText = (value, length = 12) => {
const normalized = firstFilledString(value);
if (!normalized) {
return "";
}
return normalized.length > length ? normalized.slice(0, length) : normalized;
};
const releaseCommitValue = (version = null) => {
if (!isPlainRecord(version)) {
return "";
}
const commit = version.commit;
if (isPlainRecord(commit)) {
return firstFilledString(commit.sha, commit.commit_sha);
}
return firstFilledString(version.commit_sha, commit);
};
const releaseVersionPrimaryText = (version = null, fallback = "Missing version") => {
if (!isPlainRecord(version)) {
return fallback;
}
return firstFilledString(version.version_label, version.tag, releaseShortText(releaseCommitValue(version)), fallback);
};
const releaseVersionSecondaryText = (version = null) => {
if (!isPlainRecord(version)) {
return "";
}
const parts = [];
const commit = releaseShortText(releaseCommitValue(version));
const repository = firstFilledString(version.repository);
const branch = firstFilledString(version.branch);
if (commit && commit !== firstFilledString(version.version_label, version.tag)) {
parts.push(commit);
}
if (repository || branch) {
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
}
return parts.join(" - ");
};
const releaseStatusTone = (status = "") => {
const normalized = String(status || "").trim().toLowerCase();
if (
[
"failed",
"error",
"critical",
"service_unhealthy",
"unhealthy",
"degraded",
"reconcile_failed",
"restart_failed",
"provision_blocked",
].includes(normalized)
) {
return "danger";
}
if (
[
"missing",
"missing_value",
"not_configured",
"unconfigured",
"deployment_in_progress",
"warning",
"pending",
"queued",
"running",
"deploying",
"building",
"provisioning",
"unknown",
].includes(normalized)
) {
return "warning";
}
return "ok";
};
const releaseServiceStatus = (service = null, fallback = "connected") =>
firstFilledString(
service?.deployment_status,
service?.availability_state,
service?.status,
service?.state,
fallback
);
const releaseServicePrimaryText = (service = null) => {
if (!isPlainRecord(service)) {
return "";
}
const name = firstFilledString(
service.resource_name,
service.label,
service.coolify_service_uuid,
service.health_url,
service.repository
);
const id = Number(service.id || service.target_id || 0);
return [name, id > 0 ? `#${id}` : ""].filter(Boolean).join(" ");
};
const releaseServiceSecondaryText = (service = null) => {
if (!isPlainRecord(service)) {
return "";
}
const parts = [];
const resourceUuid = firstFilledString(service.resource_uuid);
const coolifyServiceUuid = firstFilledString(service.coolify_service_uuid);
const instanceLabel = firstFilledString(service.instance_label, service.coolify_instance_label);
const repository = firstFilledString(service.repository);
const branch = firstFilledString(service.branch);
const replication = isPlainRecord(service.replication) ? service.replication : null;
const replicationStatus = firstFilledString(replication?.last_status?.status, replication?.status);
if (resourceUuid) {
parts.push(`resource ${releaseShortText(resourceUuid)}`);
}
if (coolifyServiceUuid && coolifyServiceUuid !== resourceUuid) {
parts.push(`service ${releaseShortText(coolifyServiceUuid)}`);
}
if (repository || branch) {
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
}
if (instanceLabel) {
parts.push(instanceLabel);
}
if (replicationStatus) {
parts.push(`replication ${replicationStatus}`);
}
return parts.join(" - ");
};
const releaseServiceForKey = (serviceSet = null, key = "") => {
if (!isPlainRecord(serviceSet)) {
return null;
}
const stack = isPlainRecord(serviceSet.stack) ? serviceSet.stack : {};
const dataServices = isPlainRecord(serviceSet.data_services) ? serviceSet.data_services : {};
const targets = isPlainRecord(serviceSet.targets) ? serviceSet.targets : {};
const service = stack[key] || dataServices[key] || targets[key] || null;
return isPlainRecord(service) ? service : null;
};
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
const missing = Array.isArray(availability.missing)
? availability.missing.map((value) => String(value || "").trim()).filter(Boolean)
: [];
const missingLookup = new Set(missing);
const isDefaultChannel =
channel?.default_channel === true
|| channel?.default_channel === 1
|| String(channel?.slug || "").toLowerCase() === "stable";
const urls = releaseRuntimeUrlsForDisplay(runtime);
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
const bundleId = versions.bundle_id || bundle?.id || null;
const serviceSet = isPlainRecord(versions.service_set)
? versions.service_set
: isPlainRecord(bundle?.service_set)
? bundle.service_set
: null;
const defaultSharedLabel = "Default/shared runtime";
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
const buildAppRow = (key, label, version, url) => {
const missingVersionKey = `${key}_version`;
const missingUrlKey = `${key}_base_url`;
const missingKey = missingLookup.has(missingVersionKey)
? missingVersionKey
: missingLookup.has(missingUrlKey)
? missingUrlKey
: "";
const fallback = isDefaultChannel ? defaultSharedLabel : `Missing ${label} version`;
const status = missingKey
? "missing value"
: isDefaultChannel && !version && !url
? "shared"
: firstFilledString(version?.status, url ? "active" : "unknown");
return {
key,
label,
status,
tone: missingKey ? "warning" : releaseStatusTone(status),
primaryText: releaseVersionPrimaryText(version, fallback),
secondaryText: releaseVersionSecondaryText(version),
url: url || "",
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
.filter(Boolean)
.join(" - "),
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
};
};
const buildServiceRow = ({ key, label }) => {
const service = releaseServiceForKey(serviceSet, key);
const missingKey = missingLookup.has(`${key}_service`) ? `${key}_service` : "";
if (service) {
const status = releaseServiceStatus(service);
return {
key,
label,
status,
tone: releaseStatusTone(status),
primaryText: releaseServicePrimaryText(service) || "Connected service",
secondaryText: releaseServiceSecondaryText(service),
title: [releaseServicePrimaryText(service), releaseServiceSecondaryText(service), firstFilledString(service.health_url)]
.filter(Boolean)
.join(" - "),
missingLabel: "",
};
}
const fallbackText = isDefaultChannel
? defaultSharedLabel
: bundleId
? (serviceSet ? `Missing ${label} service` : "No service set connected")
: "Missing release bundle";
const status = isDefaultChannel ? "shared" : "missing";
return {
key,
label,
status,
tone: isDefaultChannel ? "ok" : "warning",
primaryText: fallbackText,
secondaryText: "",
title: fallbackText,
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : fallbackText,
};
};
return {
channelLabel: firstFilledString(channel?.name, channel?.slug, isDefaultChannel ? "Stable" : "Unknown channel"),
channelSlug: firstFilledString(channel?.slug),
traceId: releaseRuntimeTraceId(runtime) || "unknown",
generatedAt: releaseRuntimeGeneratedAt(runtime) || "unknown",
availabilityStatus: firstFilledString(availability.status, availability.configured === false ? "unconfigured" : "ready"),
availabilityTone: availability.configured === false || missing.length > 0
? "warning"
: releaseStatusTone(availability.status || "ready"),
bundleLabel: bundleId
? `#${bundleId}${firstFilledString(bundle?.version_label) ? ` ${bundle.version_label}` : ""}`
: defaultSharedLabel,
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
serviceSetLabel: serviceSet
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
: defaultSharedLabel,
missingLabels,
appRows: [
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
buildAppRow("api", "API", apiVersion, urls.api),
],
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
};
};
export const buildCurrentReleaseHeaders = () => {
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown";
return buildReleaseHeaders({
traceId: releaseRuntimeStateMutable.traceId,
channelSlug: releaseRuntimeStateMutable.channel?.slug || "",
frontendVersion: commitSha(frontendVersion) || versionLabel(frontendVersion) || fallbackFrontendVersion,
});
};
export const buildReleaseTimelineContext = () => {
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
const browser = browserInfoFromUserAgent(userAgent);
const os = osInfoFromUserAgent(userAgent);
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
const apiVersion = releaseRuntimeStateMutable.versions?.api || {};
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || "unknown";
return {
trace_id: releaseRuntimeStateMutable.traceId,
channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
route_path: typeof window !== "undefined" ? window.location.pathname : null,
device: {
type: currentDeviceType(),
},
browser,
os,
viewport:
typeof window !== "undefined"
? {
width: window.innerWidth,
height: window.innerHeight,
device_pixel_ratio: window.devicePixelRatio || 1,
}
: null,
frontend: {
version_label: versionLabel(frontendVersion) || fallbackFrontendVersion,
commit_sha: commitSha(frontendVersion) || fallbackFrontendVersion,
},
api: {
version_label: versionLabel(apiVersion),
commit_sha: commitSha(apiVersion),
},
frontend_version: versionLabel(frontendVersion) || fallbackFrontendVersion,
frontend_commit_sha: commitSha(frontendVersion) || fallbackFrontendVersion,
api_version: versionLabel(apiVersion),
api_commit_sha: commitSha(apiVersion),
};
};
const scheduleReleaseTimelineFlush = () => {
if (flushTimer !== null || typeof window === "undefined") {
return;
@@ -242,11 +730,7 @@ export const flushReleaseTimelineEvents = async () => {
const body = {
events,
context: {
trace_id: releaseRuntimeStateMutable.traceId,
channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
frontend_version: import.meta.env.VITE_COMMIT_HASH || "unknown",
},
context: buildReleaseTimelineContext(),
};
try {
@@ -256,13 +740,14 @@ export const flushReleaseTimelineEvents = async () => {
const headers = {
"Content-Type": "application/json",
...buildCurrentReleaseHeaders(),
};
const token = typeof window !== "undefined" ? window.localStorage.getItem("token") : null;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}/release/timeline/events`, {
const response = await fetch(resolveReleaseApiUrl("/release/timeline/events"), {
method: "POST",
headers,
body: JSON.stringify(body),
@@ -380,7 +865,8 @@ export const __resetReleaseTimelineForTests = () => {
customTransport = null;
releaseRuntimeStateMutable.traceId = "test-trace";
releaseRuntimeStateMutable.channel = null;
releaseRuntimeStateMutable.versions = { frontend: null, api: null };
releaseRuntimeStateMutable.availableChannels = [];
releaseRuntimeStateMutable.versions = { frontend: null, api: null, service_set: null, bundle_id: null, bundle: null };
releaseRuntimeStateMutable.frontendBaseUrl = null;
releaseRuntimeStateMutable.apiBaseUrl = null;
releaseRuntimeStateMutable.availability = {
@@ -395,6 +881,7 @@ export const __resetReleaseTimelineForTests = () => {
all_failure_metadata: true,
retention_days: 14,
};
releaseRuntimeStateMutable.generatedAt = null;
};
export const __setReleaseTimelineTransportForTests = (transport) => {
+25 -19
View File
@@ -1,55 +1,61 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { requestReleaseManager } from "@/services/superuserReleases.js";
export const getCoolifySummary = () =>
authenticatedRequest("/superuser/coolify", "GET", {});
requestReleaseManager("/superuser/coolify", "GET", {});
export const getCoolifyLoadBalancer = () =>
authenticatedRequest("/superuser/coolify/load-balancer", "GET", {});
requestReleaseManager("/superuser/coolify/load-balancer", "GET", {});
export const reconcileCoolifyLoadBalancer = (payload) =>
authenticatedRequest("/superuser/coolify/load-balancer/reconcile", "POST", payload);
requestReleaseManager("/superuser/coolify/load-balancer/reconcile", "POST", payload);
export const deployCoolifyGatewayRoutes = (payload) =>
requestReleaseManager("/superuser/coolify/load-balancer/routes/deploy", "POST", payload);
export const deployCoolifyGatewayApiCode = (payload) =>
requestReleaseManager("/superuser/coolify/load-balancer/api/deploy", "POST", payload);
export const listCoolifyGateways = () =>
authenticatedRequest("/superuser/coolify/gateways", "GET", {});
requestReleaseManager("/superuser/coolify/gateways", "GET", {});
export const saveCoolifyGateway = (payload) =>
authenticatedRequest("/superuser/coolify/gateways", "POST", payload);
requestReleaseManager("/superuser/coolify/gateways", "POST", payload);
export const testCoolifyGateway = (id) =>
authenticatedRequest(`/superuser/coolify/gateways/${id}/test`, "POST", {});
requestReleaseManager(`/superuser/coolify/gateways/${id}/test`, "POST", {});
export const createCoolifyInstance = (payload) =>
authenticatedRequest("/superuser/coolify/instances", "POST", payload);
requestReleaseManager("/superuser/coolify/instances", "POST", payload);
export const testCoolifyInstance = (id) =>
authenticatedRequest(`/superuser/coolify/instances/${id}/test`, "POST", {});
requestReleaseManager(`/superuser/coolify/instances/${id}/test`, "POST", {});
export const discoverCoolifyPlacement = (id) =>
authenticatedRequest(`/superuser/coolify/instances/${id}/placement`, "GET", {});
requestReleaseManager(`/superuser/coolify/instances/${id}/placement`, "GET", {});
export const listCoolifyTargets = ({ kind = null } = {}) =>
authenticatedRequest("/superuser/coolify/targets", "GET", kind ? { kind } : {});
requestReleaseManager("/superuser/coolify/targets", "GET", kind ? { kind } : {});
export const createCoolifyTarget = (payload) =>
authenticatedRequest("/superuser/coolify/targets", "POST", payload);
requestReleaseManager("/superuser/coolify/targets", "POST", payload);
export const reconcileCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/reconcile`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/reconcile`, "POST", {});
export const deployCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/deploy`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/deploy`, "POST", {});
export const restartCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/restart`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/restart`, "POST", {});
export const failoverCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/failover`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/failover`, "POST", {});
export const deleteCoolifyTarget = (id, payload) =>
authenticatedRequest(`/superuser/coolify/targets/${id}`, "DELETE", payload);
requestReleaseManager(`/superuser/coolify/targets/${id}`, "DELETE", payload);
export const getCoolifyConfig = (variable = null) =>
authenticatedRequest("/coolify/config", "GET", variable ? { variable } : {});
requestReleaseManager("/coolify/config", "GET", variable ? { variable } : {});
export const setCoolifyConfig = (payload) =>
authenticatedRequest("/coolify/config", "POST", payload);
requestReleaseManager("/coolify/config", "POST", payload);
+23 -1
View File
@@ -113,7 +113,7 @@ const buildHeaders = () => {
return headers;
};
const requestReleaseManager = (url, method, data = {}) => {
export const requestReleaseManager = (url, method, data = {}) => {
const candidates = releaseManagerControlApiCandidates();
const headers = buildHeaders();
@@ -127,6 +127,7 @@ const requestReleaseManager = (url, method, data = {}) => {
url: `${baseUrl}${url}`,
...(method === "GET" ? { params: data } : { data }),
__skipRequestQueue: true,
__skipReleaseApiRewrite: true,
headers,
});
rememberWorkingControlApiUrl(baseUrl);
@@ -169,8 +170,14 @@ export const updateReleaseChannel = (id, payload) =>
export const rollbackReleaseChannel = (id) =>
requestReleaseManager(`/superuser/releases/channels/${id}/rollback`, "POST", {});
export const setReleaseChannelBundle = (id, payload) =>
requestReleaseManager(`/superuser/releases/channels/${id}/bundle`, "POST", payload);
export const listReleaseAssignments = () => requestReleaseManager("/superuser/releases/assignments", "GET", {});
export const searchReleaseAssignmentSubjects = (params = {}) =>
requestReleaseManager("/superuser/releases/assignment-subjects", "GET", params);
export const createReleaseAssignment = (payload) =>
requestReleaseManager("/superuser/releases/assignments", "POST", payload);
@@ -190,6 +197,12 @@ export const listReleaseServiceSets = () => requestReleaseManager("/superuser/re
export const createReleaseServiceSet = (payload) =>
requestReleaseManager("/superuser/releases/service-sets", "POST", payload);
export const deleteReleaseServiceSet = (id) =>
requestReleaseManager(`/superuser/releases/service-sets/${id}`, "DELETE", {});
export const completeReleaseServiceSetIsolatedDataServices = (id, payload = {}) =>
requestReleaseManager(`/superuser/releases/service-sets/${id}/isolated-data-services`, "POST", payload);
export const listReleaseBundles = (limit = 50) =>
requestReleaseManager("/superuser/releases/bundles", "GET", { limit });
@@ -223,8 +236,17 @@ 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);
export const searchReleaseTimeline = (filters = {}) =>
requestReleaseManager("/superuser/releases/timeline", "GET", filters);
export const listReleaseTimelineSessions = (filters = {}) =>
requestReleaseManager("/superuser/releases/timeline/sessions", "GET", filters);
export const getReleaseTimelineSession = (traceId) =>
requestReleaseManager(`/superuser/releases/timeline/sessions/${encodeURIComponent(traceId)}`, "GET", {});
@@ -12,7 +12,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { useAppToast } from "@/composables/useAppToast.js";
import { searchCustomer, searchCustomerResults, isSearching as isSearchingCustomers } from "@/components/search/economic/customerSearch.vue";
import { API_URL } from "@/config";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
formatSelfServeTaskButtons,
@@ -69,7 +69,7 @@ const parsePathOutcomeStreamEvent = (line) => {
};
const requestPathOutcomesStream = async (payload, onEvent, signal) => {
const response = await fetch(`${API_URL}/department/selfserve/studio/path-outcomes/stream`, {
const response = await fetch(resolveReleaseApiUrl("/department/selfserve/studio/path-outcomes/stream"), {
method: "POST",
headers: buildAuthenticatedHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
@@ -580,7 +580,7 @@ const buildLaneDynamicImagePreviewUrl = (laneId, dynamicImageId) => {
current_step: "0",
});
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
};
const laneManagementDynamicImagePreviewUrl = computed(() => (
@@ -2009,7 +2009,7 @@ const simulatorDynamicImageUrl = computed(() => {
params.set("thumb_position", String(simulatorDynamicImageThumbPosition.value));
}
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
});
const displayedSimulatorDynamicImageUrl = computed(() => (
@@ -61,7 +61,6 @@ watch([selected_date, selected_date_to], () => {
const DEPARTMENTS_TOGGLERS_COUNTER = {
2: 3,
1: 2,
3: 2,
7: 2
};
@@ -11,6 +11,8 @@ import ConfigurationError from "@/components/displays/superuser/configuration/Co
import {
createCoolifyInstance,
deleteCoolifyTarget,
deployCoolifyGatewayApiCode,
deployCoolifyGatewayRoutes,
deployCoolifyTarget,
failoverCoolifyTarget,
getCoolifyConfig,
@@ -27,6 +29,8 @@ const config = ref([]);
const summary = ref({ instances: [], targets: [], availability: {} });
const errors = ref([]);
const busy = ref(null);
const gatewayRouteResult = ref(null);
const gatewayCodeDeployResult = ref(null);
const instanceForm = reactive({
label: "Coolify",
@@ -109,6 +113,29 @@ async function runLoadBalancerReconcile(dryRun) {
});
}
async function runGatewayRouteDeploy(dryRun) {
await run(dryRun ? "lb:routes:dry-run" : "lb:routes:deploy", async () => {
const response = await deployCoolifyGatewayRoutes({ dry_run: dryRun, enforce: !dryRun });
gatewayRouteResult.value = response?.data?.data || response?.data || null;
await load();
});
}
async function runGatewayApiCodeDeploy() {
await run("lb:api-code:deploy", async () => {
const response = await deployCoolifyGatewayApiCode({
dry_run: false,
enforce: true,
deploy_routes: true,
});
gatewayCodeDeployResult.value = response?.data?.data || response?.data || null;
if (gatewayCodeDeployResult.value?.route_deploy) {
gatewayRouteResult.value = gatewayCodeDeployResult.value.route_deploy;
}
await load();
});
}
async function runGatewayTest(gateway) {
await run(`gateway:${gateway.id}:test`, async () => {
await testCoolifyGateway(gateway.id);
@@ -206,6 +233,10 @@ function driftActionLabel(action) {
return action.type || "planned change";
}
function countItems(value) {
return Array.isArray(value) ? value.length : 0;
}
onMounted(load);
</script>
@@ -323,6 +354,69 @@ onMounted(load);
>
Enforce reconcile
</button>
<button
class="button"
type="button"
:class="{ 'is-loading': busy === 'lb:routes:dry-run' }"
@click="runGatewayRouteDeploy(true)"
>
Dry-run route deploy
</button>
<button
class="button is-warning"
type="button"
:class="{ 'is-loading': busy === 'lb:routes:deploy' }"
@click="runGatewayRouteDeploy(false)"
>
Deploy api-v2 route
</button>
<button
class="button is-info"
type="button"
:class="{ 'is-loading': busy === 'lb:api-code:deploy' }"
@click="runGatewayApiCodeDeploy"
>
Deploy latest API code
</button>
</div>
<div
v-if="gatewayCodeDeployResult"
class="coolify-route-result mt-2"
data-testid="coolify-gateway-code-deploy-result"
>
<strong>{{ gatewayCodeDeployResult.dry_run ? "API code plan" : "API code deploy" }}</strong>
<span>Planned {{ countItems(gatewayCodeDeployResult.planned) }}</span>
<span>Applied {{ countItems(gatewayCodeDeployResult.applied) }}</span>
<span>Skipped {{ countItems(gatewayCodeDeployResult.skipped) }}</span>
<span>Errors {{ countItems(gatewayCodeDeployResult.errors) }}</span>
<span v-if="gatewayCodeDeployResult.route_deploy">
Route applied {{ countItems(gatewayCodeDeployResult.route_deploy.applied) }}
</span>
<span v-if="countItems(gatewayCodeDeployResult.warnings) > 0">
Warnings {{ countItems(gatewayCodeDeployResult.warnings) }}
</span>
<ul v-if="countItems(gatewayCodeDeployResult.warnings) > 0">
<li v-for="warning in gatewayCodeDeployResult.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<div
v-if="gatewayRouteResult"
class="coolify-route-result mt-2"
data-testid="coolify-gateway-route-result"
>
<strong>{{ gatewayRouteResult.dry_run ? "Route plan" : "Route deploy" }}</strong>
<span>Planned {{ countItems(gatewayRouteResult.planned) }}</span>
<span>Applied {{ countItems(gatewayRouteResult.applied) }}</span>
<span>Skipped {{ countItems(gatewayRouteResult.skipped) }}</span>
<span>Errors {{ countItems(gatewayRouteResult.errors) }}</span>
<span v-if="countItems(gatewayRouteResult.warnings) > 0">
Warnings {{ countItems(gatewayRouteResult.warnings) }}
</span>
<ul v-if="countItems(gatewayRouteResult.warnings) > 0">
<li v-for="warning in gatewayRouteResult.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<div class="table-container mt-3">
@@ -547,6 +641,19 @@ onMounted(load);
padding-left: 1.25rem;
}
.coolify-route-result {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.coolify-route-result ul {
flex-basis: 100%;
margin: 0;
padding-left: 1.25rem;
}
.coolify-muted {
color: #64748b;
font-size: 0.86rem;
File diff suppressed because it is too large Load Diff
+85
View File
@@ -443,6 +443,50 @@ function createRequiredWarningsPosFixture() {
});
}
function createBookingPoDefaultPosFixture() {
const baseFixture = createPosFixture();
const customerNumber = 12345679;
const bookingId = 8891;
return createPosFixture({
preloadCreatedOrderItemsFromBooking: true,
orderBookingListStripsDetails: true,
orderBookingListStripsMetadata: true,
customerAttributesByNumber: {
[customerNumber]: [
...(baseFixture.customerAttributesByNumber[customerNumber] || []),
{
id: 88,
customer_number: customerNumber,
attribute: "usePONumbers",
},
],
},
orderBookings: [
{
id: bookingId,
customer_number: customerNumber,
customer_name: "(TEST) Pleno Vognmandsforretning",
department: 12,
datetime: "2026-05-21 09:30:00",
date: "2026-05-21",
reg_1: "BOOKPO1",
reg_2: "",
reg_3: "",
reference: "BOOKING-PO-REF",
reference_number: "BOOKING-PO-REF",
notes: "Booking PO note",
note: "Booking PO note",
po: "BOOKING-PO-DEFAULT",
pickup: false,
items: [{ id: 53, quantity: 1, price: 649 }],
order_id: null,
created_at: "2026-05-20 08:00:00",
},
],
});
}
function createReferenceAutocompletePosFixture() {
const baseFixture = createPosFixture();
const customerNumber = 12345679;
@@ -2304,6 +2348,47 @@ test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership"
await createOrderRequest;
});
test("applies booking PO defaults on desktop even when linked booking items are already loaded", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createBookingPoDefaultPosFixture(),
});
await primeOperatorSession(page, "pos-orders-booking-po-default-token");
await page.goto(POS_BOOT_URL);
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("BOOKPO1");
await expect(page.locator(".pos-selected-customer__title")).toContainText("(TEST) Pleno Vognmandsforretning");
const createOrderRequest = waitForOrderMutation(
page,
"POST",
"/orders",
(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;
expect(capturedCreateOrderRequest.postDataJSON()).toMatchObject({
booking_id: 8891,
po: "",
});
const stepTwo = page.getByTestId("pos-step-2");
await expect(stepTwo).toBeVisible();
await expect(stepTwo.getByTestId("pos-order-registration-1")).toContainText("BOOKPO1");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference")).toContainText("BOOKING-PO-REF");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po")).toContainText("BOOKING-PO-DEFAULT");
await expect(stepTwo.getByTestId("pos-order-metadata-note")).toContainText("Booking PO note");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-control")).not.toHaveAttribute(
"data-warning-state",
"warning"
);
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-warning-icon")).toHaveCount(0);
});
test("preserves a manually selected required-reference customer and manual reference while editing reg_1", async ({
page,
}) => {
+5 -3
View File
@@ -1,6 +1,8 @@
import { test, expect } from "@playwright/test";
const PING_URL = /https:\/\/api\.truckwash\.io:4433\/ping(?:\?.*)?$/;
const PING_URL =
/(?:https:\/\/api\.truckwash\.io:4433|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
const CONNECTIVITY_PAGE_TIMEOUT_MS = 30_000;
function installPingFailure(page, onPing) {
return page.route(PING_URL, async (route) => {
@@ -22,7 +24,7 @@ test.describe("Connectivity issue", () => {
await page.goto("/connectivity-issue");
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
await expect(page.getByTestId("connectivity-icon")).toBeVisible();
await expect(page.getByTestId("connectivity-title")).toHaveText("Forbindelsesproblem");
await expect(page.getByTestId("connectivity-subtitle")).toContainText("internetforbindelse");
@@ -42,7 +44,7 @@ test.describe("Connectivity issue", () => {
});
await page.goto("/connectivity-issue");
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
await expect.poll(() => pingCount).toBeGreaterThan(0);
const initialPingCount = pingCount;
+160 -2
View File
@@ -18,6 +18,12 @@ function json(body, status = 200) {
return {
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
};
}
@@ -198,6 +204,10 @@ function createCoolifyState() {
removedHosts: [],
reconciledTargets: [],
lbReconciles: [],
routeDeploys: [],
apiCodeDeploys: [],
routePlans: [],
apiCodePlans: [],
placement: {
generated_at: "2026-05-18T08:00:00.000Z",
servers: [
@@ -360,6 +370,117 @@ async function installCoolifyMocks(page, state) {
);
});
await page.route(/\/superuser\/coolify\/load-balancer\/routes\/deploy$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
state.routeDeploys.push({ dry_run: dryRun });
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
const planned = [
{
type: "deploy_gateway_route",
target_id: 13,
channel_slug: "internal",
app: "api",
resource_uuid: "api-application-uuid",
resource_type: "application",
public_url: `${gatewayBaseUrl}/internal/api`,
frontend_public_url: `${gatewayBaseUrl}/internal/frontend`,
target_ip: "65.21.214.30",
},
];
state.routePlans.push(planned);
const warnings = [
"No managed Coolify API application route was found for gateway targets: 94.130.142.41, 23.88.23.183.",
];
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
public_host: state.loadBalancer.config.public_gateway_host,
public_url: `${gatewayBaseUrl}/internal/api`,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
warnings,
coverage: {
enabled_gateway_ips: ["94.130.142.41", "65.21.214.30", "23.88.23.183"],
covered_target_ips: ["65.21.214.30"],
uncovered_gateway_ips: ["94.130.142.41", "23.88.23.183"],
},
gateways: state.loadBalancer.gateways,
},
})
);
});
await page.route(/\/superuser\/coolify\/load-balancer\/api\/deploy$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
const deployRoutes = payload.deploy_routes !== false;
state.apiCodeDeploys.push({ dry_run: dryRun, deploy_routes: deployRoutes });
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
const planned = [
{
type: "deploy_gateway_api_code",
target_id: 13,
channel_slug: "internal",
app: "api",
repository: "copenhagentruckwash/api",
branch: "master",
resource_uuid: "api-application-uuid",
public_url: `${gatewayBaseUrl}/internal/api`,
commit_mode: "latest",
},
];
state.apiCodePlans.push(planned);
const routeDeploy = deployRoutes
? {
ok: true,
dry_run: false,
mutated: true,
planned,
applied: planned,
skipped: [],
errors: [],
warnings: [],
}
: null;
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
deploy_routes: deployRoutes,
public_host: state.loadBalancer.config.public_gateway_host,
public_url: `${gatewayBaseUrl}/internal/api`,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
warnings: [],
deployment_wait: {
ok: true,
results: [
{
deployment_uuid: "deployment-uuid",
status: "finished_or_not_running",
},
],
pending: [],
},
route_deploy: routeDeploy,
gateways: state.loadBalancer.gateways,
},
})
);
});
await page.route(/\/superuser\/coolify\/gateways$/, async (route) => {
await route.fulfill(json({ data: state.loadBalancer.gateways }));
});
@@ -659,6 +780,42 @@ test.describe("Coolify infrastructure management", () => {
expect(state.lbReconciles.at(-1)).toEqual({ dry_run: false });
await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("ok");
await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("No planned changes.");
await page.getByRole("button", { name: "Dry-run route deploy" }).click();
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: true });
expect(state.routePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
}),
])
);
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route plan");
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Warnings 1");
await page.getByRole("button", { name: "Deploy api-v2 route" }).click();
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: false });
expect(state.routePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
}),
])
);
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route deploy");
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Applied 1");
await page.getByRole("button", { name: "Deploy latest API code" }).click();
expect(state.apiCodeDeploys.at(-1)).toEqual({ dry_run: false, deploy_routes: true });
expect(state.apiCodePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
}),
])
);
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("API code deploy");
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Applied 1");
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Route applied 1");
await expect(page.getByTestId("coolify-instances-table")).toContainText("Production Coolify");
await expect(page.getByTestId("coolify-targets-table")).toContainText("failover_ready");
@@ -692,7 +849,7 @@ test.describe("Coolify infrastructure management", () => {
const state = await boot(page);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
await createDatabaseReplica(page);
await createRedisReplica(page);
@@ -782,7 +939,7 @@ test.describe("Coolify infrastructure management", () => {
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
await expect.poll(() => state.provisionRequests.includes(2)).toBeTruthy();
await expect(page.getByTestId("replication-host-database-2")).toContainText(
"Coolify: provisioned / failover_ready"
@@ -848,6 +1005,7 @@ test.describe("Coolify infrastructure management", () => {
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
const row = page.getByTestId("replication-host-minio-22");
await expect(row).toContainText("Coolify: deploying / failover_blocked");
await expect(row.getByTestId("replication-host-progress")).toBeVisible();
+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.");
+103
View File
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test";
const json = (body, status = 200) => ({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
});
test("non-default release frontend loads from api-v2.truckwash.io without redirecting", async ({ page }) => {
const releaseApiRequests = [];
const releaseEntryRequests = [];
const runtimeRequests = [];
const runtime = {
generated_at: "2026-05-20T10:00:00.000Z",
trace_id: "trace-release-bootstrap",
channel: {
id: 2,
slug: "canary",
name: "Canary",
default_channel: false,
},
versions: {
frontend: { version_label: "frontend-canary", deployed_url: "https://api-v2.truckwash.io/canary/frontend" },
api: { version_label: "api-canary", deployed_url: "https://api-v2.truckwash.io/canary/api" },
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
capture_policy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
};
await page.addInitScript(() => {
window.localStorage.setItem("release_channel_selected_slug", "canary");
});
await page.route("https://api-v2.truckwash.io/canary/api/release/runtime**", async (route) => {
runtimeRequests.push(route.request().url());
await route.fulfill(json({ data: runtime }));
});
await page.route("https://api-v2.truckwash.io/canary/frontend/release-entry.json", async (route) => {
releaseEntryRequests.push(route.request().url());
await route.fulfill(
json({
entry: "assets/release-canary.js",
css: ["assets/release-canary.css"],
})
);
});
await page.route("https://api-v2.truckwash.io/canary/frontend/assets/release-canary.css", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/css",
headers: { "access-control-allow-origin": "*" },
body: "body::before { content: ''; }",
});
});
await page.route("https://api-v2.truckwash.io/canary/frontend/assets/release-canary.js", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/javascript",
headers: { "access-control-allow-origin": "*" },
body: `
document.body.dataset.releaseFrontend = 'canary';
document.body.dataset.releaseOrigin = window.location.origin;
fetch('https://api-v2.truckwash.io/canary/api/ping').catch(() => {});
`,
});
});
await page.route("https://api-v2.truckwash.io/canary/api/ping", async (route) => {
releaseApiRequests.push(route.request().url());
await route.fulfill(json({ data: { ok: true } }));
});
await page.goto("/shared/passkey-safe-link", { waitUntil: "domcontentloaded" });
await expect(page.locator("body")).toHaveAttribute("data-release-frontend", "canary");
const currentOrigin = await page.evaluate(() => window.location.origin);
expect(page.url()).toContain("/shared/passkey-safe-link");
expect(page.url()).not.toContain("api-v2.truckwash.io");
expect(runtimeRequests).toEqual(["https://api-v2.truckwash.io/canary/api/release/runtime?release_channel=canary"]);
expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]);
await expect.poll(() => releaseApiRequests.length).toBe(1);
expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping");
await expect(page.locator("body")).toHaveAttribute("data-release-origin", currentOrigin);
});
+9 -11
View File
@@ -1,9 +1,7 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5173";
const availableRuntime = (frontendBaseUrl) => ({
const availableRuntime = () => ({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-switched-channel",
channel: {
@@ -13,15 +11,12 @@ const availableRuntime = (frontendBaseUrl) => ({
description: "Early production validation before broader rollout.",
enabled: true,
default_channel: false,
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
},
versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "feedface" },
bundle_id: 31,
},
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
availability: {
configured: true,
missing: [],
@@ -49,7 +44,7 @@ async function boot(page) {
id: 77,
customer_number: 990077,
runtime_config: {
release: availableRuntime(baseUrl),
release: availableRuntime(),
},
},
});
@@ -59,17 +54,20 @@ async function boot(page) {
test("users assigned to a configured release channel see the switched notice once on the device", async ({ page }) => {
await boot(page);
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const notice = page.getByTestId("release-channel-switched-page");
await expect(notice).toBeVisible();
await expect(notice).toBeVisible({ timeout: 30_000 });
await expect(notice).toContainText("You are now on Canary");
await expect(notice).toContainText("Early production validation");
await expect(page.getByTestId("release-channel-switched-details")).toContainText("#31");
await expect(page.getByTestId("release-channel-switched-details")).toContainText("frontend-canary");
await expect(page.getByTestId("release-channel-switched-details")).toContainText("api-canary");
const currentUrl = page.url();
await page.getByTestId("release-channel-switched-continue").click();
await expect(notice).toHaveCount(0);
await expect(page).toHaveURL(currentUrl);
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-channel-switched-page")).toHaveCount(0);
});
+359 -24
View File
@@ -4,8 +4,15 @@ import { mockApi, seedAuthenticatedState } from "./support/network.js";
const json = (body, status = 200) => ({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
});
const GUARD_TIMEOUT_MS = 30_000;
const unavailableRuntime = {
generated_at: "2026-05-19T09:00:00.000Z",
@@ -17,15 +24,11 @@ const unavailableRuntime = {
description: "Early production validation before broader rollout.",
enabled: true,
default_channel: false,
frontend_base_url: null,
api_base_url: null,
},
versions: { frontend: null, api: null },
frontend_base_url: null,
api_base_url: null,
versions: { frontend: null, api: null, bundle_id: null },
availability: {
configured: false,
missing: ["frontend_base_url", "api_base_url"],
missing: ["release_bundle", "frontend_version", "api_version"],
status: "unconfigured",
},
capture_policy: {
@@ -36,15 +39,13 @@ const unavailableRuntime = {
},
};
const availableRuntime = (frontendBaseUrl) => ({
const availableRuntime = () => ({
...unavailableRuntime,
channel: {
...unavailableRuntime.channel,
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "feedface" },
bundle_id: 31,
},
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
availability: {
configured: true,
missing: [],
@@ -52,11 +53,135 @@ const availableRuntime = (frontendBaseUrl) => ({
},
});
async function boot(page, runtime = unavailableRuntime) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
const runtimeWithSelectableReleaseDetails = () => ({
...unavailableRuntime,
versions: {
frontend: null,
api: null,
bundle_id: null,
},
availability: {
configured: false,
missing: ["release_bundle"],
status: "unconfigured",
},
available_channels: [
{
channel: {
id: 1,
slug: "stable",
name: "Stable",
description: "Standard production channel.",
enabled: true,
default_channel: true,
},
versions: {
frontend: {
version_label: "frontend-stable",
commit_sha: "abc1234567890000111122223333444455556666",
deployed_at: "2026-05-18T07:45:00.000Z",
},
api: {
version_label: "api-stable",
commit_sha: "def456789abc0000111122223333444455556666",
deployed_at: "2026-05-18T07:47:00.000Z",
},
bundle_id: 31,
bundle: {
id: 31,
promoted_at: "2026-05-18T08:00:00.000Z",
},
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: unavailableRuntime.channel,
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
deployed_at: "2026-05-19T08:15:00.000Z",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
deployed_at: "2026-05-19T08:18:00.000Z",
},
bundle_id: null,
},
availability: {
configured: false,
missing: ["release_bundle"],
status: "unconfigured",
},
},
],
});
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);
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
});
}, locale);
await mockApi(page, {
authenticated: true,
@@ -76,13 +201,14 @@ test("users assigned to an unconfigured release channel can ignore the guard tem
await route.fulfill(json({ data: unavailableRuntime }));
});
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible();
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(guard).toContainText("Release channel is not ready");
await expect(guard).toContainText("Canary");
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend URL");
await expect(page.getByTestId("release-channel-missing")).toContainText("API URL");
await expect(page.getByTestId("release-channel-missing")).toContainText("Release bundle");
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend version");
await expect(page.getByTestId("release-channel-missing")).toContainText("API version");
await expect(page.getByTestId("release-channel-next-check")).toContainText("Checking again in");
await page.getByTestId("release-channel-ignore").click();
@@ -90,6 +216,213 @@ test("users assigned to an unconfigured release channel can ignore the guard tem
await page.evaluate(() => window.localStorage.removeItem("release_channel_unavailable_ignore_until"));
});
test("invalid release runtime JSON shows the unavailable guard instead of crashing bootstrap", async ({ page }) => {
const internalRuntime = {
...unavailableRuntime,
channel: {
...unavailableRuntime.channel,
slug: "internal",
name: "Internal",
default_channel: false,
},
versions: { frontend: null, api: null, bundle_id: null },
availability: {
configured: false,
missing: ["release_runtime"],
status: "unconfigured",
},
};
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await boot(page, internalRuntime);
await page.addInitScript(() => {
window.localStorage.setItem("release_channel_selected_slug", "internal");
});
await page.route("**/release/runtime**", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: '<br /><b>Warning</b> Composer autoload warning {"success":true}',
});
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(guard).toContainText("Internal");
await expect(page.getByTestId("release-channel-missing")).toContainText("Release runtime");
expect(consoleErrors.join("\n")).not.toContain("Cannot read properties of null");
});
test("selected channel auth session 404 shows the release channel guard", async ({ page }) => {
const internalRuntime = {
...availableRuntime(),
channel: {
...unavailableRuntime.channel,
slug: "internal",
name: "Internal",
default_channel: false,
},
frontend_base_url: null,
api_base_url: "https://api-v2.truckwash.io/internal/api",
urls: {
frontend_base_url: null,
api_base_url: "https://api-v2.truckwash.io/internal/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
};
const channelApiRequests = [];
await boot(page, internalRuntime);
await page.addInitScript(() => {
window.localStorage.setItem("release_channel_selected_slug", "internal");
});
await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: internalRuntime }));
});
await page.route("https://api-v2.truckwash.io/internal/api/auth/session**", async (route) => {
channelApiRequests.push(route.request().url());
await route.fulfill(json({ data: { message: "Not found" } }, 404));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(guard).toContainText("Internal");
await expect(page.getByTestId("release-channel-missing")).toContainText("API URL");
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await expect.poll(() => channelApiRequests.length).toBeGreaterThan(0);
expect(channelApiRequests.every((url) => url.startsWith("https://api-v2.truckwash.io/internal/api/"))).toBe(true);
});
test("release channel choices show git commit and release time when available", async ({ page }) => {
const runtime = runtimeWithSelectableReleaseDetails();
await boot(page, runtime);
await page.route("**/release/runtime", async (route) => {
await route.fulfill(json({ data: runtime }));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
const canary = page.getByTestId("release-channel-option-canary");
await expect(canary).toBeVisible();
await expect(canary).toContainText("Release bundle");
await expect(page.getByTestId("release-channel-option-canary-frontend-release")).toContainText("c0ffee000000");
await expect(page.getByTestId("release-channel-option-canary-frontend-release")).toContainText(
/May 19, 2026|19 May 2026/
);
await expect(page.getByTestId("release-channel-option-canary-api-release")).toContainText("feedface0000");
await expect(page.getByTestId("release-channel-option-canary-api-release")).toContainText(/May 19, 2026|19 May 2026/);
await expect(page.getByTestId("release-channel-option-stable-bundle-release")).toContainText("#31");
await expect(page.getByTestId("release-channel-option-stable-bundle-release")).toContainText(
/May 18, 2026|18 May 2026/
);
});
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,
channel: {
...unavailableRuntime.channel,
slug: "internal",
name: "Internal",
description: "Internal staff and superuser validation channel.",
},
versions: {
frontend: {
version_label: "frontend-internal",
commit_sha: "130cc2fc106a1111222233334444555566667777",
deployed_at: "2026-05-20T09:32:00.000Z",
},
api: {
version_label: "api-internal",
commit_sha: "24ac681365511111222233334444555566667777",
deployed_at: "2026-05-20T09:32:00.000Z",
},
bundle_id: 10,
bundle: {
id: 10,
promoted_at: "2026-05-20T09:33:00.000Z",
},
},
availability: {
configured: false,
missing: ["frontend_base_url"],
status: "unconfigured",
},
};
await boot(page, internalRuntime, "da");
await page.route("**/release/runtime", async (route) => {
await route.fulfill(json({ data: internalRuntime }));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(guard).toContainText("Release-kanalen er ikke klar");
await expect(guard).toContainText("Intern");
await expect(guard).toContainText("Intern kanal til medarbejdere");
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend-URL");
await expect(page.getByTestId("release-channel-missing")).not.toContainText("frontend_base_url");
await expect(guard).not.toContainText("Internal staff");
await expect(guard).not.toContainText(/\binternal\b/);
await expect(guard).not.toContainText(/\p{L}\?\p{L}|\?\p{L}/u);
});
test("users assigned to an unconfigured release channel can check again when it becomes ready", async ({ page }) => {
await boot(page);
let releaseRuntime = unavailableRuntime;
@@ -98,11 +431,13 @@ test("users assigned to an unconfigured release channel can check again when it
await route.fulfill(json({ data: releaseRuntime }));
});
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible();
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
releaseRuntime = availableRuntime(new URL(page.url()).origin);
const currentUrl = page.url();
releaseRuntime = availableRuntime();
await page.getByTestId("release-channel-check-again").click();
await expect(guard).toHaveCount(0);
await expect(page).toHaveURL(currentUrl);
});
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,19 @@ function getLiveSettings() {
test.describe.configure({ mode: "serial" });
test("api-v2 gateway ping serves a trusted TLS API response", async ({ request }) => {
const response = await request.get("https://api-v2.truckwash.io/ping");
const body = await response.json();
expect(response.ok()).toBeTruthy();
expect(body).toMatchObject({
success: true,
data: {
message: "pong",
},
});
});
test.describe("Live smoke release gate", () => {
test.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate.");
+151
View File
@@ -0,0 +1,151 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
});
const releaseRuntime = () => ({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-session-release-e2e",
channel: {
id: 2,
slug: "canary",
name: "Canary",
enabled: true,
default_channel: false,
},
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "http://127.0.0.1:5173/canary/frontend",
api_base_url: "/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
capture_policy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
});
async function openHiddenRuntimeMenu(page: Page) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await page.keyboard.press("Shift");
await page.keyboard.press("Shift");
await page.keyboard.press("Shift");
if (
await page
.getByTestId("request-queue-runtime-box")
.isVisible({ timeout: 1000 })
.catch(() => false)
) {
break;
}
}
await expect(page.getByTestId("request-queue-runtime-box")).toBeVisible();
}
async function bootAuthenticatedSession(page: Page) {
const runtime = releaseRuntime();
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
window.localStorage.setItem(
"release_channel_switch_notice_seen",
JSON.stringify({ "user:77:canary": "2026-05-19T09:30:00.000Z" })
);
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
id: 77,
customer_number: 990077,
display_name: "Release Inspector",
runtime_config: {
release: runtime,
},
},
});
await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: runtime }));
});
await primeMockSession(page, { token: "session-release-runtime-token", bootPath: "/user" });
}
test.describe("session release runtime inspector", () => {
test.beforeEach(async ({ page }, testInfo: TestInfo) => {
test.skip(!isDesktopProject(testInfo), "Hidden runtime menu coverage is desktop-focused.");
await bootAuthenticatedSession(page);
});
test("shows the active release and connected services in the Shift menu", async ({ page }) => {
await openHiddenRuntimeMenu(page);
const runtimeBox = page.getByTestId("request-queue-runtime-box");
await expect(runtimeBox).toContainText("Session release");
await expect(runtimeBox).toContainText("Canary");
await expect(runtimeBox).toContainText("trace-session-release-e2e");
await expect(runtimeBox).toContainText("#31 canary-bundle");
await expect(runtimeBox).toContainText("Canary isolated stack");
await expect(page.getByTestId("request-queue-release-app-frontend")).toContainText("frontend-canary");
await expect(page.getByTestId("request-queue-release-app-frontend")).toContainText(
"http://127.0.0.1:5173/canary/frontend"
);
await expect(page.getByTestId("request-queue-release-app-api")).toContainText("api-canary");
await expect(page.getByTestId("request-queue-release-app-api")).toContainText("/api");
await expect(page.getByTestId("request-queue-release-service-frontend")).toContainText("canary-frontend #51");
await expect(page.getByTestId("request-queue-release-service-api")).toContainText("canary-api #52");
await expect(page.getByTestId("request-queue-release-service-database")).toContainText("canary-db #54");
await expect(page.getByTestId("request-queue-release-service-redis")).toContainText("canary-redis #55");
await expect(page.getByTestId("request-queue-release-service-minio")).toContainText("canary-minio #56");
await expect(runtimeBox).toContainText("Runtime details");
await expect(page.getByTestId("request-queue-i18n-catalog-switch")).toBeVisible();
});
});
+86 -8
View File
@@ -13,6 +13,12 @@ function json(body, status = 200) {
return {
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
};
}
@@ -178,6 +184,41 @@ function normalizePositiveIntegerValue(value) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function normalizeOrderPoValue(value) {
return String(value ?? "").trim();
}
function findPosOrderBookingById(posFixture, bookingId) {
const normalizedBookingId = normalizePositiveIntegerValue(bookingId);
if (!normalizedBookingId) {
return null;
}
return (posFixture.orderBookings || []).find((booking) => Number(booking?.id || 0) === normalizedBookingId) || null;
}
function getPosOrderBookingPo(posFixture, bookingId) {
const booking = findPosOrderBookingById(posFixture, bookingId);
const bookingPo = normalizeOrderPoValue(booking?.po);
return bookingPo || "";
}
function applyBookingPoDefaultToMockOrder(posFixture, order) {
if (!order || normalizeOrderPoValue(order.po) !== "") {
return order;
}
const bookingPo = getPosOrderBookingPo(posFixture, order.booking_id);
if (!bookingPo) {
return order;
}
return {
...order,
po: bookingPo,
};
}
function normalizeReferenceKey(value) {
return String(value ?? "")
.trim()
@@ -632,7 +673,7 @@ function parseFilterExpressions(filters) {
}, {});
}
function toOrderBookingListEntry(booking, stripDetails = false) {
function toOrderBookingListEntry(booking, stripDetails = false, options = {}) {
if (!stripDetails || !booking || typeof booking !== "object") {
return booking;
}
@@ -640,6 +681,13 @@ function toOrderBookingListEntry(booking, stripDetails = false) {
const summaryBooking = { ...booking };
delete summaryBooking.items;
delete summaryBooking.parsed_services;
if (options.stripMetadata === true) {
delete summaryBooking.reference;
delete summaryBooking.reference_number;
delete summaryBooking.notes;
delete summaryBooking.note;
delete summaryBooking.po;
}
return summaryBooking;
}
@@ -3086,7 +3134,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
json({
success: true,
data: bookings.map((booking) =>
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true)
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true, {
stripMetadata: posFixture.orderBookingListStripsMetadata === true,
})
),
})
);
@@ -3416,28 +3466,53 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
if (pathname.endsWith("/orders") && method === "POST") {
const body = request.postDataJSON?.() || {};
const orderId = posFixture.nextOrderId++;
const bookingId = normalizePositiveIntegerValue(body.booking_id);
const orderPo = normalizeOrderPoValue(body.po) || getPosOrderBookingPo(posFixture, bookingId);
posFixture.ordersById[orderId] = {
id: orderId,
customer_id: Number(body.customer_id),
department_id: Number(body.department_id || body.department || 12),
reference: body.reference || "",
po: body.po || "",
po: orderPo,
safety_seal: normalizeSafetySealValue(body.safety_seal),
notes: body.notes || "",
reg_1: normalizeRegistrationValue(body.reg_1),
reg_2: normalizeRegistrationValue(body.reg_2),
reg_3: normalizeRegistrationValue(body.reg_3),
invoice_collection_id: null,
booking_id: normalizePositiveIntegerValue(body.booking_id),
booking_id: bookingId,
completed_at: null,
closed_at: null,
created_at: normalizeCreatedAtValue(body.created_at) || toSqlDateTime(),
include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice),
};
posFixture.orderItemsByOrderId[orderId] = [];
const booking = findPosOrderBookingById(posFixture, bookingId);
const bookingItems =
posFixture.preloadCreatedOrderItemsFromBooking && Array.isArray(booking?.items) ? booking.items : [];
posFixture.orderItemsByOrderId[orderId] = bookingItems
.map((bookingItem) => {
const productId = Number(bookingItem?.id || 0);
const product = (posFixture.products || []).find((entry) => Number(entry.id) === productId);
if (!product) {
return null;
}
return buildPosOrderItem(
product,
{
order_id: orderId,
product_id: productId,
quantity: bookingItem?.quantity || 1,
price: bookingItem?.price ?? product.price,
notes: bookingItem?.notes || "",
},
posFixture.nextOrderItemId++
);
})
.filter(Boolean);
posFixture.economicModuleOrdersByOrderId[orderId] = { invoice_id: null, invoice_draft_id: null };
posFixture.stripeModuleOrdersByOrderId[orderId] = {};
await route.fulfill(json({ success: true, data: { id: orderId } }));
await route.fulfill(json({ success: true, data: { id: orderId, po: orderPo } }));
return true;
}
@@ -3455,7 +3530,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
body
);
if (posFixture.ordersById[orderId]) {
posFixture.ordersById[orderId] = {
const updatedOrder = {
...posFixture.ordersById[orderId],
...body,
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
@@ -3477,6 +3552,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
: {}),
};
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
}
if (shouldRegenerateWashCertificate) {
replaceWashCertificateAttachment(posFixture, orderId);
@@ -3506,8 +3582,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
: body.field === "created_at"
? normalizeCreatedAtValue(body.value)
: body.value;
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, posFixture.ordersById[orderId]);
} else {
posFixture.ordersById[orderId] = {
const updatedOrder = {
...posFixture.ordersById[orderId],
...body,
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
@@ -3529,6 +3606,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
: {}),
};
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
}
}
if (shouldRegenerateWashCertificate) {
+51
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { API_URL } from "@/config.js";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
@@ -9,6 +10,7 @@ import {
__resetAxiosRequestQueueInstallerForTests,
installAxiosRequestQueue,
} from "@/services/installAxiosRequestQueue.js";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
const flushMicrotasks = async () => {
await Promise.resolve();
@@ -44,6 +46,7 @@ describe("axios request queue interceptor", () => {
beforeEach(() => {
__resetAxiosRequestQueueInstallerForTests();
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
__configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 });
installAxiosRequestQueue();
});
@@ -51,6 +54,7 @@ describe("axios request queue interceptor", () => {
afterEach(() => {
__resetAxiosRequestQueueInstallerForTests();
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
});
it("queues plain axios requests system-wide", async () => {
@@ -96,6 +100,53 @@ describe("axios request queue interceptor", () => {
expect(requestQueueState.batchFailed).toBe(0);
});
it("rewrites default API requests to the active release API before dispatch", async () => {
configureReleaseRuntime({
channel: { slug: "canary" },
versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 },
api_base_url: "https://api-v2.truckwash.io/canary/api",
});
let dispatchedUrl = "";
let dispatchedHeaders = {};
const response = await axios({
url: `${API_URL}/orders`,
method: "GET",
adapter: async (config) => {
dispatchedUrl = config.url;
dispatchedHeaders = config.headers || {};
return createResponse({ ok: true });
},
});
expect(response.data).toEqual({ ok: true });
expect(dispatchedUrl).toBe("https://api-v2.truckwash.io/canary/api/orders");
expect(dispatchedHeaders["X-Release-Trace"]).toBe("test-trace");
expect(dispatchedHeaders["X-Release-Channel"]).toBe("canary");
expect(dispatchedHeaders["X-Frontend-Version"]).toEqual(expect.any(String));
});
it("does not rewrite requests that opt out for release manager control calls", async () => {
configureReleaseRuntime({
channel: { slug: "canary" },
versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 },
api_base_url: "https://api-v2.truckwash.io/canary/api",
});
let dispatchedUrl = "";
await axios({
url: `${API_URL}/superuser/releases`,
method: "GET",
__skipReleaseApiRewrite: true,
adapter: async (config) => {
dispatchedUrl = config.url;
return createResponse({ ok: true });
},
});
expect(dispatchedUrl).toBe(`${API_URL}/superuser/releases`);
});
it("allows 5 concurrent GET requests", async () => {
__configureRequestQueueForTests({ maxConcurrentGet: 5, maxConcurrentOther: 1, spacingMs: 0 });
+192
View File
@@ -0,0 +1,192 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RELEASE_RUNTIME_GLOBAL_KEY,
bootstrapReleaseApp,
fetchReleaseRuntime,
loadRemoteReleaseEntry,
runtimeApiUrl,
shouldLoadRemoteRelease,
} from "@/services/releaseBootstrap.js";
describe("release bootstrap", () => {
beforeEach(() => {
localStorage.clear();
delete window[RELEASE_RUNTIME_GLOBAL_KEY];
});
afterEach(() => {
localStorage.clear();
delete window[RELEASE_RUNTIME_GLOBAL_KEY];
document.head.innerHTML = "";
vi.restoreAllMocks();
});
it("includes selected release channel in the runtime request", () => {
localStorage.setItem("release_channel_selected_slug", "Canary Preview!");
expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
"https://api-v2.truckwash.io/canary-preview/api/release/runtime?release_channel=canary-preview"
);
});
it("keeps stable runtime requests on the configured control API", () => {
localStorage.setItem("release_channel_selected_slug", "stable");
expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
"https://api.truckwash.io/release/runtime?release_channel=stable"
);
});
it("resolves local dev API runtime requests against the current origin", () => {
expect(runtimeApiUrl("/api")).toBe(`${window.location.origin}/api/release/runtime`);
});
it("attaches release trace, channel, and frontend headers to runtime requests", async () => {
localStorage.setItem("release_trace_id", "trace-runtime");
localStorage.setItem("release_channel_selected_slug", "Internal");
const fetchFn = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { channel: { slug: "internal" } } }),
}));
await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api.truckwash.io" });
const [url, options] = fetchFn.mock.calls[0];
expect(url).toBe("https://api-v2.truckwash.io/internal/api/release/runtime?release_channel=internal");
expect(options.headers).toMatchObject({
"X-Release-Trace": "trace-runtime",
"X-Release-Channel": "internal",
"X-Frontend-Version": expect.any(String),
});
});
it("loads the local app for the default channel", async () => {
const loadLocalApp = vi.fn(async () => ({ local: true }));
const fetchFn = vi.fn(async () => ({
ok: true,
json: async () => ({
data: {
channel: { slug: "stable", default_channel: true },
availability: { configured: true },
},
}),
}));
await bootstrapReleaseApp({ loadLocalApp, fetchFn });
expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].channel.slug).toBe("stable");
});
it("loads the local unavailable app state when selected release runtime returns invalid JSON", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
localStorage.setItem("release_channel_selected_slug", "internal");
const loadLocalApp = vi.fn(async () => ({ local: true }));
const fetchFn = vi.fn(async () => ({
ok: true,
text: async () => "<br /><b>Warning</b>Composer autoload warning",
}));
await bootstrapReleaseApp({ loadLocalApp, fetchFn });
expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
channel: { slug: "internal", default_channel: false },
availability: {
configured: false,
missing: ["release_runtime"],
status: "unconfigured",
},
});
expect(shouldLoadRemoteRelease(null)).toBe(false);
});
it("loads a non-default release entry without changing the browser URL", async () => {
const runtime = {
channel: { slug: "canary", default_channel: false },
availability: { configured: true },
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
};
const fetchFn = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: runtime }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
entry: "assets/index-canary.js",
css: ["assets/index-canary.css"],
}),
});
const importModule = vi.fn(async (url) => ({ url }));
const originalHref = window.location.href;
await bootstrapReleaseApp({
loadLocalApp: vi.fn(),
fetchFn,
importModule,
documentRef: document,
});
expect(shouldLoadRemoteRelease(runtime)).toBe(true);
expect(importModule).toHaveBeenCalledWith("https://api-v2.truckwash.io/canary/frontend/assets/index-canary.js");
expect(document.querySelector("link")?.href).toBe(
"https://api-v2.truckwash.io/canary/frontend/assets/index-canary.css"
);
expect(window.location.href).toBe(originalHref);
});
it("falls back to the local app with unavailable runtime when the release entry fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const loadLocalApp = vi.fn(async () => ({ local: true }));
const runtime = {
channel: { slug: "canary", default_channel: false },
availability: { configured: true },
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
};
const fetchFn = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: runtime }),
})
.mockResolvedValueOnce({
ok: false,
status: 404,
});
await bootstrapReleaseApp({ loadLocalApp, fetchFn, importModule: vi.fn(), documentRef: document });
expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].availability).toMatchObject({
configured: false,
missing: ["frontend_entry"],
status: "unconfigured",
});
});
it("injects release entry CSS only once", async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
json: async () => ({
entry: "assets/index-canary.js",
css: ["assets/index-canary.css"],
}),
}));
const importModule = vi.fn(async () => ({}));
const runtime = {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
};
await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document });
await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document });
expect(document.querySelectorAll("link[data-release-entry-css]")).toHaveLength(1);
});
});
+189 -11
View File
@@ -4,13 +4,27 @@ import {
acknowledgeReleaseChannelSwitch,
buildReleaseFrontendRedirectUrl,
getReleaseChannelSwitchedStatus,
getReleaseChannelOptions,
getReleaseChannelUnavailableStatus,
hasSelectableReleaseChannels,
ignoreUnavailableReleaseChannel,
isReleaseChannelApiAvailabilityError,
markReleaseChannelApiUnavailable,
reconcileSelectedReleaseChannel,
RELEASE_CHANNEL_IGNORE_MS,
RELEASE_CHANNEL_IGNORE_STORAGE_KEY,
RELEASE_CHANNEL_SELECTION_STORAGE_KEY,
RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY,
releaseChannelRuntimeRequestParams,
selectReleaseChannel,
switchSelectedReleaseChannel,
__resetReleaseChannelAvailabilityForTests,
} from "@/services/releaseChannelAvailability.js";
import {
__resetReleaseTimelineForTests,
configureReleaseRuntime,
releaseRuntimeState,
} from "@/services/releaseTimeline.js";
const canaryRuntime = {
channel: {
@@ -20,19 +34,25 @@ const canaryRuntime = {
description: "Early validation channel",
default_channel: false,
},
frontend_base_url: "",
api_base_url: "",
versions: {
frontend: null,
api: null,
bundle_id: null,
},
availability: {
configured: false,
missing: ["frontend_base_url", "api_base_url"],
missing: ["release_bundle", "frontend_version", "api_version"],
status: "unconfigured",
},
};
const configuredCanaryRuntime = {
...canaryRuntime,
frontend_base_url: "https://canary.example.test",
api_base_url: "https://api-canary.example.test",
versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "feedface" },
bundle_id: 31,
},
availability: {
configured: true,
missing: [],
@@ -43,6 +63,7 @@ const configuredCanaryRuntime = {
describe("release channel availability", () => {
afterEach(() => {
__resetReleaseChannelAvailabilityForTests();
__resetReleaseTimelineForTests();
});
it("blocks a non-default assigned channel when targets are missing", () => {
@@ -50,7 +71,7 @@ describe("release channel availability", () => {
expect(status.shouldBlock).toBe(true);
expect(status.channelSlug).toBe("canary");
expect(status.missing).toEqual(["frontend_base_url", "api_base_url"]);
expect(status.missing).toEqual(["release_bundle", "frontend_version", "api_version"]);
});
it("does not block the stable default channel even when runtime targets are absent", () => {
@@ -62,7 +83,7 @@ describe("release channel availability", () => {
},
availability: {
configured: false,
missing: ["frontend_base_url"],
missing: ["release_bundle"],
},
});
@@ -70,6 +91,129 @@ describe("release channel availability", () => {
expect(status.shouldBlock).toBe(false);
});
it("exposes selectable runtime channels when a non-default assignment is available", () => {
const runtime = {
channel: configuredCanaryRuntime.channel,
versions: configuredCanaryRuntime.versions,
availability: configuredCanaryRuntime.availability,
availableChannels: [
{
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: configuredCanaryRuntime.channel,
versions: configuredCanaryRuntime.versions,
availability: configuredCanaryRuntime.availability,
},
],
};
const options = getReleaseChannelOptions(runtime);
expect(options.map((option) => option.channelSlug)).toEqual(["stable", "canary"]);
expect(hasSelectableReleaseChannels(runtime)).toBe(true);
});
it("hides the selector when only the default stable channel is available", () => {
expect(
hasSelectableReleaseChannels({
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
availableChannels: [
{
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
},
],
})
).toBe(false);
});
it("stores the selected release channel for runtime refresh requests", () => {
const selectedSlug = selectReleaseChannel({ slug: "canary" });
expect(selectedSlug).toBe("canary");
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBe("canary");
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");
const selectedSlug = reconcileSelectedReleaseChannel({
availableChannels: [
{
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
},
],
});
expect(selectedSlug).toBe("");
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
});
it("does not block legacy runtime payloads that omit availability and target URLs", () => {
const runtime = {
channel: {
@@ -134,12 +278,15 @@ describe("release channel availability", () => {
expect(anonymousStatus.shouldShow).toBe(false);
});
it("builds a same-path redirect for a configured channel frontend", () => {
it("does not build a channel-specific frontend redirect", () => {
const redirectUrl = buildReleaseFrontendRedirectUrl(
{
channel: { slug: "beta", default_channel: false },
frontend_base_url: "https://beta.example.test",
api_base_url: "https://api-beta.example.test",
versions: {
frontend: { version_label: "frontend-beta" },
api: { version_label: "api-beta" },
bundle_id: 52,
},
},
{
origin: "https://app.example.test",
@@ -149,6 +296,37 @@ describe("release channel availability", () => {
}
);
expect(redirectUrl).toBe("https://beta.example.test/user/bookings?page=2#next");
expect(redirectUrl).toBeNull();
});
it("classifies selected channel auth session 404s as release API availability failures", () => {
configureReleaseRuntime({
trace_id: "trace-channel-api",
channel: { slug: "internal", name: "Intern", default_channel: false },
versions: {
frontend: { version_label: "frontend-internal" },
api: { version_label: "api-internal" },
bundle_id: 42,
},
api_base_url: "https://api-v2.truckwash.io/internal/api",
frontend_base_url: "https://api-v2.truckwash.io/internal/frontend",
});
const error = {
response: { status: 404 },
config: { url: "https://api-v2.truckwash.io/internal/api/auth/session" },
};
expect(isReleaseChannelApiAvailabilityError(error)).toBe(true);
const unavailableRuntime = markReleaseChannelApiUnavailable();
expect(unavailableRuntime.availability).toMatchObject({
configured: false,
status: "unconfigured",
explicit: true,
});
expect(unavailableRuntime.availability.missing).toContain("api_base_url");
expect(releaseRuntimeState.availability.configured).toBe(false);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/i18n";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import { __resetReleaseChannelAvailabilityForTests } from "@/services/releaseChannelAvailability.js";
import { configureReleaseRuntime, __resetReleaseTimelineForTests } from "@/services/releaseTimeline.js";
const configureSelectableChannels = () => {
configureReleaseRuntime({
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
available_channels: [
{
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: {
slug: "canary",
name: "Canary",
default_channel: false,
},
availability: {
configured: false,
missing: ["release_bundle"],
status: "unconfigured",
},
},
],
});
};
describe("ReleaseChannelSelector", () => {
afterEach(() => {
__resetReleaseChannelAvailabilityForTests();
__resetReleaseTimelineForTests();
});
it("renders available release channels and emits the selected channel", async () => {
configureSelectableChannels();
i18n.global.locale.value = "en";
const wrapper = mount(ReleaseChannelSelector, {
global: {
plugins: [i18n],
},
});
expect(wrapper.find('[data-testid="release-channel-selector"]').exists()).toBe(true);
expect(wrapper.text()).toContain("Stable");
expect(wrapper.text()).toContain("Canary");
expect(wrapper.text()).toContain("Release bundle");
await wrapper.find('[data-testid="release-channel-option-canary"]').trigger("click");
expect(wrapper.emitted("select")).toHaveLength(1);
expect(wrapper.emitted("select")[0][0].channelSlug).toBe("canary");
});
});
+181
View File
@@ -0,0 +1,181 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const activeLocales = ["da", "en", "sv", "de", "no"];
const channelKeys = ["stable", "canary", "internal"];
const requiredReleaseAvailabilityLabels = {
channel_unavailable: [
"release_bundle",
"frontend_version",
"api_version",
"frontend_base_url",
"api_base_url",
"frontend_entry",
"release_runtime",
],
channel_selector: [
"release_bundle",
"frontend_version",
"api_version",
"frontend_base_url",
"api_base_url",
"frontend_entry",
"release_runtime",
"bundle",
"frontend",
"api",
],
};
const expectedNewReleaseLabels = {
da: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-entry",
release_runtime: "Release-runtime",
},
en: {
frontend_base_url: "Frontend URL",
api_base_url: "API URL",
frontend_entry: "Frontend entry",
release_runtime: "Release runtime",
},
sv: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-entry",
release_runtime: "Release-runtime",
},
de: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-Einstiegspunkt",
release_runtime: "Release-Laufzeit",
},
no: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-entry",
release_runtime: "Release-runtime",
},
};
const sharedReleaseDetailLabels = {
bundle: "Bundle",
frontend: "Frontend",
api: "API",
};
const suspiciousTranslationArtifact =
/(?:\p{L}\?\p{L}|(?:^|[\s([{])\?\p{L}|\p{L}\?@:\{|\u00c3|\u00c2|\ufffd|\u00ef\u00bf\u00bd)/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
const mergedMessages = { ...sharedMessages };
for (const [key, value] of Object.entries(localeMessages ?? {})) {
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
} else {
mergedMessages[key] = value;
}
}
return mergedMessages;
};
const flattenStrings = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [];
}
return Object.entries(value).flatMap(([key, nestedValue]) =>
flattenStrings(nestedValue, prefix ? `${prefix}.${key}` : key)
);
};
const loadReleaseManagerCatalogs = () =>
activeLocales.flatMap((locale) => {
const runtimeV1 = readJsonFile(join(root, `src/i18n/locales/${locale}.json`)).configuration.release_manager;
const source = readJsonFile(join(root, `src/i18n/source/${locale}/phrases/compat/configuration/index.json`)).compat
.configuration.release_manager;
const localeV2 = readJsonFile(join(root, `src/i18n/generated/${locale}-v2.json`));
const generatedV2 = localeV2.templates.generated.compat.configuration.release_manager;
const globalV2 = readJsonFile(join(root, "src/i18n/generated/global-v2.json"));
const runtimeV2 = mergeMessages(mergeMessages(globalV2.shared, globalV2.locales?.[locale]), localeV2).configuration
.release_manager;
return [
[`${locale} v1`, runtimeV1],
[`${locale} source`, source],
[`${locale} generated v2`, generatedV2],
[`${locale} runtime v2`, runtimeV2],
];
});
describe("release manager i18n", () => {
it("does not contain replacement-character translation artifacts", () => {
const failures = [];
for (const [name, releaseManager] of loadReleaseManagerCatalogs()) {
for (const { key, value } of flattenStrings(releaseManager)) {
if (suspiciousTranslationArtifact.test(value)) {
failures.push(`${name}: ${key} = ${value}`);
}
}
}
expect(failures).toEqual([]);
});
it("defines localized names and descriptions for built-in release channels", () => {
for (const [name, releaseManager] of loadReleaseManagerCatalogs()) {
expect(Object.keys(releaseManager.channel_names ?? {}), `${name} channel_names`).toEqual(channelKeys);
expect(Object.keys(releaseManager.channel_descriptions ?? {}), `${name} channel_descriptions`).toEqual(
channelKeys
);
for (const key of channelKeys) {
expect(releaseManager.channel_names[key], `${name} channel_names.${key}`).toEqual(expect.any(String));
expect(releaseManager.channel_descriptions[key], `${name} channel_descriptions.${key}`).toEqual(
expect.any(String)
);
}
}
});
it("defines release channel readiness labels for all runtime catalogs", () => {
for (const [name, releaseManager] of loadReleaseManagerCatalogs()) {
const locale = name.split(" ")[0];
for (const [group, keys] of Object.entries(requiredReleaseAvailabilityLabels)) {
for (const key of keys) {
const value = releaseManager[group]?.[key];
expect(value, `${name} ${group}.${key}`).toEqual(expect.any(String));
expect(value, `${name} ${group}.${key}`).not.toBe("");
expect(value, `${name} ${group}.${key}`).not.toBe(key);
const expectedLabel = expectedNewReleaseLabels[locale]?.[key] ?? sharedReleaseDetailLabels[key] ?? null;
if (expectedLabel && !value.startsWith("@:")) {
expect(value, `${name} ${group}.${key}`).toBe(expectedLabel);
}
}
}
}
});
it("uses Danish copy for the internal channel guard", () => {
const danishReleaseManager = readJsonFile(join(root, "src/i18n/locales/da.json")).configuration.release_manager;
expect(danishReleaseManager.channel_names.internal).toBe("Intern");
expect(danishReleaseManager.channel_descriptions.internal).toContain("Intern kanal");
expect(danishReleaseManager.channel_descriptions.internal).not.toBe(
"Internal staff and superuser validation channel."
);
});
});
+218 -1
View File
@@ -3,12 +3,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetReleaseTimelineForTests,
__setReleaseTimelineTransportForTests,
buildReleaseSessionSummary,
buildCurrentReleaseHeaders,
buildReleaseTimelineContext,
configureReleaseRuntime,
flushReleaseTimelineEvents,
getReleaseRuntimeApiBaseUrl,
getRecentFrontendFailureEvents,
recordReleaseTimelineEvent,
redactReleasePayload,
releaseRuntimeState,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
describe("release timeline runtime", () => {
@@ -29,13 +34,202 @@ describe("release timeline runtime", () => {
configureReleaseRuntime({
trace_id: "trace-123",
channel: { slug: "canary", name: "Canary" },
versions: { frontend: { version_label: "abc123" }, api: null },
available_channels: [{ channel: { slug: "stable", name: "Stable", default_channel: true } }],
versions: { frontend: { version_label: "abc123" }, api: null, bundle_id: 31 },
capture_policy: { enabled: true, capture_level: "full_redacted", retention_days: 7 },
});
expect(releaseRuntimeState.channel.slug).toBe("canary");
expect(releaseRuntimeState.availableChannels).toHaveLength(1);
expect(releaseRuntimeState.traceId).toBe("trace-123");
expect(releaseRuntimeState.versions.frontend.version_label).toBe("abc123");
expect(releaseRuntimeState.versions.bundle_id).toBe(31);
});
it("builds display rows for the active release bundle and connected services", () => {
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-session-release",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
deployment_status: "ready",
},
redis: {
id: 55,
resource_name: "canary-redis",
resource_uuid: "redis-resource-uuid",
deployment_status: "ready",
},
minio: {
id: 56,
resource_name: "canary-minio",
resource_uuid: "minio-resource-uuid",
availability_state: "healthy",
},
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
const summary = buildReleaseSessionSummary();
expect(releaseRuntimeState.versions.service_set.name).toBe("Canary isolated stack");
expect(summary.channelLabel).toBe("Canary");
expect(summary.traceId).toBe("trace-session-release");
expect(summary.bundleLabel).toBe("#31 canary-bundle");
expect(summary.serviceSetLabel).toBe("Canary isolated stack");
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
primaryText: "frontend-canary",
url: "https://api-v2.truckwash.io/canary/frontend",
tone: "ok",
});
expect(summary.appRows.find((row) => row.key === "api")?.secondaryText).toContain("feedface0000");
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
primaryText: "canary-db #54",
tone: "ok",
});
expect(summary.serviceRows.find((row) => row.key === "redis")?.secondaryText).toContain("redis-resour");
expect(summary.serviceRows.find((row) => row.key === "minio")?.status).toBe("healthy");
});
it("marks stable sessions without release bundle data as shared runtime", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-stable",
channel: { slug: "stable", name: "Stable", default_channel: true },
versions: { frontend: null, api: null, bundle_id: null, bundle: null },
availability: { configured: true, missing: [], status: "ready" },
});
expect(summary.bundleLabel).toBe("Default/shared runtime");
expect(summary.serviceSetLabel).toBe("Default/shared runtime");
expect(summary.appRows.every((row) => row.primaryText === "Default/shared runtime")).toBe(true);
expect(summary.appRows.every((row) => row.tone === "ok")).toBe(true);
expect(summary.serviceRows.every((row) => row.primaryText === "Default/shared runtime")).toBe(true);
expect(summary.serviceRows.every((row) => row.tone === "ok")).toBe(true);
});
it("surfaces non-default release runtime missing values as warnings", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-internal",
channel: { slug: "internal", name: "Internal", default_channel: false },
versions: {
frontend: null,
api: { version_label: "api-internal" },
bundle_id: null,
bundle: null,
},
availability: {
configured: false,
missing: ["release_bundle", "frontend_version", "api_base_url"],
status: "unconfigured",
},
});
expect(summary.missingLabels).toEqual(["Release bundle", "Frontend version", "API URL"]);
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
tone: "warning",
missingLabel: "Frontend version",
});
expect(summary.appRows.find((row) => row.key === "api")).toMatchObject({
tone: "warning",
missingLabel: "API URL",
});
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
tone: "warning",
primaryText: "Missing release bundle",
});
});
it("uses release runtime frontend and API URLs when provided", () => {
configureReleaseRuntime({
trace_id: "trace-urls",
channel: { slug: "canary", name: "Canary" },
versions: {
frontend: { version_label: "frontend-canary" },
api: { version_label: "api-canary" },
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend/",
api_base_url: "https://api-v2.truckwash.io/canary/api/",
},
});
expect(releaseRuntimeState.frontendBaseUrl).toBe("https://api-v2.truckwash.io/canary/frontend");
expect(releaseRuntimeState.apiBaseUrl).toBe("https://api-v2.truckwash.io/canary/api");
expect(getReleaseRuntimeApiBaseUrl()).toBe("https://api-v2.truckwash.io/canary/api");
expect(resolveReleaseApiUrl("/orders")).toBe("https://api-v2.truckwash.io/canary/api/orders");
});
it("keeps same-origin release API URLs visible in the session summary", () => {
configureReleaseRuntime({
trace_id: "trace-local-api",
channel: { slug: "canary", name: "Canary" },
versions: {
frontend: { version_label: "frontend-canary" },
api: { version_label: "api-canary" },
bundle_id: 31,
},
urls: {
api_base_url: "/api/",
},
});
const summary = buildReleaseSessionSummary();
expect(releaseRuntimeState.apiBaseUrl).toBe("/api");
expect(getReleaseRuntimeApiBaseUrl()).toBe("/api");
expect(resolveReleaseApiUrl("/orders")).toBe("/api/orders");
expect(summary.appRows.find((row) => row.key === "api")?.url).toBe("/api");
});
it("builds release headers from the active runtime state", () => {
configureReleaseRuntime({
trace_id: "trace-headers",
channel: { slug: "internal", name: "Intern" },
versions: {
frontend: { version_label: "frontend-internal", commit_sha: "130cc2fc106a" },
api: { version_label: "api-internal" },
bundle_id: 42,
},
});
expect(buildCurrentReleaseHeaders()).toMatchObject({
"X-Release-Trace": "trace-headers",
"X-Release-Channel": "internal",
"X-Frontend-Version": "130cc2fc106a",
});
});
it("redacts sensitive payload keys recursively", () => {
@@ -128,6 +322,29 @@ describe("release timeline runtime", () => {
expect(sentBodies).toHaveLength(1);
expect(sentBodies[0].context.channel_slug).toBe("beta");
expect(sentBodies[0].context.device.type).toBe("desktop");
expect(sentBodies[0].context.frontend.version_label).toBeTruthy();
expect(sentBodies[0].events[0].type).toBe("route_change");
});
it("builds device and release context for replay sessions", () => {
configureReleaseRuntime({
trace_id: "trace-context",
channel: { slug: "canary" },
versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "def456" },
},
capture_policy: { enabled: true, capture_level: "full_redacted" },
});
const context = buildReleaseTimelineContext();
expect(context.trace_id).toBe("trace-context");
expect(context.channel_slug).toBe("canary");
expect(context.device.type).toBe("desktop");
expect(context.frontend.version_label).toBe("frontend-canary");
expect(context.api.version_label).toBe("api-canary");
expect(context.viewport.width).toBeGreaterThan(0);
});
});
+64
View File
@@ -5,6 +5,7 @@ import axios from "axios";
import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { API_URL } from "@/config.js";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
@@ -62,6 +63,7 @@ describe("RequestQueueProgress", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
__resetReleaseTimelineForTests();
__resetRequestQueueForTests();
__configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 });
vi.stubGlobal(
@@ -73,6 +75,7 @@ describe("RequestQueueProgress", () => {
afterEach(() => {
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
resetSessionUserState();
localStorage.clear();
vi.restoreAllMocks();
@@ -253,6 +256,67 @@ describe("RequestQueueProgress", () => {
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
});
it("shows the active session release bundle, app URLs, and connected services", async () => {
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-request-panel",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
const wrapper = mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Session release");
expect(runtimeBox.text()).toContain("Canary");
expect(runtimeBox.text()).toContain("trace-request-panel");
expect(runtimeBox.text()).toContain("#31 canary-bundle");
expect(runtimeBox.text()).toContain("Canary isolated stack");
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain("frontend-canary");
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain(
"https://api-v2.truckwash.io/canary/frontend"
);
expect(wrapper.get("[data-testid='request-queue-release-app-api']").text()).toContain("api-canary");
expect(wrapper.get("[data-testid='request-queue-release-service-database']").text()).toContain("canary-db #54");
expect(wrapper.get("[data-testid='request-queue-release-service-redis']").text()).toContain("canary-redis #55");
expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56");
});
it("shows subuser details in the user box", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.name.value = "Sub User";
+17
View File
@@ -18,6 +18,7 @@ import {
releaseManagerControlApiCandidates,
setReleaseManagerControlApiUrl,
} from "@/services/superuserReleases.js";
import { deployCoolifyGatewayRoutes } from "@/services/superuserCoolify.js";
import { __configureRequestQueueForTests, __resetRequestQueueForTests } from "@/services/requestQueue.js";
describe("superuser release manager service", () => {
@@ -71,4 +72,20 @@ describe("superuser release manager service", () => {
expect(localStorage.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY)).toBeNull();
expect(releaseManagerControlApiCandidates()).toContain("https://api.truckwash.io:4433");
});
it("keeps Coolify route deployment on the release manager control API", async () => {
setReleaseManagerControlApiUrl("https://control.example.test");
axiosMock.mockResolvedValueOnce({ status: 200, data: { success: true } });
await deployCoolifyGatewayRoutes({ dry_run: true });
expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://control.example.test/superuser/coolify/load-balancer/routes/deploy",
method: "POST",
data: { dry_run: true },
__skipReleaseApiRewrite: true,
})
);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createApiProxyOptions } from "../../vite.config.js";
describe("Vite API proxy", () => {
it("forwards local /api requests to the working dev API by default", () => {
const options = createApiProxyOptions({});
expect(options.target).toBe("https://api.truckwash.io:4433");
expect(options.changeOrigin).toBe(true);
expect(options.secure).toBe(false);
expect(options.rewrite("/api/ping")).toBe("/ping");
expect(options.rewrite("/api/release/runtime")).toBe("/release/runtime");
expect(options.rewrite("/api")).toBe("/");
});
it("allows a local gateway override", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
});
expect(options.target).toBe("http://localhost");
expect(options.rewrite("/api/ping")).toBe("/ping");
});
it("allows disabling prefix stripping for compatible local gateways", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
VITE_API_PROXY_STRIP_PREFIX: "false",
});
expect(options.target).toBe("http://localhost");
expect(options.rewrite).toBeUndefined();
});
});
+49
View File
@@ -64,6 +64,51 @@ function patchBuefyCssMediaQuery() {
}
}
function releaseEntryManifest() {
return {
name: 'release-entry-manifest',
generateBundle(_, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk')
const mainChunk = chunks.find((chunk) =>
String(chunk.facadeModuleId || '').replace(/\\/g, '/').endsWith('/src/main.js')
) || chunks.find((chunk) => chunk.name === 'main' && /^assets\/main-[\w-]+\.js$/.test(chunk.fileName))
if (!mainChunk) {
this.warn('Could not find src/main.js chunk for release-entry.json')
return
}
const css = Array.from(mainChunk.viteMetadata?.importedCss || [])
this.emitFile({
type: 'asset',
fileName: 'release-entry.json',
source: JSON.stringify(
{
entry: mainChunk.fileName,
css
},
null,
2
)
})
}
}
}
export function createApiProxyOptions(env = process.env) {
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
return {
target: env.VITE_API_PROXY_TARGET || 'https://api.truckwash.io:4433',
changeOrigin: true,
secure: false,
...(stripPrefix
? {
rewrite: (requestPath) => requestPath.replace(/^\/api(?=\/|$)/, '') || '/'
}
: {})
}
}
export default defineConfig(({ mode }) => {
const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
@@ -107,6 +152,7 @@ export default defineConfig(({ mode }) => {
patchBuefyCssMediaQuery(),
vue(),
VueJsx(),
releaseEntryManifest(),
!isProd && !isPlaywrightRuntime && vueDevTools(),
enableSingleFile && viteSingleFile(),
VitePWA({
@@ -228,6 +274,9 @@ export default defineConfig(({ mode }) => {
isolate: true
},
server: {
proxy: {
'/api': createApiProxyOptions()
},
watch: {
ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**']
}