Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac731ae19 | ||
|
|
2f2beb774a | ||
|
|
a98dc061da | ||
|
|
184ef5670c | ||
|
|
8ab8915429 | ||
|
|
a952dd2c61 | ||
|
|
ebcd52a019 | ||
|
|
b2ddf0db5c | ||
|
|
eaf4b965d5 | ||
|
|
6ba1c754a1 | ||
|
|
468613ab68 | ||
|
|
261d84bd01 | ||
|
|
ea8db593c5 | ||
|
|
130cc2fc10 | ||
|
|
3715da8fd3 | ||
|
|
100e477a95 | ||
|
|
1132c8c256 | ||
|
|
42237fcc70 | ||
|
|
ae8b3b6b64 | ||
|
|
8378491b0b |
@@ -0,0 +1,17 @@
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
coverage
|
||||
dev-dist
|
||||
dist
|
||||
node_modules
|
||||
node_modules.*
|
||||
output
|
||||
playwright-report
|
||||
test-results
|
||||
.ai
|
||||
.ai-workflow
|
||||
.claude
|
||||
.codex
|
||||
.gradle
|
||||
gradle
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -760,6 +760,7 @@
|
||||
|
||||
.pos-step-one-insights {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
@@ -903,6 +904,7 @@
|
||||
|
||||
.pos-step-one-insight-card__section {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
@@ -970,6 +972,7 @@
|
||||
.pos-step-one-empty-state,
|
||||
.pos-step-one-loading-state {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 4rem;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ref } from "vue";
|
||||
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
|
||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { API_URL } from "@/config.js";
|
||||
import { getReleaseRuntimeApiBaseUrl } from "@/services/releaseTimeline.js";
|
||||
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||
import {
|
||||
buildCustomerAttributeTargetPayload,
|
||||
@@ -992,7 +992,7 @@ const shouldRetryPreviewRequestWithAuth = (downloadLink) => {
|
||||
|
||||
try {
|
||||
const previewUrl = new URL(downloadLink, window.location.origin);
|
||||
const apiOrigin = new URL(API_URL, window.location.origin).origin;
|
||||
const apiOrigin = new URL(getReleaseRuntimeApiBaseUrl(), window.location.origin).origin;
|
||||
return previewUrl.origin === apiOrigin || previewUrl.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -660,7 +660,9 @@ const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || []);
|
||||
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
|
||||
sourceReference: payload?.order?.reference,
|
||||
});
|
||||
if (!didCopy) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="pos-step-one-insight-card__section">
|
||||
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
|
||||
<div class="pos-step-one-insight-card__section-header">
|
||||
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="pos-step-one-insight-card__section">
|
||||
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
|
||||
<div class="pos-step-one-insight-card__section-header">
|
||||
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
|
||||
</div>
|
||||
|
||||
@@ -6,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) -->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import { API_URL, REQUEST_QUEUE_CONFIG } from "@/config.js";
|
||||
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
|
||||
import {
|
||||
buildReleaseSessionSummary,
|
||||
getReleaseRuntimeApiBaseUrl,
|
||||
resolveReleaseApiUrl,
|
||||
} from "@/services/releaseTimeline.js";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
clearErrorRequests,
|
||||
@@ -268,16 +273,19 @@ watch(recentRequests, (requests) => {
|
||||
const resolvePingUrl = () => {
|
||||
const configuredEndpoint = REQUEST_QUEUE_CONFIG.ping.endpoint;
|
||||
if (!configuredEndpoint) {
|
||||
return API_URL;
|
||||
return getReleaseRuntimeApiBaseUrl();
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(configuredEndpoint)) {
|
||||
return configuredEndpoint;
|
||||
}
|
||||
|
||||
return `${API_URL.replace(/\/+$/, "")}/${String(configuredEndpoint).replace(/^\/+/, "")}`;
|
||||
return resolveReleaseApiUrl(configuredEndpoint);
|
||||
};
|
||||
|
||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
||||
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
|
||||
|
||||
const measurePingLatency = async () => {
|
||||
if (typeof fetch !== "function") {
|
||||
pingLatencyMs.value = null;
|
||||
@@ -404,7 +412,7 @@ const grantMissingPermission = async (permission) => {
|
||||
setPermissionGrantStatus(normalizedPermission, "loading");
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/roles/permissions`,
|
||||
resolveReleaseApiUrl("/roles/permissions"),
|
||||
{
|
||||
group_id: roleId,
|
||||
permission_id: normalizedPermission,
|
||||
@@ -638,60 +646,186 @@ onBeforeUnmount(() => {
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
||||
<div class="request-queue-progress__section-title">Runtime</div>
|
||||
<ul class="request-queue-progress__meta-list request-queue-progress__section-content request-queue-progress__section-content--meta">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="API_URL">{{ API_URL }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Environment</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Commit</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version time</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
|
||||
<span class="request-queue-progress__meta-label">i18n catalog</span>
|
||||
<button
|
||||
class="request-queue-progress__catalog-switch"
|
||||
data-testid="request-queue-i18n-catalog-switch"
|
||||
type="button"
|
||||
@click="handleToggleI18nCatalogVersion"
|
||||
<div class="request-queue-progress__section-title">Session release</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Channel</span>
|
||||
<span
|
||||
class="request-queue-progress__meta-value"
|
||||
:title="releaseSessionSummary.channelSlug || releaseSessionSummary.channelLabel"
|
||||
>
|
||||
{{ releaseSessionSummary.channelLabel }}
|
||||
<span v-if="releaseSessionSummary.channelSlug">({{ releaseSessionSummary.channelSlug }})</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Trace ID</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.traceId">
|
||||
{{ releaseSessionSummary.traceId }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Generated</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.generatedAt">
|
||||
{{ releaseSessionSummary.generatedAt }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Bundle</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.bundleLabel">
|
||||
{{ releaseSessionSummary.bundleLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Service set</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.serviceSetLabel">
|
||||
{{ releaseSessionSummary.serviceSetLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="releaseSessionSummary.missingLabels.length > 0"
|
||||
class="request-queue-progress__meta-item"
|
||||
data-testid="request-queue-release-missing"
|
||||
>
|
||||
{{ i18nCatalogVersionLabel }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing requests</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing responses</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="request-queue-progress__meta-label">Missing</span>
|
||||
<span
|
||||
class="request-queue-progress__meta-value request-queue-progress__meta-value--warning"
|
||||
:title="releaseSessionSummary.missingLabels.join(', ')"
|
||||
>
|
||||
{{ releaseSessionSummary.missingLabels.join(", ") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">App releases</div>
|
||||
<ul class="request-queue-progress__release-list">
|
||||
<li
|
||||
v-for="app in releaseSessionSummary.appRows"
|
||||
:key="app.key"
|
||||
class="request-queue-progress__release-row"
|
||||
:data-testid="`request-queue-release-app-${app.key}`"
|
||||
>
|
||||
<div class="request-queue-progress__release-row-header">
|
||||
<strong>{{ app.label }}</strong>
|
||||
<span
|
||||
class="request-queue-progress__status-pill"
|
||||
:class="`request-queue-progress__status-pill--${app.tone}`"
|
||||
>
|
||||
{{ app.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="request-queue-progress__release-primary" :title="app.title">
|
||||
{{ app.primaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.secondaryText"
|
||||
class="request-queue-progress__release-secondary"
|
||||
:title="app.secondaryText"
|
||||
>
|
||||
{{ app.secondaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.url"
|
||||
class="request-queue-progress__release-url"
|
||||
:title="app.url"
|
||||
>
|
||||
{{ app.url }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.missingLabel"
|
||||
class="request-queue-progress__release-secondary request-queue-progress__release-secondary--warning"
|
||||
>
|
||||
{{ app.missingLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Connected services</div>
|
||||
<ul class="request-queue-progress__release-list">
|
||||
<li
|
||||
v-for="service in releaseSessionSummary.serviceRows"
|
||||
:key="service.key"
|
||||
class="request-queue-progress__release-row"
|
||||
:data-testid="`request-queue-release-service-${service.key}`"
|
||||
>
|
||||
<div class="request-queue-progress__release-row-header">
|
||||
<strong>{{ service.label }}</strong>
|
||||
<span
|
||||
class="request-queue-progress__status-pill"
|
||||
:class="`request-queue-progress__status-pill--${service.tone}`"
|
||||
>
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="request-queue-progress__release-primary" :title="service.title">
|
||||
{{ service.primaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="service.secondaryText"
|
||||
class="request-queue-progress__release-secondary"
|
||||
:title="service.secondaryText"
|
||||
>
|
||||
{{ service.secondaryText }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Runtime details</div>
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Environment</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Commit</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version time</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
|
||||
<span class="request-queue-progress__meta-label">i18n catalog</span>
|
||||
<button
|
||||
class="request-queue-progress__catalog-switch"
|
||||
data-testid="request-queue-i18n-catalog-switch"
|
||||
type="button"
|
||||
@click="handleToggleI18nCatalogVersion"
|
||||
>
|
||||
{{ i18nCatalogVersionLabel }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing requests</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing responses</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--user" data-testid="request-queue-user-box">
|
||||
@@ -928,6 +1062,12 @@ onBeforeUnmount(() => {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.request-queue-progress__section-content--release {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.request-queue-progress__section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -1167,6 +1307,108 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__meta-value--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.request-queue-progress__subsection-title {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 7px;
|
||||
background: rgba(2, 6, 23, 0.28);
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row-header strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill {
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 3px 7px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--ok {
|
||||
background: rgba(94, 234, 212, 0.16);
|
||||
color: #99f6e4;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--warning {
|
||||
background: rgba(251, 191, 36, 0.16);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--danger {
|
||||
background: rgba(248, 113, 113, 0.16);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-primary,
|
||||
.request-queue-progress__release-secondary,
|
||||
.request-queue-progress__release-url {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-primary {
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-secondary,
|
||||
.request-queue-progress__release-url {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-secondary--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.request-queue-progress__catalog-switch {
|
||||
border: 1px solid rgba(126, 249, 227, 0.5);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import MenuDefault from "@/components/menus/MenuDefault.vue";
|
||||
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
|
||||
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
|
||||
import {ref, computed} from "vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -70,9 +71,10 @@ const menu_items = computed(() => [
|
||||
:show_icons="false"
|
||||
/>
|
||||
<LanguageSelector />
|
||||
<ReleaseChannelSidebarSelector />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -859,7 +859,11 @@ export const createOrder = async (options = { isMobile: false }) => {
|
||||
parseError(response, "stepError");
|
||||
return false;
|
||||
}
|
||||
order_id.value = parseInt(response.data.data.id);
|
||||
const createdOrder = response.data.data || {};
|
||||
order_id.value = parseInt(createdOrder.id);
|
||||
if (!isBlankPosMetadataValue(createdOrder.po)) {
|
||||
order_po.value = String(createdOrder.po);
|
||||
}
|
||||
if (selectedDepartmentId) {
|
||||
department_id.value = selectedDepartmentId;
|
||||
}
|
||||
@@ -1230,6 +1234,20 @@ const getOrderItemNotes = (item) => {
|
||||
return notesValue === "" ? null : notesValue;
|
||||
};
|
||||
|
||||
const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
|
||||
const normalizedSourceReference = String(sourceReference ?? "").trim();
|
||||
if (!normalizedSourceReference || !isBlankPosMetadataValue(reference.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reference.value = normalizedSourceReference;
|
||||
|
||||
const normalizedOrderId = toPositiveInteger(order_id.value);
|
||||
if (normalizedOrderId) {
|
||||
await SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedSourceReference);
|
||||
}
|
||||
};
|
||||
|
||||
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
|
||||
const productId = getOrderItemProductId(sourceItem);
|
||||
const quantity = getOrderItemQuantity(sourceItem);
|
||||
@@ -1258,6 +1276,8 @@ export const copyLastWashItemsToCurrentOrder = async (sourceItems = [], options
|
||||
return false;
|
||||
}
|
||||
|
||||
await copyLastWashReferenceToEmptyCurrentOrder(options.sourceReference ?? options.sourceOrder?.reference);
|
||||
|
||||
const didEnsureOrder = await createOrder({
|
||||
...options,
|
||||
isMobile: false,
|
||||
@@ -2078,11 +2098,6 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOrderItems = await loadOrderItems();
|
||||
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let booking = getSelectedPendingOrderBooking();
|
||||
if (!booking || !Array.isArray(booking?.items)) {
|
||||
booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId, {
|
||||
@@ -2124,11 +2139,16 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
|
||||
const bookingPo = String(booking?.po ?? "").trim();
|
||||
if (bookingPo !== "" && bookingPo !== order_po.value) {
|
||||
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
|
||||
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
|
||||
order_po.value = bookingPo;
|
||||
}
|
||||
|
||||
const currentOrderItems = await loadOrderItems();
|
||||
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
|
||||
if (bookingItems.length === 0) {
|
||||
return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ADMIN_DEPARTMENT_SELECTION_CLASS } from "@/components/models/navigation
|
||||
import { isDepartmentLabelValid, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||
import { isSmall } from "@/components/displays/pagination/PaginationDisplayIsSmall.vue";
|
||||
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
|
||||
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
|
||||
|
||||
@@ -452,6 +453,7 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
|
||||
</BMenuList>
|
||||
</BMenu>
|
||||
<LanguageSelector />
|
||||
<ReleaseChannelSidebarSelector />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1500,6 +1500,16 @@
|
||||
"release_manager": {
|
||||
"title": "@:{'templates.generated.compat.configuration.release_manager.title'}",
|
||||
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.subtitle'}",
|
||||
"channel_names": {
|
||||
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_names.stable'}",
|
||||
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_names.canary'}",
|
||||
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_names.internal'}"
|
||||
},
|
||||
"channel_descriptions": {
|
||||
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.stable'}",
|
||||
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.canary'}",
|
||||
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.internal'}"
|
||||
},
|
||||
"control_api": {
|
||||
"title": "@:{'templates.generated.compat.configuration.release_manager.control_api.title'}",
|
||||
"tooltip": "@:{'templates.generated.compat.configuration.release_manager.control_api.tooltip'}",
|
||||
@@ -1619,8 +1629,13 @@
|
||||
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.title'}",
|
||||
"summary_prefix": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.summary_prefix'}",
|
||||
"summary_suffix": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.summary_suffix'}",
|
||||
"frontend_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_url'}",
|
||||
"api_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_url'}",
|
||||
"release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_bundle'}",
|
||||
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_version'}",
|
||||
"api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_version'}",
|
||||
"frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_base_url'}",
|
||||
"api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.api_base_url'}",
|
||||
"frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.frontend_entry'}",
|
||||
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.release_runtime'}",
|
||||
"checking_again": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.checking_again'}",
|
||||
"refresh_error": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.refresh_error'}",
|
||||
"ignore": "@:{'templates.generated.compat.configuration.release_manager.channel_unavailable.ignore'}",
|
||||
@@ -1633,6 +1648,7 @@
|
||||
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.title'}",
|
||||
"summary_prefix": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.summary_prefix'}",
|
||||
"summary_suffix": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.summary_suffix'}",
|
||||
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.bundle'}",
|
||||
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.frontend'}",
|
||||
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.api'}",
|
||||
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.frontend_version'}",
|
||||
@@ -1643,6 +1659,24 @@
|
||||
"base_image": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.base_image'}",
|
||||
"continue": "@:{'templates.generated.compat.configuration.release_manager.channel_switched.continue'}"
|
||||
},
|
||||
"channel_selector": {
|
||||
"title": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.title'}",
|
||||
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.subtitle'}",
|
||||
"sidebar_title": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.sidebar_title'}",
|
||||
"default": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.default'}",
|
||||
"ready": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.ready'}",
|
||||
"unavailable": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.unavailable'}",
|
||||
"release_bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_bundle'}",
|
||||
"frontend_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_version'}",
|
||||
"api_version": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_version'}",
|
||||
"frontend_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_base_url'}",
|
||||
"api_base_url": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api_base_url'}",
|
||||
"frontend_entry": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend_entry'}",
|
||||
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_runtime'}",
|
||||
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.bundle'}",
|
||||
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend'}",
|
||||
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api'}"
|
||||
},
|
||||
"common": {
|
||||
"yes": "@:{'templates.generated.compat.configuration.release_manager.common.yes'}",
|
||||
"no": "@:{'templates.generated.compat.configuration.release_manager.common.no'}",
|
||||
@@ -1658,6 +1692,7 @@
|
||||
"remove": "@:{'templates.generated.compat.configuration.release_manager.actions.remove'}",
|
||||
"test_access": "@:{'templates.generated.compat.configuration.release_manager.actions.test_access'}",
|
||||
"deploy": "@:{'templates.generated.compat.configuration.release_manager.actions.deploy'}",
|
||||
"redeploy": "@:{'templates.generated.compat.configuration.release_manager.actions.redeploy'}",
|
||||
"promote": "@:{'templates.generated.compat.configuration.release_manager.actions.promote'}",
|
||||
"enable": "@:{'templates.generated.compat.configuration.release_manager.actions.enable'}",
|
||||
"search": "@:{'templates.generated.compat.configuration.release_manager.actions.search'}",
|
||||
@@ -1792,6 +1827,18 @@
|
||||
"assignments": {
|
||||
"title": "@:{'templates.generated.compat.configuration.release_manager.assignments.title'}",
|
||||
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.assignments.subtitle'}",
|
||||
"subject": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject'}",
|
||||
"subject_message": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_message'}",
|
||||
"subject_placeholder": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_placeholder'}",
|
||||
"subject_empty": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_empty'}",
|
||||
"group_users": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_users'}",
|
||||
"group_subusers": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_subusers'}",
|
||||
"group_customers": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_customers'}",
|
||||
"group_manual": "@:{'templates.generated.compat.configuration.release_manager.assignments.group_manual'}",
|
||||
"manual_user": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_user'}",
|
||||
"manual_subuser": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_subuser'}",
|
||||
"manual_customer": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_customer'}",
|
||||
"manual_description": "@:{'templates.generated.compat.configuration.release_manager.assignments.manual_description'}",
|
||||
"subject_type": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_type'}",
|
||||
"subject_type_message": "@:{'templates.generated.compat.configuration.release_manager.assignments.subject_type_message'}",
|
||||
"customer": "@:{'templates.generated.compat.configuration.release_manager.assignments.customer'}",
|
||||
@@ -1892,6 +1939,19 @@
|
||||
"ssl_domain_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.ssl_domain_message'}",
|
||||
"ssl_domain_placeholder": "@:{'templates.generated.compat.configuration.release_manager.integrations.ssl_domain_placeholder'}",
|
||||
"https_domain_for_coolify": "@:{'templates.generated.compat.configuration.release_manager.integrations.https_domain_for_coolify'}",
|
||||
"endpoint_mode": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode'}",
|
||||
"endpoint_mode_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_message'}",
|
||||
"endpoint_mode_auto": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_auto'}",
|
||||
"endpoint_mode_manual": "@:{'templates.generated.compat.configuration.release_manager.integrations.endpoint_mode_manual'}",
|
||||
"manual_endpoint_host": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host'}",
|
||||
"manual_endpoint_host_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host_message'}",
|
||||
"manual_endpoint_host_placeholder": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_host_placeholder'}",
|
||||
"manual_endpoint_port": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_port'}",
|
||||
"manual_endpoint_port_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.manual_endpoint_port_message'}",
|
||||
"app_port": "@:{'templates.generated.compat.configuration.release_manager.integrations.app_port'}",
|
||||
"app_port_message": "@:{'templates.generated.compat.configuration.release_manager.integrations.app_port_message'}",
|
||||
"auto_gateway_endpoint": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_gateway_endpoint'}",
|
||||
"pending_automatic_endpoint": "@:{'templates.generated.compat.configuration.release_manager.integrations.pending_automatic_endpoint'}",
|
||||
"auto_deploy": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_deploy'}",
|
||||
"auto_deploy_tooltip": "@:{'templates.generated.compat.configuration.release_manager.integrations.auto_deploy_tooltip'}",
|
||||
"coolify_ssl": "@:{'templates.generated.compat.configuration.release_manager.integrations.coolify_ssl'}",
|
||||
|
||||
@@ -2552,16 +2552,26 @@
|
||||
"release_manager": {
|
||||
"title": "Release Manager",
|
||||
"subtitle": "Gradvise API- og Front-End-utgivelser",
|
||||
"channel_names": {
|
||||
"stable": "Stabil",
|
||||
"canary": "Canary",
|
||||
"internal": "Intern"
|
||||
},
|
||||
"channel_descriptions": {
|
||||
"stable": "Standard produksjonskanal.",
|
||||
"canary": "Tidlig produksjonsvalideringskanal.",
|
||||
"internal": "Intern kanal for ansatte og superbrukervalidering."
|
||||
},
|
||||
"control_api": {
|
||||
"title": "Kontroll-API",
|
||||
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
|
||||
"example": "Eksempel: https://api.truckwash.io:4433",
|
||||
"endpoint_label": "API-endepunkt",
|
||||
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
|
||||
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
|
||||
"endpoint_aria": "Release Manager kontroll-API URL",
|
||||
"use_tooltip": "Last release-data fra denne API-en",
|
||||
"use": "Bruk",
|
||||
"reset_tooltip": "G? tilbake til standard kontroll-API",
|
||||
"reset_tooltip": "Gå tilbake til standard kontroll-API",
|
||||
"reset": "Tilbakestill",
|
||||
"known_endpoints": "Kjente kontroll-API-endepunkter"
|
||||
},
|
||||
@@ -2572,7 +2582,7 @@
|
||||
},
|
||||
"channels": {
|
||||
"label": "Kanaler",
|
||||
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
|
||||
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
|
||||
},
|
||||
"assignments": {
|
||||
"label": "Tildelinger",
|
||||
@@ -2584,7 +2594,7 @@
|
||||
},
|
||||
"replay": {
|
||||
"label": "Replay",
|
||||
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
|
||||
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
|
||||
},
|
||||
"integrations": {
|
||||
"label": "Integrasjoner",
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -17,13 +17,23 @@ import i18n from '@/i18n'
|
||||
import VueApexCharts from "vue3-apexcharts";
|
||||
import { initializeAutoTableExports } from '@/services/AutoTableExportService.js';
|
||||
import { installAxiosRequestQueue } from '@/services/installAxiosRequestQueue.js';
|
||||
import { installReleaseErrorInstrumentation } from '@/services/releaseTimeline.js';
|
||||
import {
|
||||
configureReleaseRuntime,
|
||||
getReleaseRuntimeApiBaseUrl,
|
||||
installReleaseErrorInstrumentation,
|
||||
} from '@/services/releaseTimeline.js';
|
||||
import { RELEASE_RUNTIME_GLOBAL_KEY } from '@/services/releaseBootstrap.js';
|
||||
|
||||
import { API_URL, IS_DEV } from './config';
|
||||
import { IS_DEV } from './config';
|
||||
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || '';
|
||||
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || '';
|
||||
const LAST_VERSION_CHECK_STORAGE_KEY = 'lastVersionCheck';
|
||||
|
||||
const bootstrapRuntime = typeof window !== 'undefined' ? window[RELEASE_RUNTIME_GLOBAL_KEY] : null;
|
||||
if (bootstrapRuntime && typeof bootstrapRuntime === 'object') {
|
||||
configureReleaseRuntime(bootstrapRuntime);
|
||||
}
|
||||
|
||||
void import('bulma/css/bulma.min.css');
|
||||
void import('buefy/dist/css/buefy.css');
|
||||
|
||||
@@ -76,7 +86,8 @@ const logBuildBanner = () => {
|
||||
'',
|
||||
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
|
||||
`Last version check: ${lastVersionCheckDisplay}`,
|
||||
`Remote API: ${API_URL}`,
|
||||
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
|
||||
`Debug: Is running beta FE.`,
|
||||
].join('\n'));
|
||||
};
|
||||
|
||||
@@ -121,7 +132,7 @@ const app = createApp(App)
|
||||
.use(VueApexCharts)
|
||||
.provide('Colors', Colors)
|
||||
.provide('IS_DEV', IS_DEV)
|
||||
.provide('API_URL', API_URL)
|
||||
.provide('API_URL', getReleaseRuntimeApiBaseUrl())
|
||||
|
||||
installReleaseErrorInstrumentation(app, Router);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { bootstrapReleaseApp } from "@/services/releaseBootstrap.js";
|
||||
|
||||
void bootstrapReleaseApp({
|
||||
loadLocalApp: () => import("./main.js"),
|
||||
});
|
||||
+9
-4
@@ -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 }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "axios";
|
||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||
import { buildCurrentReleaseHeaders, rewriteReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
|
||||
let requestInterceptorId = null;
|
||||
|
||||
@@ -29,6 +30,19 @@ export const installAxiosRequestQueue = () => {
|
||||
}
|
||||
|
||||
requestInterceptorId = axios.interceptors.request.use((config) => {
|
||||
if (config?.__skipReleaseApiRewrite !== true && config?.url) {
|
||||
config.url = rewriteReleaseApiUrl(config.url);
|
||||
}
|
||||
if (config?.__skipReleaseApiRewrite !== true && config?.baseURL) {
|
||||
config.baseURL = rewriteReleaseApiUrl(config.baseURL);
|
||||
}
|
||||
if (config?.__skipReleaseApiRewrite !== true) {
|
||||
config.headers = {
|
||||
...buildCurrentReleaseHeaders(),
|
||||
...(config.headers || {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (isQueueBypassed(config) || config?.__queueAdapterWrapped) {
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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()}`);
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
+1617
-109
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+6134
-1026
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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;
|
||||
|
||||
@@ -443,6 +443,50 @@ function createRequiredWarningsPosFixture() {
|
||||
});
|
||||
}
|
||||
|
||||
function createBookingPoDefaultPosFixture() {
|
||||
const baseFixture = createPosFixture();
|
||||
const customerNumber = 12345679;
|
||||
const bookingId = 8891;
|
||||
|
||||
return createPosFixture({
|
||||
preloadCreatedOrderItemsFromBooking: true,
|
||||
orderBookingListStripsDetails: true,
|
||||
orderBookingListStripsMetadata: true,
|
||||
customerAttributesByNumber: {
|
||||
[customerNumber]: [
|
||||
...(baseFixture.customerAttributesByNumber[customerNumber] || []),
|
||||
{
|
||||
id: 88,
|
||||
customer_number: customerNumber,
|
||||
attribute: "usePONumbers",
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBookings: [
|
||||
{
|
||||
id: bookingId,
|
||||
customer_number: customerNumber,
|
||||
customer_name: "(TEST) Pleno Vognmandsforretning",
|
||||
department: 12,
|
||||
datetime: "2026-05-21 09:30:00",
|
||||
date: "2026-05-21",
|
||||
reg_1: "BOOKPO1",
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
reference: "BOOKING-PO-REF",
|
||||
reference_number: "BOOKING-PO-REF",
|
||||
notes: "Booking PO note",
|
||||
note: "Booking PO note",
|
||||
po: "BOOKING-PO-DEFAULT",
|
||||
pickup: false,
|
||||
items: [{ id: 53, quantity: 1, price: 649 }],
|
||||
order_id: null,
|
||||
created_at: "2026-05-20 08:00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createReferenceAutocompletePosFixture() {
|
||||
const baseFixture = createPosFixture();
|
||||
const customerNumber = 12345679;
|
||||
@@ -2304,6 +2348,47 @@ test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership"
|
||||
await createOrderRequest;
|
||||
});
|
||||
|
||||
test("applies booking PO defaults on desktop even when linked booking items are already loaded", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createBookingPoDefaultPosFixture(),
|
||||
});
|
||||
await primeOperatorSession(page, "pos-orders-booking-po-default-token");
|
||||
|
||||
await page.goto(POS_BOOT_URL);
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
|
||||
await page.locator("#reg_1").fill("BOOKPO1");
|
||||
await expect(page.locator(".pos-selected-customer__title")).toContainText("(TEST) Pleno Vognmandsforretning");
|
||||
|
||||
const createOrderRequest = waitForOrderMutation(
|
||||
page,
|
||||
"POST",
|
||||
"/orders",
|
||||
(body) => Number(body.booking_id) === 8891 && Number(body.customer_id) === 12345679
|
||||
);
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
const capturedCreateOrderRequest = await createOrderRequest;
|
||||
expect(capturedCreateOrderRequest.postDataJSON()).toMatchObject({
|
||||
booking_id: 8891,
|
||||
po: "",
|
||||
});
|
||||
|
||||
const stepTwo = page.getByTestId("pos-step-2");
|
||||
await expect(stepTwo).toBeVisible();
|
||||
await expect(stepTwo.getByTestId("pos-order-registration-1")).toContainText("BOOKPO1");
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference")).toContainText("BOOKING-PO-REF");
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po")).toContainText("BOOKING-PO-DEFAULT");
|
||||
await expect(stepTwo.getByTestId("pos-order-metadata-note")).toContainText("Booking PO note");
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-control")).not.toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"warning"
|
||||
);
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-warning-icon")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("preserves a manually selected required-reference customer and manual reference while editing reg_1", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const PING_URL = /https:\/\/api\.truckwash\.io:4433\/ping(?:\?.*)?$/;
|
||||
const PING_URL =
|
||||
/(?:https:\/\/api\.truckwash\.io:4433|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
|
||||
const CONNECTIVITY_PAGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function installPingFailure(page, onPing) {
|
||||
return page.route(PING_URL, async (route) => {
|
||||
@@ -22,7 +24,7 @@ test.describe("Connectivity issue", () => {
|
||||
|
||||
await page.goto("/connectivity-issue");
|
||||
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
|
||||
await expect(page.getByTestId("connectivity-icon")).toBeVisible();
|
||||
await expect(page.getByTestId("connectivity-title")).toHaveText("Forbindelsesproblem");
|
||||
await expect(page.getByTestId("connectivity-subtitle")).toContainText("internetforbindelse");
|
||||
@@ -42,7 +44,7 @@ test.describe("Connectivity issue", () => {
|
||||
});
|
||||
|
||||
await page.goto("/connectivity-issue");
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
|
||||
await expect.poll(() => pingCount).toBeGreaterThan(0);
|
||||
|
||||
const initialPingCount = pingCount;
|
||||
|
||||
@@ -18,6 +18,12 @@ function json(body, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers":
|
||||
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
@@ -198,6 +204,10 @@ function createCoolifyState() {
|
||||
removedHosts: [],
|
||||
reconciledTargets: [],
|
||||
lbReconciles: [],
|
||||
routeDeploys: [],
|
||||
apiCodeDeploys: [],
|
||||
routePlans: [],
|
||||
apiCodePlans: [],
|
||||
placement: {
|
||||
generated_at: "2026-05-18T08:00:00.000Z",
|
||||
servers: [
|
||||
@@ -360,6 +370,117 @@ async function installCoolifyMocks(page, state) {
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/\/superuser\/coolify\/load-balancer\/routes\/deploy$/, async (route) => {
|
||||
const payload = route.request().postDataJSON?.() || {};
|
||||
const dryRun = payload.dry_run !== false;
|
||||
state.routeDeploys.push({ dry_run: dryRun });
|
||||
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
|
||||
const planned = [
|
||||
{
|
||||
type: "deploy_gateway_route",
|
||||
target_id: 13,
|
||||
channel_slug: "internal",
|
||||
app: "api",
|
||||
resource_uuid: "api-application-uuid",
|
||||
resource_type: "application",
|
||||
public_url: `${gatewayBaseUrl}/internal/api`,
|
||||
frontend_public_url: `${gatewayBaseUrl}/internal/frontend`,
|
||||
target_ip: "65.21.214.30",
|
||||
},
|
||||
];
|
||||
state.routePlans.push(planned);
|
||||
const warnings = [
|
||||
"No managed Coolify API application route was found for gateway targets: 94.130.142.41, 23.88.23.183.",
|
||||
];
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
ok: true,
|
||||
dry_run: dryRun,
|
||||
mutated: !dryRun,
|
||||
public_host: state.loadBalancer.config.public_gateway_host,
|
||||
public_url: `${gatewayBaseUrl}/internal/api`,
|
||||
planned,
|
||||
applied: dryRun ? [] : planned,
|
||||
skipped: [],
|
||||
errors: [],
|
||||
warnings,
|
||||
coverage: {
|
||||
enabled_gateway_ips: ["94.130.142.41", "65.21.214.30", "23.88.23.183"],
|
||||
covered_target_ips: ["65.21.214.30"],
|
||||
uncovered_gateway_ips: ["94.130.142.41", "23.88.23.183"],
|
||||
},
|
||||
gateways: state.loadBalancer.gateways,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/\/superuser\/coolify\/load-balancer\/api\/deploy$/, async (route) => {
|
||||
const payload = route.request().postDataJSON?.() || {};
|
||||
const dryRun = payload.dry_run !== false;
|
||||
const deployRoutes = payload.deploy_routes !== false;
|
||||
state.apiCodeDeploys.push({ dry_run: dryRun, deploy_routes: deployRoutes });
|
||||
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
|
||||
const planned = [
|
||||
{
|
||||
type: "deploy_gateway_api_code",
|
||||
target_id: 13,
|
||||
channel_slug: "internal",
|
||||
app: "api",
|
||||
repository: "copenhagentruckwash/api",
|
||||
branch: "master",
|
||||
resource_uuid: "api-application-uuid",
|
||||
public_url: `${gatewayBaseUrl}/internal/api`,
|
||||
commit_mode: "latest",
|
||||
},
|
||||
];
|
||||
state.apiCodePlans.push(planned);
|
||||
const routeDeploy = deployRoutes
|
||||
? {
|
||||
ok: true,
|
||||
dry_run: false,
|
||||
mutated: true,
|
||||
planned,
|
||||
applied: planned,
|
||||
skipped: [],
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}
|
||||
: null;
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
ok: true,
|
||||
dry_run: dryRun,
|
||||
mutated: !dryRun,
|
||||
deploy_routes: deployRoutes,
|
||||
public_host: state.loadBalancer.config.public_gateway_host,
|
||||
public_url: `${gatewayBaseUrl}/internal/api`,
|
||||
planned,
|
||||
applied: dryRun ? [] : planned,
|
||||
skipped: [],
|
||||
errors: [],
|
||||
warnings: [],
|
||||
deployment_wait: {
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
deployment_uuid: "deployment-uuid",
|
||||
status: "finished_or_not_running",
|
||||
},
|
||||
],
|
||||
pending: [],
|
||||
},
|
||||
route_deploy: routeDeploy,
|
||||
gateways: state.loadBalancer.gateways,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/\/superuser\/coolify\/gateways$/, async (route) => {
|
||||
await route.fulfill(json({ data: state.loadBalancer.gateways }));
|
||||
});
|
||||
@@ -659,6 +780,42 @@ test.describe("Coolify infrastructure management", () => {
|
||||
expect(state.lbReconciles.at(-1)).toEqual({ dry_run: false });
|
||||
await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("ok");
|
||||
await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("No planned changes.");
|
||||
await page.getByRole("button", { name: "Dry-run route deploy" }).click();
|
||||
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: true });
|
||||
expect(state.routePlans.at(-1)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
public_url: "https://api-v2.truckwash.io/internal/api",
|
||||
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
|
||||
}),
|
||||
])
|
||||
);
|
||||
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route plan");
|
||||
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Warnings 1");
|
||||
await page.getByRole("button", { name: "Deploy api-v2 route" }).click();
|
||||
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: false });
|
||||
expect(state.routePlans.at(-1)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
public_url: "https://api-v2.truckwash.io/internal/api",
|
||||
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
|
||||
}),
|
||||
])
|
||||
);
|
||||
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route deploy");
|
||||
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Applied 1");
|
||||
await page.getByRole("button", { name: "Deploy latest API code" }).click();
|
||||
expect(state.apiCodeDeploys.at(-1)).toEqual({ dry_run: false, deploy_routes: true });
|
||||
expect(state.apiCodePlans.at(-1)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
public_url: "https://api-v2.truckwash.io/internal/api",
|
||||
}),
|
||||
])
|
||||
);
|
||||
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("API code deploy");
|
||||
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Applied 1");
|
||||
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Route applied 1");
|
||||
await expect(page.getByTestId("coolify-instances-table")).toContainText("Production Coolify");
|
||||
await expect(page.getByTestId("coolify-targets-table")).toContainText("failover_ready");
|
||||
|
||||
@@ -692,7 +849,7 @@ test.describe("Coolify infrastructure management", () => {
|
||||
const state = await boot(page);
|
||||
|
||||
await page.goto("/superuser/system/replication");
|
||||
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await createDatabaseReplica(page);
|
||||
await createRedisReplica(page);
|
||||
@@ -782,7 +939,7 @@ test.describe("Coolify infrastructure management", () => {
|
||||
await boot(page, state);
|
||||
await page.goto("/superuser/system/replication");
|
||||
|
||||
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
|
||||
await expect.poll(() => state.provisionRequests.includes(2)).toBeTruthy();
|
||||
await expect(page.getByTestId("replication-host-database-2")).toContainText(
|
||||
"Coolify: provisioned / failover_ready"
|
||||
@@ -848,6 +1005,7 @@ test.describe("Coolify infrastructure management", () => {
|
||||
await boot(page, state);
|
||||
await page.goto("/superuser/system/replication");
|
||||
|
||||
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
|
||||
const row = page.getByTestId("replication-host-minio-22");
|
||||
await expect(row).toContainText("Coolify: deploying / failover_blocked");
|
||||
await expect(row.getByTestId("replication-host-progress")).toBeVisible();
|
||||
|
||||
@@ -1201,9 +1201,14 @@ test.describe("POS flow", () => {
|
||||
{
|
||||
...fixture.vehicles[0],
|
||||
reg: "AB12345",
|
||||
reference: "",
|
||||
last_order_id: 9201,
|
||||
},
|
||||
];
|
||||
fixture.ordersById[9201] = {
|
||||
...fixture.ordersById[9201],
|
||||
reference: "LAST-WASH-REF-9201",
|
||||
};
|
||||
fixture.orderItemsByOrderId[9201] = [
|
||||
{
|
||||
id: 92011,
|
||||
@@ -1251,6 +1256,7 @@ test.describe("POS flow", () => {
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect.poll(() => (fixture.orderItemsByOrderId[9300] || []).length, { timeout: 10_000 }).toBe(3);
|
||||
expect(fixture.ordersById[9300].department_id).toBe(1);
|
||||
expect(fixture.ordersById[9300].reference).toBe("LAST-WASH-REF-9201");
|
||||
|
||||
const copiedItems = fixture.orderItemsByOrderId[9300] || [];
|
||||
const primaryItem = copiedItems.find((item) => Number(item.product_id) === 53);
|
||||
|
||||
@@ -194,6 +194,29 @@ async function getHorizontalBounds(locator) {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectVehicleEmptyStateMatchesLastWashContentHeight(page) {
|
||||
const vehicleEmptyState = page.getByTestId("pos-desktop-vehicle-empty-state");
|
||||
const lastWashList = page.getByTestId("pos-desktop-last-wash-section").locator(".pos-step-one-insight-list");
|
||||
const lastWashCopyButton = page.getByTestId("pos-desktop-last-wash-copy");
|
||||
|
||||
await expect(vehicleEmptyState).toBeVisible();
|
||||
await expect(lastWashList).toBeVisible();
|
||||
await expect(lastWashCopyButton).toBeVisible();
|
||||
|
||||
const [emptyStateBox, lastWashListBox, lastWashCopyButtonBox] = await Promise.all([
|
||||
vehicleEmptyState.boundingBox(),
|
||||
lastWashList.boundingBox(),
|
||||
lastWashCopyButton.boundingBox(),
|
||||
]);
|
||||
|
||||
expect(emptyStateBox).not.toBeNull();
|
||||
expect(lastWashListBox).not.toBeNull();
|
||||
expect(lastWashCopyButtonBox).not.toBeNull();
|
||||
|
||||
const lastWashContentHeight = lastWashCopyButtonBox.y + lastWashCopyButtonBox.height - lastWashListBox.y;
|
||||
expect(Math.abs(emptyStateBox.height - lastWashContentHeight)).toBeLessThanOrEqual(3);
|
||||
}
|
||||
|
||||
async function expectPrimaryActionAboveClearAll(primaryAction, clearAllAction) {
|
||||
const primaryBounds = await getHorizontalBounds(primaryAction);
|
||||
const clearAllBounds = await getHorizontalBounds(clearAllAction);
|
||||
@@ -425,6 +448,45 @@ test.describe("POS visuals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop step 1 stretches vehicle empty state beside last wash content", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-desktop", "Layout assertion is covered on chromium desktop.");
|
||||
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
|
||||
const posFixture = createPosFixture();
|
||||
posFixture.vehicles = posFixture.vehicles.map((vehicle) =>
|
||||
vehicle.reg === "EC21235"
|
||||
? {
|
||||
...vehicle,
|
||||
type: null,
|
||||
wash_subscription: false,
|
||||
addons: { enabled: 0, available: 0, list: [] },
|
||||
last_order_id: 54518,
|
||||
}
|
||||
: vehicle
|
||||
);
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: posFixture,
|
||||
});
|
||||
await primeSession(page, "pos-visual-desktop-empty-state-height-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
const stepOne = page.getByTestId("pos-step-1");
|
||||
await expect(stepOne).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await page.locator("#reg_1").fill("EC21235");
|
||||
|
||||
await expect(page.getByTestId("pos-desktop-vehicle-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-desktop-last-wash")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-desktop-vehicle-empty-state")).toContainText(
|
||||
"Ingen abonnement- eller tilvalgsdata"
|
||||
);
|
||||
await expectVehicleEmptyStateMatchesLastWashContentHeight(page);
|
||||
});
|
||||
|
||||
test("desktop step 1 required reference warning snapshot", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-desktop", "Covered on chromium desktop.");
|
||||
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+1845
-131
File diff suppressed because it is too large
Load Diff
@@ -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>');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user