Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration.

This commit is contained in:
Jeppe Bundgaard
2026-05-26 14:14:13 +02:00
parent a952dd2c61
commit 8ab8915429
77 changed files with 8353 additions and 1143 deletions
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+139
View File
@@ -0,0 +1,139 @@
name: Frontend Release
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
jobs:
build-upload-and-verify:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 30
RELEASE_WAIT_TIMEOUT_SECONDS: 300
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks
run: |
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
run: npm run build
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Production Playwright gate
run: npm run test:e2e:prod
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 14
- name: Install lftp
run: |
if ! command -v lftp >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y lftp
fi
- name: Upload hashed assets before release metadata
run: |
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
npm run release:upload:lftp
env:
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
- name: Wait for exact uploaded build
run: npm run release:verify-upload
- name: Public live Playwright gate
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
- name: Update server version after verification
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.sha }}
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 14
Vendored
+1
View File
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
readonly VITE_APP_VERSION: string, readonly VITE_APP_VERSION: string,
readonly VITE_COMMIT_HASH: string, readonly VITE_COMMIT_HASH: string,
readonly VITE_IS_DEV: string, readonly VITE_IS_DEV: string,
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv
+4 -5
View File
@@ -2,11 +2,10 @@
<html lang="" class="theme-light" data-theme="light"> <html lang="" class="theme-light" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/favicons/favicon.ico" /> <link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" /> <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="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"> <meta name="apple-mobile-web-app-capable" content="yes">
+24 -7
View File
@@ -5,14 +5,27 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location = /release-entry.json { location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
try_files $uri =404; try_files $uri =404;
} }
location = /internal/frontend/release-entry.json { location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
try_files /release-entry.json =404; 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/ { location /assets/ {
@@ -20,14 +33,18 @@ server {
try_files $uri =404; try_files $uri =404;
} }
location ^~ /internal/frontend/assets/ { location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
rewrite ^/internal/frontend/(.*)$ /$1 break; rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404; try_files $uri =404;
} }
location ^~ /internal/frontend/ { location ~ ^/(master|beta|canary|internal)/frontend$ {
rewrite ^/internal/frontend/(.*)$ /$1 break; 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; try_files $uri $uri/ /index.html;
} }
+5
View File
@@ -42,7 +42,12 @@
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile", "test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts", "test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:live": "playwright test --config=playwright.live.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", "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: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", "test:all": "npm run test:unit && npm run test:e2e:pr",
"twa:build": "bubblewrap build", "twa:build": "bubblewrap build",
+42 -2
View File
@@ -4,10 +4,50 @@
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L] RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d 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> </IfModule>
+2 -2
View File
@@ -4,13 +4,13 @@
"description": "Access your Truck Wash accounts and transactions from anywhere.", "description": "Access your Truck Wash accounts and transactions from anywhere.",
"icons": [ "icons": [
{ {
"src": "/favicons/web-app-manifest-192x192.png", "src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/favicons/web-app-manifest-512x512.png", "src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
+50
View File
@@ -0,0 +1,50 @@
import { execSync } from "node:child_process";
function gitCommit() {
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch {
return "";
}
}
async function main() {
const token = process.env.SERVER_UPDATE_TOKEN;
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
if (!token) {
if (required) {
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
}
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
return;
}
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
if (!version) {
throw new Error("Could not determine release version for server update.");
}
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
const url = new URL(baseUrl);
url.searchParams.set("version", version);
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
},
});
const body = await response.text();
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
}
console.log(`Server version updated to ${version}.`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
DIST_DIR="${DIST_DIR:-dist}"
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
HOST="${RELEASE_DEPLOY_HOST:-}"
USER_NAME="${RELEASE_DEPLOY_USER:-}"
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
if [[ ! -d "$DIST_DIR" ]]; then
echo "Missing dist directory: $DIST_DIR" >&2
exit 1
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required for FTP/SFTP uploads." >&2
exit 1
fi
if [[ -z "$DEPLOY_URL" ]]; then
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
fi
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
fi
done
find "$DIST_DIR" -maxdepth 1 -type f \
! -name 'index.html' \
! -name 'release-entry.json' \
! -name 'release-manifest.json' \
-print0 | while IFS= read -r -d '' file; do
upload_file "$file" "$(basename "$file")"
done
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
upload_file "$DIST_DIR/index.html" "index.html"
+215
View File
@@ -0,0 +1,215 @@
import crypto from "node:crypto";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function numberEnv(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) {
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
}
return value.endsWith("/") ? value : `${value}/`;
}
function pathUrl(baseUrl, assetPath) {
const path = String(assetPath || "").replace(/^\/+/, "");
return new URL(path, baseUrl).href;
}
async function fetchBytes(url) {
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
bytes,
text: () => bytes.toString("utf8"),
};
}
async function fetchJson(baseUrl, assetPath) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
try {
return JSON.parse(result.text());
} catch (error) {
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
}
}
function compareCommit(actual, expected) {
if (!expected) {
return true;
}
return actual === expected || actual.startsWith(expected);
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean)));
}
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function shouldRejectHtml(assetPath) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
async function verifyAsset(baseUrl, assetPath, expectedHash) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML (${contentType})`);
}
if (expectedHash?.sha256) {
const actualHash = sha256(result.bytes);
if (actualHash !== expectedHash.sha256) {
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
}
}
}
async function verifyShell(baseUrl, shellPath) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
const contentType = result.response.headers.get("content-type") || "";
const body = result.text();
if (!result.response.ok) {
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
}
if (!contentType.includes("text/html")) {
throw new Error(`${shellPath} did not return HTML (${contentType})`);
}
if (body.replace(/\s+/g, "").length < 40) {
throw new Error(`${shellPath} returned an empty app shell`);
}
if (!body.includes('<div id="app"></div>')) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
if (!manifest.build_id) {
throw new Error("release-manifest.json is missing build_id");
}
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
}
if (releaseEntry.entry !== manifest.entry) {
throw new Error("release-entry.json entry does not match release-manifest.json");
}
const releaseCss = JSON.stringify(releaseEntry.css || []);
const manifestCss = JSON.stringify(manifest.css || []);
if (releaseCss !== manifestCss) {
throw new Error("release-entry.json css does not match release-manifest.json");
}
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
for (const shellPath of shellPaths) {
await verifyShell(baseUrl, shellPath);
}
const assetUrls = unique([
"release-manifest.json",
"release-entry.json",
manifest.entry,
...(manifest.css || []),
...(manifest.index_asset_urls || []),
...(manifest.pwa_asset_urls || []),
...(manifest.asset_urls || []),
]);
for (const assetUrl of assetUrls) {
if (assetUrl === "/index.html") {
continue;
}
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
}
return {
build_id: manifest.build_id,
commit_sha: manifest.commit_sha,
assets: assetUrls.length,
};
}
async function main() {
const baseUrl = requiredUrl();
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
if (initialWaitSeconds > 0) {
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
await sleep(initialWaitSeconds * 1000);
}
const deadline = Date.now() + timeoutSeconds * 1000;
let attempt = 0;
let lastError = null;
while (Date.now() <= deadline) {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
);
return;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
if (Date.now() > deadline) {
break;
}
await sleep(pollIntervalSeconds * 1000);
}
}
throw lastError || new Error("Release upload verification timed out.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
@@ -6,8 +6,11 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useSelfServeLogic } from "@/composables/useSelfServeLogic"; import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue"; import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue"; import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js"; import {
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js"; buildSelfServeDynamicImageUrl,
getSelfServeDynamicImageButtonsToPress,
getSelfServeDynamicImageThumbPosition,
} from "@/services/selfServeDynamicImage.js";
const { t: $t } = useI18n(); const { t: $t } = useI18n();
@@ -138,36 +141,9 @@ const canClearAnswers = computed(() => (
)); ));
const hideDynamicImage = ref(false); const hideDynamicImage = ref(false);
const parseNonNegativeInt = (value) => { const dynamicImageButtons = computed(() => getSelfServeDynamicImageButtonsToPress(activeTasks.value));
const parsed = parseInt(value);
if (Number.isNaN(parsed) || parsed < 0) {
return null;
}
return parsed;
};
const dynamicImageButtons = computed(() => { const dynamicImageThumbPosition = computed(() => getSelfServeDynamicImageThumbPosition(activeTasks.value));
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 hasDynamicImageContext = computed(() => ( const hasDynamicImageContext = computed(() => (
dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null
@@ -180,23 +156,15 @@ const dynamicImageUrl = computed(() => {
return null; 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); const selectedVehicleType = parseInt(selectedVehicleTypeId.value);
if (!Number.isNaN(selectedVehicleType) && selectedVehicleType > 0) { return buildSelfServeDynamicImageUrl({
params.set("vehicle_type", String(selectedVehicleType)); departmentId,
} laneId,
buttons: dynamicImageButtons.value,
if (dynamicImageThumbPosition.value !== null) { currentStep: 0,
params.set("thumb_position", String(dynamicImageThumbPosition.value)); vehicleTypeId: !Number.isNaN(selectedVehicleType) && selectedVehicleType > 0 ? selectedVehicleType : null,
} thumbPosition: dynamicImageThumbPosition.value,
});
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
}); });
const displayedDynamicImageUrl = computed(() => ( const displayedDynamicImageUrl = computed(() => (
@@ -48,15 +48,32 @@ const getNonImageAttachments = (task: any) => {
return task.attachments.filter((attachment: any) => !isImageAttachment(attachment)); 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) => { const getVisibleServices = (task: any) => {
if (!Array.isArray(task?.services)) { if (!Array.isArray(task?.services)) {
return []; 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) => { const getProgramWheelSelection = (task: any) => {
if (!taskUsesProgramPicker(task)) {
return null;
}
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType; const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
if (rawValue === null || rawValue === undefined || rawValue === "") { if (rawValue === null || rawValue === undefined || rawValue === "") {
return null; return null;
@@ -1,8 +1,9 @@
<script setup lang="ts"> <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"; import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
defineProps<{ const props = defineProps<{
isLoading: boolean; isLoading: boolean;
dynamicImageUrl: string | null; dynamicImageUrl: string | null;
activeTasks: Array<any>; activeTasks: Array<any>;
@@ -17,6 +18,12 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void; (e: "clear-dynamic-image"): void;
}>(); }>();
const isDynamicImageLoading = ref(false);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => { const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value); emit("toggle-task", taskId, value);
}; };
@@ -24,6 +31,15 @@ const emitToggleTask = (taskId: number, value: boolean) => {
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => { const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
emit("download-attachment", taskId, attachmentId, attachment); emit("download-attachment", taskId, attachmentId, attachment);
}; };
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
emit("clear-dynamic-image");
};
</script> </script>
<template> <template>
@@ -42,13 +58,27 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span> <span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div> </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 <img
:key="dynamicImageUrl"
:src="dynamicImageUrl" :src="dynamicImageUrl"
alt="Machine status" alt="Machine status"
data-testid="self-serve-dynamic-image" data-testid="self-serve-dynamic-image"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;" class="self-serve-dynamic-image"
@error="emit('clear-dynamic-image')" :class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/> />
</div> </div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4"> <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>
</div> </div>
</template> </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>
@@ -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,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>
@@ -97,6 +97,10 @@ const detailErrorReports = computed(() =>
Array.isArray(selectedDetail.value?.error_reports) ? selectedDetail.value.error_reports : [] Array.isArray(selectedDetail.value?.error_reports) ? selectedDetail.value.error_reports : []
); );
const selectedRelease = computed(() => selectedDetail.value?.release || selectedSession.value?.release || {}); 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 filterPayload = () => {
const payload = {}; const payload = {};
@@ -382,6 +386,17 @@ onMounted(loadReplayData);
</div> </div>
</form> </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"> <div class="table-container mt-3">
<table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions"> <table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions">
<thead> <thead>
@@ -397,37 +412,47 @@ onMounted(loadReplayData);
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="session in sessionRows" :key="session.trace_id"> <tr v-if="sessionRowsLoading" data-testid="release-timeline-sessions-loading">
<td><code>{{ shortValue(session.trace_id) }}</code></td> <td colspan="8">
<td>{{ userLabel(session) }}</td> <div class="release-replay-inspector__loading" role="status" aria-live="polite">
<td>{{ deviceLabel(session) }}</td> <i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<td>{{ session.channel_slug || "--" }}</td> <span>{{ trFallback("loading.timeline_sessions", "Loading release timeline sessions...") }}</span>
<td>{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }}</td> </div>
<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> </td>
</tr> </tr>
<tr v-if="sessionRows.length === 0"> <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> <td colspan="8">{{ tr("replay.no_timeline_events") }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -559,7 +584,17 @@ onMounted(loadReplayData);
</div> </div>
</Teleport> </Teleport>
<ol v-if="!selectedDetail" class="release-timeline" data-testid="release-timeline-events"> <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"> <li v-for="event in timelineEvents" :key="event.id">
<time>{{ formatDate(event.occurred_at) }}</time> <time>{{ formatDate(event.occurred_at) }}</time>
<strong>{{ event.event_type }}</strong> <strong>{{ event.event_type }}</strong>
@@ -585,6 +620,27 @@ onMounted(loadReplayData);
gap: 1rem; 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 { .release-session-table td {
vertical-align: middle; vertical-align: middle;
} }
@@ -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,5 +1,6 @@
export const SELF_SERVE_TASK_BUTTON_RESET = "reset"; export const SELF_SERVE_TASK_BUTTON_RESET = "reset";
export const SELF_SERVE_TASK_BUTTON_START = "start"; 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 = [ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
{ {
@@ -7,6 +8,11 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
name: "Reset", name: "Reset",
description: "Reset button", description: "Reset button",
}, },
{
id: SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
name: "Program picker",
description: "Program picker wheel",
},
...Array.from({ length: 12 }, (_, index) => ({ ...Array.from({ length: 12 }, (_, index) => ({
id: index, id: index,
name: `Program ${index + 1}`, name: `Program ${index + 1}`,
@@ -22,6 +28,7 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
const KNOWN_SPECIAL_BUTTONS = new Set([ const KNOWN_SPECIAL_BUTTONS = new Set([
SELF_SERVE_TASK_BUTTON_RESET, SELF_SERVE_TASK_BUTTON_RESET,
SELF_SERVE_TASK_BUTTON_START, SELF_SERVE_TASK_BUTTON_START,
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
]); ]);
const BUTTON_LABELS = new Map( const BUTTON_LABELS = new Map(
+17 -41
View File
@@ -1,6 +1,11 @@
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js"; import {
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js"; buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
getSelfServeDynamicImageButtonsToPress,
getSelfServeDynamicImageThumbPosition,
getSelfServeTaskDynamicImageButtons,
} from "@/services/selfServeDynamicImage.js";
export function useWashSessionActions(options) { export function useWashSessionActions(options) {
const { const {
@@ -53,21 +58,12 @@ export function useWashSessionActions(options) {
return message || fallback; return message || fallback;
}; };
const getTaskButtons = (task) => normalizeSelfServeTaskButtons( const getTaskButtons = (task) => getSelfServeTaskDynamicImageButtons(task);
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
const getButtonsToPress = () => activeTasks.value.flatMap((task) => getTaskButtons(task)); const getButtonsToPress = () => getSelfServeDynamicImageButtonsToPress(activeTasks.value);
const getCompletedButtons = () => { const getCompletedButtons = () => {
let totalButtonsCompleted = 0; const totalButtonsCompleted = getSelfServeCompletedDynamicImageStep(activeTasks.value, completedTasks.value);
activeTasks.value.forEach((task) => {
if (completedTasks.value[task.id]) {
totalButtonsCompleted += getTaskButtons(task).length;
}
});
machineStartCurrentStep.value = totalButtonsCompleted; machineStartCurrentStep.value = totalButtonsCompleted;
return totalButtonsCompleted; return totalButtonsCompleted;
}; };
@@ -85,18 +81,6 @@ export function useWashSessionActions(options) {
clearProgress(); 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 dynamicImageUrl = computed(() => {
const departmentId = nearestDepartment.value?.id; const departmentId = nearestDepartment.value?.id;
const laneId = washLaneId.value; const laneId = washLaneId.value;
@@ -106,22 +90,14 @@ export function useWashSessionActions(options) {
return null; return null;
} }
const params = new URLSearchParams({ return buildSelfServeDynamicImageUrl({
department: String(departmentId), departmentId,
lane: String(laneId), laneId,
current_step: String(getCompletedButtons()), buttons: getButtonsToPress(),
buttons: JSON.stringify(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 resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
}); });
const executeSelfServeCommand = async ( const executeSelfServeCommand = async (
+19 -4
View File
@@ -16,22 +16,36 @@ const parseBoolean = (value, fallback = false) => {
const normalizeApiUrl = (value) => String(value || "").replace(/\/+$/, ""); 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 // Development mode
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1); export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
export const API_URL = normalizeApiUrl( export const API_URL = normalizeApiUrl(
import.meta.env.VITE_API_URL || (IS_DEV ? "/api" : "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( export const RELEASE_MANAGER_CONTROL_API_URL = normalizeApiUrl(
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL
); );
export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl( export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl(
import.meta.env.VITE_RELEASE_PUBLIC_GATEWAY_API_URL || "https://api-v2.truckwash.io" import.meta.env.VITE_RELEASE_PUBLIC_GATEWAY_API_URL || DEFAULT_PUBLIC_GATEWAY_API_URL
); );
export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String( export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS || import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS ||
(IS_DEV ? "https://api-v2.truckwash.io,https://api.truckwash.io" : "") (IS_DEV ? `${DEFAULT_STABLE_API_URL},${DEFAULT_PUBLIC_GATEWAY_API_URL}` : "")
) )
.split(",") .split(",")
.map((url) => normalizeApiUrl(url.trim())) .map((url) => normalizeApiUrl(url.trim()))
@@ -47,6 +61,7 @@ export const ALLOWED_ORIGINS = [
"http://127.0.0.1:5173", "http://127.0.0.1:5173",
"http://127.0.0.1:4173", "http://127.0.0.1:4173",
"http://127.0.0.1:4174", "http://127.0.0.1:4174",
"https://dev.truckwash.io",
]; ];
export const MIGRATION_ORIGIN = "https://truckwash.io"; export const MIGRATION_ORIGIN = "https://truckwash.io";
+17
View File
@@ -2494,6 +2494,23 @@
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger." "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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- og Coolify-mål", "targets": "GitHub- og Coolify-mål",
+17
View File
@@ -2604,6 +2604,23 @@
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren." "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": { "setup": {
"channels": "Kanäle", "channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele", "targets": "GitHub- und Coolify-Ziele",
+17
View File
@@ -2328,6 +2328,23 @@
"description": "Configure Release Manager GitHub access and webhook settings." "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": { "setup": {
"channels": "Channels", "channels": "Channels",
"targets": "GitHub and Coolify targets", "targets": "GitHub and Coolify targets",
+17
View File
@@ -2605,6 +2605,23 @@
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger." "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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- og Coolify-mål", "targets": "GitHub- og Coolify-mål",
+17
View File
@@ -2655,6 +2655,23 @@
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- och Coolify-mål", "targets": "GitHub- och Coolify-mål",
+17
View File
@@ -1552,6 +1552,23 @@
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger." "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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- og Coolify-mål", "targets": "GitHub- og Coolify-mål",
+17
View File
@@ -1552,6 +1552,23 @@
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren." "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": { "setup": {
"channels": "Kanäle", "channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele", "targets": "GitHub- und Coolify-Ziele",
+17
View File
@@ -1552,6 +1552,23 @@
"description": "Configure Release Manager GitHub access and webhook settings." "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": { "setup": {
"channels": "Channels", "channels": "Channels",
"targets": "GitHub and Coolify targets", "targets": "GitHub and Coolify targets",
+17
View File
@@ -1552,6 +1552,23 @@
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger." "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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- og Coolify-mål", "targets": "GitHub- og Coolify-mål",
+17
View File
@@ -1552,6 +1552,23 @@
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- och Coolify-mål", "targets": "GitHub- och Coolify-mål",
@@ -355,6 +355,23 @@
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger." "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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- og Coolify-mål", "targets": "GitHub- og Coolify-mål",
@@ -355,6 +355,23 @@
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren." "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": { "setup": {
"channels": "Kanäle", "channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele", "targets": "GitHub- und Coolify-Ziele",
@@ -355,6 +355,23 @@
"description": "Configure Release Manager GitHub access and webhook settings." "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": { "setup": {
"channels": "Channels", "channels": "Channels",
"targets": "GitHub and Coolify targets", "targets": "GitHub and Coolify targets",
@@ -355,6 +355,23 @@
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger." "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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- og Coolify-mål", "targets": "GitHub- og Coolify-mål",
@@ -355,6 +355,23 @@
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-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": { "setup": {
"channels": "Kanaler", "channels": "Kanaler",
"targets": "GitHub- och Coolify-mål", "targets": "GitHub- och Coolify-mål",
+9 -4
View File
@@ -98,8 +98,6 @@ const ConfigurationEconomic = lazyView('@/views/dashboards/superUserDashboard/co
const ConfigurationReCAPTCHA = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue'); const ConfigurationReCAPTCHA = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue');
const ConfigurationEmail = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue'); const ConfigurationEmail = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue');
const ConfigurationBackups = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.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 ConfigurationReleaseManager = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue');
const Categories = lazyView('@/views/dashboards/superUserDashboard/Categories.vue'); const Categories = lazyView('@/views/dashboards/superUserDashboard/Categories.vue');
const Customers = lazyView('@/views/dashboards/superUserDashboard/Customers.vue'); const Customers = lazyView('@/views/dashboards/superUserDashboard/Customers.vue');
@@ -1080,19 +1078,26 @@ export const router = createRouter({
{ {
name: 'configurationFailover', name: 'configurationFailover',
path: '/superuser/configuration/failover', path: '/superuser/configuration/failover',
component: ConfigurationFailover, redirect: { path: '/superuser/configuration/releases/data-services', query: { panel: 'failover' } },
meta: { middleware: superUserMiddleware } meta: { middleware: superUserMiddleware }
}, },
{ {
name: 'configurationCoolify', name: 'configurationCoolify',
path: '/superuser/configuration/coolify', path: '/superuser/configuration/coolify',
component: ConfigurationCoolify, redirect: { path: '/superuser/configuration/releases/integrations', query: { panel: 'coolify' } },
meta: { middleware: superUserMiddleware } meta: { middleware: superUserMiddleware }
}, },
{ {
name: 'configurationReleaseManager', name: 'configurationReleaseManager',
path: '/superuser/configuration/releases', 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, component: ConfigurationReleaseManager,
props: (route) => ({ panel: route.params.panel }),
meta: { middleware: superUserMiddleware } meta: { middleware: superUserMiddleware }
}, },
{ {
+111 -9
View File
@@ -1,9 +1,15 @@
import { API_URL, RELEASE_PUBLIC_GATEWAY_API_URL } from "@/config.js"; import { API_URL, IS_DEV, RELEASE_PUBLIC_GATEWAY_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
import { buildReleaseHeaders } from "@/services/releaseHeaders.js"; import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__"; export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug"; 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_ENTRY_FILENAME = "release-entry.json";
export const RELEASE_SOURCE_MODES = Object.freeze({
LOCAL: "local",
DEPLOYMENT: "deployment",
AUTO: "auto",
});
const normalizeReleaseChannelSlug = (value) => const normalizeReleaseChannelSlug = (value) =>
String(value || "") String(value || "")
@@ -16,6 +22,16 @@ const normalizeReleaseChannelSlug = (value) =>
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, ""); 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 = () => { const browserOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) { if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin; return window.location.origin;
@@ -48,18 +64,35 @@ const browserStorage = () => {
const readSelectedReleaseChannel = () => const readSelectedReleaseChannel = () =>
normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || ""); 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 releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => { const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
const slug = normalizeReleaseChannelSlug(channelSlug); const slug = normalizeReleaseChannelSlug(channelSlug);
if (!slug || slug === "stable") { if (!slug) {
return ""; return "";
} }
const routeSlug = slug === "stable" ? "master" : slug;
const baseUrl = normalizeBaseUrl(gatewayBaseUrl); const baseUrl = normalizeBaseUrl(gatewayBaseUrl);
if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) { if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) {
return ""; return "";
} }
return new URL(`${slug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, ""); return new URL(`${routeSlug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
}; };
const buildRuntimeHeaders = () => { const buildRuntimeHeaders = () => {
@@ -135,6 +168,9 @@ const runtimeFrontendBaseUrl = (runtime = {}) => {
const runtimeChannel = (runtime = {}) => runtime?.channel || {}; const runtimeChannel = (runtime = {}) => runtime?.channel || {};
export const shouldLoadRemoteRelease = (runtime = {}) => { export const shouldLoadRemoteRelease = (runtime = {}) => {
if (normalizeReleaseSourceValue(runtime?.source) === RELEASE_SOURCE_MODES.LOCAL) {
return false;
}
const channel = runtimeChannel(runtime); const channel = runtimeChannel(runtime);
const slug = normalizeReleaseChannelSlug(channel?.slug || ""); const slug = normalizeReleaseChannelSlug(channel?.slug || "");
const isDefault = channel?.default_channel === true || channel?.default_channel === 1 || slug === "stable"; const isDefault = channel?.default_channel === true || channel?.default_channel === 1 || slug === "stable";
@@ -195,13 +231,56 @@ const unavailableRuntime = (runtime, missing) => ({
}, },
}); });
const unavailableSelectedRuntime = (missing) => { 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(); const selectedChannel = readSelectedReleaseChannel();
if (!selectedChannel || selectedChannel === "stable") { if (!selectedChannel || selectedChannel === "stable") {
return null; return null;
} }
return unavailableRuntime( return runtimeWithSource(unavailableRuntime(
{ {
channel: { channel: {
slug: selectedChannel, slug: selectedChannel,
@@ -213,7 +292,7 @@ const unavailableSelectedRuntime = (missing) => {
}, },
}, },
missing missing
); ), source, requestedSource);
}; };
export const setReleaseRuntimeGlobal = (runtime) => { export const setReleaseRuntimeGlobal = (runtime) => {
@@ -228,18 +307,36 @@ export const bootstrapReleaseApp = async ({
fetchFn = globalThis.fetch, fetchFn = globalThis.fetch,
importModule, importModule,
documentRef = globalThis.document, documentRef = globalThis.document,
sourceMode,
} = {}) => { } = {}) => {
if (typeof loadLocalApp !== "function") { if (typeof loadLocalApp !== "function") {
throw new Error("Release bootstrap requires a local app loader."); 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; let runtime = null;
try { try {
runtime = await fetchReleaseRuntime({ fetchFn }); runtime = runtimeWithSource(
await fetchReleaseRuntime({ fetchFn }),
RELEASE_SOURCE_MODES.DEPLOYMENT,
resolvedSourceMode
);
setReleaseRuntimeGlobal(runtime); setReleaseRuntimeGlobal(runtime);
} catch (error) { } catch (error) {
console.warn("Could not resolve release runtime before app bootstrap.", error); console.warn("Could not resolve release runtime before app bootstrap.", error);
const unavailable = unavailableSelectedRuntime("release_runtime"); const unavailable =
unavailableSelectedRuntime("release_runtime", {
source: RELEASE_SOURCE_MODES.LOCAL,
requestedSource: resolvedSourceMode,
}) || localReleaseRuntime({ requestedSource: resolvedSourceMode, missing: ["release_runtime"] });
if (unavailable) { if (unavailable) {
setReleaseRuntimeGlobal(unavailable); setReleaseRuntimeGlobal(unavailable);
} }
@@ -253,7 +350,12 @@ export const bootstrapReleaseApp = async ({
return await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef }); return await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef });
} catch (error) { } catch (error) {
console.error("Could not load release channel frontend entry.", error); console.error("Could not load release channel frontend entry.", error);
setReleaseRuntimeGlobal(unavailableRuntime(runtime, "frontend_entry")); setReleaseRuntimeGlobal(
unavailableRuntime(
runtimeWithSource(runtime, RELEASE_SOURCE_MODES.LOCAL, resolvedSourceMode),
"frontend_entry"
)
);
return loadLocalApp(); return loadLocalApp();
} }
}; };
+25 -12
View File
@@ -122,6 +122,15 @@ export const releaseChannelKey = (channel) => {
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
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) => { const hasExplicitReleaseRuntime = (runtime) => {
return ( return (
Boolean(runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false) || Boolean(runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false) ||
@@ -131,15 +140,22 @@ const hasExplicitReleaseRuntime = (runtime) => {
const runtimeAvailability = (runtime, channel) => { const runtimeAvailability = (runtime, channel) => {
if (runtime?.availability && typeof runtime.availability === "object") { if (runtime?.availability && typeof runtime.availability === "object") {
const missing = Array.isArray(runtime.availability.missing) ? runtime.availability.missing : []; const missing = normalizeReadinessMissingValues(runtime.availability.missing);
const configured = let configured = missing.length === 0;
typeof runtime.availability.configured === "boolean" if (!configured) {
? runtime.availability.configured configured =
: missing.length === 0 && runtime.availability.status !== "unconfigured"; 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 { return {
configured, configured,
missing, missing,
status: runtime.availability.status || (configured ? "ready" : "unconfigured"), status,
}; };
} }
@@ -163,9 +179,6 @@ const runtimeAvailability = (runtime, channel) => {
const versions = runtime?.versions || {}; const versions = runtime?.versions || {};
const missing = []; const missing = [];
if (!versions.bundle_id) {
missing.push("release_bundle");
}
if (!versions.frontend) { if (!versions.frontend) {
missing.push("frontend_version"); missing.push("frontend_version");
} else if (!(runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || runtime?.frontendBaseUrl)) { } else if (!(runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || runtime?.frontendBaseUrl)) {
@@ -200,7 +213,7 @@ const normalizeReleaseChannelOption = (entry = {}, runtime = {}) => {
(releaseChannelKey(channel) === releaseChannelKey(runtime?.channel) ? runtime?.versions || {} : {}); (releaseChannelKey(channel) === releaseChannelKey(runtime?.channel) ? runtime?.versions || {} : {});
const availability = const availability =
entry?.availability && typeof entry.availability === "object" entry?.availability && typeof entry.availability === "object"
? entry.availability ? runtimeAvailability({ availability: entry.availability }, channel)
: runtimeAvailability({ channel, versions }, channel); : runtimeAvailability({ channel, versions }, channel);
return { return {
@@ -212,7 +225,7 @@ const normalizeReleaseChannelOption = (entry = {}, runtime = {}) => {
versions, versions,
availability, availability,
configured: availability.configured !== false, configured: availability.configured !== false,
missing: Array.isArray(availability.missing) ? availability.missing : [], missing: normalizeReadinessMissingValues(availability.missing),
status: availability.status || (availability.configured === false ? "unconfigured" : "ready"), status: availability.status || (availability.configured === false ? "unconfigured" : "ready"),
}; };
}; };
@@ -353,7 +366,7 @@ export const markReleaseChannelApiUnavailable = (
} }
const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {}; const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {};
const missing = Array.from(new Set([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey])); const missing = normalizeReadinessMissingValues([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey]);
const nextRuntime = { const nextRuntime = {
trace_id: runtime?.traceId || runtime?.trace_id || null, trace_id: runtime?.traceId || runtime?.trace_id || null,
channel: runtime?.channel || { channel: runtime?.channel || {
+61 -18
View File
@@ -11,6 +11,18 @@ const FRONTEND_FAILURE_EVENT_TYPES = new Set([
"window_error", "window_error",
"unhandled_rejection", "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 = () => { const createTraceId = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -38,6 +50,8 @@ const readTraceId = () => {
}; };
const releaseRuntimeStateMutable = reactive({ const releaseRuntimeStateMutable = reactive({
source: null,
requestedSource: null,
traceId: readTraceId(), traceId: readTraceId(),
channel: null, channel: null,
availableChannels: [], availableChannels: [],
@@ -74,6 +88,15 @@ export const releaseRuntimeState = readonly(releaseRuntimeStateMutable);
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
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 normalizeRuntimeBaseUrl = (value) => {
const raw = String(value || "").trim().replace(/\/+$/, ""); const raw = String(value || "").trim().replace(/\/+$/, "");
if (!raw) { if (!raw) {
@@ -106,6 +129,17 @@ export const configureReleaseRuntime = (runtime = {}) => {
return releaseRuntimeStateMutable; 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) { if (runtime.trace_id) {
releaseRuntimeStateMutable.traceId = String(runtime.trace_id); releaseRuntimeStateMutable.traceId = String(runtime.trace_id);
try { try {
@@ -136,9 +170,6 @@ export const configureReleaseRuntime = (runtime = {}) => {
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable"; channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
const missingReleaseContent = []; const missingReleaseContent = [];
if (!isDefaultChannel && hasOwn(runtime, "versions")) { if (!isDefaultChannel && hasOwn(runtime, "versions")) {
if (!runtime?.versions?.bundle_id) {
missingReleaseContent.push("release_bundle");
}
if (!runtime?.versions?.frontend) { if (!runtime?.versions?.frontend) {
missingReleaseContent.push("frontend_version"); missingReleaseContent.push("frontend_version");
} else if (!urls.frontendBaseUrl) { } else if (!urls.frontendBaseUrl) {
@@ -151,10 +182,24 @@ export const configureReleaseRuntime = (runtime = {}) => {
} }
} }
releaseRuntimeStateMutable.availability = runtime.availability releaseRuntimeStateMutable.availability = runtime.availability
? { ? (() => {
...runtime.availability, const missing = normalizeReadinessMissingValues(runtime.availability.missing);
explicit: true, 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) : hasExplicitReleaseRuntime(runtime)
? { ? {
configured: missingReleaseContent.length === 0, configured: missingReleaseContent.length === 0,
@@ -544,9 +589,7 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {}; const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null; const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {}; const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
const missing = Array.isArray(availability.missing) const missing = normalizeReadinessMissingValues(availability.missing);
? availability.missing.map((value) => String(value || "").trim()).filter(Boolean)
: [];
const missingLookup = new Set(missing); const missingLookup = new Set(missing);
const isDefaultChannel = const isDefaultChannel =
channel?.default_channel === true channel?.default_channel === true
@@ -614,22 +657,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
}; };
} }
const fallbackText = isDefaultChannel const fallbackText = missingKey ? `Missing ${label} service` : defaultSharedLabel;
? defaultSharedLabel const status = missingKey ? "missing" : "shared";
: bundleId
? (serviceSet ? `Missing ${label} service` : "No service set connected")
: "Missing release bundle";
const status = isDefaultChannel ? "shared" : "missing";
return { return {
key, key,
label, label,
status, status,
tone: isDefaultChannel ? "ok" : "warning", tone: missingKey ? "warning" : "ok",
primaryText: fallbackText, primaryText: fallbackText,
secondaryText: "", secondaryText: "",
title: fallbackText, title: fallbackText,
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : fallbackText, missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
}; };
}; };
@@ -679,6 +718,8 @@ export const buildReleaseTimelineContext = () => {
return { return {
trace_id: releaseRuntimeStateMutable.traceId, trace_id: releaseRuntimeStateMutable.traceId,
source: releaseRuntimeStateMutable.source,
requested_source: releaseRuntimeStateMutable.requestedSource,
channel_slug: releaseRuntimeStateMutable.channel?.slug || null, channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
route_path: typeof window !== "undefined" ? window.location.pathname : null, route_path: typeof window !== "undefined" ? window.location.pathname : null,
device: { device: {
@@ -863,6 +904,8 @@ export const __resetReleaseTimelineForTests = () => {
} }
flushTimer = null; flushTimer = null;
customTransport = null; customTransport = null;
releaseRuntimeStateMutable.source = null;
releaseRuntimeStateMutable.requestedSource = null;
releaseRuntimeStateMutable.traceId = "test-trace"; releaseRuntimeStateMutable.traceId = "test-trace";
releaseRuntimeStateMutable.channel = null; releaseRuntimeStateMutable.channel = null;
releaseRuntimeStateMutable.availableChannels = []; releaseRuntimeStateMutable.availableChannels = [];
+113
View File
@@ -0,0 +1,113 @@
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import {
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
const normalizeTaskServices = (task) => (
Array.isArray(task?.services)
? task.services.map((service) => String(service || "").trim().toUpperCase()).filter(Boolean)
: []
);
export const selfServeTaskUsesProgramPicker = (task) => (
normalizeTaskServices(task).includes("PROGRAM_PICKER")
);
export const getSelfServeTaskDynamicImageButtons = (task) => {
const buttons = normalizeSelfServeTaskButtons(
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
if (!selfServeTaskUsesProgramPicker(task)) {
return buttons;
}
return normalizeSelfServeTaskButtons([SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER, ...buttons]);
};
export const getSelfServeDynamicImageButtonsToPress = (tasks = []) => (
Array.isArray(tasks) ? tasks : []
).flatMap((task) => getSelfServeTaskDynamicImageButtons(task));
export const getSelfServeCompletedDynamicImageStep = (tasks = [], completedTasks = {}) => {
let totalButtonsCompleted = 0;
(Array.isArray(tasks) ? tasks : []).forEach((task) => {
if (completedTasks?.[task?.id] || completedTasks?.[task?.task_id]) {
totalButtonsCompleted += getSelfServeTaskDynamicImageButtons(task).length;
}
});
return totalButtonsCompleted;
};
export const parseSelfServeDynamicImageThumbPosition = (value) => {
if (value === null || value === undefined || value === "") {
return null;
}
const thumbPosition = Number.parseInt(String(value), 10);
return Number.isInteger(thumbPosition) && thumbPosition >= 1 && thumbPosition <= 12
? thumbPosition
: null;
};
export const getSelfServeDynamicImageThumbPosition = (tasks = []) => {
for (const task of Array.isArray(tasks) ? tasks : []) {
const thumbPosition = parseSelfServeDynamicImageThumbPosition(
task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType
);
if (thumbPosition !== null) {
return thumbPosition;
}
}
return null;
};
export const buildSelfServeDynamicImageUrl = ({
departmentId,
laneId,
dynamicImageId = null,
buttons = null,
currentStep = 0,
vehicleTypeId = null,
thumbPosition = null,
onlyCurrentStep = false,
}) => {
const normalizedDepartmentId = Number.parseInt(String(departmentId ?? ""), 10);
const normalizedLaneId = Number.parseInt(String(laneId ?? ""), 10);
if (!Number.isInteger(normalizedDepartmentId) || normalizedDepartmentId <= 0 || !Number.isInteger(normalizedLaneId) || normalizedLaneId <= 0) {
return null;
}
const params = new URLSearchParams({
department: String(normalizedDepartmentId),
lane: String(normalizedLaneId),
current_step: String(Math.max(0, Number.parseInt(String(currentStep ?? 0), 10) || 0)),
});
if (dynamicImageId !== null && dynamicImageId !== undefined && dynamicImageId !== "") {
params.set("dynamic_image_id", String(dynamicImageId));
}
if (Array.isArray(buttons)) {
params.set("buttons", JSON.stringify(buttons));
}
if (vehicleTypeId !== null && vehicleTypeId !== undefined && vehicleTypeId !== "") {
params.set("vehicle_type", String(vehicleTypeId));
}
const normalizedThumbPosition = parseSelfServeDynamicImageThumbPosition(thumbPosition);
if (normalizedThumbPosition !== null) {
params.set("thumb_position", String(normalizedThumbPosition));
}
if (onlyCurrentStep) {
params.set("only_current_step", "1");
}
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
};
+55 -11
View File
@@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js"; import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
import { enqueueRequest } from "@/services/requestQueue.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"; 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"; 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 || "") const url = String(value || "")
.trim() .trim()
.replace(/\/+$/, ""); .replace(/\/+$/, "");
if (url.startsWith("/") && !url.startsWith("//")) {
return url || "/";
}
if (!/^https?:\/\//i.test(url)) { if (!/^https?:\/\//i.test(url)) {
return ""; return "";
} }
return url; 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) => { const browserStorage = (name) => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return null; return null;
@@ -47,22 +67,34 @@ const uniqueUrls = (urls) => {
const releaseManagerLocalStorage = () => browserStorage("localStorage"); const releaseManagerLocalStorage = () => browserStorage("localStorage");
const releaseManagerSessionStorage = () => browserStorage("sessionStorage"); const releaseManagerSessionStorage = () => browserStorage("sessionStorage");
export const getReleaseManagerControlApiUrl = () => export const releaseManagerControlApiCandidates = () => {
uniqueUrls([ const runtimeApiBaseUrl = getReleaseRuntimeApiBaseUrl();
releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY), const lastWorkingUrl = releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY);
releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_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, RELEASE_MANAGER_CONTROL_API_URL,
API_URL, API_URL,
])[0] || API_URL; ]).filter(isLocalApiUrl);
export const releaseManagerControlApiCandidates = () => if (localSource && localCandidates.length > 0) {
uniqueUrls([ return localCandidates;
releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY), }
releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY),
RELEASE_MANAGER_CONTROL_API_URL, 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, ...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
API_URL,
]); ]);
};
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
export const setReleaseManagerControlApiUrl = (url) => { export const setReleaseManagerControlApiUrl = (url) => {
const normalized = normalizeApiUrl(url); const normalized = normalizeApiUrl(url);
@@ -170,9 +202,21 @@ export const updateReleaseChannel = (id, payload) =>
export const rollbackReleaseChannel = (id) => export const rollbackReleaseChannel = (id) =>
requestReleaseManager(`/superuser/releases/channels/${id}/rollback`, "POST", {}); requestReleaseManager(`/superuser/releases/channels/${id}/rollback`, "POST", {});
export const syncReleaseChannel = (id) =>
requestReleaseManager(`/superuser/releases/channels/${id}/sync`, "POST", {});
export const setReleaseChannelBundle = (id, payload) => export const setReleaseChannelBundle = (id, payload) =>
requestReleaseManager(`/superuser/releases/channels/${id}/bundle`, "POST", 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 listReleaseAssignments = () => requestReleaseManager("/superuser/releases/assignments", "GET", {});
export const searchReleaseAssignmentSubjects = (params = {}) => export const searchReleaseAssignmentSubjects = (params = {}) =>
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,7 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
import { useWashProgress } from "@/composables/useWashProgress"; import { useWashProgress } from "@/composables/useWashProgress";
import { useWashFlowState } from "@/composables/useWashFlowState"; import { useWashFlowState } from "@/composables/useWashFlowState";
import { useWashSessionActions } from "@/composables/useWashSessionActions"; 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"; import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
type VehicleTypeTemplate = { type VehicleTypeTemplate = {
@@ -199,9 +199,7 @@ const normalizeTaskServices = (task: any) => (
: [] : []
); );
const taskButtonList = (task: any) => normalizeSelfServeTaskButtons( const taskButtonList = (task: any) => getSelfServeTaskDynamicImageButtons(task);
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
const dynamicImageVehicleType = (task: any) => { const dynamicImageVehicleType = (task: any) => {
const value = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType; const value = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
@@ -44,7 +44,6 @@ const availableRuntime = () => ({
versions: { versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" }, frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "feedface" }, api: { version_label: "api-canary", commit_sha: "feedface" },
bundle_id: 31,
}, },
availability: { availability: {
configured: true, configured: true,
@@ -62,7 +61,7 @@ const runtimeWithSelectableReleaseDetails = () => ({
}, },
availability: { availability: {
configured: false, configured: false,
missing: ["release_bundle"], missing: ["api_base_url"],
status: "unconfigured", status: "unconfigured",
}, },
available_channels: [ available_channels: [
@@ -115,7 +114,7 @@ const runtimeWithSelectableReleaseDetails = () => ({
}, },
availability: { availability: {
configured: false, configured: false,
missing: ["release_bundle"], missing: ["api_base_url"],
status: "unconfigured", status: "unconfigured",
}, },
}, },
@@ -181,6 +180,7 @@ async function boot(page, runtime = unavailableRuntime, locale = "en") {
await page.addInitScript((selectedLocale) => { await page.addInitScript((selectedLocale) => {
window.localStorage.setItem("locale", selectedLocale); window.localStorage.setItem("locale", selectedLocale);
window.localStorage.removeItem("release_channel_unavailable_ignore_until"); window.localStorage.removeItem("release_channel_unavailable_ignore_until");
window.localStorage.setItem("release_source_override", "deployment");
}, locale); }, locale);
await mockApi(page, { await mockApi(page, {
@@ -206,7 +206,7 @@ test("users assigned to an unconfigured release channel can ignore the guard tem
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS }); await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(guard).toContainText("Release channel is not ready"); await expect(guard).toContainText("Release channel is not ready");
await expect(guard).toContainText("Canary"); await expect(guard).toContainText("Canary");
await expect(page.getByTestId("release-channel-missing")).toContainText("Release bundle"); 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("Frontend version");
await expect(page.getByTestId("release-channel-missing")).toContainText("API version"); await expect(page.getByTestId("release-channel-missing")).toContainText("API version");
await expect(page.getByTestId("release-channel-next-check")).toContainText("Checking again in"); await expect(page.getByTestId("release-channel-next-check")).toContainText("Checking again in");
@@ -242,6 +242,7 @@ test("invalid release runtime JSON shows the unavailable guard instead of crashi
await boot(page, internalRuntime); await boot(page, internalRuntime);
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.setItem("release_channel_selected_slug", "internal"); window.localStorage.setItem("release_channel_selected_slug", "internal");
window.localStorage.setItem("release_source_override", "deployment");
}); });
await page.route("**/release/runtime**", async (route) => { await page.route("**/release/runtime**", async (route) => {
await route.fulfill({ await route.fulfill({
@@ -291,6 +292,7 @@ test("selected channel auth session 404 shows the release channel guard", async
await boot(page, internalRuntime); await boot(page, internalRuntime);
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.setItem("release_channel_selected_slug", "internal"); window.localStorage.setItem("release_channel_selected_slug", "internal");
window.localStorage.setItem("release_source_override", "deployment");
}); });
await page.route("**/release/runtime**", async (route) => { await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: internalRuntime })); await route.fulfill(json({ data: internalRuntime }));
@@ -324,7 +326,7 @@ test("release channel choices show git commit and release time when available",
const canary = page.getByTestId("release-channel-option-canary"); const canary = page.getByTestId("release-channel-option-canary");
await expect(canary).toBeVisible(); await expect(canary).toBeVisible();
await expect(canary).toContainText("Release bundle"); 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("c0ffee000000");
await expect(page.getByTestId("release-channel-option-canary-frontend-release")).toContainText( await expect(page.getByTestId("release-channel-option-canary-frontend-release")).toContainText(
/May 19, 2026|19 May 2026/ /May 19, 2026|19 May 2026/
File diff suppressed because it is too large Load Diff
@@ -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>');
}
});
});
+145 -87
View File
@@ -1,99 +1,157 @@
import { expect, test } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test";
import { loginAsOperator, loginAsUser } from "../fixtures/authHelpers"; import crypto from "node:crypto";
import { attachPageHealthGuards, expectBodyHasContent, expectOneVisible, requiredEnv, settlePage } from "./helpers"; import { attachPageHealthGuards, expectBodyHasContent, settlePage } from "./helpers";
const liveSmokeEnabled = Boolean( type ReleaseManifest = {
process.env.PLAYWRIGHT_BASE_URL && build_id?: string;
process.env.PLAYWRIGHT_USER_CUSTOMER_NUMBER && commit_sha?: string;
process.env.PLAYWRIGHT_USER_PASSWORD && entry?: string;
process.env.PLAYWRIGHT_OPERATOR_USER_ID && css?: string[];
process.env.PLAYWRIGHT_OPERATOR_PASSWORD index_asset_urls?: string[];
); pwa_asset_urls?: string[];
asset_urls?: string[];
asset_hashes?: Record<string, { sha256?: string; bytes?: number }>;
};
function getLiveSettings() { const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
return {
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"), function liveUrl(assetPath: string) {
customerCredentials: { const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"), return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
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),
};
} }
test.describe.configure({ mode: "serial" }); function unique(values: string[]) {
return Array.from(new Set(values.filter(Boolean)));
}
test("api-v2 gateway ping serves a trusted TLS API response", async ({ request }) => { function sha256(bytes: Buffer) {
const response = await request.get("https://api-v2.truckwash.io/ping"); return crypto.createHash("sha256").update(bytes).digest("hex");
const body = await response.json(); }
expect(response.ok()).toBeTruthy(); function rejectsHtml(assetPath: string) {
expect(body).toMatchObject({ return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
success: true, }
data: {
message: "pong", 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();
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);
return JSON.parse(body) as T;
}
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"] || "";
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);
}
}
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.describe("Live smoke release gate", () => { test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => {
test.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate."); const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io";
const apiPingPaths = unique(
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/ping,/master/api/ping,/canary/api/ping,/stable/api/ping")
.split(",")
.map((value) => value.trim())
);
test("guest flow renders on the deployed environment", async ({ page }) => { for (const apiPingPath of apiPingPaths) {
const { baseURL } = getLiveSettings(); await expectApiPing(request, apiBaseUrl, apiPingPath);
const guards = attachPageHealthGuards(page, baseURL); }
});
await page.goto("/guest/book/wash");
await settlePage(page); test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/); const guards = attachPageHealthGuards(page, baseURL);
await expectBodyHasContent(page);
await expect(page.locator("body")).not.toContainText(/404/i); await page.goto("/guest/book/wash");
await settlePage(page);
await guards.expectHealthy(); await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
guards.dispose(); await expectBodyHasContent(page);
}); await expect(page.locator("body")).not.toContainText(/404/i);
test("customer login reaches dashboard, profile, and bookings", async ({ page }) => { await guards.expectHealthy();
const { baseURL, customerCredentials } = getLiveSettings(); guards.dispose();
const guards = attachPageHealthGuards(page, baseURL);
await loginAsUser(page, customerCredentials);
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();
});
test("operator login reaches the seeded department route", async ({ page }) => {
const { baseURL, operatorCredentials, departmentId } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
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();
});
}); });
@@ -0,0 +1,87 @@
import { expect, test } from "@playwright/test";
import { loginAsOperator, loginAsUser } from "../fixtures/authHelpers";
import { attachPageHealthGuards, expectOneVisible, requiredEnv, settlePage } from "./helpers";
const requiredCredentialNames = [
"PLAYWRIGHT_BASE_URL",
"PLAYWRIGHT_USER_CUSTOMER_NUMBER",
"PLAYWRIGHT_USER_PASSWORD",
"PLAYWRIGHT_OPERATOR_USER_ID",
"PLAYWRIGHT_OPERATOR_PASSWORD",
];
const roleSmokeEnabled = requiredCredentialNames.every((name) => Boolean(process.env[name]));
const requireLiveCredentials = process.env.PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS === "true";
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),
};
}
test.describe.configure({ mode: "serial" });
test.describe("@role-live credentialed live smoke release gate", () => {
test.skip(
!roleSmokeEnabled && !requireLiveCredentials,
"Set seeded live credentials to run credentialed role smoke tests."
);
test.beforeAll(() => {
if (!requireLiveCredentials || roleSmokeEnabled) {
return;
}
const missing = requiredCredentialNames.filter((name) => !process.env[name]);
throw new Error(`Missing required live credential environment variables: ${missing.join(", ")}`);
});
test("customer login reaches dashboard, profile, and bookings", async ({ page }) => {
const { baseURL, customerCredentials } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
await loginAsUser(page, customerCredentials);
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();
});
test("operator login reaches the seeded department route", async ({ page }) => {
const { baseURL, operatorCredentials, departmentId } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
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();
});
});
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -896,15 +896,18 @@ test.describe("Self-serve wash", () => {
permissions: ["user"], permissions: ["user"],
selfServe: true, selfServe: true,
}); });
api.selfServe.dynamicImageDelayMs = [750];
const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"]; const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"];
answeredSummary.allowed_services = ["PROGRAM_PICKER", "MACHINE"];
answeredSummary.tasks = [ answeredSummary.tasks = [
{ {
...answeredSummary.tasks[0], ...answeredSummary.tasks[0],
condition_id: null, condition_id: null,
gate_type: "ALWAYS", gate_type: "ALWAYS",
gate_ref_id: null, gate_ref_id: null,
buttons: ["reset", 0], services: ["PROGRAM_PICKER"],
dynamic_images_vehicle_type: 2, buttons: [],
dynamic_images_vehicle_type: 4,
}, },
{ {
id: 9002, id: 9002,
@@ -915,7 +918,7 @@ test.describe("Self-serve wash", () => {
condition_id: null, condition_id: null,
gate_type: "ALWAYS", gate_type: "ALWAYS",
gate_ref_id: null, gate_ref_id: null,
buttons: [2, "start", 5], buttons: [0, 2, "start", 5],
dynamic_images_vehicle_type: 3, dynamic_images_vehicle_type: 3,
attachments: [], attachments: [],
}, },
@@ -929,26 +932,36 @@ test.describe("Self-serve wash", () => {
permissions: ["user"], permissions: ["user"],
}); });
await page.goto("/user/wash/start"); await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("self-serve-dynamic-image-skeleton")).toBeVisible();
await expect(page.getByTestId("self-serve-dynamic-image-skeleton")).toBeHidden({ timeout: 10_000 });
await expect(page.getByTestId("self-serve-dynamic-image")).toBeVisible(); await expect(page.getByTestId("self-serve-dynamic-image")).toBeVisible();
await expect(page.getByTestId("self-serve-task-9002")).toBeVisible(); await expect(page.getByTestId("self-serve-task-9002")).toBeVisible();
await expect(page.getByTestId("self-serve-task-9002")).toContainText("Machine access");
await expect(page.getByTestId("self-serve-task-9002")).not.toContainText("#3 Machine access");
await expect await expect
.poll(() => JSON.parse(requests.dynamicImages.at(-1)?.url.searchParams.get("buttons") || "[]")) .poll(() => JSON.parse(requests.dynamicImages.at(-1)?.url.searchParams.get("buttons") || "[]"))
.toEqual(["reset", 0, 2, "start", 5]); .toEqual(["program_picker", 0, 2, "start", 5]);
const initialDynamicImageUrl = requests.dynamicImages.at(-1).url; const initialDynamicImageUrl = requests.dynamicImages.at(-1).url;
expect(JSON.parse(initialDynamicImageUrl.searchParams.get("buttons"))).toEqual(["reset", 0, 2, "start", 5]); expect(JSON.parse(initialDynamicImageUrl.searchParams.get("buttons"))).toEqual([
"program_picker",
0,
2,
"start",
5,
]);
expect(initialDynamicImageUrl.searchParams.get("current_step")).toBe("0"); expect(initialDynamicImageUrl.searchParams.get("current_step")).toBe("0");
expect(initialDynamicImageUrl.searchParams.get("thumb_position")).toBe("2"); expect(initialDynamicImageUrl.searchParams.get("thumb_position")).toBe("4");
await expect(page.getByTestId("self-serve-task-9001-attachment-302")).toBeVisible(); await expect(page.getByTestId("self-serve-task-9001-attachment-302")).toBeVisible();
await expect(page.getByTestId("self-serve-task-9001")).toContainText("#2 Prepare the truck"); await expect(page.getByTestId("self-serve-task-9001")).toContainText("#4 Prepare the truck");
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
await page.getByTestId("self-serve-task-9001-toggle").click(); await page.getByTestId("self-serve-task-9001-toggle").click();
await expect.poll(() => requests.dynamicImages.at(-1)?.url.searchParams.get("current_step")).toBe("2"); await expect.poll(() => requests.dynamicImages.at(-1)?.url.searchParams.get("current_step")).toBe("1");
expect(JSON.parse(requests.dynamicImages.at(-1).url.searchParams.get("buttons"))).toEqual([ expect(JSON.parse(requests.dynamicImages.at(-1).url.searchParams.get("buttons"))).toEqual([
"reset", "program_picker",
0, 0,
2, 2,
"start", "start",
@@ -959,7 +972,7 @@ test.describe("Self-serve wash", () => {
await page.getByTestId("self-serve-task-9002-toggle").click(); await page.getByTestId("self-serve-task-9002-toggle").click();
await expect.poll(() => requests.dynamicImages.at(-1)?.url.searchParams.get("current_step")).toBe("5"); await expect.poll(() => requests.dynamicImages.at(-1)?.url.searchParams.get("current_step")).toBe("5");
expect(JSON.parse(requests.dynamicImages.at(-1).url.searchParams.get("buttons"))).toEqual([ expect(JSON.parse(requests.dynamicImages.at(-1).url.searchParams.get("buttons"))).toEqual([
"reset", "program_picker",
0, 0,
2, 2,
"start", "start",
+22 -8
View File
@@ -1330,6 +1330,7 @@ function createSelfServeFixture(overrides = {}) {
relays: ["M-9"], relays: ["M-9"],
}, },
}, },
dynamicImageDelayMs: 0,
dynamicImage: TINY_PNG, dynamicImage: TINY_PNG,
dynamicImagesByLaneId: { dynamicImagesByLaneId: {
7: TINY_PNG, 7: TINY_PNG,
@@ -5954,20 +5955,29 @@ export async function mockApi(page, options = {}) {
options.edgeGateways && typeof options.edgeGateways === "object" ? options.edgeGateways : {}; options.edgeGateways && typeof options.edgeGateways === "object" ? options.edgeGateways : {};
const edgeGatewayFixture = options.edgeGateways === false ? null : createHttpEdgeGatewayFixture(edgeGatewayOptions); const edgeGatewayFixture = options.edgeGateways === false ? null : createHttpEdgeGatewayFixture(edgeGatewayOptions);
await page.route(/https:\/\/cdn\.example\.test\/orders\/\d+\/attachments\/\d+$/i, async (route) => { await page.route(/https:\/\/cdn\.example\.test\/.*$/i, async (route) => {
const request = route.request(); const request = route.request();
if (request.method() !== "GET" || !posFixture) { if (request.method() !== "GET") {
await route.continue(); await route.continue();
return; return;
} }
const url = new URL(request.url()); const url = new URL(request.url());
const pathSegments = url.pathname.split("/").filter(Boolean); const orderAttachmentMatch = url.pathname.match(/^\/orders\/(\d+)\/attachments\/(\d+)$/i);
const orderId = Number(pathSegments[1] || 0); if (orderAttachmentMatch && posFixture) {
const attachmentId = Number(pathSegments[3] || 0); const orderId = Number(orderAttachmentMatch[1] || 0);
const attachment = const attachmentId = Number(orderAttachmentMatch[2] || 0);
(posFixture.attachmentsByOrderId?.[orderId] || []).find((entry) => Number(entry.id) === attachmentId) || null; const attachment =
const previewResponse = getAttachmentPreviewContentType(attachment); (posFixture.attachmentsByOrderId?.[orderId] || []).find((entry) => Number(entry.id) === attachmentId) || null;
const previewResponse = getAttachmentPreviewContentType(attachment);
await route.fulfill(binary(previewResponse.body, previewResponse.contentType));
return;
}
const previewResponse = getAttachmentPreviewContentType({
content: { other: url.pathname.split("/").pop() || "" },
});
await route.fulfill(binary(previewResponse.body, previewResponse.contentType)); await route.fulfill(binary(previewResponse.body, previewResponse.contentType));
}); });
@@ -6698,6 +6708,10 @@ export async function mockApi(page, options = {}) {
if (pathname.endsWith("/department/lanes/dynamic-image") && method === "GET") { if (pathname.endsWith("/department/lanes/dynamic-image") && method === "GET") {
const laneId = parsedUrl.searchParams.get("lane"); const laneId = parsedUrl.searchParams.get("lane");
const dynamicImageDelayMs = Array.isArray(selfServe.dynamicImageDelayMs)
? selfServe.dynamicImageDelayMs.shift() || 0
: selfServe.dynamicImageDelayMs;
await maybeDelayFixtureResponse(dynamicImageDelayMs);
await route.fulfill(binary(selfServe.dynamicImagesByLaneId?.[String(laneId || "")] || selfServe.dynamicImage)); await route.fulfill(binary(selfServe.dynamicImagesByLaneId?.[String(laneId || "")] || selfServe.dynamicImage));
return; return;
} }
+52 -5
View File
@@ -2,12 +2,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
RELEASE_RUNTIME_GLOBAL_KEY, RELEASE_RUNTIME_GLOBAL_KEY,
RELEASE_SOURCE_OVERRIDE_STORAGE_KEY,
bootstrapReleaseApp, bootstrapReleaseApp,
fetchReleaseRuntime, fetchReleaseRuntime,
loadRemoteReleaseEntry, loadRemoteReleaseEntry,
resolveReleaseSourceMode,
runtimeApiUrl, runtimeApiUrl,
shouldLoadRemoteRelease, shouldLoadRemoteRelease,
} from "@/services/releaseBootstrap.js"; } from "@/services/releaseBootstrap.js";
import { DEFAULT_STABLE_API_URL } from "@/config.js";
describe("release bootstrap", () => { describe("release bootstrap", () => {
beforeEach(() => { beforeEach(() => {
@@ -30,14 +33,19 @@ describe("release bootstrap", () => {
); );
}); });
it("keeps stable runtime requests on the configured control API", () => { it("routes stable runtime requests through the public master API prefix", () => {
localStorage.setItem("release_channel_selected_slug", "stable"); localStorage.setItem("release_channel_selected_slug", "stable");
expect(runtimeApiUrl("https://api.truckwash.io")).toBe( expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
"https://api.truckwash.io/release/runtime?release_channel=stable" "https://api-v2.truckwash.io/master/api/release/runtime?release_channel=stable"
); );
}); });
it("uses api-v2 master as the production default API", () => {
expect(DEFAULT_STABLE_API_URL).toBe("https://api-v2.truckwash.io/master/api");
expect(runtimeApiUrl(DEFAULT_STABLE_API_URL)).toBe("https://api-v2.truckwash.io/master/api/release/runtime");
});
it("resolves local dev API runtime requests against the current origin", () => { it("resolves local dev API runtime requests against the current origin", () => {
expect(runtimeApiUrl("/api")).toBe(`${window.location.origin}/api/release/runtime`); expect(runtimeApiUrl("/api")).toBe(`${window.location.origin}/api/release/runtime`);
}); });
@@ -73,10 +81,38 @@ describe("release bootstrap", () => {
}), }),
})); }));
await bootstrapReleaseApp({ loadLocalApp, fetchFn }); await bootstrapReleaseApp({ loadLocalApp, fetchFn, sourceMode: "deployment" });
expect(loadLocalApp).toHaveBeenCalledTimes(1); expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].channel.slug).toBe("stable"); expect(window[RELEASE_RUNTIME_GLOBAL_KEY].channel.slug).toBe("stable");
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].source).toBe("deployment");
});
it("loads the local app without runtime or release entry requests in local source mode", async () => {
localStorage.setItem("release_channel_selected_slug", "internal");
const loadLocalApp = vi.fn(async () => ({ local: true }));
const fetchFn = vi.fn();
await bootstrapReleaseApp({ loadLocalApp, fetchFn, sourceMode: "local" });
expect(fetchFn).not.toHaveBeenCalled();
expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
source: "local",
requested_source: "local",
channel: { slug: "stable", default_channel: true },
api_base_url: "/api",
urls: { api_base_url: "/api" },
availability: { configured: true, missing: [], status: "ready" },
});
});
it("allows storage to steer auto source mode without overriding explicit build source mode", () => {
localStorage.setItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY, "deployment");
expect(resolveReleaseSourceMode("auto")).toBe("deployment");
expect(resolveReleaseSourceMode("local")).toBe("local");
expect(resolveReleaseSourceMode("local", { allowStorageOverride: true })).toBe("deployment");
}); });
it("loads the local unavailable app state when selected release runtime returns invalid JSON", async () => { it("loads the local unavailable app state when selected release runtime returns invalid JSON", async () => {
@@ -88,7 +124,7 @@ describe("release bootstrap", () => {
text: async () => "<br /><b>Warning</b>Composer autoload warning", text: async () => "<br /><b>Warning</b>Composer autoload warning",
})); }));
await bootstrapReleaseApp({ loadLocalApp, fetchFn }); await bootstrapReleaseApp({ loadLocalApp, fetchFn, sourceMode: "deployment" });
expect(loadLocalApp).toHaveBeenCalledTimes(1); expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({ expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
@@ -98,6 +134,8 @@ describe("release bootstrap", () => {
missing: ["release_runtime"], missing: ["release_runtime"],
status: "unconfigured", status: "unconfigured",
}, },
source: "local",
requested_source: "deployment",
}); });
expect(shouldLoadRemoteRelease(null)).toBe(false); expect(shouldLoadRemoteRelease(null)).toBe(false);
}); });
@@ -132,6 +170,7 @@ describe("release bootstrap", () => {
fetchFn, fetchFn,
importModule, importModule,
documentRef: document, documentRef: document,
sourceMode: "deployment",
}); });
expect(shouldLoadRemoteRelease(runtime)).toBe(true); expect(shouldLoadRemoteRelease(runtime)).toBe(true);
@@ -161,9 +200,17 @@ describe("release bootstrap", () => {
status: 404, status: 404,
}); });
await bootstrapReleaseApp({ loadLocalApp, fetchFn, importModule: vi.fn(), documentRef: document }); await bootstrapReleaseApp({
loadLocalApp,
fetchFn,
importModule: vi.fn(),
documentRef: document,
sourceMode: "deployment",
});
expect(loadLocalApp).toHaveBeenCalledTimes(1); expect(loadLocalApp).toHaveBeenCalledTimes(1);
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].source).toBe("local");
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].requested_source).toBe("deployment");
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].availability).toMatchObject({ expect(window[RELEASE_RUNTIME_GLOBAL_KEY].availability).toMatchObject({
configured: false, configured: false,
missing: ["frontend_entry"], missing: ["frontend_entry"],
@@ -51,7 +51,6 @@ const configuredCanaryRuntime = {
versions: { versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" }, frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "feedface" }, api: { version_label: "api-canary", commit_sha: "feedface" },
bundle_id: 31,
}, },
availability: { availability: {
configured: true, configured: true,
@@ -66,12 +65,12 @@ describe("release channel availability", () => {
__resetReleaseTimelineForTests(); __resetReleaseTimelineForTests();
}); });
it("blocks a non-default assigned channel when targets are missing", () => { it("blocks a non-default assigned channel when app targets are missing", () => {
const status = getReleaseChannelUnavailableStatus(canaryRuntime, 1_000, 0); const status = getReleaseChannelUnavailableStatus(canaryRuntime, 1_000, 0);
expect(status.shouldBlock).toBe(true); expect(status.shouldBlock).toBe(true);
expect(status.channelSlug).toBe("canary"); expect(status.channelSlug).toBe("canary");
expect(status.missing).toEqual(["release_bundle", "frontend_version", "api_version"]); expect(status.missing).toEqual(["frontend_version", "api_version"]);
}); });
it("does not block the stable default channel even when runtime targets are absent", () => { it("does not block the stable default channel even when runtime targets are absent", () => {
@@ -91,6 +90,26 @@ describe("release channel availability", () => {
expect(status.shouldBlock).toBe(false); expect(status.shouldBlock).toBe(false);
}); });
it("does not block branch-based channels only because a legacy bundle is missing", () => {
const status = getReleaseChannelUnavailableStatus({
channel: {
slug: "canary",
name: "Canary",
default_channel: false,
},
availability: {
configured: false,
missing: ["release_bundle"],
status: "unconfigured",
},
});
expect(status.configured).toBe(true);
expect(status.shouldBlock).toBe(false);
expect(status.missing).toEqual([]);
expect(status.status).toBe("ready");
});
it("exposes selectable runtime channels when a non-default assignment is available", () => { it("exposes selectable runtime channels when a non-default assignment is available", () => {
const runtime = { const runtime = {
channel: configuredCanaryRuntime.channel, channel: configuredCanaryRuntime.channel,
+1 -1
View File
@@ -61,7 +61,7 @@ describe("ReleaseChannelSelector", () => {
expect(wrapper.find('[data-testid="release-channel-selector"]').exists()).toBe(true); expect(wrapper.find('[data-testid="release-channel-selector"]').exists()).toBe(true);
expect(wrapper.text()).toContain("Stable"); expect(wrapper.text()).toContain("Stable");
expect(wrapper.text()).toContain("Canary"); expect(wrapper.text()).toContain("Canary");
expect(wrapper.text()).toContain("Release bundle"); expect(wrapper.text()).not.toContain("Release bundle");
await wrapper.find('[data-testid="release-channel-option-canary"]').trigger("click"); await wrapper.find('[data-testid="release-channel-option-canary"]').trigger("click");
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import ReleaseContextBar from "@/components/release/ReleaseContextBar.vue";
import { normalizeReleasePanel, routeSlugForReleaseChannel } from "@/components/release/useReleaseManagerContext.js";
import { releaseEntityFacts, releaseEntityTooltipText } from "@/components/release/releaseEntityFacts.js";
const global = {
stubs: {
"b-button": {
props: ["disabled", "loading"],
emits: ["click"],
template: "<button :disabled='disabled' @click='$emit(\"click\", $event)'><slot /></button>",
},
"b-tag": {
template: "<span><slot /></span>",
},
"b-tooltip": {
template: "<span><slot name='content' /><slot /></span>",
},
},
};
describe("release manager workspace primitives", () => {
it("normalizes route panels and stable channel route slugs", () => {
expect(normalizeReleasePanel("bundles")).toBe("deployments");
expect(normalizeReleasePanel("replay")).toBe("operations");
expect(normalizeReleasePanel("failover")).toBe("data-services");
expect(normalizeReleasePanel("missing")).toBe("overview");
expect(routeSlugForReleaseChannel({ slug: "stable" })).toBe("master");
expect(routeSlugForReleaseChannel({ slug: "canary" })).toBe("canary");
});
it("builds concrete data-service tooltip facts", () => {
const facts = releaseEntityFacts({
type: "data service",
entity: {
kind: "database",
mode: "production_shared",
target: {
instance_label: "node-3",
deployment_status: "running",
version: "10.11",
replication: {
label: "mariadb-primary",
host: "db-1.truckwash.internal",
port: 3306,
status: "ok",
role: "primary",
lag_seconds: 0,
last_checked_at: "2026-05-21T10:00:00Z",
uptime_seconds: 86400,
},
},
},
});
const text = releaseEntityTooltipText(facts);
expect(text).toContain("Node: node-3");
expect(text).toContain("Online state: running");
expect(text).toContain("Hostname: db-1.truckwash.internal");
expect(text).toContain("Port: 3306");
expect(text).toContain("Replication role: primary");
expect(text).toContain("Last check: 2026-05-21T10:00:00Z");
});
it("emits global context changes and endpoint actions", async () => {
const wrapper = mount(ReleaseContextBar, {
props: {
channels: [
{ id: 1, slug: "stable", name: "Stable", default_channel: true },
{ id: 2, slug: "canary", name: "Canary" },
],
selectedChannelSlug: "stable",
selectedApp: "all",
selectedBranch: "master",
branchOptions: ["master", "canary"],
branchStatus: { frontend: { state: "available" }, api: { state: "available" } },
canDeploy: true,
},
global,
});
await wrapper.get('[data-testid="release-context-channel"]').setValue("canary");
await wrapper.get('[data-testid="release-context-app"]').setValue("api");
await wrapper.get('[data-testid="release-context-branch"]').setValue("canary");
await wrapper.get('[data-testid="release-context-test"]').trigger("click");
expect(wrapper.emitted("update:channel")?.[0]).toEqual(["canary"]);
expect(wrapper.emitted("update:app")?.[0]).toEqual(["api"]);
expect(wrapper.emitted("update:branch")?.[0]).toEqual(["canary"]);
expect(wrapper.emitted("test")).toHaveLength(1);
expect(wrapper.text()).toContain("/master/api");
});
it("shows missing branch context as a warning", () => {
const wrapper = mount(ReleaseContextBar, {
props: {
channels: [{ id: 3, slug: "internal", name: "Internal" }],
selectedChannelSlug: "internal",
selectedApp: "frontend",
selectedBranch: "internal",
branchStatus: { frontend: { state: "missing" } },
},
global,
});
expect(wrapper.get('[data-testid="release-context-branch-warning"]').text()).toContain("internal is missing");
});
});
+23 -3
View File
@@ -32,6 +32,7 @@ describe("release timeline runtime", () => {
it("configures release runtime from the JSON session contract", () => { it("configures release runtime from the JSON session contract", () => {
configureReleaseRuntime({ configureReleaseRuntime({
source: "deployment",
trace_id: "trace-123", trace_id: "trace-123",
channel: { slug: "canary", name: "Canary" }, channel: { slug: "canary", name: "Canary" },
available_channels: [{ channel: { slug: "stable", name: "Stable", default_channel: true } }], available_channels: [{ channel: { slug: "stable", name: "Stable", default_channel: true } }],
@@ -39,6 +40,8 @@ describe("release timeline runtime", () => {
capture_policy: { enabled: true, capture_level: "full_redacted", retention_days: 7 }, capture_policy: { enabled: true, capture_level: "full_redacted", retention_days: 7 },
}); });
expect(releaseRuntimeState.source).toBe("deployment");
expect(releaseRuntimeState.requestedSource).toBe("deployment");
expect(releaseRuntimeState.channel.slug).toBe("canary"); expect(releaseRuntimeState.channel.slug).toBe("canary");
expect(releaseRuntimeState.availableChannels).toHaveLength(1); expect(releaseRuntimeState.availableChannels).toHaveLength(1);
expect(releaseRuntimeState.traceId).toBe("trace-123"); expect(releaseRuntimeState.traceId).toBe("trace-123");
@@ -46,6 +49,23 @@ describe("release timeline runtime", () => {
expect(releaseRuntimeState.versions.bundle_id).toBe(31); expect(releaseRuntimeState.versions.bundle_id).toBe(31);
}); });
it("preserves the bootstrap source when later session runtime omits it", () => {
configureReleaseRuntime({
source: "local",
requested_source: "auto",
api_base_url: "/api",
});
configureReleaseRuntime({
trace_id: "trace-session",
channel: { slug: "stable", name: "Stable", default_channel: true },
availability: { configured: true, missing: [], status: "ready" },
});
expect(releaseRuntimeState.source).toBe("local");
expect(releaseRuntimeState.requestedSource).toBe("auto");
expect(releaseRuntimeState.traceId).toBe("trace-session");
});
it("builds display rows for the active release bundle and connected services", () => { it("builds display rows for the active release bundle and connected services", () => {
configureReleaseRuntime({ configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z", generated_at: "2026-05-19T09:30:00.000Z",
@@ -156,7 +176,7 @@ describe("release timeline runtime", () => {
}, },
}); });
expect(summary.missingLabels).toEqual(["Release bundle", "Frontend version", "API URL"]); expect(summary.missingLabels).toEqual(["Frontend version", "API URL"]);
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({ expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
tone: "warning", tone: "warning",
missingLabel: "Frontend version", missingLabel: "Frontend version",
@@ -166,8 +186,8 @@ describe("release timeline runtime", () => {
missingLabel: "API URL", missingLabel: "API URL",
}); });
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({ expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
tone: "warning", tone: "ok",
primaryText: "Missing release bundle", primaryText: "Default/shared runtime",
}); });
}); });
@@ -44,13 +44,50 @@ describe("self-serve studio task editing", () => {
it("renders dynamic image previews in simulator results", () => { it("renders dynamic image previews in simulator results", () => {
const source = studioSource(); const source = studioSource();
expect(source).toContain("const simulatorDynamicImageButtons = computed(() => {"); expect(source).toContain("getSelfServeDynamicImageButtonsToPress(simulatorPreviewTasks.value)");
expect(source).toContain(
"const simulatorDynamicImageCurrentStep = computed(() => getSelfServeCompletedDynamicImageStep("
);
expect(source).toContain("const simulatorDynamicImageUrl = computed(() => {"); expect(source).toContain("const simulatorDynamicImageUrl = computed(() => {");
expect(source).toContain("buttons: JSON.stringify(simulatorDynamicImageButtons.value)"); expect(source).toContain("buildSelfServeDynamicImageUrl({");
expect(source).toContain("currentStep: simulatorDynamicImageCurrentStep.value");
expect(source).toContain("const buildSimulatorPayload = () => {");
expect(source).toContain('const scheduleSimulatorRun = (reason = "change", delayMs = 250) => {');
expect(source).toContain('data-testid="studio-simulator-steps"');
expect(source).toContain('data-testid="studio-simulator-dynamic-image"'); expect(source).toContain('data-testid="studio-simulator-dynamic-image"');
expect(source).toContain('data-testid="studio-simulator-dynamic-image-reason"');
expect(source).toContain('@error="onSimulatorDynamicImageError"'); expect(source).toContain('@error="onSimulatorDynamicImageError"');
}); });
it("renders the simulator as an auto-running full-width workspace", () => {
const source = studioSource();
expect(source).toContain("'is-simulator-panel': activePanel === 'simulator'");
expect(source).toContain('data-testid="studio-simulator-workspace"');
expect(source).toContain("const focusSimulatorNodes = async (nodeIds = [], openInspector = false) => {");
expect(source).toContain('activePanel.value = openInspector ? "inspector" : "flow";');
expect(source).toContain('const scheduleSimulatorRun = (reason = "change", delayMs = 250) => {');
expect(source).toContain('aria-label="Simulator controls"');
expect(source).not.toContain("<span>Run</span>");
expect(source).not.toContain('@submit.prevent="runSimulator');
});
it("requires lane and vehicle scope with URL and simulator local persistence", () => {
const source = studioSource();
expect(source).toContain("const scopeFiltersFromRoute = () => ({");
expect(source).toContain("const showScopeGuide = computed(() => (");
expect(source).toContain("const applyScopeGuideSelection = () => {");
expect(source).toContain("const writeScopeToRoute = () => {");
expect(source).toContain("const simulatorStorageKey = computed(() => [");
expect(source).toContain("const restoreSimulatorStateFromStorage = () => {");
expect(source).toContain("const persistSimulatorStateToStorage = () => {");
expect(source).toContain('data-testid="studio-scope-guide"');
expect(source).toContain('data-testid="studio-scope-apply"');
expect(source).toContain('<option value="" disabled>Select lane</option>');
expect(source).toContain('<option value="" disabled>Select vehicle type</option>');
});
it("renders task attachments in the flow nodes and simulator without saving them into draft tasks", () => { it("renders task attachments in the flow nodes and simulator without saving them into draft tasks", () => {
const source = studioSource(); const source = studioSource();
@@ -92,7 +129,7 @@ describe("self-serve studio task editing", () => {
expect(source).toContain("selfserve_enabled: true"); expect(source).toContain("selfserve_enabled: true");
expect(source).toContain("selfserve_enabled: normalizeSelfServeEnabledFlag(form?.selfserve_enabled)"); expect(source).toContain("selfserve_enabled: normalizeSelfServeEnabledFlag(form?.selfserve_enabled)");
expect(source).toContain("const buildLaneDynamicImagePreviewUrl = (laneId, dynamicImageId) => {"); expect(source).toContain("const buildLaneDynamicImagePreviewUrl = (laneId, dynamicImageId) => {");
expect(source).toContain("dynamic_image_id: String(normalizedDynamicImageId)"); expect(source).toContain("dynamicImageId: normalizedDynamicImageId");
expect(source).toContain('data-testid="studio-lane-selfserve-enabled"'); expect(source).toContain('data-testid="studio-lane-selfserve-enabled"');
expect(source).toContain('data-testid="studio-inspector-lane-selfserve-enabled"'); expect(source).toContain('data-testid="studio-inspector-lane-selfserve-enabled"');
expect(source).toContain('data-testid="studio-lane-dynamic-image"'); expect(source).toContain('data-testid="studio-lane-dynamic-image"');
@@ -11,10 +11,10 @@ describe("self-serve studio vehicle type scope", () => {
it("renders a vehicle type toolbar filter bound to product scope", () => { it("renders a vehicle type toolbar filter bound to product scope", () => {
const source = studioSource(); const source = studioSource();
expect(source).toContain('vehicle_type_id: ""'); expect(source).toContain("vehicle_type_id: normalizeScopeValue(");
expect(source).toContain('data-testid="studio-filter-vehicle-type"'); expect(source).toContain('data-testid="studio-filter-vehicle-type"');
expect(source).toContain('<option value="">All vehicle types</option>'); expect(source).toContain('<option value="" disabled>Select vehicle type</option>');
expect(source).toContain(':value="vehicleType.product || vehicleType.id"'); expect(source).toContain(':value="vehicleTypeFilterValue(vehicleType)"');
}); });
it("applies vehicle type scope filtering and defaults for new records", () => { it("applies vehicle type scope filtering and defaults for new records", () => {
+16 -6
View File
@@ -64,20 +64,28 @@ describe("SelfServeTaskList", () => {
expect(wrapper.text()).not.toContain("Button 1"); expect(wrapper.text()).not.toContain("Button 1");
}); });
it("prefixes task titles with the configured program wheel selection", () => { it("prefixes only program picker task titles with the configured wheel selection", () => {
const wrapper = mountWithApp(SelfServeTaskList, { const wrapper = mountWithApp(SelfServeTaskList, {
props: { props: {
tasks: [ tasks: [
{ {
id: 6, id: 6,
task: "Start machine", task: "Choose program",
description: "-", description: "-",
services: ["MACHINE"], services: ["PROGRAM_PICKER"],
buttons: ["start"], buttons: [],
dynamic_images_vehicle_type: 4, dynamic_images_vehicle_type: 4,
}, },
{ {
id: 7, id: 7,
task: "Tryk reset",
description: "-",
services: ["MACHINE"],
buttons: ["reset"],
dynamic_images_vehicle_type: 7,
},
{
id: 8,
task: "Manual inspection", task: "Manual inspection",
description: "-", description: "-",
services: [], services: [],
@@ -96,8 +104,10 @@ describe("SelfServeTaskList", () => {
}, },
}); });
expect(wrapper.get('[data-testid="self-serve-task-6"]').text()).toContain("#4 Start machine"); expect(wrapper.get('[data-testid="self-serve-task-6"]').text()).toContain("#4 Choose program");
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).toContain("Manual inspection"); expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).toContain("Tryk reset");
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).not.toContain("#"); expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).not.toContain("#");
expect(wrapper.get('[data-testid="self-serve-task-8"]').text()).toContain("Manual inspection");
expect(wrapper.get('[data-testid="self-serve-task-8"]').text()).not.toContain("#");
}); });
}); });
+73
View File
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import SelfServeTasksStep from "@/components/displays/selfServe/SelfServeTasksStep.vue";
import { mountWithApp } from "./helpers/mountWithApp.js";
const mountTasksStep = (props = {}) =>
mountWithApp(SelfServeTasksStep, {
props: {
isLoading: false,
dynamicImageUrl: "https://cdn.example.test/dynamic.png",
activeTasks: [],
completedTasks: {},
allVisibleQuestionsAnswered: false,
editAnswers: false,
...props,
},
global: {
stubs: {
BIcon: true,
SelfServeTaskList: {
template: '<div data-testid="self-serve-task-list-stub" />',
},
},
},
});
describe("SelfServeTasksStep", () => {
it("shows a Buefy skeleton until the dynamic image loads", async () => {
const wrapper = mountTasksStep();
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).toContain("is-loading");
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).not.toContain("is-loading");
});
it("shows the skeleton again when the dynamic image URL changes", async () => {
const wrapper = mountTasksStep();
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
await wrapper.setProps({
dynamicImageUrl: "https://cdn.example.test/dynamic-step-2.png",
});
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic-step-2.png"
);
});
it("clears loading state and emits clear-dynamic-image on image errors", async () => {
const wrapper = mountTasksStep();
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("error");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.emitted("clear-dynamic-image")).toEqual([[]]);
});
it("does not render a skeleton when there is no dynamic image URL", () => {
const wrapper = mountTasksStep({
dynamicImageUrl: null,
});
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image"]').exists()).toBe(false);
});
});
+3 -3
View File
@@ -254,9 +254,9 @@ describe("SelfServeTryModal", () => {
await flushPromises(); await flushPromises();
const image = wrapper.get('[data-testid="self-serve-try-dynamic-image"]'); const image = wrapper.get('[data-testid="self-serve-try-dynamic-image"]');
const imageUrl = new URL(image.attributes("src")); const imageUrl = new URL(image.attributes("src"), "http://localhost");
expect(imageUrl.pathname).toBe("/department/lanes/dynamic-image"); expect(imageUrl.pathname).toMatch(/\/department\/lanes\/dynamic-image$/);
expect(imageUrl.searchParams.get("department")).toBe("9"); expect(imageUrl.searchParams.get("department")).toBe("9");
expect(imageUrl.searchParams.get("lane")).toBe("3"); expect(imageUrl.searchParams.get("lane")).toBe("3");
expect(imageUrl.searchParams.get("current_step")).toBe("0"); expect(imageUrl.searchParams.get("current_step")).toBe("0");
@@ -303,7 +303,7 @@ describe("SelfServeTryModal", () => {
await flushPromises(); await flushPromises();
const image = wrapper.get('[data-testid="self-serve-try-dynamic-image"]'); const image = wrapper.get('[data-testid="self-serve-try-dynamic-image"]');
const imageUrl = new URL(image.attributes("src")); const imageUrl = new URL(image.attributes("src"), "http://localhost");
expect(imageUrl.searchParams.get("vehicle_type")).toBe("3"); expect(imageUrl.searchParams.get("vehicle_type")).toBe("3");
expect(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual([1]); expect(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual([1]);
}); });
+29 -8
View File
@@ -18,6 +18,7 @@ import {
releaseManagerControlApiCandidates, releaseManagerControlApiCandidates,
setReleaseManagerControlApiUrl, setReleaseManagerControlApiUrl,
} from "@/services/superuserReleases.js"; } from "@/services/superuserReleases.js";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import { deployCoolifyGatewayRoutes } from "@/services/superuserCoolify.js"; import { deployCoolifyGatewayRoutes } from "@/services/superuserCoolify.js";
import { __configureRequestQueueForTests, __resetRequestQueueForTests } from "@/services/requestQueue.js"; import { __configureRequestQueueForTests, __resetRequestQueueForTests } from "@/services/requestQueue.js";
@@ -27,6 +28,7 @@ describe("superuser release manager service", () => {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
localStorage.setItem("token", "release-token"); localStorage.setItem("token", "release-token");
configureReleaseRuntime({ source: "deployment", api_base_url: "https://api.truckwash.io" });
__resetRequestQueueForTests(); __resetRequestQueueForTests();
__configureRequestQueueForTests({ __configureRequestQueueForTests({
maxConcurrentGet: 1, maxConcurrentGet: 1,
@@ -36,16 +38,17 @@ describe("superuser release manager service", () => {
}); });
}); });
it("keeps release manager calls on the explicit control API", async () => { it("uses the runtime release API before legacy control API overrides", async () => {
configureReleaseRuntime({ api_base_url: "https://api-v2.truckwash.io/master/api" });
setReleaseManagerControlApiUrl("https://control.example.test/"); setReleaseManagerControlApiUrl("https://control.example.test/");
axiosMock.mockResolvedValueOnce({ status: 200, data: { data: { channels: [] } } }); axiosMock.mockResolvedValueOnce({ status: 200, data: { data: { channels: [] } } });
await getReleaseSummary(); await getReleaseSummary();
expect(getReleaseManagerControlApiUrl()).toBe("https://control.example.test"); expect(getReleaseManagerControlApiUrl()).toBe("https://api-v2.truckwash.io/master/api");
expect(axiosMock).toHaveBeenCalledWith( expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
url: "https://control.example.test/superuser/releases", url: "https://api-v2.truckwash.io/master/api/superuser/releases",
method: "GET", method: "GET",
headers: expect.objectContaining({ Authorization: "Bearer release-token" }), headers: expect.objectContaining({ Authorization: "Bearer release-token" }),
}) })
@@ -61,8 +64,25 @@ describe("superuser release manager service", () => {
await getReleaseSummary(); await getReleaseSummary();
expect(axiosMock.mock.calls[0][0].url).toBe("https://api.truckwash.io/superuser/releases"); expect(axiosMock.mock.calls[0][0].url).toBe("https://api.truckwash.io/superuser/releases");
expect(axiosMock.mock.calls[1][0].url).toBe("https://api.truckwash.io:4433/superuser/releases"); expect(axiosMock.mock.calls[1][0].url).toBe("https://api-v2.truckwash.io/master/api/superuser/releases");
expect(releaseManagerControlApiCandidates()[0]).toBe("https://api.truckwash.io:4433"); expect(releaseManagerControlApiCandidates()).toContain("https://api-v2.truckwash.io/master/api");
});
it("uses local runtime API only when the release source is local", async () => {
configureReleaseRuntime({ source: "local", api_base_url: "/api" });
localStorage.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, "https://api.truckwash.io");
axiosMock.mockResolvedValueOnce({ status: 200, data: { data: { channels: [] } } });
await getReleaseSummary();
expect(releaseManagerControlApiCandidates()).toEqual(["/api"]);
expect(getReleaseManagerControlApiUrl()).toBe("/api");
expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "/api/superuser/releases",
method: "GET",
})
);
}); });
it("can clear the explicit control API override", () => { it("can clear the explicit control API override", () => {
@@ -70,10 +90,11 @@ describe("superuser release manager service", () => {
clearReleaseManagerControlApiUrl(); clearReleaseManagerControlApiUrl();
expect(localStorage.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY)).toBeNull(); expect(localStorage.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY)).toBeNull();
expect(releaseManagerControlApiCandidates()).toContain("https://api.truckwash.io:4433"); expect(releaseManagerControlApiCandidates()).toContain("https://api-v2.truckwash.io/master/api");
}); });
it("keeps Coolify route deployment on the release manager control API", async () => { it("keeps Coolify route deployment on the runtime release API", async () => {
configureReleaseRuntime({ api_base_url: "https://api-v2.truckwash.io/master/api" });
setReleaseManagerControlApiUrl("https://control.example.test"); setReleaseManagerControlApiUrl("https://control.example.test");
axiosMock.mockResolvedValueOnce({ status: 200, data: { success: true } }); axiosMock.mockResolvedValueOnce({ status: 200, data: { success: true } });
@@ -81,7 +102,7 @@ describe("superuser release manager service", () => {
expect(axiosMock).toHaveBeenCalledWith( expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
url: "https://control.example.test/superuser/coolify/load-balancer/routes/deploy", url: "https://api-v2.truckwash.io/master/api/superuser/coolify/load-balancer/routes/deploy",
method: "POST", method: "POST",
data: { dry_run: true }, data: { dry_run: true },
__skipReleaseApiRewrite: true, __skipReleaseApiRewrite: true,
+4 -3
View File
@@ -140,6 +140,7 @@ describe("useWashSessionActions property gate commands", () => {
const activeTasks = ref([ const activeTasks = ref([
{ {
id: 101, id: 101,
services: ["PROGRAM_PICKER"],
buttons: ["reset", 0], buttons: ["reset", 0],
dynamic_images_vehicle_type: 2, dynamic_images_vehicle_type: 2,
}, },
@@ -157,10 +158,10 @@ describe("useWashSessionActions property gate commands", () => {
const url = new URL(actions.dynamicImageUrl.value, "https://app.example.test"); const url = new URL(actions.dynamicImageUrl.value, "https://app.example.test");
expect(JSON.parse(url.searchParams.get("buttons"))).toEqual(["reset", 0, 2, "start", 5]); expect(JSON.parse(url.searchParams.get("buttons"))).toEqual(["program_picker", "reset", 0, 2, "start", 5]);
expect(url.searchParams.get("current_step")).toBe("2"); expect(url.searchParams.get("current_step")).toBe("3");
expect(url.searchParams.get("thumb_position")).toBe("2"); expect(url.searchParams.get("thumb_position")).toBe("2");
expect(actions.machineStartCurrentStep.value).toBe(2); expect(actions.machineStartCurrentStep.value).toBe(3);
}); });
it("does not enter wash-in-progress state when the start command fails", async () => { it("does not enter wash-in-progress state when the start command fails", async () => {
+1 -1
View File
@@ -5,7 +5,7 @@ describe("Vite API proxy", () => {
it("forwards local /api requests to the working dev API by default", () => { it("forwards local /api requests to the working dev API by default", () => {
const options = createApiProxyOptions({}); const options = createApiProxyOptions({});
expect(options.target).toBe("https://api.truckwash.io:4433"); expect(options.target).toBe("http://localhost");
expect(options.changeOrigin).toBe(true); expect(options.changeOrigin).toBe(true);
expect(options.secure).toBe(false); expect(options.secure).toBe(false);
expect(options.rewrite("/api/ping")).toBe("/ping"); expect(options.rewrite("/api/ping")).toBe("/ping");
+259 -23
View File
@@ -1,4 +1,6 @@
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs'
import crypto from 'node:crypto'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
@@ -25,6 +27,14 @@ const BUEFY_CSS_REPLACEMENTS = new Map([
const projectRoot = fileURLToPath(new URL('.', import.meta.url)) const projectRoot = fileURLToPath(new URL('.', import.meta.url))
const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments) const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments)
const PUBLIC_ASSET_ALIASES = [
['favicons/favicon-96x96.png', 'assets/favicons/favicon-96x96.png'],
['favicons/favicon.svg', 'assets/favicons/favicon.svg'],
['favicons/favicon.ico', 'assets/favicons/favicon.ico'],
['favicons/apple-touch-icon.png', 'assets/favicons/apple-touch-icon.png'],
['favicons/web-app-manifest-192x192.png', 'assets/favicons/web-app-manifest-192x192.png'],
['favicons/web-app-manifest-512x512.png', 'assets/favicons/web-app-manifest-512x512.png'],
]
function getGitCommit() { function getGitCommit() {
try { try {
@@ -34,6 +44,72 @@ function getGitCommit() {
} }
} }
function getGitCommitSha() {
try {
return execSync('git rev-parse HEAD').toString().trim()
} catch {
return 'unknown'
}
}
function releaseBuildId(commitSha) {
const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID
if (explicitBuildId) {
return explicitBuildId
}
if (process.env.GITHUB_RUN_ID) {
return [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join('-')
}
const prefix = commitSha !== 'unknown' ? commitSha.slice(0, 12) : 'local'
return `${prefix}-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`
}
function releaseUrlPath(value, basePath = '/') {
if (!value || value.startsWith('#') || /^(?:data|mailto|tel|javascript):/i.test(value)) {
return ''
}
try {
const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`
const url = new URL(value, `https://release.local${normalizedBasePath}`)
if (url.origin !== 'https://release.local') {
return ''
}
return url.pathname
} catch {
return ''
}
}
function releaseManifestDistPath(outputDirectory, urlPath) {
try {
const normalizedPath = decodeURIComponent(new URL(urlPath, 'https://release.local').pathname).replace(/^\/+/, '')
const resolvedPath = path.resolve(outputDirectory, normalizedPath)
const resolvedOutputDirectory = path.resolve(outputDirectory)
if (
resolvedPath !== resolvedOutputDirectory &&
!resolvedPath.startsWith(`${resolvedOutputDirectory}${path.sep}`)
) {
return ''
}
return resolvedPath
} catch {
return ''
}
}
function releaseManifestHash(filePath) {
const contents = fs.readFileSync(filePath)
return {
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
bytes: contents.length
}
}
function patchBuefyCssMediaQuery() { function patchBuefyCssMediaQuery() {
return { return {
name: 'patch-buefy-css-media-query', name: 'patch-buefy-css-media-query',
@@ -94,11 +170,185 @@ function releaseEntryManifest() {
} }
} }
function publicAssetAliases() {
return {
name: 'public-asset-aliases',
generateBundle() {
for (const [source, fileName] of PUBLIC_ASSET_ALIASES) {
const sourcePath = fromProjectRoot('public', source)
if (!fs.existsSync(sourcePath)) {
this.warn(`Could not find public asset alias source: ${source}`)
continue
}
this.emitFile({
type: 'asset',
fileName,
source: fs.readFileSync(sourcePath)
})
}
}
}
}
function rootPwaManifestAlias() {
return {
name: 'root-pwa-manifest-alias',
writeBundle(options) {
const configuredOutputDirectory = options.dir || 'dist'
const outputDirectory = path.isAbsolute(configuredOutputDirectory)
? configuredOutputDirectory
: fromProjectRoot(configuredOutputDirectory)
const sourcePath = path.join(outputDirectory, 'assets', 'manifest.webmanifest')
const targetPath = path.join(outputDirectory, 'manifest.webmanifest')
if (!fs.existsSync(sourcePath)) {
this.warn('Could not find assets/manifest.webmanifest for root manifest alias')
return
}
const manifest = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
manifest.icons = (manifest.icons || []).map((icon) => {
if (
!icon.src ||
icon.src.startsWith('/') ||
icon.src.startsWith('assets/') ||
/^[a-z][a-z0-9+.-]*:/i.test(icon.src)
) {
return icon
}
return {
...icon,
src: `assets/${icon.src}`
}
})
fs.writeFileSync(targetPath, JSON.stringify(manifest))
}
}
}
function releaseMetadataManifest() {
let outputDirectory = fromProjectRoot('dist')
return {
name: 'release-metadata-manifest',
enforce: 'post',
configResolved(config) {
outputDirectory = path.isAbsolute(config.build.outDir)
? config.build.outDir
: path.resolve(config.root, config.build.outDir)
},
closeBundle() {
const releaseEntryPath = path.join(outputDirectory, 'release-entry.json')
const indexPath = path.join(outputDirectory, 'index.html')
if (!fs.existsSync(releaseEntryPath)) {
this.error('Could not find release-entry.json for release-manifest.json')
return
}
if (!fs.existsSync(indexPath)) {
this.error('Could not find index.html for release-manifest.json')
return
}
const releaseEntry = JSON.parse(fs.readFileSync(releaseEntryPath, 'utf8'))
const indexHtml = fs.readFileSync(indexPath, 'utf8')
const rootFiles = fs.readdirSync(outputDirectory)
const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g))
.map((match) => releaseUrlPath(match[1], '/index.html'))
.filter(Boolean)
const releaseEntryAssetUrls = [
releaseEntry.entry ? `/${releaseEntry.entry}` : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
].filter(Boolean)
const pwaAssetUrls = [
'manifest.json',
'manifest.webmanifest',
'assets/manifest.webmanifest',
'favicon.ico',
'favicon_default.ico',
'pleno-favicon.ico',
'registerSW.js',
'sw.js',
...rootFiles.filter((fileName) => /^workbox-[^/]+\.js$/.test(fileName))
]
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
const absoluteManifestPath = path.join(outputDirectory, manifestPath)
if (!fs.existsSync(absoluteManifestPath)) {
continue
}
try {
const pwaManifest = JSON.parse(fs.readFileSync(absoluteManifestPath, 'utf8'))
for (const icon of pwaManifest.icons || []) {
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
if (iconPath) {
pwaAssetUrls.push(iconPath)
}
}
} catch {
this.warn(`Could not parse ${manifestPath} while generating release-manifest.json`)
}
}
const criticalAssetUrls = Array.from(new Set([
'/index.html',
'/release-entry.json',
...indexAssetUrls,
...releaseEntryAssetUrls,
...pwaAssetUrls
]))
const missingAssets = []
const assetHashes = {}
for (const assetUrl of criticalAssetUrls) {
const assetPath = releaseManifestDistPath(outputDirectory, assetUrl)
if (!assetPath || !fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
missingAssets.push(assetUrl)
continue
}
assetHashes[assetUrl] = releaseManifestHash(assetPath)
}
if (missingAssets.length > 0) {
this.error(`Critical release assets are missing from dist: ${missingAssets.join(', ')}`)
return
}
const commitSha = getGitCommitSha()
fs.writeFileSync(
path.join(outputDirectory, 'release-manifest.json'),
JSON.stringify(
{
schema_version: 1,
build_id: releaseBuildId(commitSha),
commit_sha: commitSha,
created_at: new Date().toISOString(),
entry: releaseEntry.entry,
css: Array.isArray(releaseEntry.css) ? releaseEntry.css : [],
index_asset_urls: Array.from(new Set(indexAssetUrls)),
pwa_asset_urls: Array.from(new Set(pwaAssetUrls)),
asset_urls: criticalAssetUrls,
asset_hashes: assetHashes
},
null,
2
)
)
}
}
}
export function createApiProxyOptions(env = process.env) { export function createApiProxyOptions(env = process.env) {
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false' const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
return { return {
target: env.VITE_API_PROXY_TARGET || 'https://api.truckwash.io:4433', target: env.VITE_API_PROXY_TARGET || 'http://localhost',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
...(stripPrefix ...(stripPrefix
@@ -120,25 +370,7 @@ export default defineConfig(({ mode }) => {
process.env.COMMIT_HASH = commit process.env.COMMIT_HASH = commit
process.env.APP_VERSION = version process.env.APP_VERSION = version
process.env.IS_DEV = !isProd ? 'true' : 'false' process.env.IS_DEV = !isProd ? 'true' : 'false'
// If the build is a production build, update the server with the new version // Server version updates happen after deploy verification, not during asset builds.
if (isProd) {
console.log('Updating server with new version...')
const token = process.env.SERVER_UPDATE_TOKEN
if (!token) {
console.warn('SERVER_UPDATE_TOKEN is not set. Skipping server update.')
} else {
// Run GET request to update version endpoint
const url = `https://api.truckwash.io/worker/update-version?version=${commit}`
const bearer = `Bearer ${token}`
console.log(`Calling URL: ${url}`)
try {
execSync(`curl -f --ssl-no-revoke -X GET "${url}" -H "Authorization: ${bearer}"`)
console.log('Successfully updated server with new version.')
} catch (error) {
console.error('Failed to update server with new version:', error.message)
}
}
}
// Keep a relative base for static hosting; align manifest with base // Keep a relative base for static hosting; align manifest with base
const base = '/' const base = '/'
const pwaScope = base === './' ? '.' : base const pwaScope = base === './' ? '.' : base
@@ -153,11 +385,13 @@ export default defineConfig(({ mode }) => {
vue(), vue(),
VueJsx(), VueJsx(),
releaseEntryManifest(), releaseEntryManifest(),
publicAssetAliases(),
!isProd && !isPlaywrightRuntime && vueDevTools(), !isProd && !isPlaywrightRuntime && vueDevTools(),
enableSingleFile && viteSingleFile(), enableSingleFile && viteSingleFile(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
injectRegister: isProd ? 'auto' : false, injectRegister: isProd ? 'auto' : false,
manifestFilename: 'assets/manifest.webmanifest',
strategies: 'generateSW', strategies: 'generateSW',
devOptions: { devOptions: {
enabled: false // keep for local dev enabled: false // keep for local dev
@@ -167,12 +401,12 @@ export default defineConfig(({ mode }) => {
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
skipWaiting: true, skipWaiting: true,
clientsClaim: true, clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'], globPatterns: ['**/*.{js,css,html,ico,svg,png,webmanifest,woff2}'],
globIgnores: ['**/registerSW.js', '**/sw.js'], globIgnores: ['**/registerSW.js', '**/sw.js'],
runtimeCaching: [ runtimeCaching: [
{ {
// cache API responses // cache API responses
urlPattern: ({ url }) => url.origin === 'https://api.truckwash.io', urlPattern: ({ url }) => url.origin === 'https://api-v2.truckwash.io',
handler: 'NetworkFirst', handler: 'NetworkFirst',
options: { options: {
cacheName: 'pleno-api-cache', cacheName: 'pleno-api-cache',
@@ -241,7 +475,9 @@ export default defineConfig(({ mode }) => {
id: 'pleno-pwa-test' id: 'pleno-pwa-test'
}, },
useCredentials: true useCredentials: true
}) }),
rootPwaManifestAlias(),
releaseMetadataManifest()
].filter(Boolean), ].filter(Boolean),
resolve: { resolve: {
alias: { alias: {