Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a952dd2c61 | ||
|
|
ebcd52a019 | ||
|
|
b2ddf0db5c | ||
|
|
eaf4b965d5 | ||
|
|
6ba1c754a1 | ||
|
|
468613ab68 | ||
|
|
261d84bd01 | ||
|
|
ea8db593c5 | ||
|
|
130cc2fc10 | ||
|
|
3715da8fd3 | ||
|
|
100e477a95 | ||
|
|
1132c8c256 | ||
|
|
42237fcc70 | ||
|
|
ae8b3b6b64 | ||
|
|
8378491b0b |
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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(() => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { bootstrapReleaseApp } from "@/services/releaseBootstrap.js";
|
||||
|
||||
void bootstrapReleaseApp({
|
||||
loadLocalApp: () => import("./main.js"),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -1,55 +1,61 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { requestReleaseManager } from "@/services/superuserReleases.js";
|
||||
|
||||
export const getCoolifySummary = () =>
|
||||
authenticatedRequest("/superuser/coolify", "GET", {});
|
||||
requestReleaseManager("/superuser/coolify", "GET", {});
|
||||
|
||||
export const getCoolifyLoadBalancer = () =>
|
||||
authenticatedRequest("/superuser/coolify/load-balancer", "GET", {});
|
||||
requestReleaseManager("/superuser/coolify/load-balancer", "GET", {});
|
||||
|
||||
export const reconcileCoolifyLoadBalancer = (payload) =>
|
||||
authenticatedRequest("/superuser/coolify/load-balancer/reconcile", "POST", payload);
|
||||
requestReleaseManager("/superuser/coolify/load-balancer/reconcile", "POST", payload);
|
||||
|
||||
export const deployCoolifyGatewayRoutes = (payload) =>
|
||||
requestReleaseManager("/superuser/coolify/load-balancer/routes/deploy", "POST", payload);
|
||||
|
||||
export const deployCoolifyGatewayApiCode = (payload) =>
|
||||
requestReleaseManager("/superuser/coolify/load-balancer/api/deploy", "POST", payload);
|
||||
|
||||
export const listCoolifyGateways = () =>
|
||||
authenticatedRequest("/superuser/coolify/gateways", "GET", {});
|
||||
requestReleaseManager("/superuser/coolify/gateways", "GET", {});
|
||||
|
||||
export const saveCoolifyGateway = (payload) =>
|
||||
authenticatedRequest("/superuser/coolify/gateways", "POST", payload);
|
||||
requestReleaseManager("/superuser/coolify/gateways", "POST", payload);
|
||||
|
||||
export const testCoolifyGateway = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/gateways/${id}/test`, "POST", {});
|
||||
requestReleaseManager(`/superuser/coolify/gateways/${id}/test`, "POST", {});
|
||||
|
||||
export const createCoolifyInstance = (payload) =>
|
||||
authenticatedRequest("/superuser/coolify/instances", "POST", payload);
|
||||
requestReleaseManager("/superuser/coolify/instances", "POST", payload);
|
||||
|
||||
export const testCoolifyInstance = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/instances/${id}/test`, "POST", {});
|
||||
requestReleaseManager(`/superuser/coolify/instances/${id}/test`, "POST", {});
|
||||
|
||||
export const discoverCoolifyPlacement = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/instances/${id}/placement`, "GET", {});
|
||||
requestReleaseManager(`/superuser/coolify/instances/${id}/placement`, "GET", {});
|
||||
|
||||
export const listCoolifyTargets = ({ kind = null } = {}) =>
|
||||
authenticatedRequest("/superuser/coolify/targets", "GET", kind ? { kind } : {});
|
||||
requestReleaseManager("/superuser/coolify/targets", "GET", kind ? { kind } : {});
|
||||
|
||||
export const createCoolifyTarget = (payload) =>
|
||||
authenticatedRequest("/superuser/coolify/targets", "POST", payload);
|
||||
requestReleaseManager("/superuser/coolify/targets", "POST", payload);
|
||||
|
||||
export const reconcileCoolifyTarget = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/targets/${id}/reconcile`, "POST", {});
|
||||
requestReleaseManager(`/superuser/coolify/targets/${id}/reconcile`, "POST", {});
|
||||
|
||||
export const deployCoolifyTarget = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/targets/${id}/deploy`, "POST", {});
|
||||
requestReleaseManager(`/superuser/coolify/targets/${id}/deploy`, "POST", {});
|
||||
|
||||
export const restartCoolifyTarget = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/targets/${id}/restart`, "POST", {});
|
||||
requestReleaseManager(`/superuser/coolify/targets/${id}/restart`, "POST", {});
|
||||
|
||||
export const failoverCoolifyTarget = (id) =>
|
||||
authenticatedRequest(`/superuser/coolify/targets/${id}/failover`, "POST", {});
|
||||
requestReleaseManager(`/superuser/coolify/targets/${id}/failover`, "POST", {});
|
||||
|
||||
export const deleteCoolifyTarget = (id, payload) =>
|
||||
authenticatedRequest(`/superuser/coolify/targets/${id}`, "DELETE", payload);
|
||||
requestReleaseManager(`/superuser/coolify/targets/${id}`, "DELETE", payload);
|
||||
|
||||
export const getCoolifyConfig = (variable = null) =>
|
||||
authenticatedRequest("/coolify/config", "GET", variable ? { variable } : {});
|
||||
requestReleaseManager("/coolify/config", "GET", variable ? { variable } : {});
|
||||
|
||||
export const setCoolifyConfig = (payload) =>
|
||||
authenticatedRequest("/coolify/config", "POST", payload);
|
||||
requestReleaseManager("/coolify/config", "POST", payload);
|
||||
|
||||
@@ -113,7 +113,7 @@ const buildHeaders = () => {
|
||||
return headers;
|
||||
};
|
||||
|
||||
const requestReleaseManager = (url, method, data = {}) => {
|
||||
export const requestReleaseManager = (url, method, data = {}) => {
|
||||
const candidates = releaseManagerControlApiCandidates();
|
||||
const headers = buildHeaders();
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+4676
-985
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+1410
-72
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.");
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/**']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user