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:
@@ -0,0 +1,15 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.webm binary
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Frontend Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-upload-and-verify:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
RELEASE_BASE_URL: https://dev.truckwash.io
|
||||
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 30
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 300
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Source and i18n checks
|
||||
run: |
|
||||
npm run text:check-encoding
|
||||
npm run i18n:v2:source-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
- name: Build release artifact
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Production Playwright gate
|
||||
run: npm run test:e2e:prod
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||
path: dist
|
||||
retention-days: 14
|
||||
|
||||
- name: Install lftp
|
||||
run: |
|
||||
if ! command -v lftp >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lftp
|
||||
fi
|
||||
|
||||
- name: Upload hashed assets before release metadata
|
||||
run: |
|
||||
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
|
||||
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
|
||||
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
|
||||
npm run release:upload:lftp
|
||||
env:
|
||||
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
|
||||
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
|
||||
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
|
||||
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
|
||||
|
||||
- name: Wait for exact uploaded build
|
||||
run: npm run release:verify-upload
|
||||
|
||||
- name: Public live Playwright gate
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
curl --fail --show-error --silent \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
|
||||
- name: Update server version after verification
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ github.sha }}
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||
path: output/playwright
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION: string,
|
||||
readonly VITE_COMMIT_HASH: string,
|
||||
readonly VITE_IS_DEV: string,
|
||||
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
|
||||
+4
-5
@@ -2,11 +2,10 @@
|
||||
<html lang="" class="theme-light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
||||
@@ -5,14 +5,27 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /release-entry.json {
|
||||
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
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";
|
||||
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/ {
|
||||
@@ -20,14 +33,18 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ^~ /internal/frontend/assets/ {
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||
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;
|
||||
}
|
||||
|
||||
location ^~ /internal/frontend/ {
|
||||
rewrite ^/internal/frontend/(.*)$ /$1 break;
|
||||
location ~ ^/(master|beta|canary|internal)/frontend$ {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/ {
|
||||
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,12 @@
|
||||
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
||||
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
||||
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
||||
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
|
||||
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
|
||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||
"release:verify-upload": "node scripts/release/verify-upload.mjs",
|
||||
"release:update-server-version": "node scripts/release/update-server-version.mjs",
|
||||
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
|
||||
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
||||
"test:all": "npm run test:unit && npm run test:e2e:pr",
|
||||
"twa:build": "bubblewrap build",
|
||||
|
||||
+42
-2
@@ -4,10 +4,50 @@
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule \.[^/]+$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . index.html [L]
|
||||
</IfModule>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicons/web-app-manifest-192x192.png",
|
||||
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicons/web-app-manifest-512x512.png",
|
||||
"src": "assets/favicons/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = process.env.SERVER_UPDATE_TOKEN;
|
||||
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
||||
if (!token) {
|
||||
if (required) {
|
||||
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
|
||||
}
|
||||
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
|
||||
return;
|
||||
}
|
||||
|
||||
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
|
||||
if (!version) {
|
||||
throw new Error("Could not determine release version for server update.");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set("version", version);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
console.log(`Server version updated to ${version}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
|
||||
HOST="${RELEASE_DEPLOY_HOST:-}"
|
||||
USER_NAME="${RELEASE_DEPLOY_USER:-}"
|
||||
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
|
||||
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
|
||||
|
||||
if [[ ! -d "$DIST_DIR" ]]; then
|
||||
echo "Missing dist directory: $DIST_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v lftp >/dev/null 2>&1; then
|
||||
echo "lftp is required for FTP/SFTP uploads." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DEPLOY_URL" ]]; then
|
||||
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
|
||||
fi
|
||||
|
||||
upload_file() {
|
||||
local source_file="$1"
|
||||
local remote_file="$2"
|
||||
if [[ -f "$source_file" ]]; then
|
||||
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
|
||||
fi
|
||||
}
|
||||
|
||||
for directory in assets resources favicons icons img sounds .well-known; do
|
||||
if [[ -d "$DIST_DIR/$directory" ]]; then
|
||||
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
|
||||
fi
|
||||
done
|
||||
|
||||
find "$DIST_DIR" -maxdepth 1 -type f \
|
||||
! -name 'index.html' \
|
||||
! -name 'release-entry.json' \
|
||||
! -name 'release-manifest.json' \
|
||||
-print0 | while IFS= read -r -d '' file; do
|
||||
upload_file "$file" "$(basename "$file")"
|
||||
done
|
||||
|
||||
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
|
||||
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
|
||||
upload_file "$DIST_DIR/index.html" "index.html"
|
||||
@@ -0,0 +1,215 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function numberEnv(name, fallback) {
|
||||
const value = Number.parseInt(process.env[name] || "", 10);
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function requiredUrl() {
|
||||
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
||||
if (!value) {
|
||||
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
|
||||
}
|
||||
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function pathUrl(baseUrl, assetPath) {
|
||||
const path = String(assetPath || "").replace(/^\/+/, "");
|
||||
return new URL(path, baseUrl).href;
|
||||
}
|
||||
|
||||
async function fetchBytes(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
});
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
response,
|
||||
bytes,
|
||||
text: () => bytes.toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(baseUrl, assetPath) {
|
||||
const url = pathUrl(baseUrl, assetPath);
|
||||
const result = await fetchBytes(url);
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
|
||||
}
|
||||
if (contentType.includes("text/html")) {
|
||||
throw new Error(`${assetPath} was served as HTML`);
|
||||
}
|
||||
if (result.bytes.length === 0) {
|
||||
throw new Error(`${assetPath} was empty`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(result.text());
|
||||
} catch (error) {
|
||||
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function compareCommit(actual, expected) {
|
||||
if (!expected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return actual === expected || actual.startsWith(expected);
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function shouldRejectHtml(assetPath) {
|
||||
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
|
||||
}
|
||||
|
||||
async function verifyAsset(baseUrl, assetPath, expectedHash) {
|
||||
const url = pathUrl(baseUrl, assetPath);
|
||||
const result = await fetchBytes(url);
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
|
||||
}
|
||||
if (result.bytes.length === 0) {
|
||||
throw new Error(`${assetPath} was empty`);
|
||||
}
|
||||
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
|
||||
throw new Error(`${assetPath} was served as HTML (${contentType})`);
|
||||
}
|
||||
if (expectedHash?.sha256) {
|
||||
const actualHash = sha256(result.bytes);
|
||||
if (actualHash !== expectedHash.sha256) {
|
||||
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyShell(baseUrl, shellPath) {
|
||||
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
const body = result.text();
|
||||
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
|
||||
}
|
||||
if (!contentType.includes("text/html")) {
|
||||
throw new Error(`${shellPath} did not return HTML (${contentType})`);
|
||||
}
|
||||
if (body.replace(/\s+/g, "").length < 40) {
|
||||
throw new Error(`${shellPath} returned an empty app shell`);
|
||||
}
|
||||
if (!body.includes('<div id="app"></div>')) {
|
||||
throw new Error(`${shellPath} did not include the Vue app root`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyRelease(baseUrl) {
|
||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
||||
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||
|
||||
if (!manifest.build_id) {
|
||||
throw new Error("release-manifest.json is missing build_id");
|
||||
}
|
||||
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
||||
}
|
||||
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||
}
|
||||
if (releaseEntry.entry !== manifest.entry) {
|
||||
throw new Error("release-entry.json entry does not match release-manifest.json");
|
||||
}
|
||||
|
||||
const releaseCss = JSON.stringify(releaseEntry.css || []);
|
||||
const manifestCss = JSON.stringify(manifest.css || []);
|
||||
if (releaseCss !== manifestCss) {
|
||||
throw new Error("release-entry.json css does not match release-manifest.json");
|
||||
}
|
||||
|
||||
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
|
||||
for (const shellPath of shellPaths) {
|
||||
await verifyShell(baseUrl, shellPath);
|
||||
}
|
||||
|
||||
const assetUrls = unique([
|
||||
"release-manifest.json",
|
||||
"release-entry.json",
|
||||
manifest.entry,
|
||||
...(manifest.css || []),
|
||||
...(manifest.index_asset_urls || []),
|
||||
...(manifest.pwa_asset_urls || []),
|
||||
...(manifest.asset_urls || []),
|
||||
]);
|
||||
|
||||
for (const assetUrl of assetUrls) {
|
||||
if (assetUrl === "/index.html") {
|
||||
continue;
|
||||
}
|
||||
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
|
||||
}
|
||||
|
||||
return {
|
||||
build_id: manifest.build_id,
|
||||
commit_sha: manifest.commit_sha,
|
||||
assets: assetUrls.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl = requiredUrl();
|
||||
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
|
||||
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
|
||||
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
|
||||
|
||||
if (initialWaitSeconds > 0) {
|
||||
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
|
||||
await sleep(initialWaitSeconds * 1000);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
let attempt = 0;
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
attempt += 1;
|
||||
try {
|
||||
const result = await verifyRelease(baseUrl);
|
||||
console.log(
|
||||
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
|
||||
if (Date.now() > deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(pollIntervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Release upload verification timed out.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -6,8 +6,11 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
|
||||
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
import {
|
||||
buildSelfServeDynamicImageUrl,
|
||||
getSelfServeDynamicImageButtonsToPress,
|
||||
getSelfServeDynamicImageThumbPosition,
|
||||
} from "@/services/selfServeDynamicImage.js";
|
||||
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
@@ -138,36 +141,9 @@ const canClearAnswers = computed(() => (
|
||||
));
|
||||
const hideDynamicImage = ref(false);
|
||||
|
||||
const parseNonNegativeInt = (value) => {
|
||||
const parsed = parseInt(value);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const dynamicImageButtons = computed(() => getSelfServeDynamicImageButtonsToPress(activeTasks.value));
|
||||
|
||||
const dynamicImageButtons = computed(() => {
|
||||
const buttonIds = new Map();
|
||||
|
||||
activeTasks.value.forEach((task) => {
|
||||
normalizeSelfServeTaskButtons(task?.buttons).forEach((button) => {
|
||||
buttonIds.set(`${typeof button}:${button}`, button);
|
||||
});
|
||||
});
|
||||
|
||||
return [...buttonIds.values()];
|
||||
});
|
||||
|
||||
const dynamicImageThumbPosition = computed(() => {
|
||||
for (const task of activeTasks.value) {
|
||||
const parsedPosition = parseNonNegativeInt(task?.dynamic_images_vehicle_type);
|
||||
if (parsedPosition !== null) {
|
||||
return parsedPosition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
const dynamicImageThumbPosition = computed(() => getSelfServeDynamicImageThumbPosition(activeTasks.value));
|
||||
|
||||
const hasDynamicImageContext = computed(() => (
|
||||
dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null
|
||||
@@ -180,23 +156,15 @@ const dynamicImageUrl = computed(() => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
department: String(departmentId),
|
||||
lane: String(laneId),
|
||||
current_step: "0",
|
||||
buttons: JSON.stringify(dynamicImageButtons.value),
|
||||
});
|
||||
|
||||
const selectedVehicleType = parseInt(selectedVehicleTypeId.value);
|
||||
if (!Number.isNaN(selectedVehicleType) && selectedVehicleType > 0) {
|
||||
params.set("vehicle_type", String(selectedVehicleType));
|
||||
}
|
||||
|
||||
if (dynamicImageThumbPosition.value !== null) {
|
||||
params.set("thumb_position", String(dynamicImageThumbPosition.value));
|
||||
}
|
||||
|
||||
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
|
||||
return buildSelfServeDynamicImageUrl({
|
||||
departmentId,
|
||||
laneId,
|
||||
buttons: dynamicImageButtons.value,
|
||||
currentStep: 0,
|
||||
vehicleTypeId: !Number.isNaN(selectedVehicleType) && selectedVehicleType > 0 ? selectedVehicleType : null,
|
||||
thumbPosition: dynamicImageThumbPosition.value,
|
||||
});
|
||||
});
|
||||
|
||||
const displayedDynamicImageUrl = computed(() => (
|
||||
|
||||
@@ -48,15 +48,32 @@ const getNonImageAttachments = (task: any) => {
|
||||
return task.attachments.filter((attachment: any) => !isImageAttachment(attachment));
|
||||
};
|
||||
|
||||
const normalizeTaskService = (service: any) => String(service || "").trim().toUpperCase();
|
||||
|
||||
const getTaskServiceTokens = (task: any) => {
|
||||
if (!Array.isArray(task?.services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return task.services
|
||||
.map((service: any) => normalizeTaskService(service))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const taskUsesProgramPicker = (task: any) => getTaskServiceTokens(task).includes("PROGRAM_PICKER");
|
||||
|
||||
const getVisibleServices = (task: any) => {
|
||||
if (!Array.isArray(task?.services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return task.services.filter((service: any) => String(service || "").trim().toUpperCase() !== "MACHINE");
|
||||
return task.services.filter((service: any) => normalizeTaskService(service) !== "MACHINE");
|
||||
};
|
||||
|
||||
const getProgramWheelSelection = (task: any) => {
|
||||
if (!taskUsesProgramPicker(task)) {
|
||||
return null;
|
||||
}
|
||||
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
||||
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { BIcon } from "buefy";
|
||||
import { ref, watch } from "vue";
|
||||
import { BIcon, BSkeleton } from "buefy";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isLoading: boolean;
|
||||
dynamicImageUrl: string | null;
|
||||
activeTasks: Array<any>;
|
||||
@@ -17,6 +18,12 @@ const emit = defineEmits<{
|
||||
(e: "clear-dynamic-image"): void;
|
||||
}>();
|
||||
|
||||
const isDynamicImageLoading = ref(false);
|
||||
|
||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
}, { immediate: true });
|
||||
|
||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
emit("toggle-task", taskId, value);
|
||||
};
|
||||
@@ -24,6 +31,15 @@ const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
|
||||
emit("download-attachment", taskId, attachmentId, attachment);
|
||||
};
|
||||
|
||||
const onDynamicImageLoad = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
};
|
||||
|
||||
const onDynamicImageError = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
emit("clear-dynamic-image");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,13 +58,27 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="dynamicImageUrl" class="mb-4">
|
||||
<div
|
||||
v-if="dynamicImageUrl"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
||||
>
|
||||
<b-skeleton
|
||||
v-if="isDynamicImageLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
width="100%"
|
||||
height="100%"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
/>
|
||||
<img
|
||||
:key="dynamicImageUrl"
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
|
||||
@error="emit('clear-dynamic-image')"
|
||||
class="self-serve-dynamic-image"
|
||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
||||
@load="onDynamicImageLoad"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
||||
@@ -64,3 +94,40 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-dynamic-image-frame {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-frame.is-loading {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
min-height: 180px;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 : []
|
||||
);
|
||||
const selectedRelease = computed(() => selectedDetail.value?.release || selectedSession.value?.release || {});
|
||||
const timelineLoading = computed(() => busy.value === "timeline:search");
|
||||
const timelineRefreshing = computed(() => timelineLoading.value && (sessionRows.value.length > 0 || timelineEvents.value.length > 0));
|
||||
const sessionRowsLoading = computed(() => timelineLoading.value && sessionRows.value.length === 0);
|
||||
const timelineEventsLoading = computed(() => timelineLoading.value && timelineEvents.value.length === 0);
|
||||
|
||||
const filterPayload = () => {
|
||||
const payload = {};
|
||||
@@ -382,6 +386,17 @@ onMounted(loadReplayData);
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="timelineRefreshing"
|
||||
class="release-replay-inspector__refreshing"
|
||||
data-testid="release-timeline-refreshing"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ trFallback("loading.refreshing_timeline", "Refreshing release timeline...") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container mt-3">
|
||||
<table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions">
|
||||
<thead>
|
||||
@@ -397,6 +412,15 @@ onMounted(loadReplayData);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="sessionRowsLoading" data-testid="release-timeline-sessions-loading">
|
||||
<td colspan="8">
|
||||
<div class="release-replay-inspector__loading" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ trFallback("loading.timeline_sessions", "Loading release timeline sessions...") }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-for="session in sessionRows" :key="session.trace_id">
|
||||
<td><code>{{ shortValue(session.trace_id) }}</code></td>
|
||||
<td>{{ userLabel(session) }}</td>
|
||||
@@ -427,7 +451,8 @@ onMounted(loadReplayData);
|
||||
</ActionSettingsWheelButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="sessionRows.length === 0">
|
||||
</template>
|
||||
<tr v-if="!sessionRowsLoading && sessionRows.length === 0">
|
||||
<td colspan="8">{{ tr("replay.no_timeline_events") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -559,7 +584,17 @@ onMounted(loadReplayData);
|
||||
</div>
|
||||
</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">
|
||||
<time>{{ formatDate(event.occurred_at) }}</time>
|
||||
<strong>{{ event.event_type }}</strong>
|
||||
@@ -585,6 +620,27 @@ onMounted(loadReplayData);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.release-replay-inspector__loading,
|
||||
.release-replay-inspector__refreshing {
|
||||
align-items: center;
|
||||
color: #52627a;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.release-replay-inspector__refreshing {
|
||||
background: #eef6ff;
|
||||
border: 1px solid #cfe3ff;
|
||||
border-radius: 6px;
|
||||
color: #17416f;
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.release-session-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -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_START = "start";
|
||||
export const SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER = "program_picker";
|
||||
|
||||
export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
|
||||
{
|
||||
@@ -7,6 +8,11 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
|
||||
name: "Reset",
|
||||
description: "Reset button",
|
||||
},
|
||||
{
|
||||
id: SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
|
||||
name: "Program picker",
|
||||
description: "Program picker wheel",
|
||||
},
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index,
|
||||
name: `Program ${index + 1}`,
|
||||
@@ -22,6 +28,7 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
|
||||
const KNOWN_SPECIAL_BUTTONS = new Set([
|
||||
SELF_SERVE_TASK_BUTTON_RESET,
|
||||
SELF_SERVE_TASK_BUTTON_START,
|
||||
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
|
||||
]);
|
||||
|
||||
const BUTTON_LABELS = new Map(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
import {
|
||||
buildSelfServeDynamicImageUrl,
|
||||
getSelfServeCompletedDynamicImageStep,
|
||||
getSelfServeDynamicImageButtonsToPress,
|
||||
getSelfServeDynamicImageThumbPosition,
|
||||
getSelfServeTaskDynamicImageButtons,
|
||||
} from "@/services/selfServeDynamicImage.js";
|
||||
|
||||
export function useWashSessionActions(options) {
|
||||
const {
|
||||
@@ -53,21 +58,12 @@ export function useWashSessionActions(options) {
|
||||
return message || fallback;
|
||||
};
|
||||
|
||||
const getTaskButtons = (task) => normalizeSelfServeTaskButtons(
|
||||
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
|
||||
);
|
||||
const getTaskButtons = (task) => getSelfServeTaskDynamicImageButtons(task);
|
||||
|
||||
const getButtonsToPress = () => activeTasks.value.flatMap((task) => getTaskButtons(task));
|
||||
const getButtonsToPress = () => getSelfServeDynamicImageButtonsToPress(activeTasks.value);
|
||||
|
||||
const getCompletedButtons = () => {
|
||||
let totalButtonsCompleted = 0;
|
||||
|
||||
activeTasks.value.forEach((task) => {
|
||||
if (completedTasks.value[task.id]) {
|
||||
totalButtonsCompleted += getTaskButtons(task).length;
|
||||
}
|
||||
});
|
||||
|
||||
const totalButtonsCompleted = getSelfServeCompletedDynamicImageStep(activeTasks.value, completedTasks.value);
|
||||
machineStartCurrentStep.value = totalButtonsCompleted;
|
||||
return totalButtonsCompleted;
|
||||
};
|
||||
@@ -85,18 +81,6 @@ export function useWashSessionActions(options) {
|
||||
clearProgress();
|
||||
};
|
||||
|
||||
const getThumbPosition = () => {
|
||||
for (const task of activeTasks.value) {
|
||||
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
||||
const thumbPosition = Number.parseInt(String(rawValue ?? ""), 10);
|
||||
if (Number.isInteger(thumbPosition) && thumbPosition >= 1 && thumbPosition <= 12) {
|
||||
return thumbPosition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const dynamicImageUrl = computed(() => {
|
||||
const departmentId = nearestDepartment.value?.id;
|
||||
const laneId = washLaneId.value;
|
||||
@@ -106,22 +90,14 @@ export function useWashSessionActions(options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
department: String(departmentId),
|
||||
lane: String(laneId),
|
||||
current_step: String(getCompletedButtons()),
|
||||
buttons: JSON.stringify(getButtonsToPress()),
|
||||
return buildSelfServeDynamicImageUrl({
|
||||
departmentId,
|
||||
laneId,
|
||||
buttons: getButtonsToPress(),
|
||||
currentStep: getCompletedButtons(),
|
||||
vehicleTypeId: vehicleTypeSelect.value,
|
||||
thumbPosition: getSelfServeDynamicImageThumbPosition(activeTasks.value),
|
||||
});
|
||||
if (vehicleTypeSelect.value !== null && vehicleTypeSelect.value !== undefined && vehicleTypeSelect.value !== "") {
|
||||
params.set("vehicle_type", String(vehicleTypeSelect.value));
|
||||
}
|
||||
|
||||
const thumbPosition = getThumbPosition();
|
||||
if (thumbPosition !== null) {
|
||||
params.set("thumb_position", String(thumbPosition));
|
||||
}
|
||||
|
||||
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
|
||||
});
|
||||
|
||||
const executeSelfServeCommand = async (
|
||||
|
||||
+19
-4
@@ -16,22 +16,36 @@ const parseBoolean = (value, fallback = false) => {
|
||||
|
||||
const normalizeApiUrl = (value) => String(value || "").replace(/\/+$/, "");
|
||||
|
||||
export const IS_DEV = parseBoolean(import.meta.env.VITE_IS_DEV, import.meta.env.MODE !== "production");
|
||||
export const IS_DEV = parseBoolean(
|
||||
import.meta.env.VITE_IS_DEV,
|
||||
import.meta.env.DEV ?? import.meta.env.MODE !== "production"
|
||||
);
|
||||
const normalizeReleaseSource = (value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (["local", "deployment", "auto"].includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return IS_DEV ? "local" : "deployment";
|
||||
};
|
||||
export const RELEASE_SOURCE_ENV = String(import.meta.env.VITE_RELEASE_SOURCE || "").trim().toLowerCase();
|
||||
export const RELEASE_SOURCE = normalizeReleaseSource(RELEASE_SOURCE_ENV);
|
||||
export const DEFAULT_PUBLIC_GATEWAY_API_URL = "https://api-v2.truckwash.io";
|
||||
export const DEFAULT_STABLE_API_URL = `${DEFAULT_PUBLIC_GATEWAY_API_URL}/master/api`;
|
||||
|
||||
// Development mode
|
||||
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
|
||||
export const API_URL = normalizeApiUrl(
|
||||
import.meta.env.VITE_API_URL || (IS_DEV ? "/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(
|
||||
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL
|
||||
);
|
||||
export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl(
|
||||
import.meta.env.VITE_RELEASE_PUBLIC_GATEWAY_API_URL || "https://api-v2.truckwash.io"
|
||||
import.meta.env.VITE_RELEASE_PUBLIC_GATEWAY_API_URL || DEFAULT_PUBLIC_GATEWAY_API_URL
|
||||
);
|
||||
export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
|
||||
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS ||
|
||||
(IS_DEV ? "https://api-v2.truckwash.io,https://api.truckwash.io" : "")
|
||||
(IS_DEV ? `${DEFAULT_STABLE_API_URL},${DEFAULT_PUBLIC_GATEWAY_API_URL}` : "")
|
||||
)
|
||||
.split(",")
|
||||
.map((url) => normalizeApiUrl(url.trim()))
|
||||
@@ -47,6 +61,7 @@ export const ALLOWED_ORIGINS = [
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:4173",
|
||||
"http://127.0.0.1:4174",
|
||||
"https://dev.truckwash.io",
|
||||
];
|
||||
export const MIGRATION_ORIGIN = "https://truckwash.io";
|
||||
|
||||
|
||||
@@ -2494,6 +2494,23 @@
|
||||
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Indlæser Release Manager-data...",
|
||||
"refreshing_summary": "Opdaterer Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments og service-health indlæses.",
|
||||
"channels": "Indlæser release-kanaler...",
|
||||
"channels_detail": "Kanalbaner og release-afhængigheder indlæses.",
|
||||
"assignments": "Indlæser release-tildelinger...",
|
||||
"deployments": "Indlæser release-deployments...",
|
||||
"operations": "Indlæser release-operationer...",
|
||||
"data_services": "Indlæser release-datatjenester...",
|
||||
"integrations": "Indlæser release-integrationer...",
|
||||
"settings": "Indlæser Release Manager-indstillinger...",
|
||||
"github_repositories": "Indlæser GitHub-repositories...",
|
||||
"timeline_sessions": "Indlæser release-tidslinjesessioner...",
|
||||
"timeline_events": "Indlæser release-tidslinjehændelser...",
|
||||
"refreshing_timeline": "Opdaterer release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- og Coolify-mål",
|
||||
|
||||
@@ -2604,6 +2604,23 @@
|
||||
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Release Manager-Daten werden geladen...",
|
||||
"refreshing_summary": "Release Manager-Daten werden aktualisiert...",
|
||||
"summary_detail": "Release-Status, Kanale, Deployments und Servicezustand werden geladen.",
|
||||
"channels": "Release-Kanale werden geladen...",
|
||||
"channels_detail": "Kanalansichten und Release-Abhangigkeiten werden geladen.",
|
||||
"assignments": "Release-Zuweisungen werden geladen...",
|
||||
"deployments": "Release-Deployments werden geladen...",
|
||||
"operations": "Release-Operationen werden geladen...",
|
||||
"data_services": "Release-Datendienste werden geladen...",
|
||||
"integrations": "Release-Integrationen werden geladen...",
|
||||
"settings": "Release Manager-Einstellungen werden geladen...",
|
||||
"github_repositories": "GitHub-Repositories werden geladen...",
|
||||
"timeline_sessions": "Release-Timeline-Sessions werden geladen...",
|
||||
"timeline_events": "Release-Timeline-Ereignisse werden geladen...",
|
||||
"refreshing_timeline": "Release-Timeline wird aktualisiert..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanäle",
|
||||
"targets": "GitHub- und Coolify-Ziele",
|
||||
|
||||
@@ -2328,6 +2328,23 @@
|
||||
"description": "Configure Release Manager GitHub access and webhook settings."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Loading release manager data...",
|
||||
"refreshing_summary": "Refreshing release manager data...",
|
||||
"summary_detail": "Release status, channels, deployments, and service health are being loaded.",
|
||||
"channels": "Loading release channels...",
|
||||
"channels_detail": "Channel lanes and release dependencies are being loaded.",
|
||||
"assignments": "Loading release assignments...",
|
||||
"deployments": "Loading release deployments...",
|
||||
"operations": "Loading release operations...",
|
||||
"data_services": "Loading release data services...",
|
||||
"integrations": "Loading release integrations...",
|
||||
"settings": "Loading Release Manager settings...",
|
||||
"github_repositories": "Loading GitHub repositories...",
|
||||
"timeline_sessions": "Loading release timeline sessions...",
|
||||
"timeline_events": "Loading release timeline events...",
|
||||
"refreshing_timeline": "Refreshing release timeline..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Channels",
|
||||
"targets": "GitHub and Coolify targets",
|
||||
|
||||
@@ -2605,6 +2605,23 @@
|
||||
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Laster Release Manager-data...",
|
||||
"refreshing_summary": "Oppdaterer Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments og servicehelse lastes.",
|
||||
"channels": "Laster release-kanaler...",
|
||||
"channels_detail": "Kanalvisninger og release-avhengigheter lastes.",
|
||||
"assignments": "Laster release-tildelinger...",
|
||||
"deployments": "Laster release-deployments...",
|
||||
"operations": "Laster release-operasjoner...",
|
||||
"data_services": "Laster release-datatjenester...",
|
||||
"integrations": "Laster release-integrasjoner...",
|
||||
"settings": "Laster Release Manager-innstillinger...",
|
||||
"github_repositories": "Laster GitHub-repositories...",
|
||||
"timeline_sessions": "Laster release-tidslinje-sesjoner...",
|
||||
"timeline_events": "Laster release-tidslinjehendelser...",
|
||||
"refreshing_timeline": "Oppdaterer release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- og Coolify-mål",
|
||||
|
||||
@@ -2655,6 +2655,23 @@
|
||||
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Laddar Release Manager-data...",
|
||||
"refreshing_summary": "Uppdaterar Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments och servicehalsa laddas.",
|
||||
"channels": "Laddar release-kanaler...",
|
||||
"channels_detail": "Kanalvyer och release-beroenden laddas.",
|
||||
"assignments": "Laddar release-tilldelningar...",
|
||||
"deployments": "Laddar release-deployments...",
|
||||
"operations": "Laddar release-operationer...",
|
||||
"data_services": "Laddar release-datatjanster...",
|
||||
"integrations": "Laddar release-integrationer...",
|
||||
"settings": "Laddar Release Manager-installningar...",
|
||||
"github_repositories": "Laddar GitHub-repositories...",
|
||||
"timeline_sessions": "Laddar release-tidslinjesessioner...",
|
||||
"timeline_events": "Laddar release-tidslinjehandelser...",
|
||||
"refreshing_timeline": "Uppdaterar release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- och Coolify-mål",
|
||||
|
||||
@@ -1552,6 +1552,23 @@
|
||||
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Indl\u00e6ser Release Manager-data...",
|
||||
"refreshing_summary": "Opdaterer Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments og service-health indl\u00e6ses.",
|
||||
"channels": "Indl\u00e6ser release-kanaler...",
|
||||
"channels_detail": "Kanalbaner og release-afh\u00e6ngigheder indl\u00e6ses.",
|
||||
"assignments": "Indl\u00e6ser release-tildelinger...",
|
||||
"deployments": "Indl\u00e6ser release-deployments...",
|
||||
"operations": "Indl\u00e6ser release-operationer...",
|
||||
"data_services": "Indl\u00e6ser release-datatjenester...",
|
||||
"integrations": "Indl\u00e6ser release-integrationer...",
|
||||
"settings": "Indl\u00e6ser Release Manager-indstillinger...",
|
||||
"github_repositories": "Indl\u00e6ser GitHub-repositories...",
|
||||
"timeline_sessions": "Indl\u00e6ser release-tidslinjesessioner...",
|
||||
"timeline_events": "Indl\u00e6ser release-tidslinjeh\u00e6ndelser...",
|
||||
"refreshing_timeline": "Opdaterer release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- og Coolify-mål",
|
||||
|
||||
@@ -1552,6 +1552,23 @@
|
||||
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Release Manager-Daten werden geladen...",
|
||||
"refreshing_summary": "Release Manager-Daten werden aktualisiert...",
|
||||
"summary_detail": "Release-Status, Kanale, Deployments und Servicezustand werden geladen.",
|
||||
"channels": "Release-Kanale werden geladen...",
|
||||
"channels_detail": "Kanalansichten und Release-Abhangigkeiten werden geladen.",
|
||||
"assignments": "Release-Zuweisungen werden geladen...",
|
||||
"deployments": "Release-Deployments werden geladen...",
|
||||
"operations": "Release-Operationen werden geladen...",
|
||||
"data_services": "Release-Datendienste werden geladen...",
|
||||
"integrations": "Release-Integrationen werden geladen...",
|
||||
"settings": "Release Manager-Einstellungen werden geladen...",
|
||||
"github_repositories": "GitHub-Repositories werden geladen...",
|
||||
"timeline_sessions": "Release-Timeline-Sessions werden geladen...",
|
||||
"timeline_events": "Release-Timeline-Ereignisse werden geladen...",
|
||||
"refreshing_timeline": "Release-Timeline wird aktualisiert..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanäle",
|
||||
"targets": "GitHub- und Coolify-Ziele",
|
||||
|
||||
@@ -1552,6 +1552,23 @@
|
||||
"description": "Configure Release Manager GitHub access and webhook settings."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Loading release manager data...",
|
||||
"refreshing_summary": "Refreshing release manager data...",
|
||||
"summary_detail": "Release status, channels, deployments, and service health are being loaded.",
|
||||
"channels": "Loading release channels...",
|
||||
"channels_detail": "Channel lanes and release dependencies are being loaded.",
|
||||
"assignments": "Loading release assignments...",
|
||||
"deployments": "Loading release deployments...",
|
||||
"operations": "Loading release operations...",
|
||||
"data_services": "Loading release data services...",
|
||||
"integrations": "Loading release integrations...",
|
||||
"settings": "Loading Release Manager settings...",
|
||||
"github_repositories": "Loading GitHub repositories...",
|
||||
"timeline_sessions": "Loading release timeline sessions...",
|
||||
"timeline_events": "Loading release timeline events...",
|
||||
"refreshing_timeline": "Refreshing release timeline..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Channels",
|
||||
"targets": "GitHub and Coolify targets",
|
||||
|
||||
@@ -1552,6 +1552,23 @@
|
||||
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Laster Release Manager-data...",
|
||||
"refreshing_summary": "Oppdaterer Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments og servicehelse lastes.",
|
||||
"channels": "Laster release-kanaler...",
|
||||
"channels_detail": "Kanalvisninger og release-avhengigheter lastes.",
|
||||
"assignments": "Laster release-tildelinger...",
|
||||
"deployments": "Laster release-deployments...",
|
||||
"operations": "Laster release-operasjoner...",
|
||||
"data_services": "Laster release-datatjenester...",
|
||||
"integrations": "Laster release-integrasjoner...",
|
||||
"settings": "Laster Release Manager-innstillinger...",
|
||||
"github_repositories": "Laster GitHub-repositories...",
|
||||
"timeline_sessions": "Laster release-tidslinje-sesjoner...",
|
||||
"timeline_events": "Laster release-tidslinjehendelser...",
|
||||
"refreshing_timeline": "Oppdaterer release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- og Coolify-mål",
|
||||
|
||||
@@ -1552,6 +1552,23 @@
|
||||
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Laddar Release Manager-data...",
|
||||
"refreshing_summary": "Uppdaterar Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments och servicehalsa laddas.",
|
||||
"channels": "Laddar release-kanaler...",
|
||||
"channels_detail": "Kanalvyer och release-beroenden laddas.",
|
||||
"assignments": "Laddar release-tilldelningar...",
|
||||
"deployments": "Laddar release-deployments...",
|
||||
"operations": "Laddar release-operationer...",
|
||||
"data_services": "Laddar release-datatjanster...",
|
||||
"integrations": "Laddar release-integrationer...",
|
||||
"settings": "Laddar Release Manager-installningar...",
|
||||
"github_repositories": "Laddar GitHub-repositories...",
|
||||
"timeline_sessions": "Laddar release-tidslinjesessioner...",
|
||||
"timeline_events": "Laddar release-tidslinjehandelser...",
|
||||
"refreshing_timeline": "Uppdaterar release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- och Coolify-mål",
|
||||
|
||||
@@ -355,6 +355,23 @@
|
||||
"description": "Konfigurer Release Managers GitHub-adgang og webhook-indstillinger."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Indl\u00e6ser Release Manager-data...",
|
||||
"refreshing_summary": "Opdaterer Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments og service-health indl\u00e6ses.",
|
||||
"channels": "Indl\u00e6ser release-kanaler...",
|
||||
"channels_detail": "Kanalbaner og release-afh\u00e6ngigheder indl\u00e6ses.",
|
||||
"assignments": "Indl\u00e6ser release-tildelinger...",
|
||||
"deployments": "Indl\u00e6ser release-deployments...",
|
||||
"operations": "Indl\u00e6ser release-operationer...",
|
||||
"data_services": "Indl\u00e6ser release-datatjenester...",
|
||||
"integrations": "Indl\u00e6ser release-integrationer...",
|
||||
"settings": "Indl\u00e6ser Release Manager-indstillinger...",
|
||||
"github_repositories": "Indl\u00e6ser GitHub-repositories...",
|
||||
"timeline_sessions": "Indl\u00e6ser release-tidslinjesessioner...",
|
||||
"timeline_events": "Indl\u00e6ser release-tidslinjeh\u00e6ndelser...",
|
||||
"refreshing_timeline": "Opdaterer release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- og Coolify-mål",
|
||||
|
||||
@@ -355,6 +355,23 @@
|
||||
"description": "GitHub-Zugriff und Webhook-Einstellungen des Release Managers konfigurieren."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Release Manager-Daten werden geladen...",
|
||||
"refreshing_summary": "Release Manager-Daten werden aktualisiert...",
|
||||
"summary_detail": "Release-Status, Kanale, Deployments und Servicezustand werden geladen.",
|
||||
"channels": "Release-Kanale werden geladen...",
|
||||
"channels_detail": "Kanalansichten und Release-Abhangigkeiten werden geladen.",
|
||||
"assignments": "Release-Zuweisungen werden geladen...",
|
||||
"deployments": "Release-Deployments werden geladen...",
|
||||
"operations": "Release-Operationen werden geladen...",
|
||||
"data_services": "Release-Datendienste werden geladen...",
|
||||
"integrations": "Release-Integrationen werden geladen...",
|
||||
"settings": "Release Manager-Einstellungen werden geladen...",
|
||||
"github_repositories": "GitHub-Repositories werden geladen...",
|
||||
"timeline_sessions": "Release-Timeline-Sessions werden geladen...",
|
||||
"timeline_events": "Release-Timeline-Ereignisse werden geladen...",
|
||||
"refreshing_timeline": "Release-Timeline wird aktualisiert..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanäle",
|
||||
"targets": "GitHub- und Coolify-Ziele",
|
||||
|
||||
@@ -355,6 +355,23 @@
|
||||
"description": "Configure Release Manager GitHub access and webhook settings."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Loading release manager data...",
|
||||
"refreshing_summary": "Refreshing release manager data...",
|
||||
"summary_detail": "Release status, channels, deployments, and service health are being loaded.",
|
||||
"channels": "Loading release channels...",
|
||||
"channels_detail": "Channel lanes and release dependencies are being loaded.",
|
||||
"assignments": "Loading release assignments...",
|
||||
"deployments": "Loading release deployments...",
|
||||
"operations": "Loading release operations...",
|
||||
"data_services": "Loading release data services...",
|
||||
"integrations": "Loading release integrations...",
|
||||
"settings": "Loading Release Manager settings...",
|
||||
"github_repositories": "Loading GitHub repositories...",
|
||||
"timeline_sessions": "Loading release timeline sessions...",
|
||||
"timeline_events": "Loading release timeline events...",
|
||||
"refreshing_timeline": "Refreshing release timeline..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Channels",
|
||||
"targets": "GitHub and Coolify targets",
|
||||
|
||||
@@ -355,6 +355,23 @@
|
||||
"description": "Konfigurer Release Managers GitHub-tilgang og webhook-innstillinger."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Laster Release Manager-data...",
|
||||
"refreshing_summary": "Oppdaterer Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments og servicehelse lastes.",
|
||||
"channels": "Laster release-kanaler...",
|
||||
"channels_detail": "Kanalvisninger og release-avhengigheter lastes.",
|
||||
"assignments": "Laster release-tildelinger...",
|
||||
"deployments": "Laster release-deployments...",
|
||||
"operations": "Laster release-operasjoner...",
|
||||
"data_services": "Laster release-datatjenester...",
|
||||
"integrations": "Laster release-integrasjoner...",
|
||||
"settings": "Laster Release Manager-innstillinger...",
|
||||
"github_repositories": "Laster GitHub-repositories...",
|
||||
"timeline_sessions": "Laster release-tidslinje-sesjoner...",
|
||||
"timeline_events": "Laster release-tidslinjehendelser...",
|
||||
"refreshing_timeline": "Oppdaterer release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- og Coolify-mål",
|
||||
|
||||
@@ -355,6 +355,23 @@
|
||||
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"summary": "Laddar Release Manager-data...",
|
||||
"refreshing_summary": "Uppdaterar Release Manager-data...",
|
||||
"summary_detail": "Release-status, kanaler, deployments och servicehalsa laddas.",
|
||||
"channels": "Laddar release-kanaler...",
|
||||
"channels_detail": "Kanalvyer och release-beroenden laddas.",
|
||||
"assignments": "Laddar release-tilldelningar...",
|
||||
"deployments": "Laddar release-deployments...",
|
||||
"operations": "Laddar release-operationer...",
|
||||
"data_services": "Laddar release-datatjanster...",
|
||||
"integrations": "Laddar release-integrationer...",
|
||||
"settings": "Laddar Release Manager-installningar...",
|
||||
"github_repositories": "Laddar GitHub-repositories...",
|
||||
"timeline_sessions": "Laddar release-tidslinjesessioner...",
|
||||
"timeline_events": "Laddar release-tidslinjehandelser...",
|
||||
"refreshing_timeline": "Uppdaterar release-tidslinje..."
|
||||
},
|
||||
"setup": {
|
||||
"channels": "Kanaler",
|
||||
"targets": "GitHub- och Coolify-mål",
|
||||
|
||||
+9
-4
@@ -98,8 +98,6 @@ const ConfigurationEconomic = lazyView('@/views/dashboards/superUserDashboard/co
|
||||
const ConfigurationReCAPTCHA = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue');
|
||||
const ConfigurationEmail = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue');
|
||||
const ConfigurationBackups = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.vue');
|
||||
const ConfigurationFailover = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationFailover.vue');
|
||||
const ConfigurationCoolify = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationCoolify.vue');
|
||||
const ConfigurationReleaseManager = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue');
|
||||
const Categories = lazyView('@/views/dashboards/superUserDashboard/Categories.vue');
|
||||
const Customers = lazyView('@/views/dashboards/superUserDashboard/Customers.vue');
|
||||
@@ -1080,19 +1078,26 @@ export const router = createRouter({
|
||||
{
|
||||
name: 'configurationFailover',
|
||||
path: '/superuser/configuration/failover',
|
||||
component: ConfigurationFailover,
|
||||
redirect: { path: '/superuser/configuration/releases/data-services', query: { panel: 'failover' } },
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'configurationCoolify',
|
||||
path: '/superuser/configuration/coolify',
|
||||
component: ConfigurationCoolify,
|
||||
redirect: { path: '/superuser/configuration/releases/integrations', query: { panel: 'coolify' } },
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'configurationReleaseManager',
|
||||
path: '/superuser/configuration/releases',
|
||||
redirect: { path: '/superuser/configuration/releases/overview' },
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'configurationReleaseManagerPanel',
|
||||
path: '/superuser/configuration/releases/:panel(overview|channels|deployments|operations|integrations|data-services|assignments|settings)',
|
||||
component: ConfigurationReleaseManager,
|
||||
props: (route) => ({ panel: route.params.panel }),
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,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";
|
||||
|
||||
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
|
||||
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
|
||||
export const RELEASE_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
|
||||
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
|
||||
export const RELEASE_SOURCE_MODES = Object.freeze({
|
||||
LOCAL: "local",
|
||||
DEPLOYMENT: "deployment",
|
||||
AUTO: "auto",
|
||||
});
|
||||
|
||||
const normalizeReleaseChannelSlug = (value) =>
|
||||
String(value || "")
|
||||
@@ -16,6 +22,16 @@ const normalizeReleaseChannelSlug = (value) =>
|
||||
|
||||
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, "");
|
||||
|
||||
const normalizeReleaseSourceValue = (value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return Object.values(RELEASE_SOURCE_MODES).includes(normalized) ? normalized : "";
|
||||
};
|
||||
|
||||
export const normalizeReleaseSourceMode = (value, fallback = RELEASE_SOURCE) =>
|
||||
normalizeReleaseSourceValue(value) ||
|
||||
normalizeReleaseSourceValue(fallback) ||
|
||||
RELEASE_SOURCE_MODES.DEPLOYMENT;
|
||||
|
||||
const browserOrigin = () => {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
@@ -48,18 +64,35 @@ const browserStorage = () => {
|
||||
const readSelectedReleaseChannel = () =>
|
||||
normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
|
||||
|
||||
const readReleaseSourceOverride = () => {
|
||||
const override = normalizeReleaseSourceValue(browserStorage()?.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY) || "");
|
||||
return override === RELEASE_SOURCE_MODES.AUTO ? "" : override;
|
||||
};
|
||||
|
||||
export const resolveReleaseSourceMode = (
|
||||
configuredSource = RELEASE_SOURCE,
|
||||
{ allowStorageOverride = false } = {}
|
||||
) => {
|
||||
const sourceMode = normalizeReleaseSourceMode(configuredSource);
|
||||
if (sourceMode !== RELEASE_SOURCE_MODES.AUTO) {
|
||||
return allowStorageOverride ? readReleaseSourceOverride() || sourceMode : sourceMode;
|
||||
}
|
||||
return readReleaseSourceOverride() || RELEASE_SOURCE_MODES.AUTO;
|
||||
};
|
||||
|
||||
const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
|
||||
const slug = normalizeReleaseChannelSlug(channelSlug);
|
||||
if (!slug || slug === "stable") {
|
||||
if (!slug) {
|
||||
return "";
|
||||
}
|
||||
const routeSlug = slug === "stable" ? "master" : slug;
|
||||
|
||||
const baseUrl = normalizeBaseUrl(gatewayBaseUrl);
|
||||
if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new URL(`${slug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
|
||||
return new URL(`${routeSlug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const buildRuntimeHeaders = () => {
|
||||
@@ -135,6 +168,9 @@ const runtimeFrontendBaseUrl = (runtime = {}) => {
|
||||
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
|
||||
|
||||
export const shouldLoadRemoteRelease = (runtime = {}) => {
|
||||
if (normalizeReleaseSourceValue(runtime?.source) === RELEASE_SOURCE_MODES.LOCAL) {
|
||||
return false;
|
||||
}
|
||||
const channel = runtimeChannel(runtime);
|
||||
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
|
||||
const isDefault = channel?.default_channel === true || channel?.default_channel === 1 || slug === "stable";
|
||||
@@ -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();
|
||||
if (!selectedChannel || selectedChannel === "stable") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return unavailableRuntime(
|
||||
return runtimeWithSource(unavailableRuntime(
|
||||
{
|
||||
channel: {
|
||||
slug: selectedChannel,
|
||||
@@ -213,7 +292,7 @@ const unavailableSelectedRuntime = (missing) => {
|
||||
},
|
||||
},
|
||||
missing
|
||||
);
|
||||
), source, requestedSource);
|
||||
};
|
||||
|
||||
export const setReleaseRuntimeGlobal = (runtime) => {
|
||||
@@ -228,18 +307,36 @@ export const bootstrapReleaseApp = async ({
|
||||
fetchFn = globalThis.fetch,
|
||||
importModule,
|
||||
documentRef = globalThis.document,
|
||||
sourceMode,
|
||||
} = {}) => {
|
||||
if (typeof loadLocalApp !== "function") {
|
||||
throw new Error("Release bootstrap requires a local app loader.");
|
||||
}
|
||||
|
||||
const hasExplicitSourceMode = sourceMode !== undefined && sourceMode !== null && String(sourceMode).trim() !== "";
|
||||
const resolvedSourceMode = resolveReleaseSourceMode(sourceMode || RELEASE_SOURCE, {
|
||||
allowStorageOverride: IS_DEV && !hasExplicitSourceMode && !normalizeReleaseSourceValue(RELEASE_SOURCE_ENV),
|
||||
});
|
||||
if (resolvedSourceMode === RELEASE_SOURCE_MODES.LOCAL) {
|
||||
setReleaseRuntimeGlobal(localReleaseRuntime({ requestedSource: resolvedSourceMode }));
|
||||
return loadLocalApp();
|
||||
}
|
||||
|
||||
let runtime = null;
|
||||
try {
|
||||
runtime = await fetchReleaseRuntime({ fetchFn });
|
||||
runtime = runtimeWithSource(
|
||||
await fetchReleaseRuntime({ fetchFn }),
|
||||
RELEASE_SOURCE_MODES.DEPLOYMENT,
|
||||
resolvedSourceMode
|
||||
);
|
||||
setReleaseRuntimeGlobal(runtime);
|
||||
} catch (error) {
|
||||
console.warn("Could not resolve release runtime before app bootstrap.", error);
|
||||
const unavailable = unavailableSelectedRuntime("release_runtime");
|
||||
const unavailable =
|
||||
unavailableSelectedRuntime("release_runtime", {
|
||||
source: RELEASE_SOURCE_MODES.LOCAL,
|
||||
requestedSource: resolvedSourceMode,
|
||||
}) || localReleaseRuntime({ requestedSource: resolvedSourceMode, missing: ["release_runtime"] });
|
||||
if (unavailable) {
|
||||
setReleaseRuntimeGlobal(unavailable);
|
||||
}
|
||||
@@ -253,7 +350,12 @@ export const bootstrapReleaseApp = async ({
|
||||
return await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef });
|
||||
} catch (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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,6 +122,15 @@ export const releaseChannelKey = (channel) => {
|
||||
|
||||
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) => {
|
||||
return (
|
||||
Boolean(runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false) ||
|
||||
@@ -131,15 +140,22 @@ const hasExplicitReleaseRuntime = (runtime) => {
|
||||
|
||||
const runtimeAvailability = (runtime, channel) => {
|
||||
if (runtime?.availability && typeof runtime.availability === "object") {
|
||||
const missing = Array.isArray(runtime.availability.missing) ? runtime.availability.missing : [];
|
||||
const configured =
|
||||
const missing = normalizeReadinessMissingValues(runtime.availability.missing);
|
||||
let configured = missing.length === 0;
|
||||
if (!configured) {
|
||||
configured =
|
||||
typeof runtime.availability.configured === "boolean"
|
||||
? runtime.availability.configured
|
||||
: missing.length === 0 && runtime.availability.status !== "unconfigured";
|
||||
: runtime.availability.status !== "unconfigured";
|
||||
}
|
||||
const status =
|
||||
configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || ""))
|
||||
? "ready"
|
||||
: runtime.availability.status || (configured ? "ready" : "unconfigured");
|
||||
return {
|
||||
configured,
|
||||
missing,
|
||||
status: runtime.availability.status || (configured ? "ready" : "unconfigured"),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,9 +179,6 @@ const runtimeAvailability = (runtime, channel) => {
|
||||
|
||||
const versions = runtime?.versions || {};
|
||||
const missing = [];
|
||||
if (!versions.bundle_id) {
|
||||
missing.push("release_bundle");
|
||||
}
|
||||
if (!versions.frontend) {
|
||||
missing.push("frontend_version");
|
||||
} else if (!(runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || runtime?.frontendBaseUrl)) {
|
||||
@@ -200,7 +213,7 @@ const normalizeReleaseChannelOption = (entry = {}, runtime = {}) => {
|
||||
(releaseChannelKey(channel) === releaseChannelKey(runtime?.channel) ? runtime?.versions || {} : {});
|
||||
const availability =
|
||||
entry?.availability && typeof entry.availability === "object"
|
||||
? entry.availability
|
||||
? runtimeAvailability({ availability: entry.availability }, channel)
|
||||
: runtimeAvailability({ channel, versions }, channel);
|
||||
|
||||
return {
|
||||
@@ -212,7 +225,7 @@ const normalizeReleaseChannelOption = (entry = {}, runtime = {}) => {
|
||||
versions,
|
||||
availability,
|
||||
configured: availability.configured !== false,
|
||||
missing: Array.isArray(availability.missing) ? availability.missing : [],
|
||||
missing: normalizeReadinessMissingValues(availability.missing),
|
||||
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 missing = Array.from(new Set([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey]));
|
||||
const missing = normalizeReadinessMissingValues([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey]);
|
||||
const nextRuntime = {
|
||||
trace_id: runtime?.traceId || runtime?.trace_id || null,
|
||||
channel: runtime?.channel || {
|
||||
|
||||
@@ -11,6 +11,18 @@ const FRONTEND_FAILURE_EVENT_TYPES = new Set([
|
||||
"window_error",
|
||||
"unhandled_rejection",
|
||||
]);
|
||||
const RELEASE_RUNTIME_SOURCES = new Set(["local", "deployment"]);
|
||||
const RELEASE_RUNTIME_REQUESTED_SOURCES = new Set(["local", "deployment", "auto"]);
|
||||
|
||||
const normalizeRuntimeSource = (value) => {
|
||||
const source = String(value || "").trim().toLowerCase();
|
||||
return RELEASE_RUNTIME_SOURCES.has(source) ? source : "";
|
||||
};
|
||||
|
||||
const normalizeRuntimeRequestedSource = (value) => {
|
||||
const source = String(value || "").trim().toLowerCase();
|
||||
return RELEASE_RUNTIME_REQUESTED_SOURCES.has(source) ? source : "";
|
||||
};
|
||||
|
||||
const createTraceId = () => {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
@@ -38,6 +50,8 @@ const readTraceId = () => {
|
||||
};
|
||||
|
||||
const releaseRuntimeStateMutable = reactive({
|
||||
source: null,
|
||||
requestedSource: null,
|
||||
traceId: readTraceId(),
|
||||
channel: null,
|
||||
availableChannels: [],
|
||||
@@ -74,6 +88,15 @@ export const releaseRuntimeState = readonly(releaseRuntimeStateMutable);
|
||||
|
||||
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 raw = String(value || "").trim().replace(/\/+$/, "");
|
||||
if (!raw) {
|
||||
@@ -106,6 +129,17 @@ export const configureReleaseRuntime = (runtime = {}) => {
|
||||
return releaseRuntimeStateMutable;
|
||||
}
|
||||
|
||||
const runtimeSource = normalizeRuntimeSource(runtime.source);
|
||||
if (runtimeSource) {
|
||||
releaseRuntimeStateMutable.source = runtimeSource;
|
||||
}
|
||||
const requestedSource = normalizeRuntimeRequestedSource(runtime.requested_source || runtime.requestedSource);
|
||||
if (requestedSource) {
|
||||
releaseRuntimeStateMutable.requestedSource = requestedSource;
|
||||
} else if (runtimeSource) {
|
||||
releaseRuntimeStateMutable.requestedSource = runtimeSource;
|
||||
}
|
||||
|
||||
if (runtime.trace_id) {
|
||||
releaseRuntimeStateMutable.traceId = String(runtime.trace_id);
|
||||
try {
|
||||
@@ -136,9 +170,6 @@ export const configureReleaseRuntime = (runtime = {}) => {
|
||||
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
|
||||
const missingReleaseContent = [];
|
||||
if (!isDefaultChannel && hasOwn(runtime, "versions")) {
|
||||
if (!runtime?.versions?.bundle_id) {
|
||||
missingReleaseContent.push("release_bundle");
|
||||
}
|
||||
if (!runtime?.versions?.frontend) {
|
||||
missingReleaseContent.push("frontend_version");
|
||||
} else if (!urls.frontendBaseUrl) {
|
||||
@@ -151,10 +182,24 @@ export const configureReleaseRuntime = (runtime = {}) => {
|
||||
}
|
||||
}
|
||||
releaseRuntimeStateMutable.availability = runtime.availability
|
||||
? {
|
||||
...runtime.availability,
|
||||
explicit: true,
|
||||
? (() => {
|
||||
const missing = normalizeReadinessMissingValues(runtime.availability.missing);
|
||||
let configured = missing.length === 0;
|
||||
if (!configured) {
|
||||
configured = runtime.availability.configured !== false && runtime.availability.status !== "unconfigured";
|
||||
}
|
||||
const status =
|
||||
configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || ""))
|
||||
? "ready"
|
||||
: runtime.availability.status || (configured ? "ready" : "unconfigured");
|
||||
return {
|
||||
...runtime.availability,
|
||||
configured,
|
||||
missing,
|
||||
status,
|
||||
explicit: true,
|
||||
};
|
||||
})()
|
||||
: hasExplicitReleaseRuntime(runtime)
|
||||
? {
|
||||
configured: missingReleaseContent.length === 0,
|
||||
@@ -544,9 +589,7 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
||||
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
|
||||
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
|
||||
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
|
||||
const missing = Array.isArray(availability.missing)
|
||||
? availability.missing.map((value) => String(value || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
const missing = normalizeReadinessMissingValues(availability.missing);
|
||||
const missingLookup = new Set(missing);
|
||||
const isDefaultChannel =
|
||||
channel?.default_channel === true
|
||||
@@ -614,22 +657,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackText = isDefaultChannel
|
||||
? defaultSharedLabel
|
||||
: bundleId
|
||||
? (serviceSet ? `Missing ${label} service` : "No service set connected")
|
||||
: "Missing release bundle";
|
||||
const status = isDefaultChannel ? "shared" : "missing";
|
||||
const fallbackText = missingKey ? `Missing ${label} service` : defaultSharedLabel;
|
||||
const status = missingKey ? "missing" : "shared";
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status,
|
||||
tone: isDefaultChannel ? "ok" : "warning",
|
||||
tone: missingKey ? "warning" : "ok",
|
||||
primaryText: fallbackText,
|
||||
secondaryText: "",
|
||||
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 {
|
||||
trace_id: releaseRuntimeStateMutable.traceId,
|
||||
source: releaseRuntimeStateMutable.source,
|
||||
requested_source: releaseRuntimeStateMutable.requestedSource,
|
||||
channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
|
||||
route_path: typeof window !== "undefined" ? window.location.pathname : null,
|
||||
device: {
|
||||
@@ -863,6 +904,8 @@ export const __resetReleaseTimelineForTests = () => {
|
||||
}
|
||||
flushTimer = null;
|
||||
customTransport = null;
|
||||
releaseRuntimeStateMutable.source = null;
|
||||
releaseRuntimeStateMutable.requestedSource = null;
|
||||
releaseRuntimeStateMutable.traceId = "test-trace";
|
||||
releaseRuntimeStateMutable.channel = null;
|
||||
releaseRuntimeStateMutable.availableChannels = [];
|
||||
|
||||
@@ -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()}`);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
|
||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||
import { getReleaseRuntimeApiBaseUrl, releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
|
||||
export const RELEASE_MANAGER_CONTROL_API_STORAGE_KEY = "release_manager_control_api_url";
|
||||
const RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY = "release_manager_last_working_control_api_url";
|
||||
@@ -9,12 +10,31 @@ const normalizeApiUrl = (value) => {
|
||||
const url = String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (url.startsWith("/") && !url.startsWith("//")) {
|
||||
return url || "/";
|
||||
}
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return "";
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const isLocalApiUrl = (value) => {
|
||||
const url = normalizeApiUrl(value);
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
if (url.startsWith("/") && !url.startsWith("//")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const browserStorage = (name) => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
@@ -47,22 +67,34 @@ const uniqueUrls = (urls) => {
|
||||
const releaseManagerLocalStorage = () => browserStorage("localStorage");
|
||||
const releaseManagerSessionStorage = () => browserStorage("sessionStorage");
|
||||
|
||||
export const getReleaseManagerControlApiUrl = () =>
|
||||
uniqueUrls([
|
||||
releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY),
|
||||
releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY),
|
||||
export const releaseManagerControlApiCandidates = () => {
|
||||
const runtimeApiBaseUrl = getReleaseRuntimeApiBaseUrl();
|
||||
const lastWorkingUrl = releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY);
|
||||
const storedUrl = releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY);
|
||||
const localSource = String(releaseRuntimeState.source || "").toLowerCase() === "local";
|
||||
const localCandidates = uniqueUrls([
|
||||
runtimeApiBaseUrl,
|
||||
isLocalApiUrl(lastWorkingUrl) ? lastWorkingUrl : "",
|
||||
isLocalApiUrl(storedUrl) ? storedUrl : "",
|
||||
RELEASE_MANAGER_CONTROL_API_URL,
|
||||
API_URL,
|
||||
])[0] || API_URL;
|
||||
]).filter(isLocalApiUrl);
|
||||
|
||||
export const releaseManagerControlApiCandidates = () =>
|
||||
uniqueUrls([
|
||||
releaseManagerSessionStorage()?.getItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY),
|
||||
releaseManagerLocalStorage()?.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY),
|
||||
RELEASE_MANAGER_CONTROL_API_URL,
|
||||
if (localSource && localCandidates.length > 0) {
|
||||
return localCandidates;
|
||||
}
|
||||
|
||||
const deploymentConfigUrls = [RELEASE_MANAGER_CONTROL_API_URL, API_URL].filter((url) => !isLocalApiUrl(url));
|
||||
return uniqueUrls([
|
||||
getReleaseRuntimeApiBaseUrl(),
|
||||
lastWorkingUrl,
|
||||
storedUrl,
|
||||
...deploymentConfigUrls,
|
||||
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
|
||||
API_URL,
|
||||
]);
|
||||
};
|
||||
|
||||
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
|
||||
|
||||
export const setReleaseManagerControlApiUrl = (url) => {
|
||||
const normalized = normalizeApiUrl(url);
|
||||
@@ -170,9 +202,21 @@ export const updateReleaseChannel = (id, payload) =>
|
||||
export const rollbackReleaseChannel = (id) =>
|
||||
requestReleaseManager(`/superuser/releases/channels/${id}/rollback`, "POST", {});
|
||||
|
||||
export const syncReleaseChannel = (id) =>
|
||||
requestReleaseManager(`/superuser/releases/channels/${id}/sync`, "POST", {});
|
||||
|
||||
export const setReleaseChannelBundle = (id, payload) =>
|
||||
requestReleaseManager(`/superuser/releases/channels/${id}/bundle`, "POST", payload);
|
||||
|
||||
export const listReleaseOperations = (params = {}) =>
|
||||
requestReleaseManager("/superuser/releases/operations", "GET", params);
|
||||
|
||||
export const getReleaseOperation = (id) =>
|
||||
requestReleaseManager(`/superuser/releases/operations/${id}`, "GET", {});
|
||||
|
||||
export const runReleaseTest = (payload = {}) =>
|
||||
requestReleaseManager("/superuser/releases/test-runs", "POST", payload);
|
||||
|
||||
export const listReleaseAssignments = () => requestReleaseManager("/superuser/releases/assignments", "GET", {});
|
||||
|
||||
export const searchReleaseAssignmentSubjects = (params = {}) =>
|
||||
|
||||
+1603
-95
File diff suppressed because it is too large
Load Diff
+1296
-58
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 { useWashFlowState } from "@/composables/useWashFlowState";
|
||||
import { useWashSessionActions } from "@/composables/useWashSessionActions";
|
||||
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
|
||||
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
|
||||
type VehicleTypeTemplate = {
|
||||
@@ -199,9 +199,7 @@ const normalizeTaskServices = (task: any) => (
|
||||
: []
|
||||
);
|
||||
|
||||
const taskButtonList = (task: any) => normalizeSelfServeTaskButtons(
|
||||
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
|
||||
);
|
||||
const taskButtonList = (task: any) => getSelfServeTaskDynamicImageButtons(task);
|
||||
|
||||
const dynamicImageVehicleType = (task: any) => {
|
||||
const value = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
||||
|
||||
@@ -44,7 +44,6 @@ const availableRuntime = () => ({
|
||||
versions: {
|
||||
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
|
||||
api: { version_label: "api-canary", commit_sha: "feedface" },
|
||||
bundle_id: 31,
|
||||
},
|
||||
availability: {
|
||||
configured: true,
|
||||
@@ -62,7 +61,7 @@ const runtimeWithSelectableReleaseDetails = () => ({
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
missing: ["release_bundle"],
|
||||
missing: ["api_base_url"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
available_channels: [
|
||||
@@ -115,7 +114,7 @@ const runtimeWithSelectableReleaseDetails = () => ({
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
missing: ["release_bundle"],
|
||||
missing: ["api_base_url"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
},
|
||||
@@ -181,6 +180,7 @@ async function boot(page, runtime = unavailableRuntime, locale = "en") {
|
||||
await page.addInitScript((selectedLocale) => {
|
||||
window.localStorage.setItem("locale", selectedLocale);
|
||||
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
|
||||
window.localStorage.setItem("release_source_override", "deployment");
|
||||
}, locale);
|
||||
|
||||
await mockApi(page, {
|
||||
@@ -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).toContainText("Release channel is not ready");
|
||||
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("API version");
|
||||
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 page.addInitScript(() => {
|
||||
window.localStorage.setItem("release_channel_selected_slug", "internal");
|
||||
window.localStorage.setItem("release_source_override", "deployment");
|
||||
});
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -291,6 +292,7 @@ test("selected channel auth session 404 shows the release channel guard", async
|
||||
await boot(page, internalRuntime);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("release_channel_selected_slug", "internal");
|
||||
window.localStorage.setItem("release_source_override", "deployment");
|
||||
});
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
await route.fulfill(json({ data: internalRuntime }));
|
||||
@@ -324,7 +326,7 @@ test("release channel choices show git commit and release time when available",
|
||||
|
||||
const canary = page.getByTestId("release-channel-option-canary");
|
||||
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(
|
||||
/May 19, 2026|19 May 2026/
|
||||
|
||||
+518
-320
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>');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,149 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { loginAsOperator, loginAsUser } from "../fixtures/authHelpers";
|
||||
import { attachPageHealthGuards, expectBodyHasContent, expectOneVisible, requiredEnv, settlePage } from "./helpers";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import { attachPageHealthGuards, expectBodyHasContent, settlePage } from "./helpers";
|
||||
|
||||
const liveSmokeEnabled = Boolean(
|
||||
process.env.PLAYWRIGHT_BASE_URL &&
|
||||
process.env.PLAYWRIGHT_USER_CUSTOMER_NUMBER &&
|
||||
process.env.PLAYWRIGHT_USER_PASSWORD &&
|
||||
process.env.PLAYWRIGHT_OPERATOR_USER_ID &&
|
||||
process.env.PLAYWRIGHT_OPERATOR_PASSWORD
|
||||
);
|
||||
|
||||
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),
|
||||
type ReleaseManifest = {
|
||||
build_id?: string;
|
||||
commit_sha?: string;
|
||||
entry?: string;
|
||||
css?: string[];
|
||||
index_asset_urls?: string[];
|
||||
pwa_asset_urls?: string[];
|
||||
asset_urls?: string[];
|
||||
asset_hashes?: Record<string, { sha256?: string; bytes?: number }>;
|
||||
};
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
|
||||
|
||||
function liveUrl(assetPath: string) {
|
||||
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
||||
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
function unique(values: string[]) {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
test("api-v2 gateway ping serves a trusted TLS API response", async ({ request }) => {
|
||||
const response = await request.get("https://api-v2.truckwash.io/ping");
|
||||
const body = await response.json();
|
||||
function sha256(bytes: Buffer) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(body).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
message: "pong",
|
||||
function rejectsHtml(assetPath: string) {
|
||||
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
|
||||
}
|
||||
|
||||
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.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate.");
|
||||
test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => {
|
||||
const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io";
|
||||
const apiPingPaths = unique(
|
||||
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/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 }) => {
|
||||
const { baseURL } = getLiveSettings();
|
||||
for (const apiPingPath of apiPingPaths) {
|
||||
await expectApiPing(request, apiBaseUrl, apiPingPath);
|
||||
}
|
||||
});
|
||||
|
||||
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
|
||||
await page.goto("/guest/book/wash");
|
||||
@@ -58,42 +155,3 @@ test.describe("Live smoke release gate", () => {
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -896,15 +896,18 @@ test.describe("Self-serve wash", () => {
|
||||
permissions: ["user"],
|
||||
selfServe: true,
|
||||
});
|
||||
api.selfServe.dynamicImageDelayMs = [750];
|
||||
const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"];
|
||||
answeredSummary.allowed_services = ["PROGRAM_PICKER", "MACHINE"];
|
||||
answeredSummary.tasks = [
|
||||
{
|
||||
...answeredSummary.tasks[0],
|
||||
condition_id: null,
|
||||
gate_type: "ALWAYS",
|
||||
gate_ref_id: null,
|
||||
buttons: ["reset", 0],
|
||||
dynamic_images_vehicle_type: 2,
|
||||
services: ["PROGRAM_PICKER"],
|
||||
buttons: [],
|
||||
dynamic_images_vehicle_type: 4,
|
||||
},
|
||||
{
|
||||
id: 9002,
|
||||
@@ -915,7 +918,7 @@ test.describe("Self-serve wash", () => {
|
||||
condition_id: null,
|
||||
gate_type: "ALWAYS",
|
||||
gate_ref_id: null,
|
||||
buttons: [2, "start", 5],
|
||||
buttons: [0, 2, "start", 5],
|
||||
dynamic_images_vehicle_type: 3,
|
||||
attachments: [],
|
||||
},
|
||||
@@ -929,26 +932,36 @@ test.describe("Self-serve wash", () => {
|
||||
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-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-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
|
||||
.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;
|
||||
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("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")).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 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([
|
||||
"reset",
|
||||
"program_picker",
|
||||
0,
|
||||
2,
|
||||
"start",
|
||||
@@ -959,7 +972,7 @@ test.describe("Self-serve wash", () => {
|
||||
await page.getByTestId("self-serve-task-9002-toggle").click();
|
||||
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([
|
||||
"reset",
|
||||
"program_picker",
|
||||
0,
|
||||
2,
|
||||
"start",
|
||||
|
||||
@@ -1330,6 +1330,7 @@ function createSelfServeFixture(overrides = {}) {
|
||||
relays: ["M-9"],
|
||||
},
|
||||
},
|
||||
dynamicImageDelayMs: 0,
|
||||
dynamicImage: TINY_PNG,
|
||||
dynamicImagesByLaneId: {
|
||||
7: TINY_PNG,
|
||||
@@ -5954,21 +5955,30 @@ export async function mockApi(page, options = {}) {
|
||||
options.edgeGateways && typeof options.edgeGateways === "object" ? options.edgeGateways : {};
|
||||
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();
|
||||
if (request.method() !== "GET" || !posFixture) {
|
||||
if (request.method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url());
|
||||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||
const orderId = Number(pathSegments[1] || 0);
|
||||
const attachmentId = Number(pathSegments[3] || 0);
|
||||
const orderAttachmentMatch = url.pathname.match(/^\/orders\/(\d+)\/attachments\/(\d+)$/i);
|
||||
if (orderAttachmentMatch && posFixture) {
|
||||
const orderId = Number(orderAttachmentMatch[1] || 0);
|
||||
const attachmentId = Number(orderAttachmentMatch[2] || 0);
|
||||
const 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));
|
||||
});
|
||||
|
||||
@@ -6698,6 +6708,10 @@ export async function mockApi(page, options = {}) {
|
||||
|
||||
if (pathname.endsWith("/department/lanes/dynamic-image") && method === "GET") {
|
||||
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));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RELEASE_RUNTIME_GLOBAL_KEY,
|
||||
RELEASE_SOURCE_OVERRIDE_STORAGE_KEY,
|
||||
bootstrapReleaseApp,
|
||||
fetchReleaseRuntime,
|
||||
loadRemoteReleaseEntry,
|
||||
resolveReleaseSourceMode,
|
||||
runtimeApiUrl,
|
||||
shouldLoadRemoteRelease,
|
||||
} from "@/services/releaseBootstrap.js";
|
||||
import { DEFAULT_STABLE_API_URL } from "@/config.js";
|
||||
|
||||
describe("release bootstrap", () => {
|
||||
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");
|
||||
|
||||
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", () => {
|
||||
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(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 () => {
|
||||
@@ -88,7 +124,7 @@ describe("release bootstrap", () => {
|
||||
text: async () => "<br /><b>Warning</b>Composer autoload warning",
|
||||
}));
|
||||
|
||||
await bootstrapReleaseApp({ loadLocalApp, fetchFn });
|
||||
await bootstrapReleaseApp({ loadLocalApp, fetchFn, sourceMode: "deployment" });
|
||||
|
||||
expect(loadLocalApp).toHaveBeenCalledTimes(1);
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
|
||||
@@ -98,6 +134,8 @@ describe("release bootstrap", () => {
|
||||
missing: ["release_runtime"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
source: "local",
|
||||
requested_source: "deployment",
|
||||
});
|
||||
expect(shouldLoadRemoteRelease(null)).toBe(false);
|
||||
});
|
||||
@@ -132,6 +170,7 @@ describe("release bootstrap", () => {
|
||||
fetchFn,
|
||||
importModule,
|
||||
documentRef: document,
|
||||
sourceMode: "deployment",
|
||||
});
|
||||
|
||||
expect(shouldLoadRemoteRelease(runtime)).toBe(true);
|
||||
@@ -161,9 +200,17 @@ describe("release bootstrap", () => {
|
||||
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(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({
|
||||
configured: false,
|
||||
missing: ["frontend_entry"],
|
||||
|
||||
@@ -51,7 +51,6 @@ const configuredCanaryRuntime = {
|
||||
versions: {
|
||||
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
|
||||
api: { version_label: "api-canary", commit_sha: "feedface" },
|
||||
bundle_id: 31,
|
||||
},
|
||||
availability: {
|
||||
configured: true,
|
||||
@@ -66,12 +65,12 @@ describe("release channel availability", () => {
|
||||
__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);
|
||||
|
||||
expect(status.shouldBlock).toBe(true);
|
||||
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", () => {
|
||||
@@ -91,6 +90,26 @@ describe("release channel availability", () => {
|
||||
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", () => {
|
||||
const runtime = {
|
||||
channel: configuredCanaryRuntime.channel,
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("ReleaseChannelSelector", () => {
|
||||
expect(wrapper.find('[data-testid="release-channel-selector"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Stable");
|
||||
expect(wrapper.text()).toContain("Canary");
|
||||
expect(wrapper.text()).toContain("Release bundle");
|
||||
expect(wrapper.text()).not.toContain("Release bundle");
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ describe("release timeline runtime", () => {
|
||||
|
||||
it("configures release runtime from the JSON session contract", () => {
|
||||
configureReleaseRuntime({
|
||||
source: "deployment",
|
||||
trace_id: "trace-123",
|
||||
channel: { slug: "canary", name: "Canary" },
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(releaseRuntimeState.source).toBe("deployment");
|
||||
expect(releaseRuntimeState.requestedSource).toBe("deployment");
|
||||
expect(releaseRuntimeState.channel.slug).toBe("canary");
|
||||
expect(releaseRuntimeState.availableChannels).toHaveLength(1);
|
||||
expect(releaseRuntimeState.traceId).toBe("trace-123");
|
||||
@@ -46,6 +49,23 @@ describe("release timeline runtime", () => {
|
||||
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", () => {
|
||||
configureReleaseRuntime({
|
||||
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({
|
||||
tone: "warning",
|
||||
missingLabel: "Frontend version",
|
||||
@@ -166,8 +186,8 @@ describe("release timeline runtime", () => {
|
||||
missingLabel: "API URL",
|
||||
});
|
||||
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
|
||||
tone: "warning",
|
||||
primaryText: "Missing release bundle",
|
||||
tone: "ok",
|
||||
primaryText: "Default/shared runtime",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,13 +44,50 @@ describe("self-serve studio task editing", () => {
|
||||
it("renders dynamic image previews in simulator results", () => {
|
||||
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("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-reason"');
|
||||
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", () => {
|
||||
const source = studioSource();
|
||||
|
||||
@@ -92,7 +129,7 @@ describe("self-serve studio task editing", () => {
|
||||
expect(source).toContain("selfserve_enabled: true");
|
||||
expect(source).toContain("selfserve_enabled: normalizeSelfServeEnabledFlag(form?.selfserve_enabled)");
|
||||
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-inspector-lane-selfserve-enabled"');
|
||||
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", () => {
|
||||
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('<option value="">All vehicle types</option>');
|
||||
expect(source).toContain(':value="vehicleType.product || vehicleType.id"');
|
||||
expect(source).toContain('<option value="" disabled>Select vehicle type</option>');
|
||||
expect(source).toContain(':value="vehicleTypeFilterValue(vehicleType)"');
|
||||
});
|
||||
|
||||
it("applies vehicle type scope filtering and defaults for new records", () => {
|
||||
|
||||
@@ -64,20 +64,28 @@ describe("SelfServeTaskList", () => {
|
||||
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, {
|
||||
props: {
|
||||
tasks: [
|
||||
{
|
||||
id: 6,
|
||||
task: "Start machine",
|
||||
task: "Choose program",
|
||||
description: "-",
|
||||
services: ["MACHINE"],
|
||||
buttons: ["start"],
|
||||
services: ["PROGRAM_PICKER"],
|
||||
buttons: [],
|
||||
dynamic_images_vehicle_type: 4,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
task: "Tryk reset",
|
||||
description: "-",
|
||||
services: ["MACHINE"],
|
||||
buttons: ["reset"],
|
||||
dynamic_images_vehicle_type: 7,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
task: "Manual inspection",
|
||||
description: "-",
|
||||
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-7"]').text()).toContain("Manual inspection");
|
||||
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("Tryk reset");
|
||||
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("#");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -254,9 +254,9 @@ describe("SelfServeTryModal", () => {
|
||||
await flushPromises();
|
||||
|
||||
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("lane")).toBe("3");
|
||||
expect(imageUrl.searchParams.get("current_step")).toBe("0");
|
||||
@@ -303,7 +303,7 @@ describe("SelfServeTryModal", () => {
|
||||
await flushPromises();
|
||||
|
||||
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(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual([1]);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
releaseManagerControlApiCandidates,
|
||||
setReleaseManagerControlApiUrl,
|
||||
} from "@/services/superuserReleases.js";
|
||||
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
||||
import { deployCoolifyGatewayRoutes } from "@/services/superuserCoolify.js";
|
||||
import { __configureRequestQueueForTests, __resetRequestQueueForTests } from "@/services/requestQueue.js";
|
||||
|
||||
@@ -27,6 +28,7 @@ describe("superuser release manager service", () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("token", "release-token");
|
||||
configureReleaseRuntime({ source: "deployment", api_base_url: "https://api.truckwash.io" });
|
||||
__resetRequestQueueForTests();
|
||||
__configureRequestQueueForTests({
|
||||
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/");
|
||||
axiosMock.mockResolvedValueOnce({ status: 200, data: { data: { channels: [] } } });
|
||||
|
||||
await getReleaseSummary();
|
||||
|
||||
expect(getReleaseManagerControlApiUrl()).toBe("https://control.example.test");
|
||||
expect(getReleaseManagerControlApiUrl()).toBe("https://api-v2.truckwash.io/master/api");
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://control.example.test/superuser/releases",
|
||||
url: "https://api-v2.truckwash.io/master/api/superuser/releases",
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: "Bearer release-token" }),
|
||||
})
|
||||
@@ -61,8 +64,25 @@ describe("superuser release manager service", () => {
|
||||
await getReleaseSummary();
|
||||
|
||||
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(releaseManagerControlApiCandidates()[0]).toBe("https://api.truckwash.io:4433");
|
||||
expect(axiosMock.mock.calls[1][0].url).toBe("https://api-v2.truckwash.io/master/api/superuser/releases");
|
||||
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", () => {
|
||||
@@ -70,10 +90,11 @@ describe("superuser release manager service", () => {
|
||||
clearReleaseManagerControlApiUrl();
|
||||
|
||||
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");
|
||||
axiosMock.mockResolvedValueOnce({ status: 200, data: { success: true } });
|
||||
|
||||
@@ -81,7 +102,7 @@ describe("superuser release manager service", () => {
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
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",
|
||||
data: { dry_run: true },
|
||||
__skipReleaseApiRewrite: true,
|
||||
|
||||
@@ -140,6 +140,7 @@ describe("useWashSessionActions property gate commands", () => {
|
||||
const activeTasks = ref([
|
||||
{
|
||||
id: 101,
|
||||
services: ["PROGRAM_PICKER"],
|
||||
buttons: ["reset", 0],
|
||||
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");
|
||||
|
||||
expect(JSON.parse(url.searchParams.get("buttons"))).toEqual(["reset", 0, 2, "start", 5]);
|
||||
expect(url.searchParams.get("current_step")).toBe("2");
|
||||
expect(JSON.parse(url.searchParams.get("buttons"))).toEqual(["program_picker", "reset", 0, 2, "start", 5]);
|
||||
expect(url.searchParams.get("current_step")).toBe("3");
|
||||
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 () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("Vite API proxy", () => {
|
||||
it("forwards local /api requests to the working dev API by default", () => {
|
||||
const options = createApiProxyOptions({});
|
||||
|
||||
expect(options.target).toBe("https://api.truckwash.io:4433");
|
||||
expect(options.target).toBe("http://localhost");
|
||||
expect(options.changeOrigin).toBe(true);
|
||||
expect(options.secure).toBe(false);
|
||||
expect(options.rewrite("/api/ping")).toBe("/ping");
|
||||
|
||||
+259
-23
@@ -1,4 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
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 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() {
|
||||
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() {
|
||||
return {
|
||||
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) {
|
||||
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
|
||||
|
||||
return {
|
||||
target: env.VITE_API_PROXY_TARGET || 'https://api.truckwash.io:4433',
|
||||
target: env.VITE_API_PROXY_TARGET || 'http://localhost',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
...(stripPrefix
|
||||
@@ -120,25 +370,7 @@ export default defineConfig(({ mode }) => {
|
||||
process.env.COMMIT_HASH = commit
|
||||
process.env.APP_VERSION = version
|
||||
process.env.IS_DEV = !isProd ? 'true' : 'false'
|
||||
// If the build is a production build, update the server with the new version
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Server version updates happen after deploy verification, not during asset builds.
|
||||
// Keep a relative base for static hosting; align manifest with base
|
||||
const base = '/'
|
||||
const pwaScope = base === './' ? '.' : base
|
||||
@@ -153,11 +385,13 @@ export default defineConfig(({ mode }) => {
|
||||
vue(),
|
||||
VueJsx(),
|
||||
releaseEntryManifest(),
|
||||
publicAssetAliases(),
|
||||
!isProd && !isPlaywrightRuntime && vueDevTools(),
|
||||
enableSingleFile && viteSingleFile(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: isProd ? 'auto' : false,
|
||||
manifestFilename: 'assets/manifest.webmanifest',
|
||||
strategies: 'generateSW',
|
||||
devOptions: {
|
||||
enabled: false // keep for local dev
|
||||
@@ -167,12 +401,12 @@ export default defineConfig(({ mode }) => {
|
||||
cleanupOutdatedCaches: true,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'],
|
||||
globPatterns: ['**/*.{js,css,html,ico,svg,png,webmanifest,woff2}'],
|
||||
globIgnores: ['**/registerSW.js', '**/sw.js'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// cache API responses
|
||||
urlPattern: ({ url }) => url.origin === 'https://api.truckwash.io',
|
||||
urlPattern: ({ url }) => url.origin === 'https://api-v2.truckwash.io',
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'pleno-api-cache',
|
||||
@@ -241,7 +475,9 @@ export default defineConfig(({ mode }) => {
|
||||
id: 'pleno-pwa-test'
|
||||
},
|
||||
useCredentials: true
|
||||
})
|
||||
}),
|
||||
rootPwaManifestAlias(),
|
||||
releaseMetadataManifest()
|
||||
].filter(Boolean),
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user