Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard fac731ae19 Support prefixed beta frontend deployment 2026-05-27 14:33:44 +02:00
Jeppe Bundgaard 2f2beb774a Preserve release commit in Coolify frontend builds 2026-05-27 14:04:02 +02:00
Jeppe Bundgaard a98dc061da Prepare beta frontend release gates 2026-05-27 13:03:55 +02:00
Jeppe Bundgaard 184ef5670c Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels. 2026-05-26 17:28:21 +02:00
Jeppe Bundgaard 8ab8915429 Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration. 2026-05-26 14:14:13 +02:00
Jeppe B a952dd2c61 Merge pull request #52
Add endpoint mode options and enhance runtime refresh handling
2026-05-21 14:48:44 +02:00
Jeppe Bundgaard ebcd52a019 Add endpoint mode options, release channel rollback handling, and runtime refresh improvements
- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
2026-05-21 14:47:46 +02:00
Jeppe Bundgaard b2ddf0db5c Add beta frontend debug display in build info 2026-05-21 11:45:20 +02:00
Jeppe Bundgaard eaf4b965d5 Add runtime support for service sets, release management enhancements, and new unit tests
- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs.
- Update the release session builder to include `service_set` handling.
- Introduce release service definitions, status tones, and enhanced runtime display text.
- Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`.
- Implement extensive utility methods for data service handling, target context, and deployment endpoints.
- Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
2026-05-21 11:41:50 +02:00
Jeppe Bundgaard 6ba1c754a1 Serve frontend releases under channel prefixes 2026-05-20 19:27:01 +02:00
Jeppe Bundgaard 468613ab68 Add Coolify frontend release Docker image 2026-05-20 18:53:17 +02:00
Jeppe Bundgaard 261d84bd01 Route selected release runtime through gateway 2026-05-20 18:32:21 +02:00
Jeppe Bundgaard ea8db593c5 Add release headers service, runtime-aware API header handling, and i18n updates for release configuration
- Implement `releaseHeaders.js` to manage release-related HTTP headers.
- Update requests to include runtime-based release headers.
- Extend i18n phrases across multiple locales for release runtime and configuration labels.
- Enhance unit tests for header building and API availability error handling.
2026-05-20 17:52:54 +02:00
Jeppe Bundgaard 130cc2fc10 Add release runtime bootstrap implementation with unit and E2E tests
- Create `releaseBootstrap.js` service to handle runtime configuration and remote frontend loading.
- Integrate runtime-aware API URL resolution across components and services.
- Update i18n phrases to clarify new domains for `ssl_domain_message`.
- Enhance unit and E2E tests to validate runtime-based frontend loading and fallback behavior.
- Refactor session-related API calls and incorporate runtime-configured base URLs.
2026-05-20 11:26:21 +02:00
Jeppe Bundgaard 3715da8fd3 Add release channel selector components, subject autocomplete, and related tests
- Implement components for `ReleaseChannelSelector`, including panel and sidebar variants.
- Add `ReleaseAssignmentSubjectAutocomplete` for user, subuser, and customer search functionality.
- Update i18n phrases for channel selector and subject assignment.
- Enhance unit and E2E tests for release channel availability, switching, and runtime configuration.
- Modify modules for channel-related state handling and runtime refresh mechanics.
2026-05-20 10:31:49 +02:00
Jeppe Bundgaard 100e477a95 Add "Delete" functionality for release service sets in isolated stacks
- Implement deletion of inactive isolated stacks via API and UI.
- Normalize build pack handling, defaulting frontend to "static".
- Update E2E tests to verify deletion and adjusted UI behavior.
- Refactor related release manager methods and validation logic.
2026-05-19 16:55:20 +02:00
Jeppe Bundgaard 1132c8c256 Merge remote-tracking branch 'origin/master' 2026-05-19 14:47:01 +02:00
Jeppe Bundgaard 42237fcc70 Add unit tests for release manager i18n validation and implement ReleaseReplayInspector Vue component for timeline inspection and replay functionality. 2026-05-19 14:46:42 +02:00
gold-dev ae8b3b6b64 Feat: Hvidovre only one toggler 2026-05-19 14:26:23 +03:00
Jeppe B 8378491b0b Merge pull request #51
Fix chromium mobile runner flakes
2026-05-19 13:12:11 +02:00
130 changed files with 22137 additions and 2621 deletions
+17
View File
@@ -0,0 +1,17 @@
.github
.idea
.vscode
coverage
dev-dist
dist
node_modules
node_modules.*
output
playwright-report
test-results
.ai
.ai-workflow
.claude
.codex
.gradle
gradle
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+139
View File
@@ -0,0 +1,139 @@
name: Frontend Release
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
jobs:
build-upload-and-verify:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 30
RELEASE_WAIT_TIMEOUT_SECONDS: 300
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks
run: |
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
run: npm run build
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Production Playwright gate
run: npm run test:e2e:prod
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 14
- name: Install lftp
run: |
if ! command -v lftp >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y lftp
fi
- name: Upload hashed assets before release metadata
run: |
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
npm run release:upload:lftp
env:
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
- name: Wait for exact uploaded build
run: npm run release:verify-upload
- name: Public live Playwright gate
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
- name: Update server version after verification
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.sha }}
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 14
+28
View File
@@ -0,0 +1,28 @@
FROM node:24-alpine AS build
WORKDIR /app
ARG RELEASE_COMMIT_SHA=""
ARG COMMIT_SHA=""
ARG GITHUB_SHA=""
ARG SOURCE_COMMIT=""
ARG VITE_BASE_PATH=""
ENV RELEASE_COMMIT_SHA="${RELEASE_COMMIT_SHA}"
ENV COMMIT_SHA="${COMMIT_SHA}"
ENV GITHUB_SHA="${GITHUB_SHA}"
ENV SOURCE_COMMIT="${SOURCE_COMMIT}"
ENV VITE_BASE_PATH="${VITE_BASE_PATH}"
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
Vendored
+1
View File
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
readonly VITE_APP_VERSION: string,
readonly VITE_COMMIT_HASH: string,
readonly VITE_IS_DEV: string,
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
}
interface ImportMeta {
readonly env: ImportMetaEnv
+5 -6
View File
@@ -2,11 +2,10 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
<link rel="shortcut icon" href="/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
@@ -16,6 +15,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>
+54
View File
@@ -0,0 +1,54 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
location ~ \.[^/]+$ {
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend$ {
try_files /index.html =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/ {
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri $uri/ /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+5
View File
@@ -42,7 +42,12 @@
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
"test:all": "npm run test:unit && npm run test:e2e:pr",
"twa:build": "bubblewrap build",
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3,
workers: isCI ? 2 : 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results",
use: {
+42 -2
View File
@@ -4,10 +4,50 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
+2 -2
View File
@@ -4,13 +4,13 @@
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"icons": [
{
"src": "/favicons/web-app-manifest-192x192.png",
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicons/web-app-manifest-512x512.png",
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
+50
View File
@@ -0,0 +1,50 @@
import { execSync } from "node:child_process";
function gitCommit() {
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch {
return "";
}
}
async function main() {
const token = process.env.SERVER_UPDATE_TOKEN;
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
if (!token) {
if (required) {
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
}
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
return;
}
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
if (!version) {
throw new Error("Could not determine release version for server update.");
}
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
const url = new URL(baseUrl);
url.searchParams.set("version", version);
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
},
});
const body = await response.text();
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
}
console.log(`Server version updated to ${version}.`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
DIST_DIR="${DIST_DIR:-dist}"
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
HOST="${RELEASE_DEPLOY_HOST:-}"
USER_NAME="${RELEASE_DEPLOY_USER:-}"
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
if [[ ! -d "$DIST_DIR" ]]; then
echo "Missing dist directory: $DIST_DIR" >&2
exit 1
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required for FTP/SFTP uploads." >&2
exit 1
fi
if [[ -z "$DEPLOY_URL" ]]; then
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
fi
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
fi
done
find "$DIST_DIR" -maxdepth 1 -type f \
! -name 'index.html' \
! -name 'release-entry.json' \
! -name 'release-manifest.json' \
-print0 | while IFS= read -r -d '' file; do
upload_file "$file" "$(basename "$file")"
done
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
upload_file "$DIST_DIR/index.html" "index.html"
+215
View File
@@ -0,0 +1,215 @@
import crypto from "node:crypto";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function numberEnv(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) {
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
}
return value.endsWith("/") ? value : `${value}/`;
}
function pathUrl(baseUrl, assetPath) {
const path = String(assetPath || "").replace(/^\/+/, "");
return new URL(path, baseUrl).href;
}
async function fetchBytes(url) {
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
bytes,
text: () => bytes.toString("utf8"),
};
}
async function fetchJson(baseUrl, assetPath) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
try {
return JSON.parse(result.text());
} catch (error) {
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
}
}
function compareCommit(actual, expected) {
if (!expected) {
return true;
}
return actual === expected || actual.startsWith(expected);
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean)));
}
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function shouldRejectHtml(assetPath) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
async function verifyAsset(baseUrl, assetPath, expectedHash) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML (${contentType})`);
}
if (expectedHash?.sha256) {
const actualHash = sha256(result.bytes);
if (actualHash !== expectedHash.sha256) {
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
}
}
}
async function verifyShell(baseUrl, shellPath) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
const contentType = result.response.headers.get("content-type") || "";
const body = result.text();
if (!result.response.ok) {
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
}
if (!contentType.includes("text/html")) {
throw new Error(`${shellPath} did not return HTML (${contentType})`);
}
if (body.replace(/\s+/g, "").length < 40) {
throw new Error(`${shellPath} returned an empty app shell`);
}
if (!body.includes('<div id="app"></div>')) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
if (!manifest.build_id) {
throw new Error("release-manifest.json is missing build_id");
}
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
}
if (releaseEntry.entry !== manifest.entry) {
throw new Error("release-entry.json entry does not match release-manifest.json");
}
const releaseCss = JSON.stringify(releaseEntry.css || []);
const manifestCss = JSON.stringify(manifest.css || []);
if (releaseCss !== manifestCss) {
throw new Error("release-entry.json css does not match release-manifest.json");
}
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
for (const shellPath of shellPaths) {
await verifyShell(baseUrl, shellPath);
}
const assetUrls = unique([
"release-manifest.json",
"release-entry.json",
manifest.entry,
...(manifest.css || []),
...(manifest.index_asset_urls || []),
...(manifest.pwa_asset_urls || []),
...(manifest.asset_urls || []),
]);
for (const assetUrl of assetUrls) {
if (assetUrl === "/index.html") {
continue;
}
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
}
return {
build_id: manifest.build_id,
commit_sha: manifest.commit_sha,
assets: assetUrls.length,
};
}
async function main() {
const baseUrl = requiredUrl();
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
if (initialWaitSeconds > 0) {
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
await sleep(initialWaitSeconds * 1000);
}
const deadline = Date.now() + timeoutSeconds * 1000;
let attempt = 0;
let lastError = null;
while (Date.now() <= deadline) {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
);
return;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
if (Date.now() > deadline) {
break;
}
await sleep(pollIntervalSeconds * 1000);
}
}
throw lastError || new Error("Release upload verification timed out.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+3
View File
@@ -760,6 +760,7 @@
.pos-step-one-insights {
display: grid;
align-items: stretch;
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
@@ -903,6 +904,7 @@
.pos-step-one-insight-card__section {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.55rem;
min-width: 0;
@@ -970,6 +972,7 @@
.pos-step-one-empty-state,
.pos-step-one-loading-state {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 4rem;
@@ -9,7 +9,7 @@ import { ref } from "vue";
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { useI18n } from "vue-i18n";
import { API_URL } from "@/config.js";
import { getReleaseRuntimeApiBaseUrl } from "@/services/releaseTimeline.js";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
import {
buildCustomerAttributeTargetPayload,
@@ -992,7 +992,7 @@ const shouldRetryPreviewRequestWithAuth = (downloadLink) => {
try {
const previewUrl = new URL(downloadLink, window.location.origin);
const apiOrigin = new URL(API_URL, window.location.origin).origin;
const apiOrigin = new URL(getReleaseRuntimeApiBaseUrl(), window.location.origin).origin;
return previewUrl.origin === apiOrigin || previewUrl.origin === window.location.origin;
} catch {
return false;
@@ -660,7 +660,9 @@ const handleDesktopLastWashCopy = async (payload = {}) => {
return;
}
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || []);
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
sourceReference: payload?.order?.reference,
});
if (!didCopy) {
return;
}
@@ -190,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
</div>
@@ -248,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
</div>
@@ -6,8 +6,11 @@ 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 { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeDynamicImageButtonsToPress,
getSelfServeDynamicImageThumbPosition,
} from "@/services/selfServeDynamicImage.js";
const { t: $t } = useI18n();
@@ -138,36 +141,9 @@ const canClearAnswers = computed(() => (
));
const hideDynamicImage = ref(false);
const parseNonNegativeInt = (value) => {
const parsed = parseInt(value);
if (Number.isNaN(parsed) || parsed < 0) {
return null;
}
return parsed;
};
const dynamicImageButtons = computed(() => getSelfServeDynamicImageButtonsToPress(activeTasks.value));
const dynamicImageButtons = computed(() => {
const buttonIds = new Map();
activeTasks.value.forEach((task) => {
normalizeSelfServeTaskButtons(task?.buttons).forEach((button) => {
buttonIds.set(`${typeof button}:${button}`, button);
});
});
return [...buttonIds.values()];
});
const dynamicImageThumbPosition = computed(() => {
for (const task of activeTasks.value) {
const parsedPosition = parseNonNegativeInt(task?.dynamic_images_vehicle_type);
if (parsedPosition !== null) {
return parsedPosition;
}
}
return null;
});
const dynamicImageThumbPosition = computed(() => getSelfServeDynamicImageThumbPosition(activeTasks.value));
const hasDynamicImageContext = computed(() => (
dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null
@@ -180,23 +156,15 @@ const dynamicImageUrl = computed(() => {
return null;
}
const params = new URLSearchParams({
department: String(departmentId),
lane: String(laneId),
current_step: "0",
buttons: JSON.stringify(dynamicImageButtons.value),
});
const selectedVehicleType = parseInt(selectedVehicleTypeId.value);
if (!Number.isNaN(selectedVehicleType) && selectedVehicleType > 0) {
params.set("vehicle_type", String(selectedVehicleType));
}
if (dynamicImageThumbPosition.value !== null) {
params.set("thumb_position", String(dynamicImageThumbPosition.value));
}
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
return buildSelfServeDynamicImageUrl({
departmentId,
laneId,
buttons: dynamicImageButtons.value,
currentStep: 0,
vehicleTypeId: !Number.isNaN(selectedVehicleType) && selectedVehicleType > 0 ? selectedVehicleType : null,
thumbPosition: dynamicImageThumbPosition.value,
});
});
const displayedDynamicImageUrl = computed(() => (
@@ -48,15 +48,32 @@ const getNonImageAttachments = (task: any) => {
return task.attachments.filter((attachment: any) => !isImageAttachment(attachment));
};
const normalizeTaskService = (service: any) => String(service || "").trim().toUpperCase();
const getTaskServiceTokens = (task: any) => {
if (!Array.isArray(task?.services)) {
return [];
}
return task.services
.map((service: any) => normalizeTaskService(service))
.filter(Boolean);
};
const taskUsesProgramPicker = (task: any) => getTaskServiceTokens(task).includes("PROGRAM_PICKER");
const getVisibleServices = (task: any) => {
if (!Array.isArray(task?.services)) {
return [];
}
return task.services.filter((service: any) => String(service || "").trim().toUpperCase() !== "MACHINE");
return task.services.filter((service: any) => normalizeTaskService(service) !== "MACHINE");
};
const getProgramWheelSelection = (task: any) => {
if (!taskUsesProgramPicker(task)) {
return null;
}
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
if (rawValue === null || rawValue === undefined || rawValue === "") {
return null;
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { BIcon } from "buefy";
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
defineProps<{
const props = defineProps<{
isLoading: boolean;
dynamicImageUrl: string | null;
activeTasks: Array<any>;
@@ -17,6 +18,12 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -24,6 +31,15 @@ const emitToggleTask = (taskId: number, value: boolean) => {
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
emit("clear-dynamic-image");
};
</script>
<template>
@@ -42,13 +58,27 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div v-if="dynamicImageUrl" class="mb-4">
<div
v-if="dynamicImageUrl"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
@error="emit('clear-dynamic-image')"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
@@ -64,3 +94,40 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
</div>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
max-width: 100%;
margin-left: auto;
margin-right: auto;
}
.self-serve-dynamic-image-frame.is-loading {
width: 100%;
max-width: 640px;
min-height: 180px;
aspect-ratio: 16 / 9;
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
max-width: 100%;
height: auto;
border-radius: 4px;
display: block;
margin-left: auto;
margin-right: auto;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -240,7 +240,7 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
:displayActionsDirectly="true"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
+19 -2
View File
@@ -32,6 +32,24 @@ const successMessage = ref('');
const store = useStore();
const router = useRouter();
const resolveLoginRedirectPath = () => {
const redirectQuery = router.currentRoute.value.query.redirect;
const redirectValue = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
if (!redirectValue) {
return "/redirect";
}
try {
const redirectUrl = new URL(redirectValue, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
return "/redirect";
}
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
} catch {
return "/redirect";
}
};
const login = async () => {
try {
const response = await axios.post(API_URL + '/auth/login', {
@@ -55,8 +73,7 @@ const login = async () => {
successMessage.value = "Du er nu logget ind!"
// Wait 500ms before reloading the page to show the success message
setTimeout(() => {
// Reload the page to update the UI
router.go(0);
window.location.assign(resolveLoginRedirectPath());
}, 500);
// window.location.reload();
} catch (e) {
+299 -57
View File
@@ -1,7 +1,12 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import { API_URL, REQUEST_QUEUE_CONFIG } from "@/config.js";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import {
buildReleaseSessionSummary,
getReleaseRuntimeApiBaseUrl,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearErrorRequests,
@@ -268,16 +273,19 @@ watch(recentRequests, (requests) => {
const resolvePingUrl = () => {
const configuredEndpoint = REQUEST_QUEUE_CONFIG.ping.endpoint;
if (!configuredEndpoint) {
return API_URL;
return getReleaseRuntimeApiBaseUrl();
}
if (/^https?:\/\//i.test(configuredEndpoint)) {
return configuredEndpoint;
}
return `${API_URL.replace(/\/+$/, "")}/${String(configuredEndpoint).replace(/^\/+/, "")}`;
return resolveReleaseApiUrl(configuredEndpoint);
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
pingLatencyMs.value = null;
@@ -404,7 +412,7 @@ const grantMissingPermission = async (permission) => {
setPermissionGrantStatus(normalizedPermission, "loading");
try {
await axios.post(
`${API_URL}/roles/permissions`,
resolveReleaseApiUrl("/roles/permissions"),
{
group_id: roleId,
permission_id: normalizedPermission,
@@ -638,60 +646,186 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Runtime</div>
<ul class="request-queue-progress__meta-list request-queue-progress__section-content request-queue-progress__section-content--meta">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="API_URL">{{ API_URL }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Environment</span>
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Commit</span>
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version</span>
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version time</span>
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
</li>
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
<span class="request-queue-progress__meta-label">i18n catalog</span>
<button
class="request-queue-progress__catalog-switch"
data-testid="request-queue-i18n-catalog-switch"
type="button"
@click="handleToggleI18nCatalogVersion"
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
class="request-queue-progress__meta-value"
:title="releaseSessionSummary.channelSlug || releaseSessionSummary.channelLabel"
>
{{ releaseSessionSummary.channelLabel }}
<span v-if="releaseSessionSummary.channelSlug">({{ releaseSessionSummary.channelSlug }})</span>
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Trace ID</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.traceId">
{{ releaseSessionSummary.traceId }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Generated</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.generatedAt">
{{ releaseSessionSummary.generatedAt }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Bundle</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.bundleLabel">
{{ releaseSessionSummary.bundleLabel }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Service set</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.serviceSetLabel">
{{ releaseSessionSummary.serviceSetLabel }}
</span>
</li>
<li
v-if="releaseSessionSummary.missingLabels.length > 0"
class="request-queue-progress__meta-item"
data-testid="request-queue-release-missing"
>
{{ i18nCatalogVersionLabel }}
</button>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing requests</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing responses</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
</li>
</ul>
<span class="request-queue-progress__meta-label">Missing</span>
<span
class="request-queue-progress__meta-value request-queue-progress__meta-value--warning"
:title="releaseSessionSummary.missingLabels.join(', ')"
>
{{ releaseSessionSummary.missingLabels.join(", ") }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">App releases</div>
<ul class="request-queue-progress__release-list">
<li
v-for="app in releaseSessionSummary.appRows"
:key="app.key"
class="request-queue-progress__release-row"
:data-testid="`request-queue-release-app-${app.key}`"
>
<div class="request-queue-progress__release-row-header">
<strong>{{ app.label }}</strong>
<span
class="request-queue-progress__status-pill"
:class="`request-queue-progress__status-pill--${app.tone}`"
>
{{ app.status }}
</span>
</div>
<span class="request-queue-progress__release-primary" :title="app.title">
{{ app.primaryText }}
</span>
<span
v-if="app.secondaryText"
class="request-queue-progress__release-secondary"
:title="app.secondaryText"
>
{{ app.secondaryText }}
</span>
<span
v-if="app.url"
class="request-queue-progress__release-url"
:title="app.url"
>
{{ app.url }}
</span>
<span
v-if="app.missingLabel"
class="request-queue-progress__release-secondary request-queue-progress__release-secondary--warning"
>
{{ app.missingLabel }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">Connected services</div>
<ul class="request-queue-progress__release-list">
<li
v-for="service in releaseSessionSummary.serviceRows"
:key="service.key"
class="request-queue-progress__release-row"
:data-testid="`request-queue-release-service-${service.key}`"
>
<div class="request-queue-progress__release-row-header">
<strong>{{ service.label }}</strong>
<span
class="request-queue-progress__status-pill"
:class="`request-queue-progress__status-pill--${service.tone}`"
>
{{ service.status }}
</span>
</div>
<span class="request-queue-progress__release-primary" :title="service.title">
{{ service.primaryText }}
</span>
<span
v-if="service.secondaryText"
class="request-queue-progress__release-secondary"
:title="service.secondaryText"
>
{{ service.secondaryText }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Environment</span>
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Commit</span>
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version</span>
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version time</span>
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
</li>
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
<span class="request-queue-progress__meta-label">i18n catalog</span>
<button
class="request-queue-progress__catalog-switch"
data-testid="request-queue-i18n-catalog-switch"
type="button"
@click="handleToggleI18nCatalogVersion"
>
{{ i18nCatalogVersionLabel }}
</button>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing requests</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing responses</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
</li>
</ul>
</div>
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--user" data-testid="request-queue-user-box">
@@ -928,6 +1062,12 @@ onBeforeUnmount(() => {
min-height: 56px;
}
.request-queue-progress__section-content--release {
display: flex;
flex-direction: column;
gap: 8px;
}
.request-queue-progress__section-title {
font-size: 12px;
font-weight: 600;
@@ -1167,6 +1307,108 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.request-queue-progress__meta-value--warning {
color: #fbbf24;
}
.request-queue-progress__subsection-title {
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
padding-top: 7px;
}
.request-queue-progress__release-list {
display: grid;
gap: 6px;
list-style: none;
margin: 0;
padding: 0;
}
.request-queue-progress__release-row {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
background: rgba(2, 6, 23, 0.28);
display: grid;
gap: 3px;
min-width: 0;
padding: 7px 8px;
}
.request-queue-progress__release-row-header {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.request-queue-progress__release-row-header strong {
font-size: 12px;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__status-pill {
border-radius: 999px;
display: inline-flex;
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
line-height: 1;
max-width: 120px;
overflow: hidden;
padding: 3px 7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__status-pill--ok {
background: rgba(94, 234, 212, 0.16);
color: #99f6e4;
}
.request-queue-progress__status-pill--warning {
background: rgba(251, 191, 36, 0.16);
color: #fde68a;
}
.request-queue-progress__status-pill--danger {
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
}
.request-queue-progress__release-primary,
.request-queue-progress__release-secondary,
.request-queue-progress__release-url {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__release-primary {
font-size: 12px;
line-height: 1.25;
}
.request-queue-progress__release-secondary,
.request-queue-progress__release-url {
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
line-height: 1.25;
}
.request-queue-progress__release-secondary--warning {
color: #fbbf24;
}
.request-queue-progress__catalog-switch {
border: 1px solid rgba(126, 249, 227, 0.5);
border-radius: 999px;
+3 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import MenuDefault from "@/components/menus/MenuDefault.vue";
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import {ref, computed} from "vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n';
@@ -70,9 +71,10 @@ const menu_items = computed(() => [
:show_icons="false"
/>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
</div>
</template>
<style scoped>
</style>
</style>
@@ -0,0 +1,18 @@
<script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
defineProps({
testid: {
type: String,
default: "",
},
});
</script>
<template>
<ActionSettingsWheelButton class="release-action-menu" :data-testid="testid || undefined">
<template #actions>
<slot />
</template>
</ActionSettingsWheelButton>
</template>
@@ -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,322 @@
<script setup>
import { computed } from "vue";
import ReleaseEntityLink from "@/components/release/ReleaseEntityLink.vue";
const props = defineProps({
channels: {
type: Array,
default: () => [],
},
selectedChannelSlug: {
type: String,
default: "",
},
selectedApp: {
type: String,
default: "all",
},
selectedBranch: {
type: String,
default: "master",
},
branchOptions: {
type: Array,
default: () => [],
},
branchStatus: {
type: Object,
default: () => ({}),
},
runtimeSource: {
type: String,
default: "",
},
runtimeApiBaseUrl: {
type: String,
default: "",
},
runtimeFrontendBaseUrl: {
type: String,
default: "",
},
busy: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
refreshing: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release context...",
},
canDeploy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:channel", "update:app", "update:branch", "test", "sync", "refresh", "open-entity"]);
const routeSlugForChannel = (channel) => {
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
return slug === "stable" ? "master" : slug;
};
const selectedChannel = computed(
() =>
props.channels.find((channel) => String(channel.slug || channel.channel_slug) === props.selectedChannelSlug) ||
props.channels.find((channel) => channel.default_channel) ||
props.channels[0] ||
null
);
const selectedRouteSlug = computed(() => routeSlugForChannel(selectedChannel.value) || props.selectedBranch || "master");
const selectedEndpointApps = computed(() => {
if (props.selectedApp === "frontend") return ["frontend"];
if (props.selectedApp === "api") return ["api"];
return ["frontend", "api"];
});
const isLocalRuntime = computed(() => String(props.runtimeSource || "").toLowerCase() === "local");
const releaseSourceLabel = computed(() => (isLocalRuntime.value ? "Local source" : "Deployment source"));
const runtimeFrontendBaseUrl = computed(() => {
const value = String(props.runtimeFrontendBaseUrl || "").replace(/\/+$/, "");
if (value) return value;
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
return "/";
});
const runtimeApiBaseUrl = computed(() => String(props.runtimeApiBaseUrl || "/api").replace(/\/+$/, "") || "/api");
const endpointForApp = (app) =>
isLocalRuntime.value
? app === "api"
? runtimeApiBaseUrl.value
: runtimeFrontendBaseUrl.value
: `https://api-v2.truckwash.io/${selectedRouteSlug.value}/${app}`;
const endpointLabelForApp = (app) =>
isLocalRuntime.value
? app === "api"
? runtimeApiBaseUrl.value
: "local frontend"
: `/${selectedRouteSlug.value}/${app}`;
const appBranchStatus = computed(() => {
if (props.selectedApp === "frontend" || props.selectedApp === "api") {
return props.branchStatus?.[props.selectedApp] || {};
}
const values = [props.branchStatus?.frontend, props.branchStatus?.api].filter(Boolean);
const missing = values.find((status) => ["missing", "not_configured", "unknown"].includes(String(status.state || "").toLowerCase()));
return missing || values[0] || {};
});
const branchState = computed(() => String(appBranchStatus.value?.state || "unknown").toLowerCase());
const branchWarning = computed(() =>
["missing", "not_configured", "unknown", "stale", "failed"].includes(branchState.value)
? `${props.selectedBranch || "unknown"} is ${branchState.value || "unknown"} for the selected context.`
: ""
);
</script>
<template>
<section
class="release-context-bar"
data-testid="release-context-bar"
aria-label="Release context"
:aria-busy="loading || refreshing"
>
<div v-if="loading" class="release-context-bar__loading" data-testid="release-context-loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ loadingText }}</span>
</div>
<div class="release-context-bar__controls">
<label>
<span>Channel</span>
<select
class="select is-small"
:value="selectedChannelSlug"
data-testid="release-context-channel"
:disabled="loading"
@change="emit('update:channel', $event.target.value)"
>
<option v-for="channel in channels" :key="channel.id || channel.slug" :value="channel.slug">
{{ channel.name || channel.slug }} / {{ routeSlugForChannel(channel) || "unknown" }}
</option>
</select>
</label>
<label>
<span>App</span>
<select
class="select is-small"
:value="selectedApp"
data-testid="release-context-app"
:disabled="loading"
@change="emit('update:app', $event.target.value)"
>
<option value="all">All</option>
<option value="frontend">Frontend</option>
<option value="api">API</option>
</select>
</label>
<label class="release-context-bar__branch">
<span>Branch</span>
<input
class="input is-small"
list="release-context-branches"
:value="selectedBranch"
data-testid="release-context-branch"
:disabled="loading"
@change="emit('update:branch', $event.target.value)"
@keydown.enter.prevent="emit('update:branch', $event.target.value)"
/>
<datalist id="release-context-branches">
<option v-for="branch in branchOptions" :key="branch" :value="branch" />
</datalist>
</label>
</div>
<div class="release-context-bar__status">
<b-tag
:class="isLocalRuntime ? 'is-info' : 'is-success'"
data-testid="release-context-source"
>
{{ releaseSourceLabel }}
</b-tag>
<b-tag :class="branchState === 'available' || branchState === 'ready' ? 'is-success' : 'is-warning'">
{{ branchState }}
</b-tag>
<span v-if="branchWarning" class="release-context-bar__warning" data-testid="release-context-branch-warning">
{{ branchWarning }}
</span>
</div>
<div class="release-context-bar__endpoints" data-testid="release-context-endpoints">
<ReleaseEntityLink
v-for="app in selectedEndpointApps"
:key="app"
type="endpoint"
:label="endpointLabelForApp(app)"
:entity="{ url: endpointForApp(app), route_prefix: endpointLabelForApp(app), app, channel: selectedChannelSlug }"
@open="(payload) => emit('open-entity', payload)"
/>
</div>
<div class="release-context-bar__actions">
<b-button
size="is-small"
icon-left="vial"
icon-pack="fas"
:disabled="loading || !canDeploy"
:loading="busy === 'release:test'"
data-testid="release-context-test"
@click="emit('test')"
>
Test
</b-button>
<b-button
size="is-small"
icon-left="rotate"
icon-pack="fas"
:disabled="loading || !canDeploy || !selectedChannel"
:loading="busy === `channel:${selectedChannel?.id}:sync`"
data-testid="release-context-sync"
@click="emit('sync', selectedChannel)"
>
Sync
</b-button>
<b-button
size="is-small"
icon-left="refresh"
icon-pack="fas"
data-testid="release-context-refresh"
:disabled="loading || refreshing"
:loading="refreshing"
@click="emit('refresh')"
>
Refresh
</b-button>
</div>
</section>
</template>
<style scoped>
.release-context-bar {
align-items: center;
background: #f8fafc;
border-bottom: 1px solid #d8dee8;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1.4fr) minmax(11rem, 0.6fr) minmax(0, 1fr) auto;
min-height: 4rem;
padding: 0.6rem 0.75rem;
}
.release-context-bar__loading {
align-items: center;
color: #52627a;
display: inline-flex;
font-size: 0.82rem;
font-weight: 700;
gap: 0.45rem;
grid-column: 1 / -1;
}
.release-context-bar__controls,
.release-context-bar__status,
.release-context-bar__endpoints,
.release-context-bar__actions {
align-items: center;
display: flex;
gap: 0.5rem;
min-width: 0;
}
.release-context-bar label {
display: grid;
gap: 0.2rem;
min-width: 8rem;
}
.release-context-bar label > span {
color: #667085;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.release-context-bar__branch {
min-width: 12rem;
}
.release-context-bar__warning {
color: #9f1f17;
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.release-context-bar__endpoints {
flex-wrap: wrap;
}
.release-context-bar__actions {
justify-content: flex-end;
}
@media (max-width: 1180px) {
.release-context-bar {
grid-template-columns: 1fr;
}
.release-context-bar__controls,
.release-context-bar__actions {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,74 @@
<script setup>
defineProps({
emptyText: {
type: String,
default: "No records",
},
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release data...",
},
loadingColspan: {
type: Number,
default: 1,
},
});
</script>
<template>
<div class="release-data-grid" data-testid="release-data-grid" :aria-busy="loading">
<table class="table is-fullwidth is-hoverable">
<slot name="head" />
<tbody v-if="loading" data-testid="release-data-grid-loading">
<tr>
<td :colspan="loadingColspan">
<div class="release-data-grid__loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ loadingText }}</span>
</div>
</td>
</tr>
</tbody>
<slot v-else />
<tbody v-if="!loading && $slots.empty">
<slot name="empty" />
</tbody>
</table>
</div>
</template>
<style scoped>
.release-data-grid {
border: 1px solid #d8dee8;
border-radius: 8px;
max-height: 100%;
min-height: 0;
overflow: auto;
}
.release-data-grid :deep(table) {
margin-bottom: 0;
}
.release-data-grid :deep(thead th) {
background: #f8fafc;
position: sticky;
top: 0;
z-index: 1;
}
.release-data-grid__loading {
align-items: center;
color: #52627a;
display: flex;
font-size: 0.9rem;
font-weight: 700;
gap: 0.5rem;
justify-content: center;
min-height: 5rem;
}
</style>
@@ -0,0 +1,129 @@
<script setup>
import { computed } from "vue";
import {
releaseEntityDisplayLabel,
releaseEntityFacts,
releaseEntityTooltipText,
} from "@/components/release/releaseEntityFacts.js";
const emit = defineEmits(["open"]);
const props = defineProps({
type: {
type: String,
default: "entity",
},
label: {
type: String,
default: "",
},
entity: {
type: Object,
default: () => ({}),
},
detail: {
type: String,
default: "",
},
facts: {
type: Array,
default: null,
},
});
const displayLabel = computed(() => releaseEntityDisplayLabel(props.label, props.entity));
const tooltipFacts = computed(() =>
Array.isArray(props.facts) && props.facts.length > 0
? props.facts
: releaseEntityFacts({ type: props.type, entity: props.entity || {}, detail: props.detail })
);
const tooltip = computed(() => releaseEntityTooltipText(tooltipFacts.value));
const open = () => {
emit("open", {
type: props.type,
label: displayLabel.value,
detail: props.detail,
entity: props.entity || {},
facts: tooltipFacts.value,
});
};
</script>
<template>
<b-tooltip :label="tooltip || displayLabel" multilined type="is-dark">
<template #content>
<div class="release-entity-tooltip" data-testid="release-entity-tooltip">
<strong>{{ displayLabel }}</strong>
<dl>
<template v-for="fact in tooltipFacts" :key="`${fact.label}-${fact.value}`">
<dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd>
</template>
</dl>
</div>
</template>
<button
class="release-entity-link"
type="button"
:title="tooltip || displayLabel"
:aria-label="tooltip || displayLabel"
:data-entity-type="type"
@click="open"
>
<span>{{ displayLabel }}</span>
<i class="fas fa-circle-info" aria-hidden="true"></i>
</button>
</b-tooltip>
</template>
<style scoped>
.release-entity-link {
align-items: center;
background: transparent;
border: 0;
color: #0747a6;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
padding: 0;
text-align: left;
}
.release-entity-link span {
min-width: 0;
overflow-wrap: anywhere;
}
.release-entity-link i {
color: #52627a;
font-size: 0.78rem;
}
.release-entity-tooltip {
display: grid;
gap: 0.45rem;
max-width: min(28rem, 82vw);
text-align: left;
}
.release-entity-tooltip dl {
display: grid;
gap: 0.25rem 0.75rem;
grid-template-columns: max-content minmax(0, 1fr);
margin: 0;
}
.release-entity-tooltip dt {
color: #cbd5e1;
font-weight: 700;
}
.release-entity-tooltip dd {
margin: 0;
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,188 @@
<script setup>
import ReleaseContextBar from "@/components/release/ReleaseContextBar.vue";
import ReleaseRouteTabs from "@/components/release/ReleaseRouteTabs.vue";
defineProps({
tabs: {
type: Array,
default: () => [],
},
activePanel: {
type: String,
default: "overview",
},
channels: {
type: Array,
default: () => [],
},
selectedChannelSlug: {
type: String,
default: "",
},
selectedApp: {
type: String,
default: "all",
},
selectedBranch: {
type: String,
default: "master",
},
branchOptions: {
type: Array,
default: () => [],
},
branchStatus: {
type: Object,
default: () => ({}),
},
runtimeSource: {
type: String,
default: "",
},
runtimeApiBaseUrl: {
type: String,
default: "",
},
runtimeFrontendBaseUrl: {
type: String,
default: "",
},
busy: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
refreshing: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading Release Manager...",
},
refreshingText: {
type: String,
default: "Refreshing release data...",
},
canDeploy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"navigate",
"update:channel",
"update:app",
"update:branch",
"test",
"sync",
"refresh",
"open-entity",
]);
</script>
<template>
<section class="release-manager-workspace" data-testid="release-manager-workspace" :aria-busy="loading || refreshing">
<ReleaseContextBar
:channels="channels"
:selected-channel-slug="selectedChannelSlug"
:selected-app="selectedApp"
:selected-branch="selectedBranch"
:branch-options="branchOptions"
:branch-status="branchStatus"
:runtime-source="runtimeSource"
:runtime-api-base-url="runtimeApiBaseUrl"
:runtime-frontend-base-url="runtimeFrontendBaseUrl"
:busy="busy"
:loading="loading"
:refreshing="refreshing"
:loading-text="loadingText"
:can-deploy="canDeploy"
@update:channel="(value) => emit('update:channel', value)"
@update:app="(value) => emit('update:app', value)"
@update:branch="(value) => emit('update:branch', value)"
@test="emit('test')"
@sync="(channel) => emit('sync', channel)"
@refresh="emit('refresh')"
@open-entity="(payload) => emit('open-entity', payload)"
/>
<ReleaseRouteTabs :tabs="tabs" :active-key="activePanel" @navigate="(panel) => emit('navigate', panel)" />
<div
class="release-manager-workspace__refreshing"
:class="{ 'release-manager-workspace__refreshing--active': refreshing && !loading }"
data-testid="release-manager-refreshing"
role="status"
aria-live="polite"
:aria-hidden="!(refreshing && !loading)"
>
<template v-if="refreshing && !loading">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ refreshingText }}</span>
</template>
</div>
<main class="release-manager-workspace__panel" :data-active-panel="activePanel">
<slot />
</main>
<slot name="overlays" />
</section>
</template>
<style scoped>
.release-manager-workspace {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 8px;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
height: calc(100vh - 11rem);
min-height: 42rem;
overflow: hidden;
}
.release-manager-workspace__refreshing {
align-items: center;
background: transparent;
border-bottom: 0 solid transparent;
color: #17416f;
display: flex;
font-size: 0.82rem;
font-weight: 700;
gap: 0.45rem;
min-height: 0;
overflow: hidden;
padding: 0 0.75rem;
}
.release-manager-workspace__refreshing--active {
background: #eef6ff;
border-bottom-color: #cfe3ff;
border-bottom-width: 1px;
min-height: 2.35rem;
padding-bottom: 0.45rem;
padding-top: 0.45rem;
}
.release-manager-workspace__panel {
background: #eef3f8;
min-height: 0;
overflow: auto;
padding: 0.75rem;
}
@media (max-width: 768px) {
.release-manager-workspace {
display: block;
height: auto;
min-height: 0;
overflow: visible;
}
.release-manager-workspace__panel {
overflow: visible;
}
}
</style>
@@ -0,0 +1,89 @@
<script setup>
import ReleaseOperationTimeline from "@/components/release/ReleaseOperationTimeline.vue";
const emit = defineEmits(["close", "retry"]);
defineProps({
operation: {
type: Object,
default: null,
},
});
</script>
<template>
<div v-if="operation" class="release-operation-modal" role="dialog" aria-modal="true" data-testid="release-operation-modal">
<div class="release-operation-modal__backdrop" @click="emit('close')"></div>
<section class="release-operation-modal__panel">
<header>
<div>
<span class="release-operation-modal__eyebrow">{{ operation.operation_type || "operation" }}</span>
<h3>{{ operation.title || `Operation #${operation.id}` }}</h3>
<p>{{ operation.summary || "Operation is running." }}</p>
</div>
<button type="button" class="delete" aria-label="Close" @click="emit('close')"></button>
</header>
<ReleaseOperationTimeline :operation="operation" @retry="(step) => emit('retry', step)" />
</section>
</div>
</template>
<style scoped>
.release-operation-modal {
inset: 0;
position: fixed;
z-index: 60;
}
.release-operation-modal__backdrop {
background: rgba(15, 23, 42, 0.42);
inset: 0;
position: absolute;
}
.release-operation-modal__panel {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.28);
display: grid;
gap: 1rem;
max-height: min(86vh, 780px);
max-width: min(920px, calc(100vw - 2rem));
overflow: auto;
padding: 1rem;
position: absolute;
right: 1rem;
top: 1rem;
width: 56rem;
}
.release-operation-modal__panel header {
align-items: start;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.release-operation-modal__panel h3,
.release-operation-modal__panel p {
margin: 0;
}
.release-operation-modal__eyebrow {
color: #667085;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
}
@media (max-width: 768px) {
.release-operation-modal__panel {
inset: 0.5rem;
max-height: calc(100vh - 1rem);
max-width: none;
width: auto;
}
}
</style>
@@ -0,0 +1,137 @@
<script setup>
const emit = defineEmits(["retry"]);
defineProps({
operation: {
type: Object,
default: null,
},
});
const statusClass = (status) => {
const normalized = String(status || "").toLowerCase();
if (["passed", "deployed", "completed", "success"].includes(normalized)) return "is-success";
if (["failed", "error"].includes(normalized)) return "is-danger";
if (["warning", "skipped"].includes(normalized)) return "is-warning";
if (["running", "queued", "pending"].includes(normalized)) return "is-info";
return "is-light";
};
</script>
<template>
<section class="release-operation-timeline" data-testid="release-operation-timeline">
<div class="release-operation-timeline__status">
<b-tag :class="statusClass(operation?.status)">{{ operation?.status || "unknown" }}</b-tag>
<span>{{ operation?.passed_step_count || 0 }} passed</span>
<span>{{ operation?.failed_step_count || 0 }} failed</span>
<span>{{ operation?.warning_step_count || 0 }} warnings</span>
</div>
<ol class="release-operation-timeline__steps">
<li
v-for="step in operation?.steps || []"
:key="step.id || step.step_key"
:class="`is-${step.status || 'unknown'}`"
data-testid="release-operation-step"
>
<div class="release-operation-timeline__step-head">
<strong>{{ step.label || step.step_key }}</strong>
<b-tag :class="statusClass(step.status)" size="is-small">{{ step.status || "unknown" }}</b-tag>
</div>
<p v-if="step.message">{{ step.message }}</p>
<div v-if="step.diagnostic" class="release-operation-timeline__diagnostic">
<strong>Diagnostic</strong>
<span>{{ step.diagnostic }}</span>
</div>
<div v-if="step.solution_hint" class="release-operation-timeline__solution">
<strong>Solution</strong>
<span>{{ step.solution_hint }}</span>
</div>
<button
v-if="step.context?.retry_action"
class="button is-small"
type="button"
@click="emit('retry', step)"
>
Retry
</button>
</li>
</ol>
<footer v-if="operation?.solution_hint">
<strong>Next</strong>
<span>{{ operation.solution_hint }}</span>
</footer>
</section>
</template>
<style scoped>
.release-operation-timeline {
display: grid;
gap: 1rem;
}
.release-operation-timeline__status,
.release-operation-timeline__step-head,
.release-operation-timeline__diagnostic,
.release-operation-timeline__solution,
.release-operation-timeline footer {
align-items: start;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.release-operation-timeline__status {
justify-content: flex-start;
}
.release-operation-timeline__steps {
display: grid;
gap: 0.75rem;
list-style: none;
margin: 0;
padding: 0;
}
.release-operation-timeline__steps li {
border: 1px solid #d8dee8;
border-left: 4px solid #94a3b8;
border-radius: 7px;
display: grid;
gap: 0.45rem;
padding: 0.75rem;
}
.release-operation-timeline__steps li.is-passed,
.release-operation-timeline__steps li.is-deployed,
.release-operation-timeline__steps li.is-completed,
.release-operation-timeline__steps li.is-success {
border-left-color: #1f9d5a;
}
.release-operation-timeline__steps li.is-failed,
.release-operation-timeline__steps li.is-error {
border-left-color: #d92d20;
}
.release-operation-timeline__steps li.is-warning,
.release-operation-timeline__steps li.is-skipped {
border-left-color: #d99a20;
}
.release-operation-timeline__steps li.is-running,
.release-operation-timeline__steps li.is-queued,
.release-operation-timeline__steps li.is-pending {
border-left-color: #1f6feb;
}
.release-operation-timeline__diagnostic,
.release-operation-timeline__solution,
.release-operation-timeline footer {
background: #f8fafc;
border-radius: 6px;
justify-content: flex-start;
padding: 0.55rem 0.65rem;
}
</style>
@@ -0,0 +1,807 @@
<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 timelineLoading = computed(() => busy.value === "timeline:search");
const timelineRefreshing = computed(() => timelineLoading.value && (sessionRows.value.length > 0 || timelineEvents.value.length > 0));
const sessionRowsLoading = computed(() => timelineLoading.value && sessionRows.value.length === 0);
const timelineEventsLoading = computed(() => timelineLoading.value && timelineEvents.value.length === 0);
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
v-if="timelineRefreshing"
class="release-replay-inspector__refreshing"
data-testid="release-timeline-refreshing"
role="status"
aria-live="polite"
>
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ trFallback("loading.refreshing_timeline", "Refreshing release timeline...") }}</span>
</div>
<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-if="sessionRowsLoading" data-testid="release-timeline-sessions-loading">
<td colspan="8">
<div class="release-replay-inspector__loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ trFallback("loading.timeline_sessions", "Loading release timeline sessions...") }}</span>
</div>
</td>
</tr>
<template v-else>
<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>
</template>
<tr v-if="!sessionRowsLoading && 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>
<div
v-if="!selectedDetail && timelineEventsLoading"
class="release-replay-inspector__loading"
data-testid="release-timeline-events-loading"
role="status"
aria-live="polite"
>
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ trFallback("loading.timeline_events", "Loading release timeline events...") }}</span>
</div>
<ol v-else-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-replay-inspector__loading,
.release-replay-inspector__refreshing {
align-items: center;
color: #52627a;
display: flex;
font-weight: 700;
gap: 0.5rem;
justify-content: center;
min-height: 4rem;
}
.release-replay-inspector__refreshing {
background: #eef6ff;
border: 1px solid #cfe3ff;
border-radius: 6px;
color: #17416f;
justify-content: flex-start;
min-height: 0;
padding: 0.55rem 0.75rem;
}
.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>
@@ -0,0 +1,71 @@
<script setup>
defineProps({
tabs: {
type: Array,
default: () => [],
},
activeKey: {
type: String,
default: "overview",
},
});
const emit = defineEmits(["navigate"]);
</script>
<template>
<nav class="release-route-tabs" data-testid="release-management-interface" aria-label="Release Manager">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="release-route-tabs__item"
:class="{ 'release-route-tabs__item--active': activeKey === tab.key }"
:data-testid="`release-section-link-${tab.key}`"
@click="emit('navigate', tab.key)"
>
<i :class="`fas fa-${tab.icon}`" aria-hidden="true"></i>
<span>{{ tab.label }}</span>
</button>
</nav>
</template>
<style scoped>
.release-route-tabs {
align-items: center;
border-bottom: 1px solid #d8dee8;
display: flex;
flex-wrap: nowrap;
gap: 0.25rem;
min-height: 2.75rem;
overflow-x: auto;
padding: 0 0.25rem;
}
.release-route-tabs__item {
align-items: center;
background: transparent;
border: 0;
border-bottom: 3px solid transparent;
color: #52627a;
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
font: inherit;
font-size: 0.9rem;
gap: 0.45rem;
min-height: 2.75rem;
padding: 0 0.75rem;
}
.release-route-tabs__item--active {
border-bottom-color: #1f6feb;
color: #172033;
font-weight: 700;
}
.release-route-tabs__item:focus-visible {
outline: 2px solid #1f6feb;
outline-offset: -2px;
}
</style>
@@ -0,0 +1,208 @@
<script setup>
import { useAttrs } from "vue";
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
defineProps({
title: {
type: String,
default: "",
},
subtitle: {
type: String,
default: "",
},
icon: {
type: String,
default: "fas fa-table-columns",
},
padded: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release data...",
},
loadingDetail: {
type: String,
default: "",
},
loadingRows: {
type: Number,
default: 4,
},
});
</script>
<template>
<section
v-bind="attrs"
class="release-workspace-panel"
:data-testid="attrs['data-testid'] || 'release-workspace-panel'"
:aria-busy="loading"
>
<header class="release-workspace-panel__header">
<div>
<span class="release-workspace-panel__kicker">
<i :class="icon" aria-hidden="true"></i>
Release Manager
</span>
<h3>{{ title }}</h3>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="release-workspace-panel__actions">
<slot name="actions" />
</div>
</header>
<div
v-if="loading"
class="release-workspace-panel__body release-workspace-panel__body--padded"
data-testid="release-workspace-panel-loading"
role="status"
aria-live="polite"
>
<div class="release-workspace-panel__loading">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<div>
<strong>{{ loadingText }}</strong>
<span v-if="loadingDetail">{{ loadingDetail }}</span>
</div>
</div>
<div class="release-workspace-panel__skeleton" aria-hidden="true">
<span v-for="index in loadingRows" :key="index"></span>
</div>
</div>
<div v-else class="release-workspace-panel__body" :class="{ 'release-workspace-panel__body--padded': padded }">
<slot />
</div>
</section>
</template>
<style scoped>
.release-workspace-panel {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 8px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.release-workspace-panel__header {
align-items: center;
border-bottom: 1px solid #e1e7ef;
display: flex;
gap: 1rem;
justify-content: space-between;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.release-workspace-panel__header h3,
.release-workspace-panel__header p {
margin: 0;
}
.release-workspace-panel__header h3 {
color: #172033;
font-size: 1rem;
line-height: 1.25;
}
.release-workspace-panel__header p,
.release-workspace-panel__kicker {
color: #667085;
font-size: 0.82rem;
}
.release-workspace-panel__kicker {
align-items: center;
display: inline-flex;
font-weight: 700;
gap: 0.35rem;
text-transform: uppercase;
}
.release-workspace-panel__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.release-workspace-panel__body {
min-height: 0;
overflow: auto;
}
.release-workspace-panel__body--padded {
padding: 1rem;
}
.release-workspace-panel__loading {
align-items: center;
color: #172033;
display: flex;
gap: 0.75rem;
}
.release-workspace-panel__loading > div {
display: grid;
gap: 0.15rem;
}
.release-workspace-panel__loading span {
color: #667085;
font-size: 0.86rem;
}
.release-workspace-panel__skeleton {
display: grid;
gap: 0.65rem;
margin-top: 1rem;
}
.release-workspace-panel__skeleton span {
animation: release-loading-shimmer 1.2s ease-in-out infinite;
background: linear-gradient(90deg, #edf2f7 0%, #f8fafc 50%, #edf2f7 100%);
background-size: 220% 100%;
border-radius: 6px;
display: block;
height: 2.25rem;
}
@keyframes release-loading-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 768px) {
.release-workspace-panel {
display: block;
}
.release-workspace-panel__header {
align-items: stretch;
flex-direction: column;
}
.release-workspace-panel__body {
overflow: visible;
}
}
</style>
@@ -0,0 +1,236 @@
const UNKNOWN = "unknown";
const NOT_CONFIGURED = "not configured";
const NOT_CHECKED = "not checked";
const isBlank = (value) => value === null || value === undefined || String(value).trim() === "";
const valueOr = (value, fallback = UNKNOWN) => (isBlank(value) ? fallback : String(value));
const boolValue = (value, trueLabel = "yes", falseLabel = "no", fallback = UNKNOWN) => {
if (value === true || value === 1 || value === "1") return trueLabel;
if (value === false || value === 0 || value === "0") return falseLabel;
return fallback;
};
const shortSha = (value) => {
const normalized = String(value || "").trim();
return normalized.length > 12 ? normalized.slice(0, 12) : normalized || UNKNOWN;
};
const publicRouteSlug = (entity = {}) => {
const slug = String(entity.route_slug || entity.public_route_slug || entity.channel_slug || entity.slug || "").trim();
if (!slug) return UNKNOWN;
return slug.toLowerCase() === "stable" ? "master" : slug;
};
const readableDuration = (start, end) => {
const started = Date.parse(start || "");
const finished = Date.parse(end || "");
if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started) {
return UNKNOWN;
}
const seconds = Math.round((finished - started) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.round(minutes / 60)}h`;
};
const uptimeText = (entity = {}) => {
const direct = entity.uptime || entity.uptime_text || entity.running_for || entity.uptime_human;
if (!isBlank(direct)) return String(direct);
const seconds = Number(entity.uptime_seconds || entity.uptime_sec || entity.last_status?.uptime_seconds);
if (!Number.isFinite(seconds) || seconds <= 0) return UNKNOWN;
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
};
const endpointHost = (entity = {}) =>
entity.hostname ||
entity.host ||
entity.ip ||
entity.endpoint?.host ||
entity.replication?.host ||
entity.target?.endpoint?.host ||
entity.target?.replication?.host;
const endpointPort = (entity = {}) =>
entity.port || entity.endpoint?.port || entity.replication?.port || entity.target?.endpoint?.port || entity.target?.replication?.port;
const lastCheck = (entity = {}) =>
entity.last_checked_at ||
entity.checked_at ||
entity.health_checked_at ||
entity.last_health_check_at ||
entity.last_check ||
entity.replication?.last_checked_at ||
entity.target?.last_reconciled_at ||
entity.target?.replication?.last_checked_at ||
NOT_CHECKED;
const addFact = (facts, label, value, fallback = UNKNOWN) => {
facts.push({ label, value: valueOr(value, fallback) });
};
const currentDeploymentLabel = (deployment) => {
if (!deployment) return NOT_CONFIGURED;
return [`#${deployment.id || UNKNOWN}`, deployment.status || UNKNOWN, shortSha(deployment.commit_sha)].join(" / ");
};
const channelFacts = (entity = {}) => {
const facts = [];
const frontend = entity.current_deployments?.frontend;
const api = entity.current_deployments?.api;
const branch = entity.branch || entity.mapped_branch || entity.branch_status?.frontend?.branch || publicRouteSlug(entity);
addFact(facts, "Public route", `/${publicRouteSlug(entity)}/{api|frontend}`);
addFact(facts, "Mapped branch", branch);
addFact(facts, "Frontend deployment", currentDeploymentLabel(frontend));
addFact(facts, "API deployment", currentDeploymentLabel(api));
addFact(facts, "Health", entity.readiness || entity.state || entity.status || entity.availability?.status);
addFact(facts, "Last sync", entity.last_sync_at || entity.last_synced_at || entity.operation?.completed_at, NOT_CHECKED);
addFact(facts, "Active blockers", entity.active_blockers ?? entity.issues?.length ?? entity.blockers?.length ?? 0);
return facts;
};
const branchFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Repository", entity.repository || entity.full_name);
addFact(facts, "Branch", entity.branch || entity.name);
addFact(facts, "Existence", entity.exists === false ? "missing" : entity.state || entity.status || "available");
addFact(facts, "Latest commit", shortSha(entity.latest_commit_sha || entity.commit_sha || entity.commit?.sha));
addFact(facts, "Commit date", entity.commit_date || entity.latest_commit_at || entity.commit?.date, UNKNOWN);
addFact(facts, "Protected", boolValue(entity.protected));
addFact(facts, "Last sync", entity.last_sync_result || entity.last_webhook_result || entity.last_checked_at, NOT_CHECKED);
return facts;
};
const deploymentFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Channel", entity.channel_slug || entity.channel_name);
addFact(facts, "App", entity.app || entity.deployment_kind);
addFact(facts, "Commit", shortSha(entity.commit_sha));
addFact(facts, "Status", entity.status);
addFact(facts, "Target", entity.coolify_service_uuid || entity.target_id || entity.service_set_id, NOT_CONFIGURED);
addFact(facts, "Started", entity.started_at || entity.created_at, UNKNOWN);
addFact(facts, "Finished", entity.completed_at || entity.updated_at, entity.status === "running" ? "running" : UNKNOWN);
addFact(facts, "Duration", entity.duration || readableDuration(entity.started_at || entity.created_at, entity.completed_at || entity.updated_at));
addFact(facts, "Current state", entity.active_current || entity.current ? "current" : entity.status === "superseded" ? "superseded" : "history");
return facts;
};
const endpointFacts = (entity = {}) => {
const facts = [];
const url = entity.url || entity.public_url || entity.endpoint?.url || entity.endpoint?.display || entity.endpoint;
addFact(facts, "Public URL", url);
addFact(facts, "Route prefix", entity.route_prefix || entity.prefix || (url ? `/${String(url).split("/").slice(3, 5).join("/")}` : ""));
addFact(facts, "Target", entity.app || entity.service || entity.target || entity.target_service, UNKNOWN);
addFact(facts, "Last probe", entity.probe_status || entity.health_status || entity.status, NOT_CHECKED);
addFact(facts, "Response time", entity.response_time_ms ? `${entity.response_time_ms}ms` : entity.latency_ms ? `${entity.latency_ms}ms` : "", UNKNOWN);
addFact(facts, "TLS/route", entity.tls_warning || entity.route_warning || entity.warning || "ok");
return facts;
};
const coolifyFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Instance", entity.instance_label || entity.instance || entity.instance_id);
addFact(facts, "Project/App", entity.project_uuid || entity.project_id || entity.app_id || entity.resource_uuid, NOT_CONFIGURED);
addFact(facts, "Environment", entity.environment || entity.environment_name || entity.coolify_environment_name, UNKNOWN);
addFact(facts, "Target host", endpointHost(entity), UNKNOWN);
addFact(facts, "Status", entity.status || entity.deployment_status || entity.availability_state || (entity.integrated ? "integrated" : ""));
addFact(facts, "Last deploy", entity.last_deployed_at || entity.last_reconciled_at || entity.updated_at, NOT_CHECKED);
addFact(facts, "Exposed URL", entity.public_url || entity.coolify_public_url || entity.endpoint?.url || entity.endpoint?.display, NOT_CONFIGURED);
addFact(facts, "Health check", entity.health_status || entity.health_check_result || entity.last_reconcile_status, NOT_CHECKED);
return facts;
};
const hostFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Node", entity.node || entity.node_name || entity.label || entity.host_label);
addFact(facts, "Hostname/IP", endpointHost(entity));
addFact(facts, "Provider/location", entity.provider || entity.location || entity.region, UNKNOWN);
addFact(facts, "SSH", boolValue(entity.ssh_available ?? entity.ssh_enabled ?? entity.ssh));
addFact(facts, "Services", entity.service_count ?? entity.services?.length, UNKNOWN);
addFact(facts, "Last health check", lastCheck(entity), NOT_CHECKED);
return facts;
};
const dataServiceFacts = (entity = {}) => {
const target = entity.target || entity;
const replication = entity.replication || target.replication || {};
const facts = [];
addFact(facts, "Service", entity.kind || target.kind || replication.kind);
addFact(facts, "Mode", entity.mode || target.mode || entity.replication_policy?.mode);
addFact(facts, "Node", entity.node || entity.node_name || target.instance_label || replication.label, UNKNOWN);
addFact(facts, "Online state", entity.online === true ? "online" : entity.online === false ? "offline" : entity.online_state || entity.state || target.availability_state || target.deployment_status || replication.status);
addFact(facts, "Hostname", endpointHost({ ...entity, target, replication }), NOT_CONFIGURED);
addFact(facts, "Port", endpointPort({ ...entity, target, replication }), NOT_CONFIGURED);
addFact(facts, "Uptime", uptimeText({ ...entity, ...target, ...replication }));
addFact(facts, "Version", entity.version || target.version || replication.version, UNKNOWN);
addFact(facts, "Replication role", entity.replication_role || replication.role || entity.role, UNKNOWN);
addFact(facts, "Replication lag", entity.replication_lag || replication.lag || replication.lag_seconds, UNKNOWN);
addFact(facts, "Last check", lastCheck({ ...entity, target, replication }), NOT_CHECKED);
return facts;
};
const failoverFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Primary node", entity.primary_node || entity.primary_host || entity.primary?.host, UNKNOWN);
addFact(facts, "Replica node", entity.replica_node || entity.replica_host || entity.replica?.host, UNKNOWN);
addFact(facts, "Readiness", entity.readiness || entity.status || (entity.integrated ? "ready" : "not configured"));
addFact(facts, "Blockers", entity.blockers?.length ?? entity.issues?.length ?? 0);
addFact(facts, "Last failover", entity.last_failover_at || entity.last_checked_at, NOT_CHECKED);
addFact(facts, "Recovery action", entity.recovery_action || entity.next_action || entity.change_control, UNKNOWN);
return facts;
};
const operationFacts = (entity = {}) => {
const currentStep =
entity.current_step ||
(Array.isArray(entity.steps) ? entity.steps.find((step) => ["running", "failed"].includes(step.status)) : null);
const failedStep =
entity.failed_step ||
(Array.isArray(entity.steps) ? entity.steps.find((step) => String(step.status || "") === "failed") : null);
const facts = [];
addFact(facts, "Status", entity.status);
addFact(facts, "Current step", currentStep?.label || currentStep?.step_key, UNKNOWN);
addFact(facts, "Failed step", failedStep?.label || failedStep?.step_key, entity.status === "failed" ? UNKNOWN : "none");
addFact(facts, "Diagnostic", failedStep?.diagnostic || entity.diagnostic, UNKNOWN);
addFact(facts, "Solution", failedStep?.solution_hint || entity.solution_hint, UNKNOWN);
addFact(facts, "Started", entity.started_at || entity.created_at, UNKNOWN);
addFact(facts, "Duration", entity.duration || readableDuration(entity.started_at || entity.created_at, entity.completed_at || entity.updated_at));
return facts;
};
const genericFacts = (entity = {}, detail = "") => {
const facts = [];
addFact(facts, "Type", entity.type || entity.kind || UNKNOWN);
addFact(facts, "Status", entity.status || entity.state || entity.availability_state, UNKNOWN);
addFact(facts, "Host", endpointHost(entity), UNKNOWN);
addFact(facts, "Port", endpointPort(entity), UNKNOWN);
addFact(facts, "Last check", lastCheck(entity), NOT_CHECKED);
if (!isBlank(detail)) addFact(facts, "Detail", detail);
return facts;
};
export const releaseEntityFacts = ({ type = "entity", entity = {}, detail = "" } = {}) => {
const normalized = String(type || "").toLowerCase();
const enriched = { ...(entity || {}), ...(entity?.entity_facts || {}) };
if (normalized.includes("channel")) return channelFacts(enriched);
if (normalized.includes("branch") || normalized.includes("repo") || normalized.includes("source")) return branchFacts(enriched);
if (normalized.includes("deployment")) return deploymentFacts(enriched);
if (normalized.includes("endpoint")) return endpointFacts(enriched);
if (normalized.includes("coolify")) return coolifyFacts(enriched);
if (normalized.includes("host") || normalized.includes("location")) return hostFacts(enriched);
if (normalized.includes("data") || ["database", "redis", "minio"].some((kind) => normalized.includes(kind))) return dataServiceFacts(enriched);
if (normalized.includes("failover")) return failoverFacts(enriched);
if (normalized.includes("operation") || normalized.includes("test")) return operationFacts(enriched);
return genericFacts(enriched, detail);
};
export const releaseEntityTooltipText = (facts = []) =>
facts.map((fact) => `${fact.label}: ${valueOr(fact.value)}`).join("\n");
export const releaseEntityDisplayLabel = (label, entity = {}) =>
label || entity.label || entity.name || entity.slug || entity.resource_name || entity.resource_uuid || "--";
@@ -0,0 +1,19 @@
import { ref } from "vue";
export function useReleaseEntityDrawer() {
const selectedReleaseEntity = ref(null);
const openReleaseEntity = (payload) => {
selectedReleaseEntity.value = payload;
};
const closeReleaseEntity = () => {
selectedReleaseEntity.value = null;
};
return {
selectedReleaseEntity,
openReleaseEntity,
closeReleaseEntity,
};
}
@@ -0,0 +1,11 @@
import { onBeforeUnmount, onMounted } from "vue";
export function useReleaseKeyboardShortcuts(handler) {
onMounted(() => {
window.addEventListener("keydown", handler);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handler);
});
}
@@ -0,0 +1,93 @@
import { computed } from "vue";
export const RELEASE_MANAGER_PANELS = [
"overview",
"channels",
"deployments",
"operations",
"integrations",
"data-services",
"assignments",
"settings",
];
export const RELEASE_MANAGER_PANEL_ALIASES = {
bundles: "deployments",
replay: "operations",
coolify: "integrations",
failover: "data-services",
"data": "data-services",
};
export const routeSlugForReleaseChannel = (channel) => {
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
return slug === "stable" ? "master" : slug;
};
export const normalizeReleasePanel = (panel) => {
const normalized = String(panel || "overview").trim().toLowerCase();
const mapped = RELEASE_MANAGER_PANEL_ALIASES[normalized] || normalized;
return RELEASE_MANAGER_PANELS.includes(mapped) ? mapped : "overview";
};
export const defaultBranchForReleaseChannel = (channel) =>
routeSlugForReleaseChannel(channel) || String(channel?.branch || "").trim() || "master";
export function useReleaseManagerContext({ route, router, channels, branchSuggestions }) {
const selectedChannel = computed(() => {
const selectedSlug = String(route.query.channel || "").trim().toLowerCase();
return (
channels.value.find((channel) => String(channel.slug || channel.channel_slug).toLowerCase() === selectedSlug) ||
channels.value.find((channel) => channel.default_channel) ||
channels.value[0] ||
null
);
});
const selectedChannelSlug = computed(
() => String(route.query.channel || selectedChannel.value?.slug || selectedChannel.value?.channel_slug || "").trim()
);
const selectedApp = computed(() => {
const app = String(route.query.app || "all").trim().toLowerCase();
return ["all", "frontend", "api"].includes(app) ? app : "all";
});
const selectedBranch = computed(() =>
String(route.query.branch || defaultBranchForReleaseChannel(selectedChannel.value)).trim()
);
const branchOptions = computed(() => {
const values = new Set([
"master",
"beta",
"canary",
"internal",
defaultBranchForReleaseChannel(selectedChannel.value),
...(Array.isArray(branchSuggestions.value) ? branchSuggestions.value : []),
]);
return Array.from(values).filter(Boolean);
});
const updateContext = (patch = {}) => {
const query = {
...route.query,
...patch,
};
for (const key of ["channel", "app", "branch"]) {
if (query[key] === "" || query[key] === null || query[key] === undefined) {
delete query[key];
}
}
router.replace({ path: route.path, query });
};
return {
selectedChannel,
selectedChannelSlug,
selectedApp,
selectedBranch,
branchOptions,
updateContext,
};
}
@@ -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
+187 -125
View File
@@ -48,7 +48,15 @@ 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 { normalizeSessionPayload } from "@/services/sessionPayload.js";
import {
isReleaseChannelApiAvailabilityError,
markReleaseChannelApiUnavailable,
reconcileSelectedReleaseChannel,
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -89,19 +97,37 @@ const hydrateSessionFromStorage = () => {
return true;
};
const clearStoredSession = () => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.removeItem("token");
window.localStorage.removeItem("is_subuser");
} catch {
// Storage can be unavailable in test or private browsing contexts.
}
};
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 +142,108 @@ 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;
}
};
const resetSessionState = () => {
SessionUser.user.id.value = null;
SessionUser.user.customer_number.value = null;
SessionUser.user.display_name.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.user.phone.country_code.value = null;
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
SessionUser.permissions.value = [];
SessionUser.token.value = null;
SessionUser.authenticated.value = false;
SessionUser.isSubuser.value = false;
SessionUser.initiated.value = false;
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
SessionUser.subuser.suspended_at.value = null;
SessionUser.subuser.cached_at.value = null;
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("selected_customer_number");
} catch {
// Storage is optional for the in-memory reset.
}
}
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
SessionUser.runtimeConfig.release.traceId.value = null;
SessionUser.runtimeConfig.release.channel.value = 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 = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
SessionUser.runtimeConfig.release.capturePolicy.value = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
setReleaseChannelSwitchNoticePrincipal("");
configureReleaseRuntime({});
};
export const refreshSessionData = async () => {
if (!hydrateSessionFromStorage()) {
resetSessionState();
return null;
}
if (SessionUser.isSubuser.value) {
return await getSubuserSessionData();
}
return await getSessionData();
};
/**
@@ -253,6 +373,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,57 +398,63 @@ 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;
SessionUser.user.group_id.value = response.data.data.group_id;
SessionUser.user.email.value = response.data.data.email;
SessionUser.user.phone.number.value = response.data.data.phone.number;
SessionUser.user.phone.country_code.value = response.data.data.phone.country_code;
SessionUser.user.notifications.wash_certificate_email.value =
response.data.data.notifications.wash_certificate_email;
const session = normalizeSessionPayload(response?.data?.data);
SessionUser.user.id.value = session.id;
SessionUser.user.customer_number.value = session.customer_number;
SessionUser.user.group_id.value = session.group_id;
SessionUser.user.email.value = session.email;
SessionUser.user.phone.number.value = session.phone.number;
SessionUser.user.phone.country_code.value = session.phone.country_code;
SessionUser.user.notifications.wash_certificate_email.value = session.notifications.wash_certificate_email;
SessionUser.user.notifications.email_notifications_enabled.value =
response.data.data.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value =
response.data.data.notifications.sms_notifications_enabled;
SessionUser.user.created_at.value = response.data.data.created_at;
SessionUser.user.updated_at.value = response.data.data.updated_at;
SessionUser.user.display_name.value = response.data.data.display_name;
session.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
SessionUser.user.created_at.value = session.created_at;
SessionUser.user.updated_at.value = session.updated_at;
SessionUser.user.display_name.value = session.display_name;
setReleaseChannelSwitchNoticePrincipal(
`user:${response.data.data.id || response.data.data.customer_number || response.data.data.email || "unknown"}`
`user:${session.id || session.customer_number || session.email || "unknown"}`
);
// Set the last cached time to now, this is used to determine if the user's data is outdated
SessionUser.user.cached_at.value = new Date();
SessionUser.permissions.value = response.data.data.permissions;
SessionUser.permissions.value = session.permissions;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.transaction_draft_customer_number
session.runtime_config.economic.transaction_draft_customer_number
);
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.default_distribution_department_id
session.runtime_config.economic.default_distribution_department_id
);
applyReleaseRuntimeConfig(response?.data?.data?.runtime_config?.release || {});
applyReleaseRuntimeConfig(session.runtime_config.release);
// E-conomic data is only fetched if the array isn't empty
if (response.data.data.economic_customer.length > 0) {
SessionUser.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
SessionUser.economicData.name.value = response.data.data.economic_customer.name;
if (session.economic_customer) {
SessionUser.economicData.customerNumber.value = session.economic_customer.customerNumber;
SessionUser.economicData.name.value = session.economic_customer.name;
// Set the display name to the e-conomic name if it's empty
if (SessionUser.user.display_name.value === null || SessionUser.user.display_name.value === "Unnamed") {
SessionUser.user.display_name.value = response.data.data.economic_customer.name;
SessionUser.user.display_name.value = session.economic_customer.name;
}
SessionUser.economicData.address.value = response.data.data.economic_customer.address;
SessionUser.economicData.zip.value = response.data.data.economic_customer.zip;
SessionUser.economicData.city.value = response.data.data.economic_customer.city;
SessionUser.economicData.mobilePhone.value = response.data.data.economic_customer.mobilePhone;
SessionUser.economicData.email.value = response.data.data.economic_customer.email;
SessionUser.economicData.cvr.value = response.data.data.economic_customer.corporateIdentificationNumber;
SessionUser.economicData.currency.value = response.data.data.economic_customer.currency;
SessionUser.economicData.country.value = response.data.data.economic_customer.country;
SessionUser.economicData.address.value = session.economic_customer.address;
SessionUser.economicData.zip.value = session.economic_customer.zip;
SessionUser.economicData.city.value = session.economic_customer.city;
SessionUser.economicData.mobilePhone.value = session.economic_customer.mobilePhone;
SessionUser.economicData.email.value = session.economic_customer.email;
SessionUser.economicData.cvr.value = session.economic_customer.corporateIdentificationNumber;
SessionUser.economicData.currency.value = session.economic_customer.currency;
SessionUser.economicData.country.value = session.economic_customer.country;
SessionUser.economicData.cached_at.value = new Date();
}
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({
@@ -343,22 +475,13 @@ export const getSessionData = async () => {
*/
export const destroySession = async () => {
return await authenticatedRequest("/auth/logout", "GET")
.then(() => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
})
.catch((error) => {
parseError(error, "auth");
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
console.error(error);
})
.finally(() => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
clearStoredSession();
resetSessionState();
});
};
@@ -366,9 +489,8 @@ export const destroySession = async () => {
* Force sign out the user
*/
export const forceClearSession = () => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
clearStoredSession();
resetSessionState();
};
/**
@@ -487,6 +609,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),
@@ -697,69 +820,7 @@ export const SessionUser = {
},
/** Reset the user's session data (Cache) */
forceRefresh: () => {
SessionUser.user.id.value = null;
SessionUser.user.customer_number.value = null;
SessionUser.user.display_name.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.user.phone.country_code.value = null;
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
SessionUser.permissions.value = [];
SessionUser.authenticated.value = false;
SessionUser.isSubuser.value = false;
// Clear subuser data
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
SessionUser.subuser.suspended_at.value = null;
SessionUser.subuser.cached_at.value = null;
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
localStorage.removeItem("selected_customer_number");
// Clear economic data
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
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.frontendBaseUrl.value = null;
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
SessionUser.runtimeConfig.release.availability.value = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
SessionUser.runtimeConfig.release.capturePolicy.value = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
setReleaseChannelSwitchNoticePrincipal("");
getSessionData();
resetSessionState();
},
auth: {
/** Authenticate the user */
@@ -768,7 +829,7 @@ export const SessionUser = {
authenticateSubuser: authenticateSubuser,
/** Logout the user */
logout: destroySession,
/** Force clear the user's session WARNING: This will not destroy the user's token */
/** Force clear the user's local session state and stored token */
forceClearSession: forceClearSession,
/** reCAPTCHA */
reCAPTCHA: reCAPTCHA,
@@ -1136,6 +1197,7 @@ export const SessionUser = {
editField: EditFieldForm,
getSessionData: getSessionData,
getSubuserSessionData: getSubuserSessionData,
refreshSessionData: refreshSessionData,
refreshReleaseRuntime: refreshReleaseRuntime,
initiateOnAppStart: initiateOnAppStart,
checkForUpdates() {
@@ -1,5 +1,6 @@
export const SELF_SERVE_TASK_BUTTON_RESET = "reset";
export const SELF_SERVE_TASK_BUTTON_START = "start";
export const SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER = "program_picker";
export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
{
@@ -7,6 +8,11 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
name: "Reset",
description: "Reset button",
},
{
id: SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
name: "Program picker",
description: "Program picker wheel",
},
...Array.from({ length: 12 }, (_, index) => ({
id: index,
name: `Program ${index + 1}`,
@@ -22,6 +28,7 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
const KNOWN_SPECIAL_BUTTONS = new Set([
SELF_SERVE_TASK_BUTTON_RESET,
SELF_SERVE_TASK_BUTTON_START,
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
]);
const BUTTON_LABELS = new Map(
@@ -1,13 +1,14 @@
<script>
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
export const unauthenticatedRequest = (url, method, data) => {
return axios({
method,
url: API_URL + url,
url: resolveReleaseApiUrl(url),
data,
headers: buildCurrentReleaseHeaders(),
});
};
</script>
+27 -7
View File
@@ -859,7 +859,11 @@ export const createOrder = async (options = { isMobile: false }) => {
parseError(response, "stepError");
return false;
}
order_id.value = parseInt(response.data.data.id);
const createdOrder = response.data.data || {};
order_id.value = parseInt(createdOrder.id);
if (!isBlankPosMetadataValue(createdOrder.po)) {
order_po.value = String(createdOrder.po);
}
if (selectedDepartmentId) {
department_id.value = selectedDepartmentId;
}
@@ -1230,6 +1234,20 @@ const getOrderItemNotes = (item) => {
return notesValue === "" ? null : notesValue;
};
const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
const normalizedSourceReference = String(sourceReference ?? "").trim();
if (!normalizedSourceReference || !isBlankPosMetadataValue(reference.value)) {
return;
}
reference.value = normalizedSourceReference;
const normalizedOrderId = toPositiveInteger(order_id.value);
if (normalizedOrderId) {
await SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedSourceReference);
}
};
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
const productId = getOrderItemProductId(sourceItem);
const quantity = getOrderItemQuantity(sourceItem);
@@ -1258,6 +1276,8 @@ export const copyLastWashItemsToCurrentOrder = async (sourceItems = [], options
return false;
}
await copyLastWashReferenceToEmptyCurrentOrder(options.sourceReference ?? options.sourceOrder?.reference);
const didEnsureOrder = await createOrder({
...options,
isMobile: false,
@@ -2078,11 +2098,6 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
try {
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
let booking = getSelectedPendingOrderBooking();
if (!booking || !Array.isArray(booking?.items)) {
booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId, {
@@ -2124,11 +2139,16 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
const bookingPo = String(booking?.po ?? "").trim();
if (bookingPo !== "" && bookingPo !== order_po.value) {
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
order_po.value = bookingPo;
}
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
if (bookingItems.length === 0) {
return true;
@@ -8,6 +8,7 @@ import { ADMIN_DEPARTMENT_SELECTION_CLASS } from "@/components/models/navigation
import { isDepartmentLabelValid, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isSmall } from "@/components/displays/pagination/PaginationDisplayIsSmall.vue";
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
@@ -452,6 +453,7 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
</BMenuList>
</BMenu>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
</div>
</template>
+17 -41
View File
@@ -1,6 +1,11 @@
import { computed, ref, watch } from "vue";
import { API_URL } from "@/config";
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
getSelfServeDynamicImageButtonsToPress,
getSelfServeDynamicImageThumbPosition,
getSelfServeTaskDynamicImageButtons,
} from "@/services/selfServeDynamicImage.js";
export function useWashSessionActions(options) {
const {
@@ -53,21 +58,12 @@ export function useWashSessionActions(options) {
return message || fallback;
};
const getTaskButtons = (task) => normalizeSelfServeTaskButtons(
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
const getTaskButtons = (task) => getSelfServeTaskDynamicImageButtons(task);
const getButtonsToPress = () => activeTasks.value.flatMap((task) => getTaskButtons(task));
const getButtonsToPress = () => getSelfServeDynamicImageButtonsToPress(activeTasks.value);
const getCompletedButtons = () => {
let totalButtonsCompleted = 0;
activeTasks.value.forEach((task) => {
if (completedTasks.value[task.id]) {
totalButtonsCompleted += getTaskButtons(task).length;
}
});
const totalButtonsCompleted = getSelfServeCompletedDynamicImageStep(activeTasks.value, completedTasks.value);
machineStartCurrentStep.value = totalButtonsCompleted;
return totalButtonsCompleted;
};
@@ -85,18 +81,6 @@ export function useWashSessionActions(options) {
clearProgress();
};
const getThumbPosition = () => {
for (const task of activeTasks.value) {
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
const thumbPosition = Number.parseInt(String(rawValue ?? ""), 10);
if (Number.isInteger(thumbPosition) && thumbPosition >= 1 && thumbPosition <= 12) {
return thumbPosition;
}
}
return null;
};
const dynamicImageUrl = computed(() => {
const departmentId = nearestDepartment.value?.id;
const laneId = washLaneId.value;
@@ -106,22 +90,14 @@ export function useWashSessionActions(options) {
return null;
}
const params = new URLSearchParams({
department: String(departmentId),
lane: String(laneId),
current_step: String(getCompletedButtons()),
buttons: JSON.stringify(getButtonsToPress()),
return buildSelfServeDynamicImageUrl({
departmentId,
laneId,
buttons: getButtonsToPress(),
currentStep: getCompletedButtons(),
vehicleTypeId: vehicleTypeSelect.value,
thumbPosition: getSelfServeDynamicImageThumbPosition(activeTasks.value),
});
if (vehicleTypeSelect.value !== null && vehicleTypeSelect.value !== undefined && vehicleTypeSelect.value !== "") {
params.set("vehicle_type", String(vehicleTypeSelect.value));
}
const thumbPosition = getThumbPosition();
if (thumbPosition !== null) {
params.set("thumb_position", String(thumbPosition));
}
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
});
const executeSelfServeCommand = async (
+21 -3
View File
@@ -16,19 +16,36 @@ const parseBoolean = (value, fallback = false) => {
const normalizeApiUrl = (value) => String(value || "").replace(/\/+$/, "");
export const IS_DEV = parseBoolean(import.meta.env.VITE_IS_DEV, import.meta.env.MODE !== "production");
export const IS_DEV = parseBoolean(
import.meta.env.VITE_IS_DEV,
import.meta.env.DEV ?? import.meta.env.MODE !== "production"
);
const normalizeReleaseSource = (value) => {
const normalized = String(value || "").trim().toLowerCase();
if (["local", "deployment", "auto"].includes(normalized)) {
return normalized;
}
return IS_DEV ? "local" : "deployment";
};
export const RELEASE_SOURCE_ENV = String(import.meta.env.VITE_RELEASE_SOURCE || "").trim().toLowerCase();
export const RELEASE_SOURCE = normalizeReleaseSource(RELEASE_SOURCE_ENV);
export const DEFAULT_PUBLIC_GATEWAY_API_URL = "https://api-v2.truckwash.io";
export const DEFAULT_STABLE_API_URL = `${DEFAULT_PUBLIC_GATEWAY_API_URL}/master/api`;
// 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" : DEFAULT_STABLE_API_URL)
);
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 || DEFAULT_PUBLIC_GATEWAY_API_URL
);
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 ? `${DEFAULT_STABLE_API_URL},${DEFAULT_PUBLIC_GATEWAY_API_URL}` : "")
)
.split(",")
.map((url) => normalizeApiUrl(url.trim()))
@@ -44,6 +61,7 @@ export const ALLOWED_ORIGINS = [
"http://127.0.0.1:5173",
"http://127.0.0.1:4173",
"http://127.0.0.1:4174",
"https://dev.truckwash.io",
];
export const MIGRATION_ORIGIN = "https://truckwash.io";
+120 -26
View File
@@ -2441,14 +2441,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -2457,15 +2467,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -2473,7 +2483,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -2484,28 +2494,60 @@
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger."
}
},
"loading": {
"summary": "Indlæser Release Manager-data...",
"refreshing_summary": "Opdaterer Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments og service-health indlæses.",
"channels": "Indlæser release-kanaler...",
"channels_detail": "Kanalbaner og release-afhængigheder indlæses.",
"assignments": "Indlæser release-tildelinger...",
"deployments": "Indlæser release-deployments...",
"operations": "Indlæser release-operationer...",
"data_services": "Indlæser release-datatjenester...",
"integrations": "Indlæser release-integrationer...",
"settings": "Indlæser Release Manager-indstillinger...",
"github_repositories": "Indlæser GitHub-repositories...",
"timeline_sessions": "Indlæser release-tidslinjesessioner...",
"timeline_events": "Indlæser release-tidslinjehændelser...",
"refreshing_timeline": "Opdaterer release-tidslinje..."
},
"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 +2557,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 +2601,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 +2661,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 +2802,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 +2843,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 +2914,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",
+102 -25
View File
@@ -2551,27 +2551,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -2594,31 +2604,48 @@
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren."
}
},
"loading": {
"summary": "Release Manager-Daten werden geladen...",
"refreshing_summary": "Release Manager-Daten werden aktualisiert...",
"summary_detail": "Release-Status, Kanale, Deployments und Servicezustand werden geladen.",
"channels": "Release-Kanale werden geladen...",
"channels_detail": "Kanalansichten und Release-Abhangigkeiten werden geladen.",
"assignments": "Release-Zuweisungen werden geladen...",
"deployments": "Release-Deployments werden geladen...",
"operations": "Release-Operationen werden geladen...",
"data_services": "Release-Datendienste werden geladen...",
"integrations": "Release-Integrationen werden geladen...",
"settings": "Release Manager-Einstellungen werden geladen...",
"github_repositories": "GitHub-Repositories werden geladen...",
"timeline_sessions": "Release-Timeline-Sessions werden geladen...",
"timeline_events": "Release-Timeline-Ereignisse werden geladen...",
"refreshing_timeline": "Release-Timeline wird aktualisiert..."
},
"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 +2662,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 +2696,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 +2715,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 +2727,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 +2760,7 @@
"remove": "Entfernen",
"test_access": "Zugriff testen",
"deploy": "Deployen",
"redeploy": "Erneut deployen",
"promote": "Promoten",
"enable": "Aktivieren",
"search": "Suchen",
@@ -2843,6 +2895,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 +2936,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 +3007,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",
+98 -4
View File
@@ -2275,6 +2275,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -2318,6 +2328,23 @@
"description": "Configure Release Manager GitHub access and webhook settings."
}
},
"loading": {
"summary": "Loading release manager data...",
"refreshing_summary": "Refreshing release manager data...",
"summary_detail": "Release status, channels, deployments, and service health are being loaded.",
"channels": "Loading release channels...",
"channels_detail": "Channel lanes and release dependencies are being loaded.",
"assignments": "Loading release assignments...",
"deployments": "Loading release deployments...",
"operations": "Loading release operations...",
"data_services": "Loading release data services...",
"integrations": "Loading release integrations...",
"settings": "Loading Release Manager settings...",
"github_repositories": "Loading GitHub repositories...",
"timeline_sessions": "Loading release timeline sessions...",
"timeline_events": "Loading release timeline events...",
"refreshing_timeline": "Refreshing release timeline..."
},
"setup": {
"channels": "Channels",
"targets": "GitHub and Coolify targets",
@@ -2340,6 +2367,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 +2435,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 +2455,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 +2466,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 +2495,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 +2636,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 +2677,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 +2748,19 @@
"ssl_domain_message": "Must be a DNS domain routed to the Coolify load balancer. Example: api-v2.truckwash.io",
"ssl_domain_placeholder": "Load balancer domain",
"https_domain_for_coolify": "Domain controlled by the load balancer",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Automatically create deployments when this target changes.",
"coolify_ssl": "Coolify SSL",
+62 -2
View File
@@ -1500,6 +1500,16 @@
"release_manager": {
"title": "@:{'templates.generated.compat.configuration.release_manager.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.subtitle'}",
"channel_names": {
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_names.stable'}",
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_names.canary'}",
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_names.internal'}"
},
"channel_descriptions": {
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.stable'}",
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.canary'}",
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.internal'}"
},
"control_api": {
"title": "@:{'templates.generated.compat.configuration.release_manager.control_api.title'}",
"tooltip": "@:{'templates.generated.compat.configuration.release_manager.control_api.tooltip'}",
@@ -1619,8 +1629,13 @@
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.title'}",
"summary_prefix": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.summary_prefix'}",
"summary_suffix": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.summary_suffix'}",
"frontend_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_url'}",
"api_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_url'}",
"release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_bundle'}",
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_version'}",
"api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_version'}",
"frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_base_url'}",
"api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_base_url'}",
"frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_entry'}",
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_runtime'}",
"checking_again": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.checking_again'}",
"refresh_error": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.refresh_error'}",
"ignore": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.ignore'}",
@@ -1633,6 +1648,7 @@
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.title'}",
"summary_prefix": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.summary_prefix'}",
"summary_suffix": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.summary_suffix'}",
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.bundle'}",
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.frontend'}",
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.api'}",
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.frontend_version'}",
@@ -1643,6 +1659,24 @@
"base_image": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.base_image'}",
"continue": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.continue'}"
},
"channel_selector": {
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.subtitle'}",
"sidebar_title": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.sidebar_title'}",
"default": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.default'}",
"ready": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.ready'}",
"unavailable": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.unavailable'}",
"release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_bundle'}",
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_version'}",
"api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_version'}",
"frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_base_url'}",
"api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_base_url'}",
"frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_entry'}",
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_runtime'}",
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.bundle'}",
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend'}",
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api'}"
},
"common": {
"yes": "@:{'templates.generated.compat.configuration.release_manager.common.yes'}",
"no": "@:{'templates.generated.compat.configuration.release_manager.common.no'}",
@@ -1658,6 +1692,7 @@
"remove": "@:{'templates.generated.compat.configuration.release_manager.actions.remove'}",
"test_access": "@:{'templates.generated.compat.configuration.release_manager.actions.test_access'}",
"deploy": "@:{'templates.generated.compat.configuration.release_manager.actions.deploy'}",
"redeploy": "@:{'templates.generated.compat.configuration.release_manager.actions.redeploy'}",
"promote": "@:{'templates.generated.compat.configuration.release_manager.actions.promote'}",
"enable": "@:{'templates.generated.compat.configuration.release_manager.actions.enable'}",
"search": "@:{'templates.generated.compat.configuration.release_manager.actions.search'}",
@@ -1792,6 +1827,18 @@
"assignments": {
"title": "@:{'templates.generated.compat.configuration.release_manager.assignments.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.assignments.subtitle'}",
"subject": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject'}",
"subject_message": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_message'}",
"subject_placeholder": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_placeholder'}",
"subject_empty": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_empty'}",
"group_users": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_users'}",
"group_subusers": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_subusers'}",
"group_customers": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_customers'}",
"group_manual": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_manual'}",
"manual_user": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_user'}",
"manual_subuser": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_subuser'}",
"manual_customer": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_customer'}",
"manual_description": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_description'}",
"subject_type": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_type'}",
"subject_type_message": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_type_message'}",
"customer": "@:{'templates.generated.compat.configuration.release_manager.assignments.customer'}",
@@ -1892,6 +1939,19 @@
"ssl_domain_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.ssl_domain_message'}",
"ssl_domain_placeholder": "@:{'templates.generated.compat.configuration.release_manager.integrations.ssl_domain_placeholder'}",
"https_domain_for_coolify": "@:{'templates.generated.compat.configuration.release_manager.integrations.https_domain_for_coolify'}",
"endpoint_mode": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode'}",
"endpoint_mode_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_message'}",
"endpoint_mode_auto": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_auto'}",
"endpoint_mode_manual": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_manual'}",
"manual_endpoint_host": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host'}",
"manual_endpoint_host_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host_message'}",
"manual_endpoint_host_placeholder": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host_placeholder'}",
"manual_endpoint_port": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_port'}",
"manual_endpoint_port_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_port_message'}",
"app_port": "@:{'templates.generated.compat.configuration.release_manager.integrations.app_port'}",
"app_port_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.app_port_message'}",
"auto_gateway_endpoint": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_gateway_endpoint'}",
"pending_automatic_endpoint": "@:{'templates.generated.compat.configuration.release_manager.integrations.pending_automatic_endpoint'}",
"auto_deploy": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_deploy'}",
"auto_deploy_tooltip": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_deploy_tooltip'}",
"coolify_ssl": "@:{'templates.generated.compat.configuration.release_manager.integrations.coolify_ssl'}",
+95 -18
View File
@@ -2552,16 +2552,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -2572,7 +2582,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -2584,7 +2594,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -2595,16 +2605,33 @@
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger."
}
},
"loading": {
"summary": "Laster Release Manager-data...",
"refreshing_summary": "Oppdaterer Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments og servicehelse lastes.",
"channels": "Laster release-kanaler...",
"channels_detail": "Kanalvisninger og release-avhengigheter lastes.",
"assignments": "Laster release-tildelinger...",
"deployments": "Laster release-deployments...",
"operations": "Laster release-operasjoner...",
"data_services": "Laster release-datatjenester...",
"integrations": "Laster release-integrasjoner...",
"settings": "Laster Release Manager-innstillinger...",
"github_repositories": "Laster GitHub-repositories...",
"timeline_sessions": "Laster release-tidslinje-sesjoner...",
"timeline_events": "Laster release-tidslinjehendelser...",
"refreshing_timeline": "Oppdaterer release-tidslinje..."
},
"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 +2655,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 +2697,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 +2714,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 +2761,7 @@
"remove": "Fjern",
"test_access": "Test tilgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promoter",
"enable": "Aktiver",
"search": "Søk",
@@ -2844,6 +2896,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 +2937,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 +3008,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",
+119 -42
View File
@@ -2602,31 +2602,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -2634,58 +2644,75 @@
},
"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."
}
},
"loading": {
"summary": "Laddar Release Manager-data...",
"refreshing_summary": "Uppdaterar Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments och servicehalsa laddas.",
"channels": "Laddar release-kanaler...",
"channels_detail": "Kanalvyer och release-beroenden laddas.",
"assignments": "Laddar release-tilldelningar...",
"deployments": "Laddar release-deployments...",
"operations": "Laddar release-operationer...",
"data_services": "Laddar release-datatjanster...",
"integrations": "Laddar release-integrationer...",
"settings": "Laddar Release Manager-installningar...",
"github_repositories": "Laddar GitHub-repositories...",
"timeline_sessions": "Laddar release-tidslinjesessioner...",
"timeline_events": "Laddar release-tidslinjehandelser...",
"refreshing_timeline": "Uppdaterar release-tidslinje..."
},
"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 +2745,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 +2764,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 +2776,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 +2811,7 @@
"remove": "Ta bort",
"test_access": "Testa åtkomst",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promota",
"enable": "Aktivera",
"search": "Sök",
@@ -2894,6 +2946,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 +2987,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 +3058,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",
+90 -26
View File
@@ -1499,14 +1499,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -1515,15 +1525,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -1531,7 +1541,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -1542,24 +1552,41 @@
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger."
}
},
"loading": {
"summary": "Indl\u00e6ser Release Manager-data...",
"refreshing_summary": "Opdaterer Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments og service-health indl\u00e6ses.",
"channels": "Indl\u00e6ser release-kanaler...",
"channels_detail": "Kanalbaner og release-afh\u00e6ngigheder indl\u00e6ses.",
"assignments": "Indl\u00e6ser release-tildelinger...",
"deployments": "Indl\u00e6ser release-deployments...",
"operations": "Indl\u00e6ser release-operationer...",
"data_services": "Indl\u00e6ser release-datatjenester...",
"integrations": "Indl\u00e6ser release-integrationer...",
"settings": "Indl\u00e6ser Release Manager-indstillinger...",
"github_repositories": "Indl\u00e6ser GitHub-repositories...",
"timeline_sessions": "Indl\u00e6ser release-tidslinjesessioner...",
"timeline_events": "Indl\u00e6ser release-tidslinjeh\u00e6ndelser...",
"refreshing_timeline": "Opdaterer release-tidslinje..."
},
"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 +1600,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 +1644,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 +1708,7 @@
"remove": "Fjern",
"test_access": "Test adgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promover",
"enable": "Aktiver",
"search": "Søg",
@@ -1791,6 +1843,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 +1884,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 -25
View File
@@ -1499,27 +1499,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -1542,31 +1552,48 @@
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren."
}
},
"loading": {
"summary": "Release Manager-Daten werden geladen...",
"refreshing_summary": "Release Manager-Daten werden aktualisiert...",
"summary_detail": "Release-Status, Kanale, Deployments und Servicezustand werden geladen.",
"channels": "Release-Kanale werden geladen...",
"channels_detail": "Kanalansichten und Release-Abhangigkeiten werden geladen.",
"assignments": "Release-Zuweisungen werden geladen...",
"deployments": "Release-Deployments werden geladen...",
"operations": "Release-Operationen werden geladen...",
"data_services": "Release-Datendienste werden geladen...",
"integrations": "Release-Integrationen werden geladen...",
"settings": "Release Manager-Einstellungen werden geladen...",
"github_repositories": "GitHub-Repositories werden geladen...",
"timeline_sessions": "Release-Timeline-Sessions werden geladen...",
"timeline_events": "Release-Timeline-Ereignisse werden geladen...",
"refreshing_timeline": "Release-Timeline wird aktualisiert..."
},
"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 +1610,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 +1644,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 +1663,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 +1675,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 +1708,7 @@
"remove": "Entfernen",
"test_access": "Zugriff testen",
"deploy": "Deployen",
"redeploy": "Erneut deployen",
"promote": "Promoten",
"enable": "Aktivieren",
"search": "Suchen",
@@ -1791,6 +1843,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 +1884,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.",
+68 -4
View File
@@ -1499,6 +1499,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -1542,6 +1552,23 @@
"description": "Configure Release Manager GitHub access and webhook settings."
}
},
"loading": {
"summary": "Loading release manager data...",
"refreshing_summary": "Refreshing release manager data...",
"summary_detail": "Release status, channels, deployments, and service health are being loaded.",
"channels": "Loading release channels...",
"channels_detail": "Channel lanes and release dependencies are being loaded.",
"assignments": "Loading release assignments...",
"deployments": "Loading release deployments...",
"operations": "Loading release operations...",
"data_services": "Loading release data services...",
"integrations": "Loading release integrations...",
"settings": "Loading Release Manager settings...",
"github_repositories": "Loading GitHub repositories...",
"timeline_sessions": "Loading release timeline sessions...",
"timeline_events": "Loading release timeline events...",
"refreshing_timeline": "Refreshing release timeline..."
},
"setup": {
"channels": "Channels",
"targets": "GitHub and Coolify targets",
@@ -1617,9 +1644,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 +1664,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 +1675,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 +1708,7 @@
"remove": "Remove",
"test_access": "Test access",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promote",
"enable": "Enable",
"search": "Search",
@@ -1791,6 +1843,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 +1884,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.",
+82 -18
View File
@@ -1499,16 +1499,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -1519,7 +1529,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -1531,7 +1541,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -1542,16 +1552,33 @@
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger."
}
},
"loading": {
"summary": "Laster Release Manager-data...",
"refreshing_summary": "Oppdaterer Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments og servicehelse lastes.",
"channels": "Laster release-kanaler...",
"channels_detail": "Kanalvisninger og release-avhengigheter lastes.",
"assignments": "Laster release-tildelinger...",
"deployments": "Laster release-deployments...",
"operations": "Laster release-operasjoner...",
"data_services": "Laster release-datatjenester...",
"integrations": "Laster release-integrasjoner...",
"settings": "Laster Release Manager-innstillinger...",
"github_repositories": "Laster GitHub-repositories...",
"timeline_sessions": "Laster release-tidslinje-sesjoner...",
"timeline_events": "Laster release-tidslinjehendelser...",
"refreshing_timeline": "Oppdaterer release-tidslinje..."
},
"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 +1602,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 +1644,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 +1661,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 +1708,7 @@
"remove": "Fjern",
"test_access": "Test tilgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promoter",
"enable": "Aktiver",
"search": "Søk",
@@ -1791,6 +1843,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 +1884,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.",
+106 -42
View File
@@ -1499,31 +1499,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -1531,58 +1541,75 @@
},
"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."
}
},
"loading": {
"summary": "Laddar Release Manager-data...",
"refreshing_summary": "Uppdaterar Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments och servicehalsa laddas.",
"channels": "Laddar release-kanaler...",
"channels_detail": "Kanalvyer och release-beroenden laddas.",
"assignments": "Laddar release-tilldelningar...",
"deployments": "Laddar release-deployments...",
"operations": "Laddar release-operationer...",
"data_services": "Laddar release-datatjanster...",
"integrations": "Laddar release-integrationer...",
"settings": "Laddar Release Manager-installningar...",
"github_repositories": "Laddar GitHub-repositories...",
"timeline_sessions": "Laddar release-tidslinjesessioner...",
"timeline_events": "Laddar release-tidslinjehandelser...",
"refreshing_timeline": "Uppdaterar release-tidslinje..."
},
"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 +1642,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 +1661,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 +1673,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 +1708,7 @@
"remove": "Ta bort",
"test_access": "Testa åtkomst",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promota",
"enable": "Aktivera",
"search": "Sök",
@@ -1791,6 +1843,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 +1884,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",
@@ -345,28 +355,60 @@
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger."
}
},
"loading": {
"summary": "Indl\u00e6ser Release Manager-data...",
"refreshing_summary": "Opdaterer Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments og service-health indl\u00e6ses.",
"channels": "Indl\u00e6ser release-kanaler...",
"channels_detail": "Kanalbaner og release-afh\u00e6ngigheder indl\u00e6ses.",
"assignments": "Indl\u00e6ser release-tildelinger...",
"deployments": "Indl\u00e6ser release-deployments...",
"operations": "Indl\u00e6ser release-operationer...",
"data_services": "Indl\u00e6ser release-datatjenester...",
"integrations": "Indl\u00e6ser release-integrationer...",
"settings": "Indl\u00e6ser Release Manager-indstillinger...",
"github_repositories": "Indl\u00e6ser GitHub-repositories...",
"timeline_sessions": "Indl\u00e6ser release-tidslinjesessioner...",
"timeline_events": "Indl\u00e6ser release-tidslinjeh\u00e6ndelser...",
"refreshing_timeline": "Opdaterer release-tidslinje..."
},
"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 +418,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 +462,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 +522,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 +663,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 +704,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 +775,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",
@@ -345,31 +355,48 @@
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren."
}
},
"loading": {
"summary": "Release Manager-Daten werden geladen...",
"refreshing_summary": "Release Manager-Daten werden aktualisiert...",
"summary_detail": "Release-Status, Kanale, Deployments und Servicezustand werden geladen.",
"channels": "Release-Kanale werden geladen...",
"channels_detail": "Kanalansichten und Release-Abhangigkeiten werden geladen.",
"assignments": "Release-Zuweisungen werden geladen...",
"deployments": "Release-Deployments werden geladen...",
"operations": "Release-Operationen werden geladen...",
"data_services": "Release-Datendienste werden geladen...",
"integrations": "Release-Integrationen werden geladen...",
"settings": "Release Manager-Einstellungen werden geladen...",
"github_repositories": "GitHub-Repositories werden geladen...",
"timeline_sessions": "Release-Timeline-Sessions werden geladen...",
"timeline_events": "Release-Timeline-Ereignisse werden geladen...",
"refreshing_timeline": "Release-Timeline wird aktualisiert..."
},
"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 +413,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 +447,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 +466,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 +478,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 +511,7 @@
"remove": "Entfernen",
"test_access": "Zugriff testen",
"deploy": "Deployen",
"redeploy": "Erneut deployen",
"promote": "Promoten",
"enable": "Aktivieren",
"search": "Suchen",
@@ -594,6 +646,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 +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-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
@@ -694,6 +758,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.",
@@ -345,6 +355,23 @@
"description": "Configure Release Manager GitHub access and webhook settings."
}
},
"loading": {
"summary": "Loading release manager data...",
"refreshing_summary": "Refreshing release manager data...",
"summary_detail": "Release status, channels, deployments, and service health are being loaded.",
"channels": "Loading release channels...",
"channels_detail": "Channel lanes and release dependencies are being loaded.",
"assignments": "Loading release assignments...",
"deployments": "Loading release deployments...",
"operations": "Loading release operations...",
"data_services": "Loading release data services...",
"integrations": "Loading release integrations...",
"settings": "Loading Release Manager settings...",
"github_repositories": "Loading GitHub repositories...",
"timeline_sessions": "Loading release timeline sessions...",
"timeline_events": "Loading release timeline events...",
"refreshing_timeline": "Refreshing release timeline..."
},
"setup": {
"channels": "Channels",
"targets": "GitHub and Coolify targets",
@@ -367,6 +394,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 +462,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 +482,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 +493,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 +522,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 +663,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 +704,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 +775,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",
@@ -345,16 +355,33 @@
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger."
}
},
"loading": {
"summary": "Laster Release Manager-data...",
"refreshing_summary": "Oppdaterer Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments og servicehelse lastes.",
"channels": "Laster release-kanaler...",
"channels_detail": "Kanalvisninger og release-avhengigheter lastes.",
"assignments": "Laster release-tildelinger...",
"deployments": "Laster release-deployments...",
"operations": "Laster release-operasjoner...",
"data_services": "Laster release-datatjenester...",
"integrations": "Laster release-integrasjoner...",
"settings": "Laster Release Manager-innstillinger...",
"github_repositories": "Laster GitHub-repositories...",
"timeline_sessions": "Laster release-tidslinje-sesjoner...",
"timeline_events": "Laster release-tidslinjehendelser...",
"refreshing_timeline": "Oppdaterer release-tidslinje..."
},
"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 +405,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 +447,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 +464,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 +511,7 @@
"remove": "Fjern",
"test_access": "Test tilgang",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promoter",
"enable": "Aktiver",
"search": "Søk",
@@ -594,6 +646,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 +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": "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,75 @@
},
"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."
}
},
"loading": {
"summary": "Laddar Release Manager-data...",
"refreshing_summary": "Uppdaterar Release Manager-data...",
"summary_detail": "Release-status, kanaler, deployments och servicehalsa laddas.",
"channels": "Laddar release-kanaler...",
"channels_detail": "Kanalvyer och release-beroenden laddas.",
"assignments": "Laddar release-tilldelningar...",
"deployments": "Laddar release-deployments...",
"operations": "Laddar release-operationer...",
"data_services": "Laddar release-datatjanster...",
"integrations": "Laddar release-integrationer...",
"settings": "Laddar Release Manager-installningar...",
"github_repositories": "Laddar GitHub-repositories...",
"timeline_sessions": "Laddar release-tidslinjesessioner...",
"timeline_events": "Laddar release-tidslinjehandelser...",
"refreshing_timeline": "Uppdaterar release-tidslinje..."
},
"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 +445,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 +464,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 +476,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 +511,7 @@
"remove": "Ta bort",
"test_access": "Testa åtkomst",
"deploy": "Deploy",
"redeploy": "Redeploy",
"promote": "Promota",
"enable": "Aktivera",
"search": "Sök",
@@ -594,6 +646,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 +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-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
@@ -694,6 +758,19 @@
"ssl_domain_message": "Måste vara en DNS-domän som routas till Coolify load balancern. Exempel: api-v2.truckwash.io",
"ssl_domain_placeholder": "Load balancer-domän",
"https_domain_for_coolify": "Domän som kontrolleras av load balancern",
"endpoint_mode": "Endpoint mode",
"endpoint_mode_message": "Use automatic gateway routing or manually override the public host and port.",
"endpoint_mode_auto": "Auto",
"endpoint_mode_manual": "Manual",
"manual_endpoint_host": "Manual host",
"manual_endpoint_host_message": "Public host shown in release previews when manual mode is selected.",
"manual_endpoint_host_placeholder": "api-v2.truckwash.io",
"manual_endpoint_port": "Manual public port",
"manual_endpoint_port_message": "Optional public port from 1 to 65535.",
"app_port": "App port",
"app_port_message": "Internal container port exposed to Coolify routing.",
"auto_gateway_endpoint": "Automatic gateway endpoint",
"pending_automatic_endpoint": "Automatic endpoint resolution pending",
"auto_deploy": "Auto deploy",
"auto_deploy_tooltip": "Opret automatisk deployments, når dette mål ændres.",
"coolify_ssl": "Coolify SSL",
+15 -4
View File
@@ -17,13 +17,23 @@ import i18n from '@/i18n'
import VueApexCharts from "vue3-apexcharts";
import { initializeAutoTableExports } from '@/services/AutoTableExportService.js';
import { installAxiosRequestQueue } from '@/services/installAxiosRequestQueue.js';
import { installReleaseErrorInstrumentation } from '@/services/releaseTimeline.js';
import {
configureReleaseRuntime,
getReleaseRuntimeApiBaseUrl,
installReleaseErrorInstrumentation,
} from '@/services/releaseTimeline.js';
import { RELEASE_RUNTIME_GLOBAL_KEY } from '@/services/releaseBootstrap.js';
import { API_URL, IS_DEV } from './config';
import { IS_DEV } from './config';
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || '';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || '';
const LAST_VERSION_CHECK_STORAGE_KEY = 'lastVersionCheck';
const bootstrapRuntime = typeof window !== 'undefined' ? window[RELEASE_RUNTIME_GLOBAL_KEY] : null;
if (bootstrapRuntime && typeof bootstrapRuntime === 'object') {
configureReleaseRuntime(bootstrapRuntime);
}
void import('bulma/css/bulma.min.css');
void import('buefy/dist/css/buefy.css');
@@ -76,7 +86,8 @@ const logBuildBanner = () => {
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Last version check: ${lastVersionCheckDisplay}`,
`Remote API: ${API_URL}`,
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
`Debug: Is running beta FE.`,
].join('\n'));
};
@@ -121,7 +132,7 @@ const app = createApp(App)
.use(VueApexCharts)
.provide('Colors', Colors)
.provide('IS_DEV', IS_DEV)
.provide('API_URL', API_URL)
.provide('API_URL', getReleaseRuntimeApiBaseUrl())
installReleaseErrorInstrumentation(app, Router);
app.mount('#app');
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapReleaseApp } from "@/services/releaseBootstrap.js";
void bootstrapReleaseApp({
loadLocalApp: () => import("./main.js"),
});
+9 -4
View File
@@ -98,8 +98,6 @@ const ConfigurationEconomic = lazyView('@/views/dashboards/superUserDashboard/co
const ConfigurationReCAPTCHA = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue');
const ConfigurationEmail = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue');
const ConfigurationBackups = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.vue');
const ConfigurationFailover = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationFailover.vue');
const ConfigurationCoolify = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationCoolify.vue');
const ConfigurationReleaseManager = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue');
const Categories = lazyView('@/views/dashboards/superUserDashboard/Categories.vue');
const Customers = lazyView('@/views/dashboards/superUserDashboard/Customers.vue');
@@ -1080,19 +1078,26 @@ export const router = createRouter({
{
name: 'configurationFailover',
path: '/superuser/configuration/failover',
component: ConfigurationFailover,
redirect: { path: '/superuser/configuration/releases/data-services', query: { panel: 'failover' } },
meta: { middleware: superUserMiddleware }
},
{
name: 'configurationCoolify',
path: '/superuser/configuration/coolify',
component: ConfigurationCoolify,
redirect: { path: '/superuser/configuration/releases/integrations', query: { panel: 'coolify' } },
meta: { middleware: superUserMiddleware }
},
{
name: 'configurationReleaseManager',
path: '/superuser/configuration/releases',
redirect: { path: '/superuser/configuration/releases/overview' },
meta: { middleware: superUserMiddleware }
},
{
name: 'configurationReleaseManagerPanel',
path: '/superuser/configuration/releases/:panel(overview|channels|deployments|operations|integrations|data-services|assignments|settings)',
component: ConfigurationReleaseManager,
props: (route) => ({ panel: route.params.panel }),
meta: { middleware: superUserMiddleware }
},
{
+14
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, rewriteReleaseApiUrl } from "@/services/releaseTimeline.js";
let requestInterceptorId = null;
@@ -29,6 +30,19 @@ export const installAxiosRequestQueue = () => {
}
requestInterceptorId = axios.interceptors.request.use((config) => {
if (config?.__skipReleaseApiRewrite !== true && config?.url) {
config.url = rewriteReleaseApiUrl(config.url);
}
if (config?.__skipReleaseApiRewrite !== true && config?.baseURL) {
config.baseURL = rewriteReleaseApiUrl(config.baseURL);
}
if (config?.__skipReleaseApiRewrite !== true) {
config.headers = {
...buildCurrentReleaseHeaders(),
...(config.headers || {}),
};
}
if (isQueueBypassed(config) || config?.__queueAdapterWrapped) {
return config;
}
+352
View File
@@ -0,0 +1,352 @@
import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } 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_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
export const RELEASE_SOURCE_MODES = Object.freeze({
LOCAL: "local",
DEPLOYMENT: "deployment",
AUTO: "auto",
});
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 normalizeReleaseSourceValue = (value) => {
const normalized = String(value || "").trim().toLowerCase();
return Object.values(RELEASE_SOURCE_MODES).includes(normalized) ? normalized : "";
};
export const normalizeReleaseSourceMode = (value, fallback = RELEASE_SOURCE) =>
normalizeReleaseSourceValue(value) ||
normalizeReleaseSourceValue(fallback) ||
RELEASE_SOURCE_MODES.DEPLOYMENT;
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 readReleaseSourceOverride = () => {
const override = normalizeReleaseSourceValue(browserStorage()?.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY) || "");
return override === RELEASE_SOURCE_MODES.AUTO ? "" : override;
};
export const resolveReleaseSourceMode = (
configuredSource = RELEASE_SOURCE,
{ allowStorageOverride = false } = {}
) => {
const sourceMode = normalizeReleaseSourceMode(configuredSource);
if (sourceMode !== RELEASE_SOURCE_MODES.AUTO) {
return allowStorageOverride ? readReleaseSourceOverride() || sourceMode : sourceMode;
}
return readReleaseSourceOverride() || RELEASE_SOURCE_MODES.AUTO;
};
const selectedReleaseChannelSlug = (selectedChannel) => {
if (selectedChannel !== undefined && selectedChannel !== null) {
return normalizeReleaseChannelSlug(selectedChannel);
}
return readSelectedReleaseChannel();
};
const buildRuntimeHeaders = (selectedChannel) => {
const storage = browserStorage();
const headers = {
Accept: "application/json",
...buildReleaseHeaders({ channelSlug: selectedReleaseChannelSlug(selectedChannel) }),
};
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 = RELEASE_MANAGER_CONTROL_API_URL || API_URL, options = {}) => {
const selectedChannel = selectedReleaseChannelSlug(options?.selectedChannel);
const runtimeBaseUrl = resolveRuntimeBaseUrl(apiBaseUrl || API_URL);
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 = RELEASE_MANAGER_CONTROL_API_URL || API_URL,
selectedChannel,
} = {}) => {
if (typeof fetchFn !== "function") {
return null;
}
const response = await fetchFn(runtimeApiUrl(apiBaseUrl, { selectedChannel }), {
method: "GET",
headers: buildRuntimeHeaders(selectedChannel),
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 = {}) => {
if (normalizeReleaseSourceValue(runtime?.source) === RELEASE_SOURCE_MODES.LOCAL) {
return false;
}
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 runtimeWithSource = (runtime, source, requestedSource = source) => ({
...(runtime || {}),
source,
requested_source: requestedSource,
});
const localReleaseRuntime = ({ requestedSource = RELEASE_SOURCE_MODES.LOCAL, missing = [] } = {}) => {
const missingValues = Array.from(new Set((Array.isArray(missing) ? missing : []).filter(Boolean)));
const apiBaseUrl = normalizeBaseUrl(API_URL || "/api") || "/api";
const frontendBaseUrl = normalizeBaseUrl(browserOrigin());
return {
source: RELEASE_SOURCE_MODES.LOCAL,
requested_source: requestedSource,
generated_at: new Date().toISOString(),
channel: {
slug: "stable",
name: "Local",
default_channel: true,
route_slug: "master",
},
available_channels: [],
versions: {
frontend: null,
api: null,
service_set: null,
bundle_id: null,
bundle: null,
},
frontend_base_url: frontendBaseUrl,
api_base_url: apiBaseUrl,
urls: {
frontend_base_url: frontendBaseUrl,
api_base_url: apiBaseUrl,
},
availability: {
configured: missingValues.length === 0,
missing: missingValues,
status: missingValues.length === 0 ? "ready" : "unconfigured",
explicit: true,
},
};
};
const unavailableSelectedRuntime = (missing, { source = RELEASE_SOURCE_MODES.LOCAL, requestedSource = source } = {}) => {
const selectedChannel = readSelectedReleaseChannel();
if (!selectedChannel || selectedChannel === "stable") {
return null;
}
return runtimeWithSource(unavailableRuntime(
{
channel: {
slug: selectedChannel,
name: selectedChannel,
default_channel: false,
},
availability: {
explicit: true,
},
},
missing
), source, requestedSource);
};
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,
sourceMode,
} = {}) => {
if (typeof loadLocalApp !== "function") {
throw new Error("Release bootstrap requires a local app loader.");
}
const hasExplicitSourceMode = sourceMode !== undefined && sourceMode !== null && String(sourceMode).trim() !== "";
const resolvedSourceMode = resolveReleaseSourceMode(sourceMode || RELEASE_SOURCE, {
allowStorageOverride: IS_DEV && !hasExplicitSourceMode && !normalizeReleaseSourceValue(RELEASE_SOURCE_ENV),
});
if (resolvedSourceMode === RELEASE_SOURCE_MODES.LOCAL) {
setReleaseRuntimeGlobal(localReleaseRuntime({ requestedSource: resolvedSourceMode }));
return loadLocalApp();
}
let runtime = null;
try {
runtime = runtimeWithSource(
await fetchReleaseRuntime({ fetchFn }),
RELEASE_SOURCE_MODES.DEPLOYMENT,
resolvedSourceMode
);
setReleaseRuntimeGlobal(runtime);
} catch (error) {
console.warn("Could not resolve release runtime before app bootstrap.", error);
const unavailable =
unavailableSelectedRuntime("release_runtime", {
source: RELEASE_SOURCE_MODES.LOCAL,
requestedSource: resolvedSourceMode,
}) || localReleaseRuntime({ requestedSource: resolvedSourceMode, missing: ["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(
runtimeWithSource(runtime, RELEASE_SOURCE_MODES.LOCAL, resolvedSourceMode),
"frontend_entry"
)
);
return loadLocalApp();
}
};
+317 -80
View File
@@ -1,8 +1,9 @@
import { computed, reactive, readonly } from "vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
import { configureReleaseRuntime, releaseRuntimeState } from "@/services/releaseTimeline.js";
export const RELEASE_CHANNEL_IGNORE_STORAGE_KEY = "release_channel_unavailable_ignore_until";
export const RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY = "release_channel_switch_notice_seen";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_CHANNEL_IGNORE_MS = 5 * 60 * 1000;
export const RELEASE_CHANNEL_CHECK_INTERVAL_MS = 10 * 1000;
@@ -56,11 +57,50 @@ const writeSwitchNoticeMap = (map) => {
}
};
const normalizeReleaseChannelSlug = (value) => {
const slug = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug.slice(0, 64);
};
const readSelectedChannelSlug = () => {
if (typeof window === "undefined") {
return "";
}
try {
return normalizeReleaseChannelSlug(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
} catch {
return "";
}
};
const writeSelectedChannelSlug = (slug) => {
if (typeof window === "undefined") {
return;
}
try {
if (slug) {
window.localStorage.setItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY, slug);
} else {
window.localStorage.removeItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY);
}
} catch {
// Storage is optional; the runtime endpoint will fall back to the assigned channel.
}
};
const state = reactive({
now: Date.now(),
ignoredUntilByChannel: readIgnoreMap(),
switchNoticeSeenByKey: readSwitchNoticeMap(),
switchNoticePrincipalKey: "",
selectedChannelSlug: readSelectedChannelSlug(),
});
let clockTimer = null;
@@ -72,7 +112,7 @@ export const setReleaseChannelSwitchNoticePrincipal = (principalKey) => {
};
export const releaseChannelKey = (channel) => {
const slug = String(channel?.slug || "").trim();
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
if (slug) {
return slug;
}
@@ -80,53 +120,46 @@ 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 normalizeReadinessMissingValues = (missing = []) =>
Array.from(
new Set(
(Array.isArray(missing) ? missing : [])
.map((value) => String(value || "").trim())
.filter((value) => value && value !== "release_bundle")
)
);
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 =
typeof runtime.availability.configured === "boolean"
? runtime.availability.configured
: missing.length === 0 && runtime.availability.status !== "unconfigured";
const missing = normalizeReadinessMissingValues(runtime.availability.missing);
let configured = missing.length === 0;
if (!configured) {
configured =
typeof runtime.availability.configured === "boolean"
? runtime.availability.configured
: runtime.availability.status !== "unconfigured";
}
const status =
configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || ""))
? "ready"
: runtime.availability.status || (configured ? "ready" : "unconfigured");
return {
configured,
missing,
status: runtime.availability.status || (configured ? "ready" : "unconfigured"),
status,
};
}
if (!hasExplicitRuntimeTargets(runtime)) {
if (!hasOwn(runtime, "versions")) {
return {
configured: true,
missing: [],
@@ -134,11 +167,26 @@ 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.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 +197,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"
? runtimeAvailability({ availability: entry.availability }, channel)
: runtimeAvailability({ channel, versions }, channel);
return {
channel,
channelSlug,
channelName,
defaultChannel,
description: channel?.description || "",
versions,
availability,
configured: availability.configured !== false,
missing: normalizeReadinessMissingValues(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 +290,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 +307,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 = normalizeReadinessMissingValues([...(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 +568,20 @@ export const stopReleaseChannelAvailabilityClock = () => {
clockTimer = null;
};
export const buildReleaseFrontendRedirectUrl = (runtime = releaseRuntimeState, currentLocation = window.location) => {
const frontendBaseUrl = runtimeUrl(runtime, "frontend_base_url");
if (!frontendBaseUrl || !currentLocation) {
return null;
}
export const buildReleaseFrontendRedirectUrl = () => null;
const target = new URL(frontendBaseUrl);
const currentOrigin = currentLocation.origin || `${currentLocation.protocol}//${currentLocation.host}`;
if (target.origin === currentOrigin) {
return null;
}
target.pathname = currentLocation.pathname || "/";
target.search = currentLocation.search || "";
target.hash = currentLocation.hash || "";
return target.toString();
};
export const redirectToConfiguredReleaseFrontend = (runtime = releaseRuntimeState) => {
const status = getReleaseChannelUnavailableStatus(runtime, Date.now(), 0);
if (!status.configured) {
return false;
}
const redirectUrl = buildReleaseFrontendRedirectUrl(runtime);
if (!redirectUrl) {
return false;
}
window.location.assign(redirectUrl);
return true;
};
export const redirectToConfiguredReleaseFrontend = () => false;
export const __resetReleaseChannelAvailabilityForTests = () => {
state.now = Date.now();
state.ignoredUntilByChannel = {};
state.switchNoticeSeenByKey = {};
state.switchNoticePrincipalKey = "";
state.selectedChannelSlug = "";
stopReleaseChannelAvailabilityClock();
if (typeof window !== "undefined") {
window.localStorage.removeItem(RELEASE_CHANNEL_IGNORE_STORAGE_KEY);
window.localStorage.removeItem(RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY);
window.localStorage.removeItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY);
}
};
+61
View File
@@ -0,0 +1,61 @@
export const RELEASE_TRACE_STORAGE_KEY = "release_trace_id";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
const browserStorage = () => {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage || null;
} catch {
return null;
}
};
const normalizeIdentifier = (value, maxLength = 128) =>
String(value || "")
.trim()
.replace(/[^a-zA-Z0-9_.:-]/g, "")
.slice(0, Math.max(1, maxLength));
export const normalizeReleaseChannelSlug = (value) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
export const selectedReleaseChannelSlugFromStorage = () =>
normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
export const releaseTraceIdFromStorage = () =>
normalizeIdentifier(browserStorage()?.getItem(RELEASE_TRACE_STORAGE_KEY) || "", 64);
const fallbackFrontendVersion = () =>
normalizeIdentifier(import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown", 128);
export const buildReleaseHeaders = ({
traceId = "",
channelSlug = "",
frontendVersion = "",
} = {}) => {
const headers = {};
const normalizedTraceId = normalizeIdentifier(traceId || releaseTraceIdFromStorage(), 64);
const normalizedChannelSlug = normalizeReleaseChannelSlug(channelSlug || selectedReleaseChannelSlugFromStorage());
const normalizedFrontendVersion = normalizeIdentifier(frontendVersion || fallbackFrontendVersion(), 128);
if (normalizedTraceId) {
headers["X-Release-Trace"] = normalizedTraceId;
}
if (normalizedChannelSlug) {
headers["X-Release-Channel"] = normalizedChannelSlug;
}
if (normalizedFrontendVersion) {
headers["X-Frontend-Version"] = normalizedFrontendVersion;
}
return headers;
};
+571 -41
View File
@@ -1,7 +1,8 @@
import { reactive, readonly } from "vue";
import { API_URL } from "@/config.js";
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
const TRACE_STORAGE_KEY = "release_trace_id";
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
const MAX_QUEUE_SIZE = 50;
const MAX_FRONTEND_FAILURE_BUFFER_SIZE = 50;
const FRONTEND_FAILURE_EVENT_TYPES = new Set([
@@ -10,6 +11,18 @@ const FRONTEND_FAILURE_EVENT_TYPES = new Set([
"window_error",
"unhandled_rejection",
]);
const RELEASE_RUNTIME_SOURCES = new Set(["local", "deployment"]);
const RELEASE_RUNTIME_REQUESTED_SOURCES = new Set(["local", "deployment", "auto"]);
const normalizeRuntimeSource = (value) => {
const source = String(value || "").trim().toLowerCase();
return RELEASE_RUNTIME_SOURCES.has(source) ? source : "";
};
const normalizeRuntimeRequestedSource = (value) => {
const source = String(value || "").trim().toLowerCase();
return RELEASE_RUNTIME_REQUESTED_SOURCES.has(source) ? source : "";
};
const createTraceId = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -37,11 +50,17 @@ const readTraceId = () => {
};
const releaseRuntimeStateMutable = reactive({
source: null,
requestedSource: null,
traceId: readTraceId(),
channel: null,
availableChannels: [],
versions: {
frontend: null,
api: null,
service_set: null,
bundle_id: null,
bundle: null,
},
frontendBaseUrl: null,
apiBaseUrl: null,
@@ -69,22 +88,58 @@ 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 normalizeReadinessMissingValues = (missing = []) =>
Array.from(
new Set(
(Array.isArray(missing) ? missing : [])
.map((value) => String(value || "").trim())
.filter((value) => value && value !== "release_bundle")
)
);
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;
}
const runtimeSource = normalizeRuntimeSource(runtime.source);
if (runtimeSource) {
releaseRuntimeStateMutable.source = runtimeSource;
}
const requestedSource = normalizeRuntimeRequestedSource(runtime.requested_source || runtime.requestedSource);
if (requestedSource) {
releaseRuntimeStateMutable.requestedSource = requestedSource;
} else if (runtimeSource) {
releaseRuntimeStateMutable.requestedSource = runtimeSource;
}
if (runtime.trace_id) {
releaseRuntimeStateMutable.traceId = String(runtime.trace_id);
try {
@@ -95,40 +150,69 @@ 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?.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
? (() => {
const missing = normalizeReadinessMissingValues(runtime.availability.missing);
let configured = missing.length === 0;
if (!configured) {
configured = runtime.availability.configured !== false && runtime.availability.status !== "unconfigured";
}
const status =
configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || ""))
? "ready"
: runtime.availability.status || (configured ? "ready" : "unconfigured");
return {
...runtime.availability,
configured,
missing,
status,
explicit: true,
};
})()
: hasExplicitReleaseRuntime(runtime)
? {
...runtime.availability,
configured: missingReleaseContent.length === 0,
missing: missingReleaseContent,
status: missingReleaseContent.length === 0 ? "ready" : "unconfigured",
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,
};
: {
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 +223,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 +335,421 @@ 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 = normalizeReadinessMissingValues(availability.missing);
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 = missingKey ? `Missing ${label} service` : defaultSharedLabel;
const status = missingKey ? "missing" : "shared";
return {
key,
label,
status,
tone: missingKey ? "warning" : "ok",
primaryText: fallbackText,
secondaryText: "",
title: fallbackText,
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
};
};
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,
source: releaseRuntimeStateMutable.source,
requested_source: releaseRuntimeStateMutable.requestedSource,
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 +771,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 +781,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),
@@ -378,9 +904,12 @@ export const __resetReleaseTimelineForTests = () => {
}
flushTimer = null;
customTransport = null;
releaseRuntimeStateMutable.source = null;
releaseRuntimeStateMutable.requestedSource = 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 +924,7 @@ export const __resetReleaseTimelineForTests = () => {
all_failure_metadata: true,
retention_days: 14,
};
releaseRuntimeStateMutable.generatedAt = null;
};
export const __setReleaseTimelineTransportForTests = (transport) => {
+113
View File
@@ -0,0 +1,113 @@
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import {
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
const normalizeTaskServices = (task) => (
Array.isArray(task?.services)
? task.services.map((service) => String(service || "").trim().toUpperCase()).filter(Boolean)
: []
);
export const selfServeTaskUsesProgramPicker = (task) => (
normalizeTaskServices(task).includes("PROGRAM_PICKER")
);
export const getSelfServeTaskDynamicImageButtons = (task) => {
const buttons = normalizeSelfServeTaskButtons(
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
if (!selfServeTaskUsesProgramPicker(task)) {
return buttons;
}
return normalizeSelfServeTaskButtons([SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER, ...buttons]);
};
export const getSelfServeDynamicImageButtonsToPress = (tasks = []) => (
Array.isArray(tasks) ? tasks : []
).flatMap((task) => getSelfServeTaskDynamicImageButtons(task));
export const getSelfServeCompletedDynamicImageStep = (tasks = [], completedTasks = {}) => {
let totalButtonsCompleted = 0;
(Array.isArray(tasks) ? tasks : []).forEach((task) => {
if (completedTasks?.[task?.id] || completedTasks?.[task?.task_id]) {
totalButtonsCompleted += getSelfServeTaskDynamicImageButtons(task).length;
}
});
return totalButtonsCompleted;
};
export const parseSelfServeDynamicImageThumbPosition = (value) => {
if (value === null || value === undefined || value === "") {
return null;
}
const thumbPosition = Number.parseInt(String(value), 10);
return Number.isInteger(thumbPosition) && thumbPosition >= 1 && thumbPosition <= 12
? thumbPosition
: null;
};
export const getSelfServeDynamicImageThumbPosition = (tasks = []) => {
for (const task of Array.isArray(tasks) ? tasks : []) {
const thumbPosition = parseSelfServeDynamicImageThumbPosition(
task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType
);
if (thumbPosition !== null) {
return thumbPosition;
}
}
return null;
};
export const buildSelfServeDynamicImageUrl = ({
departmentId,
laneId,
dynamicImageId = null,
buttons = null,
currentStep = 0,
vehicleTypeId = null,
thumbPosition = null,
onlyCurrentStep = false,
}) => {
const normalizedDepartmentId = Number.parseInt(String(departmentId ?? ""), 10);
const normalizedLaneId = Number.parseInt(String(laneId ?? ""), 10);
if (!Number.isInteger(normalizedDepartmentId) || normalizedDepartmentId <= 0 || !Number.isInteger(normalizedLaneId) || normalizedLaneId <= 0) {
return null;
}
const params = new URLSearchParams({
department: String(normalizedDepartmentId),
lane: String(normalizedLaneId),
current_step: String(Math.max(0, Number.parseInt(String(currentStep ?? 0), 10) || 0)),
});
if (dynamicImageId !== null && dynamicImageId !== undefined && dynamicImageId !== "") {
params.set("dynamic_image_id", String(dynamicImageId));
}
if (Array.isArray(buttons)) {
params.set("buttons", JSON.stringify(buttons));
}
if (vehicleTypeId !== null && vehicleTypeId !== undefined && vehicleTypeId !== "") {
params.set("vehicle_type", String(vehicleTypeId));
}
const normalizedThumbPosition = parseSelfServeDynamicImageThumbPosition(thumbPosition);
if (normalizedThumbPosition !== null) {
params.set("thumb_position", String(normalizedThumbPosition));
}
if (onlyCurrentStep) {
params.set("only_current_step", "1");
}
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
};
+53
View File
@@ -0,0 +1,53 @@
const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const objectOrEmpty = (value) => (isPlainObject(value) ? value : {});
const arrayOrEmpty = (value) => (Array.isArray(value) ? value : []);
const normalizeEconomicCustomer = (value) => {
if (Array.isArray(value)) {
return value.find(isPlainObject) || null;
}
if (!isPlainObject(value) || Object.keys(value).length === 0) {
return null;
}
return value;
};
export const normalizeSessionPayload = (payload = {}) => {
const data = objectOrEmpty(payload);
const phone = objectOrEmpty(data.phone);
const notifications = objectOrEmpty(data.notifications);
const runtimeConfig = objectOrEmpty(data.runtime_config);
const economicConfig = objectOrEmpty(runtimeConfig.economic);
return {
id: data.id ?? null,
customer_number: data.customer_number ?? null,
group_id: data.group_id ?? null,
email: data.email ?? null,
phone: {
number: phone.number ?? null,
country_code: phone.country_code ?? null,
},
notifications: {
wash_certificate_email: notifications.wash_certificate_email ?? null,
email_notifications_enabled: notifications.email_notifications_enabled ?? null,
sms_notifications_enabled: notifications.sms_notifications_enabled ?? null,
},
created_at: data.created_at ?? null,
updated_at: data.updated_at ?? null,
display_name: data.display_name ?? null,
permissions: arrayOrEmpty(data.permissions),
economic_customer: normalizeEconomicCustomer(data.economic_customer),
runtime_config: {
economic: {
transaction_draft_customer_number: economicConfig.transaction_draft_customer_number ?? null,
default_distribution_department_id: economicConfig.default_distribution_department_id ?? null,
},
release: objectOrEmpty(runtimeConfig.release),
},
};
};
+25 -19
View File
@@ -1,55 +1,61 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { requestReleaseManager } from "@/services/superuserReleases.js";
export const getCoolifySummary = () =>
authenticatedRequest("/superuser/coolify", "GET", {});
requestReleaseManager("/superuser/coolify", "GET", {});
export const getCoolifyLoadBalancer = () =>
authenticatedRequest("/superuser/coolify/load-balancer", "GET", {});
requestReleaseManager("/superuser/coolify/load-balancer", "GET", {});
export const reconcileCoolifyLoadBalancer = (payload) =>
authenticatedRequest("/superuser/coolify/load-balancer/reconcile", "POST", payload);
requestReleaseManager("/superuser/coolify/load-balancer/reconcile", "POST", payload);
export const deployCoolifyGatewayRoutes = (payload) =>
requestReleaseManager("/superuser/coolify/load-balancer/routes/deploy", "POST", payload);
export const deployCoolifyGatewayApiCode = (payload) =>
requestReleaseManager("/superuser/coolify/load-balancer/api/deploy", "POST", payload);
export const listCoolifyGateways = () =>
authenticatedRequest("/superuser/coolify/gateways", "GET", {});
requestReleaseManager("/superuser/coolify/gateways", "GET", {});
export const saveCoolifyGateway = (payload) =>
authenticatedRequest("/superuser/coolify/gateways", "POST", payload);
requestReleaseManager("/superuser/coolify/gateways", "POST", payload);
export const testCoolifyGateway = (id) =>
authenticatedRequest(`/superuser/coolify/gateways/${id}/test`, "POST", {});
requestReleaseManager(`/superuser/coolify/gateways/${id}/test`, "POST", {});
export const createCoolifyInstance = (payload) =>
authenticatedRequest("/superuser/coolify/instances", "POST", payload);
requestReleaseManager("/superuser/coolify/instances", "POST", payload);
export const testCoolifyInstance = (id) =>
authenticatedRequest(`/superuser/coolify/instances/${id}/test`, "POST", {});
requestReleaseManager(`/superuser/coolify/instances/${id}/test`, "POST", {});
export const discoverCoolifyPlacement = (id) =>
authenticatedRequest(`/superuser/coolify/instances/${id}/placement`, "GET", {});
requestReleaseManager(`/superuser/coolify/instances/${id}/placement`, "GET", {});
export const listCoolifyTargets = ({ kind = null } = {}) =>
authenticatedRequest("/superuser/coolify/targets", "GET", kind ? { kind } : {});
requestReleaseManager("/superuser/coolify/targets", "GET", kind ? { kind } : {});
export const createCoolifyTarget = (payload) =>
authenticatedRequest("/superuser/coolify/targets", "POST", payload);
requestReleaseManager("/superuser/coolify/targets", "POST", payload);
export const reconcileCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/reconcile`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/reconcile`, "POST", {});
export const deployCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/deploy`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/deploy`, "POST", {});
export const restartCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/restart`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/restart`, "POST", {});
export const failoverCoolifyTarget = (id) =>
authenticatedRequest(`/superuser/coolify/targets/${id}/failover`, "POST", {});
requestReleaseManager(`/superuser/coolify/targets/${id}/failover`, "POST", {});
export const deleteCoolifyTarget = (id, payload) =>
authenticatedRequest(`/superuser/coolify/targets/${id}`, "DELETE", payload);
requestReleaseManager(`/superuser/coolify/targets/${id}`, "DELETE", payload);
export const getCoolifyConfig = (variable = null) =>
authenticatedRequest("/coolify/config", "GET", variable ? { variable } : {});
requestReleaseManager("/coolify/config", "GET", variable ? { variable } : {});
export const setCoolifyConfig = (payload) =>
authenticatedRequest("/coolify/config", "POST", payload);
requestReleaseManager("/coolify/config", "POST", payload);
+78 -12
View File
@@ -1,6 +1,7 @@
import axios from "axios";
import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
import { enqueueRequest } from "@/services/requestQueue.js";
import { getReleaseRuntimeApiBaseUrl, releaseRuntimeState } from "@/services/releaseTimeline.js";
export const RELEASE_MANAGER_CONTROL_API_STORAGE_KEY = "release_manager_control_api_url";
const RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY = "release_manager_last_working_control_api_url";
@@ -9,12 +10,31 @@ const normalizeApiUrl = (value) => {
const url = String(value || "")
.trim()
.replace(/\/+$/, "");
if (url.startsWith("/") && !url.startsWith("//")) {
return url || "/";
}
if (!/^https?:\/\//i.test(url)) {
return "";
}
return url;
};
const isLocalApiUrl = (value) => {
const url = normalizeApiUrl(value);
if (!url) {
return false;
}
if (url.startsWith("/") && !url.startsWith("//")) {
return true;
}
try {
const parsed = new URL(url);
return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
} catch {
return false;
}
};
const browserStorage = (name) => {
if (typeof window === "undefined") {
return null;
@@ -47,22 +67,34 @@ const uniqueUrls = (urls) => {
const releaseManagerLocalStorage = () => browserStorage("localStorage");
const releaseManagerSessionStorage = () => browserStorage("sessionStorage");
export const getReleaseManagerControlApiUrl = () =>
uniqueUrls([
releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY),
releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY),
export const releaseManagerControlApiCandidates = () => {
const runtimeApiBaseUrl = getReleaseRuntimeApiBaseUrl();
const lastWorkingUrl = releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY);
const storedUrl = releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY);
const localSource = String(releaseRuntimeState.source || "").toLowerCase() === "local";
const localCandidates = uniqueUrls([
runtimeApiBaseUrl,
isLocalApiUrl(lastWorkingUrl) ? lastWorkingUrl : "",
isLocalApiUrl(storedUrl) ? storedUrl : "",
RELEASE_MANAGER_CONTROL_API_URL,
API_URL,
])[0] || API_URL;
]).filter(isLocalApiUrl);
export const releaseManagerControlApiCandidates = () =>
uniqueUrls([
releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY),
releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY),
RELEASE_MANAGER_CONTROL_API_URL,
if (localSource && localCandidates.length > 0) {
return localCandidates;
}
const deploymentConfigUrls = [RELEASE_MANAGER_CONTROL_API_URL, API_URL].filter((url) => !isLocalApiUrl(url));
return uniqueUrls([
getReleaseRuntimeApiBaseUrl(),
lastWorkingUrl,
storedUrl,
...deploymentConfigUrls,
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
API_URL,
]);
};
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
export const setReleaseManagerControlApiUrl = (url) => {
const normalized = normalizeApiUrl(url);
@@ -113,7 +145,7 @@ const buildHeaders = () => {
return headers;
};
const requestReleaseManager = (url, method, data = {}) => {
export const requestReleaseManager = (url, method, data = {}) => {
const candidates = releaseManagerControlApiCandidates();
const headers = buildHeaders();
@@ -127,6 +159,7 @@ const requestReleaseManager = (url, method, data = {}) => {
url: `${baseUrl}${url}`,
...(method === "GET" ? { params: data } : { data }),
__skipRequestQueue: true,
__skipReleaseApiRewrite: true,
headers,
});
rememberWorkingControlApiUrl(baseUrl);
@@ -169,8 +202,26 @@ export const updateReleaseChannel = (id, payload) =>
export const rollbackReleaseChannel = (id) =>
requestReleaseManager(`/superuser/releases/channels/${id}/rollback`, "POST", {});
export const syncReleaseChannel = (id) =>
requestReleaseManager(`/superuser/releases/channels/${id}/sync`, "POST", {});
export const setReleaseChannelBundle = (id, payload) =>
requestReleaseManager(`/superuser/releases/channels/${id}/bundle`, "POST", payload);
export const listReleaseOperations = (params = {}) =>
requestReleaseManager("/superuser/releases/operations", "GET", params);
export const getReleaseOperation = (id) =>
requestReleaseManager(`/superuser/releases/operations/${id}`, "GET", {});
export const runReleaseTest = (payload = {}) =>
requestReleaseManager("/superuser/releases/test-runs", "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 +241,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 +280,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", {});
@@ -61,7 +61,6 @@ watch([selected_date, selected_date_to], () => {
const DEPARTMENTS_TOGGLERS_COUNTER = {
2: 3,
1: 2,
3: 2,
7: 2
};
@@ -11,6 +11,8 @@ import ConfigurationError from "@/components/displays/superuser/configuration/Co
import {
createCoolifyInstance,
deleteCoolifyTarget,
deployCoolifyGatewayApiCode,
deployCoolifyGatewayRoutes,
deployCoolifyTarget,
failoverCoolifyTarget,
getCoolifyConfig,
@@ -27,6 +29,8 @@ const config = ref([]);
const summary = ref({ instances: [], targets: [], availability: {} });
const errors = ref([]);
const busy = ref(null);
const gatewayRouteResult = ref(null);
const gatewayCodeDeployResult = ref(null);
const instanceForm = reactive({
label: "Coolify",
@@ -109,6 +113,29 @@ async function runLoadBalancerReconcile(dryRun) {
});
}
async function runGatewayRouteDeploy(dryRun) {
await run(dryRun ? "lb:routes:dry-run" : "lb:routes:deploy", async () => {
const response = await deployCoolifyGatewayRoutes({ dry_run: dryRun, enforce: !dryRun });
gatewayRouteResult.value = response?.data?.data || response?.data || null;
await load();
});
}
async function runGatewayApiCodeDeploy() {
await run("lb:api-code:deploy", async () => {
const response = await deployCoolifyGatewayApiCode({
dry_run: false,
enforce: true,
deploy_routes: true,
});
gatewayCodeDeployResult.value = response?.data?.data || response?.data || null;
if (gatewayCodeDeployResult.value?.route_deploy) {
gatewayRouteResult.value = gatewayCodeDeployResult.value.route_deploy;
}
await load();
});
}
async function runGatewayTest(gateway) {
await run(`gateway:${gateway.id}:test`, async () => {
await testCoolifyGateway(gateway.id);
@@ -206,6 +233,10 @@ function driftActionLabel(action) {
return action.type || "planned change";
}
function countItems(value) {
return Array.isArray(value) ? value.length : 0;
}
onMounted(load);
</script>
@@ -323,6 +354,69 @@ onMounted(load);
>
Enforce reconcile
</button>
<button
class="button"
type="button"
:class="{ 'is-loading': busy === 'lb:routes:dry-run' }"
@click="runGatewayRouteDeploy(true)"
>
Dry-run route deploy
</button>
<button
class="button is-warning"
type="button"
:class="{ 'is-loading': busy === 'lb:routes:deploy' }"
@click="runGatewayRouteDeploy(false)"
>
Deploy api-v2 route
</button>
<button
class="button is-info"
type="button"
:class="{ 'is-loading': busy === 'lb:api-code:deploy' }"
@click="runGatewayApiCodeDeploy"
>
Deploy latest API code
</button>
</div>
<div
v-if="gatewayCodeDeployResult"
class="coolify-route-result mt-2"
data-testid="coolify-gateway-code-deploy-result"
>
<strong>{{ gatewayCodeDeployResult.dry_run ? "API code plan" : "API code deploy" }}</strong>
<span>Planned {{ countItems(gatewayCodeDeployResult.planned) }}</span>
<span>Applied {{ countItems(gatewayCodeDeployResult.applied) }}</span>
<span>Skipped {{ countItems(gatewayCodeDeployResult.skipped) }}</span>
<span>Errors {{ countItems(gatewayCodeDeployResult.errors) }}</span>
<span v-if="gatewayCodeDeployResult.route_deploy">
Route applied {{ countItems(gatewayCodeDeployResult.route_deploy.applied) }}
</span>
<span v-if="countItems(gatewayCodeDeployResult.warnings) > 0">
Warnings {{ countItems(gatewayCodeDeployResult.warnings) }}
</span>
<ul v-if="countItems(gatewayCodeDeployResult.warnings) > 0">
<li v-for="warning in gatewayCodeDeployResult.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<div
v-if="gatewayRouteResult"
class="coolify-route-result mt-2"
data-testid="coolify-gateway-route-result"
>
<strong>{{ gatewayRouteResult.dry_run ? "Route plan" : "Route deploy" }}</strong>
<span>Planned {{ countItems(gatewayRouteResult.planned) }}</span>
<span>Applied {{ countItems(gatewayRouteResult.applied) }}</span>
<span>Skipped {{ countItems(gatewayRouteResult.skipped) }}</span>
<span>Errors {{ countItems(gatewayRouteResult.errors) }}</span>
<span v-if="countItems(gatewayRouteResult.warnings) > 0">
Warnings {{ countItems(gatewayRouteResult.warnings) }}
</span>
<ul v-if="countItems(gatewayRouteResult.warnings) > 0">
<li v-for="warning in gatewayRouteResult.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<div class="table-container mt-3">
@@ -547,6 +641,19 @@ onMounted(load);
padding-left: 1.25rem;
}
.coolify-route-result {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.coolify-route-result ul {
flex-basis: 100%;
margin: 0;
padding-left: 1.25rem;
}
.coolify-muted {
color: #64748b;
font-size: 0.86rem;
File diff suppressed because it is too large Load Diff
@@ -197,7 +197,7 @@ onMounted(() => {
<div class="error-state__content">
<p>{{ t('superuser_invoice_distribution.errors.overview_load_failed') }}: {{ errorMessage }}</p>
<b-button type="is-danger" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }}
{{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button>
</div>
</b-message>
@@ -281,7 +281,7 @@ onMounted(() => {
<div v-if="!sortedMonthSummaries.length" class="empty-state has-text-centered">
<p class="has-text-grey mb-3">{{ t('superuser_invoice_distribution.empty.no_months') }}</p>
<b-button type="is-link" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }}
{{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button>
</div>
@@ -19,7 +19,7 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
import { useWashProgress } from "@/composables/useWashProgress";
import { useWashFlowState } from "@/composables/useWashFlowState";
import { useWashSessionActions } from "@/composables/useWashSessionActions";
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
type VehicleTypeTemplate = {
@@ -199,9 +199,7 @@ const normalizeTaskServices = (task: any) => (
: []
);
const taskButtonList = (task: any) => normalizeSelfServeTaskButtons(
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
const taskButtonList = (task: any) => getSelfServeTaskDynamicImageButtons(task);
const dynamicImageVehicleType = (task: any) => {
const value = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
+85
View File
@@ -443,6 +443,50 @@ function createRequiredWarningsPosFixture() {
});
}
function createBookingPoDefaultPosFixture() {
const baseFixture = createPosFixture();
const customerNumber = 12345679;
const bookingId = 8891;
return createPosFixture({
preloadCreatedOrderItemsFromBooking: true,
orderBookingListStripsDetails: true,
orderBookingListStripsMetadata: true,
customerAttributesByNumber: {
[customerNumber]: [
...(baseFixture.customerAttributesByNumber[customerNumber] || []),
{
id: 88,
customer_number: customerNumber,
attribute: "usePONumbers",
},
],
},
orderBookings: [
{
id: bookingId,
customer_number: customerNumber,
customer_name: "(TEST) Pleno Vognmandsforretning",
department: 12,
datetime: "2026-05-21 09:30:00",
date: "2026-05-21",
reg_1: "BOOKPO1",
reg_2: "",
reg_3: "",
reference: "BOOKING-PO-REF",
reference_number: "BOOKING-PO-REF",
notes: "Booking PO note",
note: "Booking PO note",
po: "BOOKING-PO-DEFAULT",
pickup: false,
items: [{ id: 53, quantity: 1, price: 649 }],
order_id: null,
created_at: "2026-05-20 08:00:00",
},
],
});
}
function createReferenceAutocompletePosFixture() {
const baseFixture = createPosFixture();
const customerNumber = 12345679;
@@ -2304,6 +2348,47 @@ test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership"
await createOrderRequest;
});
test("applies booking PO defaults on desktop even when linked booking items are already loaded", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createBookingPoDefaultPosFixture(),
});
await primeOperatorSession(page, "pos-orders-booking-po-default-token");
await page.goto(POS_BOOT_URL);
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("BOOKPO1");
await expect(page.locator(".pos-selected-customer__title")).toContainText("(TEST) Pleno Vognmandsforretning");
const createOrderRequest = waitForOrderMutation(
page,
"POST",
"/orders",
(body) => Number(body.booking_id) === 8891 && Number(body.customer_id) === 12345679
);
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
const capturedCreateOrderRequest = await createOrderRequest;
expect(capturedCreateOrderRequest.postDataJSON()).toMatchObject({
booking_id: 8891,
po: "",
});
const stepTwo = page.getByTestId("pos-step-2");
await expect(stepTwo).toBeVisible();
await expect(stepTwo.getByTestId("pos-order-registration-1")).toContainText("BOOKPO1");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference")).toContainText("BOOKING-PO-REF");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po")).toContainText("BOOKING-PO-DEFAULT");
await expect(stepTwo.getByTestId("pos-order-metadata-note")).toContainText("Booking PO note");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-control")).not.toHaveAttribute(
"data-warning-state",
"warning"
);
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-warning-icon")).toHaveCount(0);
});
test("preserves a manually selected required-reference customer and manual reference while editing reg_1", async ({
page,
}) => {
+5 -3
View File
@@ -1,6 +1,8 @@
import { test, expect } from "@playwright/test";
const PING_URL = /https:\/\/api\.truckwash\.io:4433\/ping(?:\?.*)?$/;
const PING_URL =
/(?:https:\/\/api\.truckwash\.io:4433|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
const CONNECTIVITY_PAGE_TIMEOUT_MS = 30_000;
function installPingFailure(page, onPing) {
return page.route(PING_URL, async (route) => {
@@ -22,7 +24,7 @@ test.describe("Connectivity issue", () => {
await page.goto("/connectivity-issue");
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
await expect(page.getByTestId("connectivity-icon")).toBeVisible();
await expect(page.getByTestId("connectivity-title")).toHaveText("Forbindelsesproblem");
await expect(page.getByTestId("connectivity-subtitle")).toContainText("internetforbindelse");
@@ -42,7 +44,7 @@ test.describe("Connectivity issue", () => {
});
await page.goto("/connectivity-issue");
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
await expect.poll(() => pingCount).toBeGreaterThan(0);
const initialPingCount = pingCount;
+160 -2
View File
@@ -18,6 +18,12 @@ function json(body, status = 200) {
return {
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
};
}
@@ -198,6 +204,10 @@ function createCoolifyState() {
removedHosts: [],
reconciledTargets: [],
lbReconciles: [],
routeDeploys: [],
apiCodeDeploys: [],
routePlans: [],
apiCodePlans: [],
placement: {
generated_at: "2026-05-18T08:00:00.000Z",
servers: [
@@ -360,6 +370,117 @@ async function installCoolifyMocks(page, state) {
);
});
await page.route(/\/superuser\/coolify\/load-balancer\/routes\/deploy$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
state.routeDeploys.push({ dry_run: dryRun });
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
const planned = [
{
type: "deploy_gateway_route",
target_id: 13,
channel_slug: "internal",
app: "api",
resource_uuid: "api-application-uuid",
resource_type: "application",
public_url: `${gatewayBaseUrl}/internal/api`,
frontend_public_url: `${gatewayBaseUrl}/internal/frontend`,
target_ip: "65.21.214.30",
},
];
state.routePlans.push(planned);
const warnings = [
"No managed Coolify API application route was found for gateway targets: 94.130.142.41, 23.88.23.183.",
];
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
public_host: state.loadBalancer.config.public_gateway_host,
public_url: `${gatewayBaseUrl}/internal/api`,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
warnings,
coverage: {
enabled_gateway_ips: ["94.130.142.41", "65.21.214.30", "23.88.23.183"],
covered_target_ips: ["65.21.214.30"],
uncovered_gateway_ips: ["94.130.142.41", "23.88.23.183"],
},
gateways: state.loadBalancer.gateways,
},
})
);
});
await page.route(/\/superuser\/coolify\/load-balancer\/api\/deploy$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
const deployRoutes = payload.deploy_routes !== false;
state.apiCodeDeploys.push({ dry_run: dryRun, deploy_routes: deployRoutes });
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
const planned = [
{
type: "deploy_gateway_api_code",
target_id: 13,
channel_slug: "internal",
app: "api",
repository: "copenhagentruckwash/api",
branch: "master",
resource_uuid: "api-application-uuid",
public_url: `${gatewayBaseUrl}/internal/api`,
commit_mode: "latest",
},
];
state.apiCodePlans.push(planned);
const routeDeploy = deployRoutes
? {
ok: true,
dry_run: false,
mutated: true,
planned,
applied: planned,
skipped: [],
errors: [],
warnings: [],
}
: null;
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
deploy_routes: deployRoutes,
public_host: state.loadBalancer.config.public_gateway_host,
public_url: `${gatewayBaseUrl}/internal/api`,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
warnings: [],
deployment_wait: {
ok: true,
results: [
{
deployment_uuid: "deployment-uuid",
status: "finished_or_not_running",
},
],
pending: [],
},
route_deploy: routeDeploy,
gateways: state.loadBalancer.gateways,
},
})
);
});
await page.route(/\/superuser\/coolify\/gateways$/, async (route) => {
await route.fulfill(json({ data: state.loadBalancer.gateways }));
});
@@ -659,6 +780,42 @@ test.describe("Coolify infrastructure management", () => {
expect(state.lbReconciles.at(-1)).toEqual({ dry_run: false });
await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("ok");
await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("No planned changes.");
await page.getByRole("button", { name: "Dry-run route deploy" }).click();
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: true });
expect(state.routePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
}),
])
);
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route plan");
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Warnings 1");
await page.getByRole("button", { name: "Deploy api-v2 route" }).click();
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: false });
expect(state.routePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
}),
])
);
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route deploy");
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Applied 1");
await page.getByRole("button", { name: "Deploy latest API code" }).click();
expect(state.apiCodeDeploys.at(-1)).toEqual({ dry_run: false, deploy_routes: true });
expect(state.apiCodePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
}),
])
);
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("API code deploy");
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Applied 1");
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Route applied 1");
await expect(page.getByTestId("coolify-instances-table")).toContainText("Production Coolify");
await expect(page.getByTestId("coolify-targets-table")).toContainText("failover_ready");
@@ -692,7 +849,7 @@ test.describe("Coolify infrastructure management", () => {
const state = await boot(page);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
await createDatabaseReplica(page);
await createRedisReplica(page);
@@ -782,7 +939,7 @@ test.describe("Coolify infrastructure management", () => {
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
await expect.poll(() => state.provisionRequests.includes(2)).toBeTruthy();
await expect(page.getByTestId("replication-host-database-2")).toContainText(
"Coolify: provisioned / failover_ready"
@@ -848,6 +1005,7 @@ test.describe("Coolify infrastructure management", () => {
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
const row = page.getByTestId("replication-host-minio-22");
await expect(row).toContainText("Coolify: deploying / failover_blocked");
await expect(row.getByTestId("replication-host-progress")).toBeVisible();
+6
View File
@@ -1201,9 +1201,14 @@ test.describe("POS flow", () => {
{
...fixture.vehicles[0],
reg: "AB12345",
reference: "",
last_order_id: 9201,
},
];
fixture.ordersById[9201] = {
...fixture.ordersById[9201],
reference: "LAST-WASH-REF-9201",
};
fixture.orderItemsByOrderId[9201] = [
{
id: 92011,
@@ -1251,6 +1256,7 @@ test.describe("POS flow", () => {
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => (fixture.orderItemsByOrderId[9300] || []).length, { timeout: 10_000 }).toBe(3);
expect(fixture.ordersById[9300].department_id).toBe(1);
expect(fixture.ordersById[9300].reference).toBe("LAST-WASH-REF-9201");
const copiedItems = fixture.orderItemsByOrderId[9300] || [];
const primaryItem = copiedItems.find((item) => Number(item.product_id) === 53);
+62
View File
@@ -194,6 +194,29 @@ async function getHorizontalBounds(locator) {
});
}
async function expectVehicleEmptyStateMatchesLastWashContentHeight(page) {
const vehicleEmptyState = page.getByTestId("pos-desktop-vehicle-empty-state");
const lastWashList = page.getByTestId("pos-desktop-last-wash-section").locator(".pos-step-one-insight-list");
const lastWashCopyButton = page.getByTestId("pos-desktop-last-wash-copy");
await expect(vehicleEmptyState).toBeVisible();
await expect(lastWashList).toBeVisible();
await expect(lastWashCopyButton).toBeVisible();
const [emptyStateBox, lastWashListBox, lastWashCopyButtonBox] = await Promise.all([
vehicleEmptyState.boundingBox(),
lastWashList.boundingBox(),
lastWashCopyButton.boundingBox(),
]);
expect(emptyStateBox).not.toBeNull();
expect(lastWashListBox).not.toBeNull();
expect(lastWashCopyButtonBox).not.toBeNull();
const lastWashContentHeight = lastWashCopyButtonBox.y + lastWashCopyButtonBox.height - lastWashListBox.y;
expect(Math.abs(emptyStateBox.height - lastWashContentHeight)).toBeLessThanOrEqual(3);
}
async function expectPrimaryActionAboveClearAll(primaryAction, clearAllAction) {
const primaryBounds = await getHorizontalBounds(primaryAction);
const clearAllBounds = await getHorizontalBounds(clearAllAction);
@@ -425,6 +448,45 @@ test.describe("POS visuals", () => {
});
});
test("desktop step 1 stretches vehicle empty state beside last wash content", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Layout assertion is covered on chromium desktop.");
await page.setViewportSize({ width: 1920, height: 1080 });
const posFixture = createPosFixture();
posFixture.vehicles = posFixture.vehicles.map((vehicle) =>
vehicle.reg === "EC21235"
? {
...vehicle,
type: null,
wash_subscription: false,
addons: { enabled: 0, available: 0, list: [] },
last_order_id: 54518,
}
: vehicle
);
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: posFixture,
});
await primeSession(page, "pos-visual-desktop-empty-state-height-token");
await page.goto("/admin/12/modules/pos");
const stepOne = page.getByTestId("pos-step-1");
await expect(stepOne).toBeVisible({ timeout: POS_STEP_TIMEOUT });
await page.locator("#reg_1").fill("EC21235");
await expect(page.getByTestId("pos-desktop-vehicle-summary")).toBeVisible();
await expect(page.getByTestId("pos-desktop-last-wash")).toBeVisible();
await expect(page.getByTestId("pos-desktop-vehicle-empty-state")).toContainText(
"Ingen abonnement- eller tilvalgsdata"
);
await expectVehicleEmptyStateMatchesLastWashContentHeight(page);
});
test("desktop step 1 required reference warning snapshot", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Covered on chromium desktop.");
+108
View File
@@ -0,0 +1,108 @@
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");
window.localStorage.setItem("release_source_override", "deployment");
});
await page.route("**/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).toHaveLength(1);
const runtimeRequestUrl = new URL(runtimeRequests[0]);
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(runtimeRequestUrl.pathname);
expect(runtimeRequestUrl.pathname).not.toBe("/canary/api/release/runtime");
expect(runtimeRequestUrl.searchParams.get("release_channel")).toBe("canary");
expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]);
await expect.poll(() => releaseApiRequests.length).toBe(1);
expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping");
await expect(page.locator("body")).toHaveAttribute("data-release-origin", currentOrigin);
});
+9 -11
View File
@@ -1,9 +1,7 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5173";
const availableRuntime = (frontendBaseUrl) => ({
const availableRuntime = () => ({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-switched-channel",
channel: {
@@ -13,15 +11,12 @@ const availableRuntime = (frontendBaseUrl) => ({
description: "Early production validation before broader rollout.",
enabled: true,
default_channel: false,
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
},
versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "feedface" },
bundle_id: 31,
},
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
availability: {
configured: true,
missing: [],
@@ -49,7 +44,7 @@ async function boot(page) {
id: 77,
customer_number: 990077,
runtime_config: {
release: availableRuntime(baseUrl),
release: availableRuntime(),
},
},
});
@@ -59,17 +54,20 @@ async function boot(page) {
test("users assigned to a configured release channel see the switched notice once on the device", async ({ page }) => {
await boot(page);
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const notice = page.getByTestId("release-channel-switched-page");
await expect(notice).toBeVisible();
await expect(notice).toBeVisible({ timeout: 30_000 });
await expect(notice).toContainText("You are now on Canary");
await expect(notice).toContainText("Early production validation");
await expect(page.getByTestId("release-channel-switched-details")).toContainText("#31");
await expect(page.getByTestId("release-channel-switched-details")).toContainText("frontend-canary");
await expect(page.getByTestId("release-channel-switched-details")).toContainText("api-canary");
const currentUrl = page.url();
await page.getByTestId("release-channel-switched-continue").click();
await expect(notice).toHaveCount(0);
await expect(page).toHaveURL(currentUrl);
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-channel-switched-page")).toHaveCount(0);
});
+597 -24
View File
@@ -1,11 +1,20 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
test.describe.configure({ mode: "serial" });
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 +26,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 +41,12 @@ 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" },
},
frontend_base_url: frontendBaseUrl,
api_base_url: "https://api-canary.example.test",
availability: {
configured: true,
missing: [],
@@ -52,11 +54,251 @@ 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: ["api_base_url"],
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: ["api_base_url"],
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",
},
},
],
});
const runtimeWithReadyBetaSelected = () => {
const stableRuntime = runtimeWithFailingBetaSwitch();
const betaChannel = stableRuntime.available_channels.find((option) => option.channel.slug === "beta").channel;
return {
...stableRuntime,
generated_at: "2026-05-19T09:11:00.000Z",
trace_id: "trace-beta-channel",
channel: betaChannel,
versions: {
frontend: { version_label: "frontend-beta", commit_sha: "be7afe" },
api: { version_label: "api-beta", commit_sha: "ba5eba11" },
bundle_id: 32,
},
frontend_base_url: "https://api-v2.truckwash.io/beta/frontend",
api_base_url: "https://api-v2.truckwash.io/beta/api",
urls: {
frontend_base_url: "https://api-v2.truckwash.io/beta/frontend",
api_base_url: "https://api-v2.truckwash.io/beta/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
available_channels: stableRuntime.available_channels,
};
};
const internalMissingFrontendEntryRuntime = () => ({
generated_at: "2026-05-20T17:50:00.000Z",
trace_id: "trace-internal-missing-frontend-entry",
channel: {
id: 4,
slug: "internal",
name: "Intern",
description: "Internal staff and superuser validation channel.",
enabled: true,
default_channel: false,
},
versions: {
frontend: null,
api: {
version_label: "api-internal",
commit_sha: "e40cf6b6ac31",
deployed_at: "2026-05-20T17:50:00.000Z",
},
bundle_id: null,
},
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: false,
explicit: true,
missing: ["frontend_entry"],
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: "abc123" },
api: { version_label: "api-stable", commit_sha: "def456" },
bundle_id: 31,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: {
id: 4,
slug: "internal",
name: "Intern",
description: "Internal staff and superuser validation channel.",
enabled: true,
default_channel: false,
},
versions: {
frontend: null,
api: {
version_label: "api-internal",
commit_sha: "e40cf6b6ac31",
deployed_at: "2026-05-20T17:50:00.000Z",
},
bundle_id: null,
},
availability: {
configured: false,
explicit: true,
missing: ["frontend_entry"],
status: "unconfigured",
},
},
],
capture_policy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
});
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");
});
window.localStorage.setItem("release_source_override", "deployment");
}, locale);
await mockApi(page, {
authenticated: true,
@@ -76,13 +318,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")).not.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 +333,334 @@ 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");
window.localStorage.setItem("release_source_override", "deployment");
});
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");
window.localStorage.setItem("release_source_override", "deployment");
});
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("login assigned to an unconfigured internal channel shows guard actions without the user-data modal", async ({
page,
}) => {
const runtime = internalMissingFrontendEntryRuntime();
let releaseRuntimeResponse = runtimeWithFailingBetaSwitch();
const sessionRequests = [];
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
window.localStorage.removeItem("token");
window.localStorage.removeItem("is_subuser");
window.localStorage.removeItem("selected_customer_number");
window.localStorage.removeItem("release_channel_selected_slug");
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
window.localStorage.setItem("release_source_override", "deployment");
});
await mockApi(page, {
authenticated: true,
loginToken: "release-login-token",
permissions: ["user"],
sessionData: {
phone: undefined,
notifications: undefined,
permissions: undefined,
economic_customer: undefined,
runtime_config: {
release: runtime,
},
},
});
await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: releaseRuntimeResponse }));
});
await page.route("https://api-v2.truckwash.io/internal/api/**", async (route) => {
await route.fulfill(json({ data: [] }));
});
page.on("request", (request) => {
if (request.method() === "GET" && request.url().includes("/auth/session")) {
sessionRequests.push(request.url());
}
});
await page.goto("/login?redirect=%2Fuser", { waitUntil: "domcontentloaded" });
await page.getByTestId("login-customer-number").fill("12345");
await page.getByTestId("login-password").fill("correct horse battery staple");
releaseRuntimeResponse = runtime;
await page.getByTestId("login-submit").click();
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(page).toHaveURL(/\/user/);
await expect(guard).toContainText("Release channel is not ready");
await expect(guard).toContainText("Intern");
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend entry");
await expect(page.getByTestId("release-channel-option-stable")).toBeVisible();
await expect(page.getByTestId("release-channel-ignore")).toBeVisible();
await expect(page.getByTestId("release-channel-check-again")).toBeVisible();
await expect(guard.getByRole("button", { name: /log\s*out|logout/i })).toBeVisible();
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await page.getByTestId("release-channel-ignore").click();
await expect(guard).toHaveCount(0);
await expect(page).toHaveURL(/\/user/);
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await page.evaluate(() => window.localStorage.removeItem("release_channel_unavailable_ignore_until"));
await page.reload({ waitUntil: "domcontentloaded" });
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
const sessionRequestCountBeforeLogout = sessionRequests.length;
await guard.getByRole("button", { name: /log\s*out|logout/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("token"))).toBeNull();
await page.waitForTimeout(500);
expect(sessionRequests.length).toBe(sessionRequestCountBeforeLogout);
});
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).not.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("ready sidebar release channel switches are confirmed through the control runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
const stableRuntime = runtimeWithFailingBetaSwitch();
const betaRuntime = runtimeWithReadyBetaSelected();
await boot(page, stableRuntime);
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(route.request().url());
await route.fulfill(json({ data: selectedChannel === "beta" ? betaRuntime : stableRuntime }));
});
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(page.getByTestId("release-channel-switched-page")).toContainText("You are now on Beta");
await expect(page.locator(".release-channel-sidebar-selector__error")).toHaveCount(0);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug")))
.toBe("beta");
await page.getByTestId("release-channel-switched-continue").click();
await expect(beta).toHaveAttribute("aria-pressed", "true");
await expect(stable).toHaveAttribute("aria-pressed", "false");
const betaRuntimeRequest = runtimeRequests.find((url) => new URL(url).searchParams.get("release_channel") === "beta");
expect(betaRuntimeRequest).toBeTruthy();
const betaRuntimeRequestUrl = new URL(betaRuntimeRequest);
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(betaRuntimeRequestUrl.pathname);
expect(betaRuntimeRequestUrl.pathname).not.toBe("/beta/api/release/runtime");
});
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 +669,13 @@ test("users assigned to an unconfigured release channel can check again when it
await route.fulfill(json({ data: releaseRuntime }));
});
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible();
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
releaseRuntime = availableRuntime(new URL(page.url()).origin);
const currentUrl = page.url();
releaseRuntime = availableRuntime();
await page.getByTestId("release-channel-check-again").click();
await expect(guard).toHaveCount(0);
await expect(page).toHaveURL(currentUrl);
});
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -91,7 +91,7 @@ export function attachPageHealthGuards(page: Page, baseURL: string) {
};
}
export async function expectBodyHasContent(page: Page, minimumLength = 20) {
export async function expectBodyHasContent(page: Page, minimumLength = 20, timeout = 45_000) {
await expect
.poll(
async () => {
@@ -101,7 +101,7 @@ export async function expectBodyHasContent(page: Page, minimumLength = 20) {
.catch(() => "");
return text.replace(/\s+/g, " ").trim().length;
},
{ timeout: 15_000 }
{ timeout }
)
.toBeGreaterThan(minimumLength);
}
@@ -0,0 +1,340 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs/promises";
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(TEST_DIR, "../../..");
const DIST_DIR = path.join(PROJECT_ROOT, "dist");
const PUBLIC_DIR = path.join(PROJECT_ROOT, "public");
const HTACCESS_PATH = path.join(PROJECT_ROOT, "public", ".htaccess");
const NGINX_CONFIG_PATH = path.join(PROJECT_ROOT, "nginx.coolify-frontend.conf");
const STATIC_DIRECTORIES = ["assets", "resources", "favicons", "icons", "img", "sounds", ".well-known"];
const STATIC_FILES = [
"manifest.json",
"manifest.webmanifest",
"favicon.ico",
"favicon_default.ico",
"pleno-favicon.ico",
"release-entry.json",
"release-manifest.json",
"registerSW.js",
"sw.js",
];
const GENERATED_HTML_STATIC_PATHS = [
"/assets/favicons/favicon-96x96.png",
"/assets/favicons/favicon.svg",
"/assets/favicons/favicon.ico",
"/assets/favicons/apple-touch-icon.png",
"/assets/manifest.webmanifest",
];
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".mp3", "audio/mpeg"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".webmanifest", "application/manifest+json; charset=utf-8"],
[".woff2", "font/woff2"],
]);
let server: http.Server;
let serverBaseUrl = "";
test.describe.configure({ mode: "serial" });
function isStaticSingleFile(segment: string) {
return STATIC_FILES.includes(segment) || /^workbox-[^/]+\.js$/.test(segment);
}
function staticDirectoryPath(requestPath: string) {
const staticPattern = new RegExp(`(?:^|/)((?:${STATIC_DIRECTORIES.map(escapeRegExp).join("|")})/.+)$`);
return requestPath.match(staticPattern)?.[1] || "";
}
function staticSingleFilePath(requestPath: string) {
const segment = requestPath.split("/").pop() || "";
return isStaticSingleFile(segment) ? segment : "";
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function safePath(rootDirectory: string, relativePath: string) {
const normalized = relativePath.replace(/^\/+/, "");
const resolved = path.resolve(rootDirectory, normalized);
const root = path.resolve(rootDirectory);
const lowerResolved = resolved.toLowerCase();
const lowerRoot = root.toLowerCase();
if (lowerResolved !== lowerRoot && !lowerResolved.startsWith(`${lowerRoot.toLowerCase()}${path.sep}`)) {
return null;
}
return resolved;
}
function safeDistPath(relativePath: string) {
return safePath(DIST_DIR, relativePath);
}
function safePublicPath(relativePath: string) {
return safePath(PUBLIC_DIR, relativePath);
}
async function fileExists(filePath: string) {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
function requestPath(request: IncomingMessage) {
const pathname = new URL(request.url || "/", "http://localhost").pathname;
return decodeURIComponent(pathname).replace(/^\/+/, "");
}
async function serveFile(response: ServerResponse, filePath: string) {
const extension = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES.get(extension) || "application/octet-stream";
response.writeHead(200, { "Content-Type": contentType });
response.end(await fs.readFile(filePath));
}
function serveNotFound(response: ServerResponse) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
async function handleRequest(request: IncomingMessage, response: ServerResponse) {
const pathName = requestPath(request);
const exactPath = safeDistPath(pathName || "index.html");
if (exactPath && (await fileExists(exactPath))) {
await serveFile(response, exactPath);
return;
}
const mappedStaticPath = staticDirectoryPath(pathName) || staticSingleFilePath(pathName);
if (mappedStaticPath) {
const candidates = [
safeDistPath(mappedStaticPath),
safePublicPath(mappedStaticPath),
safeDistPath(path.join("dist", mappedStaticPath)),
];
for (const candidate of candidates) {
if (candidate && (await fileExists(candidate))) {
await serveFile(response, candidate);
return;
}
}
const prefixedSingleFile = staticSingleFilePath(pathName);
if (prefixedSingleFile) {
const singleFileCandidates = [
safePublicPath(prefixedSingleFile),
safeDistPath(path.join("dist", prefixedSingleFile)),
];
for (const candidate of singleFileCandidates) {
if (candidate && (await fileExists(candidate))) {
await serveFile(response, candidate);
return;
}
}
}
serveNotFound(response);
return;
}
if (/\.[^/]+$/.test(pathName)) {
serveNotFound(response);
return;
}
await serveFile(response, path.join(DIST_DIR, "index.html"));
}
async function distStaticPaths() {
const releaseEntry = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-entry.json"), "utf8"));
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
const pwaManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "assets", "manifest.webmanifest"), "utf8"));
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
const rootFiles = await fs.readdir(DIST_DIR);
const workboxFile = rootFiles.find((fileName) => /^workbox-[^/]+\.js$/.test(fileName));
const bundledStaticFiles = (
await Promise.all(
["assets", "resources"].map(async (directory) => {
try {
return (await fs.readdir(path.join(DIST_DIR, directory))).map((fileName) => `/${directory}/${fileName}`);
} catch {
return [];
}
})
)
).flat();
const cssFile = bundledStaticFiles.find((fileName) => fileName.endsWith(".css"));
const indexStaticPaths = Array.from(indexHtml.matchAll(/\b(?:href|src)="([^"]+)"/g))
.map((match) => match[1])
.filter((url) => url.startsWith("/") && !url.startsWith("//"));
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
if (!icon.src) {
return "";
}
return new URL(icon.src, "https://example.test/assets/manifest.webmanifest").pathname;
});
return Array.from(
new Set(
[
...indexStaticPaths,
...GENERATED_HTML_STATIC_PATHS,
...manifestIconPaths,
"/manifest.json",
"/manifest.webmanifest",
"/release-manifest.json",
"/favicon.ico",
"/favicons/favicon-96x96.png",
"/favicons/favicon.svg",
"/.well-known/assetlinks.json",
"/registerSW.js",
"/sw.js",
workboxFile ? `/${workboxFile}` : "",
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
...(releaseManifest.index_asset_urls || []),
...(releaseManifest.pwa_asset_urls || []),
...(releaseManifest.asset_urls || []),
cssFile || "",
].filter(Boolean)
)
).filter((staticPath) => staticPath !== "/index.html");
}
test.beforeAll(async () => {
const indexPath = path.join(DIST_DIR, "index.html");
if (!(await fileExists(indexPath))) {
throw new Error("dist/index.html is missing. Run `npm run build` before this test.");
}
server = http.createServer((request, response) => {
void handleRequest(request, response).catch((error) => {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end(error instanceof Error ? error.message : String(error));
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Could not start public .htaccess test server.");
}
serverBaseUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
test.describe("public .htaccess static fallback", () => {
test("keeps SPA fallback base agnostic and static misses as 404", async () => {
const source = await fs.readFile(HTACCESS_PATH, "utf8");
const sourceWithoutRegexEscapes = source.replace(/\\/g, "");
expect(source).not.toMatch(/RewriteBase\s+\//);
expect(source).not.toContain("/index.html");
expect(source).toContain("index.html [L]");
expect(source).toContain("R=404");
for (const token of [...STATIC_DIRECTORIES, ...STATIC_FILES, "workbox-"]) {
expect(sourceWithoutRegexEscapes).toContain(token);
}
});
test("keeps nginx static fallbacks aligned with the public .htaccess", async () => {
const source = await fs.readFile(NGINX_CONFIG_PATH, "utf8");
const sourceWithoutRegexEscapes = source.replace(/\\/g, "");
expect(source).toContain("static_asset_path");
expect(source).toContain("static_file_path");
expect(source).toContain("=404");
for (const token of [...STATIC_DIRECTORIES, ...STATIC_FILES, "workbox-"]) {
expect(sourceWithoutRegexEscapes).toContain(token);
}
});
test("emits favicon and manifest links from generated assets", async () => {
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
const legacyManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.json"), "utf8"));
const rootWebManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.webmanifest"), "utf8"));
for (const staticPath of GENERATED_HTML_STATIC_PATHS) {
expect(indexHtml).toContain(`href="${staticPath}"`);
}
expect(indexHtml).not.toContain('href="/favicons/');
expect(indexHtml).not.toContain('href="/favicon.ico"');
expect(indexHtml).not.toContain('href="/manifest.json"');
for (const icon of [...(legacyManifest.icons || []), ...(rootWebManifest.icons || [])]) {
expect(icon.src).toMatch(/^assets\/favicons\//);
}
});
test("serves the app shell from root, deep links, and release-prefixed paths", async ({ request }) => {
for (const appPath of ["/", "/guest/book/wash", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
const response = await request.get(`${serverBaseUrl}${appPath}`);
expect(response.status(), appPath).toBe(200);
expect(response.headers()["content-type"], appPath).toContain("text/html");
expect(await response.text(), appPath).toContain('<div id="app"></div>');
}
});
test("serves copied and generated static inclusions below any base path", async ({ request }) => {
const staticPaths = await distStaticPaths();
for (const staticPath of staticPaths) {
for (const prefix of ["", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
const response = await request.get(`${serverBaseUrl}${prefix}${staticPath}`);
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
}
}
});
test("does not rewrite missing static-looking requests to the SPA shell", async ({ request }) => {
for (const missingPath of [
"/missing.js",
"/canary/frontend/assets/missing.js",
"/canary/frontend/guest/book/wash/resources/missing.css",
"/canary/frontend/not-real.png",
]) {
const response = await request.get(`${serverBaseUrl}${missingPath}`);
expect(response.status(), missingPath).toBe(404);
expect(response.headers()["content-type"], missingPath).not.toContain("text/html");
expect(await response.text(), missingPath).not.toContain('<div id="app"></div>');
}
});
});
+139 -70
View File
@@ -1,86 +1,155 @@
import { expect, test } from "@playwright/test";
import { loginAsOperator, loginAsUser } from "../fixtures/authHelpers";
import { attachPageHealthGuards, expectBodyHasContent, expectOneVisible, requiredEnv, settlePage } from "./helpers";
import { expect, test, type APIRequestContext } from "@playwright/test";
import crypto from "node:crypto";
import { attachPageHealthGuards, expectBodyHasContent, settlePage } from "./helpers";
const liveSmokeEnabled = Boolean(
process.env.PLAYWRIGHT_BASE_URL &&
process.env.PLAYWRIGHT_USER_CUSTOMER_NUMBER &&
process.env.PLAYWRIGHT_USER_PASSWORD &&
process.env.PLAYWRIGHT_OPERATOR_USER_ID &&
process.env.PLAYWRIGHT_OPERATOR_PASSWORD
);
type ReleaseManifest = {
build_id?: string;
commit_sha?: string;
entry?: string;
css?: string[];
index_asset_urls?: string[];
pwa_asset_urls?: string[];
asset_urls?: string[];
asset_hashes?: Record<string, { sha256?: string; bytes?: number }>;
};
function getLiveSettings() {
return {
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
customerCredentials: {
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
otpSecret: process.env.PLAYWRIGHT_USER_OTP_SECRET || "",
twoFactorAuthentication: Boolean(process.env.PLAYWRIGHT_USER_OTP_SECRET),
},
operatorCredentials: {
userId: requiredEnv("PLAYWRIGHT_OPERATOR_USER_ID"),
password: requiredEnv("PLAYWRIGHT_OPERATOR_PASSWORD"),
},
departmentId: Number.parseInt(process.env.PLAYWRIGHT_DEPARTMENT_ID || "12", 10),
};
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
function liveUrl(assetPath: string) {
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
}
test.describe.configure({ mode: "serial" });
function unique(values: string[]) {
return Array.from(new Set(values.filter(Boolean)));
}
test.describe("Live smoke release gate", () => {
test.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate.");
function sha256(bytes: Buffer) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
test("guest flow renders on the deployed environment", async ({ page }) => {
const { baseURL } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
function rejectsHtml(assetPath: string) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
await page.goto("/guest/book/wash");
await settlePage(page);
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
await expectBodyHasContent(page);
await expect(page.locator("body")).not.toContainText(/404/i);
await guards.expectHealthy();
guards.dispose();
async function expectJson<T>(request: APIRequestContext, assetPath: string): Promise<T> {
const response = await request.get(liveUrl(assetPath), {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const body = await response.text();
test("customer login reaches dashboard, profile, and bookings", async ({ page }) => {
const { baseURL, customerCredentials } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
expect(response.status(), `${assetPath} status`).toBe(200);
expect(response.headers()["content-type"] || "", `${assetPath} content-type`).not.toContain("text/html");
expect(body.trim().length, `${assetPath} body`).toBeGreaterThan(0);
await loginAsUser(page, customerCredentials);
return JSON.parse(body) as T;
}
await expect(page.locator("#book-wash-button")).toBeVisible();
await expect(page.locator("#download-invoices-button")).toBeVisible();
await page.goto("/user/profile");
await settlePage(page);
await expect(page).toHaveURL(/\/user\/profile(?:\?.*)?$/);
await expect(page.locator(".card-header").first()).toBeVisible();
await page.goto("/user/bookings");
await settlePage(page);
await expect(page).toHaveURL(/\/user\/bookings(?:\?.*)?$/);
await expect(page.locator(".title").first()).toBeVisible();
await guards.expectHealthy();
guards.dispose();
async function expectStaticAsset(
request: APIRequestContext,
assetPath: string,
expectedHash?: { sha256?: string; bytes?: number }
) {
const response = await request.get(liveUrl(assetPath), {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const body = await response.body();
const contentType = response.headers()["content-type"] || "";
test("operator login reaches the seeded department route", async ({ page }) => {
const { baseURL, operatorCredentials, departmentId } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
expect(response.status(), `${assetPath} status`).toBe(200);
expect(body.length, `${assetPath} body`).toBeGreaterThan(0);
if (rejectsHtml(assetPath)) {
expect(contentType, `${assetPath} content-type`).not.toContain("text/html");
}
if (expectedHash?.bytes !== undefined) {
expect(body.length, `${assetPath} bytes`).toBe(expectedHash.bytes);
}
if (expectedHash?.sha256) {
expect(sha256(body), `${assetPath} sha256`).toBe(expectedHash.sha256);
}
}
await loginAsOperator(page, operatorCredentials);
await page.goto(`/admin/${departmentId}/modules/pos`);
await settlePage(page);
await expect(page).toHaveURL(new RegExp(`/admin/${departmentId}/modules/pos(?:\\?.*)?$`));
await expectOneVisible([page.getByTestId("pos-step-1"), page.getByTestId("pos-mobile-step-1-shell")]);
await guards.expectHealthy();
guards.dispose();
async function expectApiPing(request: APIRequestContext, apiBaseUrl: string, path: string) {
const url = new URL(path.replace(/^\/+/, ""), apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`).toString();
const response = await request.get(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const body = await response.text();
expect(response.status(), `${url} status`).toBe(200);
expect(response.headers()["content-type"] || "", `${url} content-type`).toMatch(/json/i);
expect(body.trim().length, `${url} body`).toBeGreaterThan(0);
expect(JSON.parse(body), `${url} JSON`).toMatchObject({ success: true });
}
test("@public-live release manifest, shell, and static assets are available", async ({ request }) => {
const manifest = await expectJson<ReleaseManifest>(request, "release-manifest.json");
const releaseEntry = await expectJson<{ entry?: string; css?: string[] }>(request, "release-entry.json");
expect(manifest.build_id, "release-manifest.json build_id").toBeTruthy();
expect(manifest.commit_sha, "release-manifest.json commit_sha").toBeTruthy();
expect(releaseEntry.entry, "release-entry.json entry").toBe(manifest.entry);
expect(releaseEntry.css || [], "release-entry.json css").toEqual(manifest.css || []);
for (const shellPath of ["/", "/guest/book/wash"]) {
const response = await request.get(liveUrl(shellPath), {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const body = await response.text();
expect(response.status(), `${shellPath} status`).toBe(200);
expect(response.headers()["content-type"] || "", `${shellPath} content-type`).toContain("text/html");
expect(body.replace(/\s+/g, "").length, `${shellPath} body`).toBeGreaterThan(40);
expect(body, `${shellPath} Vue root`).toContain('<div id="app"></div>');
}
const assetPaths = unique([
"release-manifest.json",
"release-entry.json",
manifest.entry || "",
...(manifest.css || []),
...(manifest.index_asset_urls || []),
...(manifest.pwa_asset_urls || []),
...(manifest.asset_urls || []),
]).filter((assetPath) => assetPath !== "/index.html");
for (const assetPath of assetPaths) {
const hashKey = assetPath.startsWith("/") ? assetPath : `/${assetPath}`;
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[hashKey]);
}
});
test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => {
const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io";
const apiPingPaths = unique(
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/master/api/ping").split(",").map((value) => value.trim())
);
for (const apiPingPath of apiPingPaths) {
await expectApiPing(request, apiBaseUrl, apiPingPath);
}
});
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
const guards = attachPageHealthGuards(page, baseURL);
await page.goto(liveUrl("guest/book/wash"));
await settlePage(page);
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
await expectBodyHasContent(page);
await expect(page.locator("body")).not.toContainText(/404/i);
await guards.expectHealthy();
guards.dispose();
});

Some files were not shown because too many files have changed in this diff Show More