From 8ab891542949060829c1d1b776023627f79db65c Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 26 May 2026 14:14:13 +0200 Subject: [PATCH] Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration. --- .gitattributes | 15 + .github/workflows/release.yml | 139 ++ env.d.ts | 1 + index.html | 9 +- nginx.coolify-frontend.conf | 31 +- package.json | 5 + public/.htaccess | 44 +- public/manifest.json | 4 +- scripts/release/update-server-version.mjs | 50 + scripts/release/upload-dist-lftp.sh | 53 + scripts/release/verify-upload.mjs | 215 ++ .../department/tables/SelfServeTryModal.vue | 62 +- .../displays/selfServe/SelfServeTaskList.vue | 19 +- .../displays/selfServe/SelfServeTasksStep.vue | 77 +- src/components/release/ReleaseActionMenu.vue | 18 + src/components/release/ReleaseContextBar.vue | 322 +++ src/components/release/ReleaseDataGrid.vue | 74 + src/components/release/ReleaseEntityLink.vue | 129 ++ .../release/ReleaseManagerShell.vue | 188 ++ .../release/ReleaseOperationModal.vue | 89 + .../release/ReleaseOperationTimeline.vue | 137 ++ .../release/ReleaseReplayInspector.vue | 116 +- src/components/release/ReleaseRouteTabs.vue | 71 + .../release/ReleaseWorkspacePanel.vue | 208 ++ src/components/release/releaseEntityFacts.js | 236 +++ .../release/useReleaseEntityDrawer.js | 19 + .../release/useReleaseKeyboardShortcuts.js | 11 + .../release/useReleaseManagerContext.js | 93 + .../Objects/selfServeTaskButtons.js | 7 + src/composables/useWashSessionActions.js | 58 +- src/config.js | 23 +- src/i18n/generated/da-v2.json | 17 + src/i18n/generated/de-v2.json | 17 + src/i18n/generated/en-v2.json | 17 + src/i18n/generated/no-v2.json | 17 + src/i18n/generated/sv-v2.json | 17 + src/i18n/locales/da.json | 17 + src/i18n/locales/de.json | 17 + src/i18n/locales/en.json | 17 + src/i18n/locales/no.json | 17 + src/i18n/locales/sv.json | 17 + .../phrases/compat/configuration/index.json | 17 + .../phrases/compat/configuration/index.json | 17 + .../phrases/compat/configuration/index.json | 17 + .../phrases/compat/configuration/index.json | 17 + .../phrases/compat/configuration/index.json | 17 + src/router.js | 13 +- src/services/releaseBootstrap.js | 120 +- src/services/releaseChannelAvailability.js | 37 +- src/services/releaseTimeline.js | 79 +- src/services/selfServeDynamicImage.js | 113 ++ src/services/superuserReleases.js | 66 +- .../self-serve/DepartmentSelfServeStudio.vue | 1722 ++++++++++++++++- .../ConfigurationReleaseManager.vue | 1354 ++++++++++++- .../userDashboard/wash/MyWashStart.vue | 6 +- tests/e2e/release-channel-unavailable.spec.js | 12 +- tests/e2e/release-manager.spec.js | 830 +++++--- .../public-htaccess.local-prod.spec.ts | 340 ++++ tests/e2e/release/release.live-smoke.spec.ts | 232 ++- .../release/release.roles.live-smoke.spec.ts | 87 + tests/e2e/self-serve-studio-flow.spec.js | 942 ++++++--- tests/e2e/self-serve-wash.spec.js | 35 +- tests/e2e/support/network.js | 30 +- tests/unit/release-bootstrap.spec.js | 57 +- .../unit/release-channel-availability.spec.js | 25 +- tests/unit/release-channel-selector.spec.js | 2 +- tests/unit/release-manager-workspace.spec.js | 112 ++ tests/unit/release-timeline.spec.js | 26 +- .../unit/self-serve-studio-task-scope.spec.js | 43 +- ...lf-serve-studio-vehicle-type-scope.spec.js | 6 +- tests/unit/self-serve-task-list.spec.js | 22 +- tests/unit/self-serve-tasks-step.spec.js | 73 + tests/unit/self-serve-try-modal.spec.js | 6 +- tests/unit/superuser-releases.spec.js | 37 +- tests/unit/use-wash-session-actions.spec.js | 7 +- tests/unit/vite-api-proxy.spec.js | 2 +- vite.config.js | 282 ++- 77 files changed, 8353 insertions(+), 1143 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/release.yml create mode 100644 scripts/release/update-server-version.mjs create mode 100644 scripts/release/upload-dist-lftp.sh create mode 100644 scripts/release/verify-upload.mjs create mode 100644 src/components/release/ReleaseActionMenu.vue create mode 100644 src/components/release/ReleaseContextBar.vue create mode 100644 src/components/release/ReleaseDataGrid.vue create mode 100644 src/components/release/ReleaseEntityLink.vue create mode 100644 src/components/release/ReleaseManagerShell.vue create mode 100644 src/components/release/ReleaseOperationModal.vue create mode 100644 src/components/release/ReleaseOperationTimeline.vue create mode 100644 src/components/release/ReleaseRouteTabs.vue create mode 100644 src/components/release/ReleaseWorkspacePanel.vue create mode 100644 src/components/release/releaseEntityFacts.js create mode 100644 src/components/release/useReleaseEntityDrawer.js create mode 100644 src/components/release/useReleaseKeyboardShortcuts.js create mode 100644 src/components/release/useReleaseManagerContext.js create mode 100644 src/services/selfServeDynamicImage.js create mode 100644 tests/e2e/release/public-htaccess.local-prod.spec.ts create mode 100644 tests/e2e/release/release.roles.live-smoke.spec.ts create mode 100644 tests/unit/release-manager-workspace.spec.js create mode 100644 tests/unit/self-serve-tasks-step.spec.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2aef528d --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5205e9c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/env.d.ts b/env.d.ts index 032b9e2c..997caad1 100644 --- a/env.d.ts +++ b/env.d.ts @@ -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 diff --git a/index.html b/index.html index 9161771a..74b9e467 100644 --- a/index.html +++ b/index.html @@ -2,11 +2,10 @@ - - - - - + + + + diff --git a/nginx.coolify-frontend.conf b/nginx.coolify-frontend.conf index 81636aa3..b442cba4 100644 --- a/nginx.coolify-frontend.conf +++ b/nginx.coolify-frontend.conf @@ -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 ~ ^/(?:.+/)?(?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files /$static_asset_path =404; + } + + location ~ ^/(?:.+/)?(?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; } diff --git a/package.json b/package.json index 56edff96..3f41f03c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/.htaccess b/public/.htaccess index b670aea6..3a520841 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -4,10 +4,50 @@ 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] diff --git a/public/manifest.json b/public/manifest.json index 7b589f68..f695f701 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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" diff --git a/scripts/release/update-server-version.mjs b/scripts/release/update-server-version.mjs new file mode 100644 index 00000000..b19d5422 --- /dev/null +++ b/scripts/release/update-server-version.mjs @@ -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); +}); diff --git a/scripts/release/upload-dist-lftp.sh b/scripts/release/upload-dist-lftp.sh new file mode 100644 index 00000000..83a96644 --- /dev/null +++ b/scripts/release/upload-dist-lftp.sh @@ -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" diff --git a/scripts/release/verify-upload.mjs b/scripts/release/verify-upload.mjs new file mode 100644 index 00000000..3a2b25ae --- /dev/null +++ b/scripts/release/verify-upload.mjs @@ -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('
')) { + 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); +}); diff --git a/src/components/displays/department/tables/SelfServeTryModal.vue b/src/components/displays/department/tables/SelfServeTryModal.vue index 624cbf17..9f12f52c 100644 --- a/src/components/displays/department/tables/SelfServeTryModal.vue +++ b/src/components/displays/department/tables/SelfServeTryModal.vue @@ -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(() => ( diff --git a/src/components/displays/selfServe/SelfServeTaskList.vue b/src/components/displays/selfServe/SelfServeTaskList.vue index 142bf213..0c22e071 100644 --- a/src/components/displays/selfServe/SelfServeTaskList.vue +++ b/src/components/displays/selfServe/SelfServeTaskList.vue @@ -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; diff --git a/src/components/displays/selfServe/SelfServeTasksStep.vue b/src/components/displays/selfServe/SelfServeTasksStep.vue index 12d853ad..8c3c1d2c 100644 --- a/src/components/displays/selfServe/SelfServeTasksStep.vue +++ b/src/components/displays/selfServe/SelfServeTasksStep.vue @@ -1,8 +1,9 @@ + + diff --git a/src/components/release/ReleaseActionMenu.vue b/src/components/release/ReleaseActionMenu.vue new file mode 100644 index 00000000..d34805da --- /dev/null +++ b/src/components/release/ReleaseActionMenu.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/release/ReleaseContextBar.vue b/src/components/release/ReleaseContextBar.vue new file mode 100644 index 00000000..433e2a5d --- /dev/null +++ b/src/components/release/ReleaseContextBar.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/src/components/release/ReleaseDataGrid.vue b/src/components/release/ReleaseDataGrid.vue new file mode 100644 index 00000000..a8a86125 --- /dev/null +++ b/src/components/release/ReleaseDataGrid.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/components/release/ReleaseEntityLink.vue b/src/components/release/ReleaseEntityLink.vue new file mode 100644 index 00000000..7d2ab314 --- /dev/null +++ b/src/components/release/ReleaseEntityLink.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/src/components/release/ReleaseManagerShell.vue b/src/components/release/ReleaseManagerShell.vue new file mode 100644 index 00000000..c8d0fa49 --- /dev/null +++ b/src/components/release/ReleaseManagerShell.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/src/components/release/ReleaseOperationModal.vue b/src/components/release/ReleaseOperationModal.vue new file mode 100644 index 00000000..c7a2e0fd --- /dev/null +++ b/src/components/release/ReleaseOperationModal.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/components/release/ReleaseOperationTimeline.vue b/src/components/release/ReleaseOperationTimeline.vue new file mode 100644 index 00000000..5892280e --- /dev/null +++ b/src/components/release/ReleaseOperationTimeline.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/components/release/ReleaseReplayInspector.vue b/src/components/release/ReleaseReplayInspector.vue index 2f2b82bb..84673edd 100644 --- a/src/components/release/ReleaseReplayInspector.vue +++ b/src/components/release/ReleaseReplayInspector.vue @@ -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); +
+ + {{ trFallback("loading.refreshing_timeline", "Refreshing release timeline...") }} +
+
@@ -397,37 +412,47 @@ onMounted(loadReplayData); - - - - - - - - - + - + + @@ -559,7 +584,17 @@ onMounted(loadReplayData); -
    +
    + + {{ trFallback("loading.timeline_events", "Loading release timeline events...") }} +
    +
    1. {{ event.event_type }} @@ -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; } diff --git a/src/components/release/ReleaseRouteTabs.vue b/src/components/release/ReleaseRouteTabs.vue new file mode 100644 index 00000000..05fcbfcc --- /dev/null +++ b/src/components/release/ReleaseRouteTabs.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/components/release/ReleaseWorkspacePanel.vue b/src/components/release/ReleaseWorkspacePanel.vue new file mode 100644 index 00000000..86d2253b --- /dev/null +++ b/src/components/release/ReleaseWorkspacePanel.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/src/components/release/releaseEntityFacts.js b/src/components/release/releaseEntityFacts.js new file mode 100644 index 00000000..840a9358 --- /dev/null +++ b/src/components/release/releaseEntityFacts.js @@ -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 || "--"; diff --git a/src/components/release/useReleaseEntityDrawer.js b/src/components/release/useReleaseEntityDrawer.js new file mode 100644 index 00000000..40cff08b --- /dev/null +++ b/src/components/release/useReleaseEntityDrawer.js @@ -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, + }; +} diff --git a/src/components/release/useReleaseKeyboardShortcuts.js b/src/components/release/useReleaseKeyboardShortcuts.js new file mode 100644 index 00000000..2612e2a6 --- /dev/null +++ b/src/components/release/useReleaseKeyboardShortcuts.js @@ -0,0 +1,11 @@ +import { onBeforeUnmount, onMounted } from "vue"; + +export function useReleaseKeyboardShortcuts(handler) { + onMounted(() => { + window.addEventListener("keydown", handler); + }); + + onBeforeUnmount(() => { + window.removeEventListener("keydown", handler); + }); +} diff --git a/src/components/release/useReleaseManagerContext.js b/src/components/release/useReleaseManagerContext.js new file mode 100644 index 00000000..b6e50db6 --- /dev/null +++ b/src/components/release/useReleaseManagerContext.js @@ -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, + }; +} diff --git a/src/components/session/token/SessionUser/Objects/selfServeTaskButtons.js b/src/components/session/token/SessionUser/Objects/selfServeTaskButtons.js index f1e34570..25a42810 100644 --- a/src/components/session/token/SessionUser/Objects/selfServeTaskButtons.js +++ b/src/components/session/token/SessionUser/Objects/selfServeTaskButtons.js @@ -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( diff --git a/src/composables/useWashSessionActions.js b/src/composables/useWashSessionActions.js index 9ee8168b..35e2dbae 100644 --- a/src/composables/useWashSessionActions.js +++ b/src/composables/useWashSessionActions.js @@ -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 ( diff --git a/src/config.js b/src/config.js index 22fa7968..c08cac00 100644 --- a/src/config.js +++ b/src/config.js @@ -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"; diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index aa2ecaee..bc663012 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -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", diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 01c9fe8a..349a0269 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -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", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 4254e454..3c85e5ee 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -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", diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index 05a993fc..10bf7761 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -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", diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index 2e9d8792..65fd1317 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -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", diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index 44283b15..560f0fc0 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -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", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c280b549..20c50845 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 92ae76b9..0ce4f095 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/no.json b/src/i18n/locales/no.json index 9b647c09..5357b09c 100644 --- a/src/i18n/locales/no.json +++ b/src/i18n/locales/no.json @@ -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", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index a9228701..11fbd556 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -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", diff --git a/src/i18n/source/da/phrases/compat/configuration/index.json b/src/i18n/source/da/phrases/compat/configuration/index.json index 45dc1230..30a412e5 100644 --- a/src/i18n/source/da/phrases/compat/configuration/index.json +++ b/src/i18n/source/da/phrases/compat/configuration/index.json @@ -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", diff --git a/src/i18n/source/de/phrases/compat/configuration/index.json b/src/i18n/source/de/phrases/compat/configuration/index.json index e2e817d6..af5ccbd8 100644 --- a/src/i18n/source/de/phrases/compat/configuration/index.json +++ b/src/i18n/source/de/phrases/compat/configuration/index.json @@ -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", diff --git a/src/i18n/source/en/phrases/compat/configuration/index.json b/src/i18n/source/en/phrases/compat/configuration/index.json index e7ec8d2f..f9627ab7 100644 --- a/src/i18n/source/en/phrases/compat/configuration/index.json +++ b/src/i18n/source/en/phrases/compat/configuration/index.json @@ -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", diff --git a/src/i18n/source/no/phrases/compat/configuration/index.json b/src/i18n/source/no/phrases/compat/configuration/index.json index ea8d9f1e..388cb83f 100644 --- a/src/i18n/source/no/phrases/compat/configuration/index.json +++ b/src/i18n/source/no/phrases/compat/configuration/index.json @@ -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", diff --git a/src/i18n/source/sv/phrases/compat/configuration/index.json b/src/i18n/source/sv/phrases/compat/configuration/index.json index 9d94f597..70c837af 100644 --- a/src/i18n/source/sv/phrases/compat/configuration/index.json +++ b/src/i18n/source/sv/phrases/compat/configuration/index.json @@ -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", diff --git a/src/router.js b/src/router.js index 3bb0bbfe..f448a0e9 100644 --- a/src/router.js +++ b/src/router.js @@ -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 } }, { diff --git a/src/services/releaseBootstrap.js b/src/services/releaseBootstrap.js index a961df51..b99d2631 100644 --- a/src/services/releaseBootstrap.js +++ b/src/services/releaseBootstrap.js @@ -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(); } }; diff --git a/src/services/releaseChannelAvailability.js b/src/services/releaseChannelAvailability.js index c16067d0..ee55b325 100644 --- a/src/services/releaseChannelAvailability.js +++ b/src/services/releaseChannelAvailability.js @@ -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 = - typeof runtime.availability.configured === "boolean" - ? runtime.availability.configured - : missing.length === 0 && runtime.availability.status !== "unconfigured"; + const missing = normalizeReadinessMissingValues(runtime.availability.missing); + let configured = missing.length === 0; + if (!configured) { + configured = + typeof runtime.availability.configured === "boolean" + ? runtime.availability.configured + : runtime.availability.status !== "unconfigured"; + } + const status = + configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || "")) + ? "ready" + : runtime.availability.status || (configured ? "ready" : "unconfigured"); return { configured, missing, - status: runtime.availability.status || (configured ? "ready" : "unconfigured"), + status, }; } @@ -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 || { diff --git a/src/services/releaseTimeline.js b/src/services/releaseTimeline.js index c51f5d5e..edf4b5dd 100644 --- a/src/services/releaseTimeline.js +++ b/src/services/releaseTimeline.js @@ -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 = []; diff --git a/src/services/selfServeDynamicImage.js b/src/services/selfServeDynamicImage.js new file mode 100644 index 00000000..b1112cc6 --- /dev/null +++ b/src/services/selfServeDynamicImage.js @@ -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()}`); +}; diff --git a/src/services/superuserReleases.js b/src/services/superuserReleases.js index 2ac852c7..eb94d964 100644 --- a/src/services/superuserReleases.js +++ b/src/services/superuserReleases.js @@ -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 = {}) => diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue index 88c785ef..b66cf42d 100644 --- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue +++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue @@ -8,11 +8,19 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrap import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue"; import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue"; import PageTitle from "@/components/global/PageTitle.vue"; +import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { useAppToast } from "@/composables/useAppToast.js"; import { searchCustomer, searchCustomerResults, isSearching as isSearchingCustomers } from "@/components/search/economic/customerSearch.vue"; import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js"; +import { + buildSelfServeDynamicImageUrl, + getSelfServeCompletedDynamicImageStep, + getSelfServeDynamicImageButtonsToPress, + getSelfServeTaskDynamicImageButtons, + getSelfServeDynamicImageThumbPosition, +} from "@/services/selfServeDynamicImage.js"; import { SELF_SERVE_TASK_BUTTON_OPTIONS, formatSelfServeTaskButtons, @@ -38,6 +46,12 @@ const parseNullableInt = (value) => { return Number.isNaN(parsed) ? null : parsed; }; +const routeQueryValue = (value) => (Array.isArray(value) ? value[0] : value); +const normalizeScopeValue = (value) => { + const normalized = String(routeQueryValue(value) ?? "").trim(); + return normalized && normalized !== "0" ? normalized : ""; +}; + const unwrapData = (response) => response?.data?.data ?? response?.data ?? response ?? null; const requestGet = async (url, params = {}) => unwrapData(await authenticatedRequest(url, "GET", params)); @@ -150,6 +164,14 @@ const withErrorToast = (error, fallbackMessage) => { }; const departmentId = computed(() => parseIntOrZero(currentRoute?.params?.departmentId || SessionUser.functions.getDepartmentIdFromUrl())); +const scopeFiltersFromRoute = () => ({ + lane_id: normalizeScopeValue(currentRoute?.query?.lane_id ?? currentRoute?.query?.lane), + vehicle_type_id: normalizeScopeValue( + currentRoute?.query?.vehicle_type_id + ?? currentRoute?.query?.vehicle_type + ?? currentRoute?.query?.product_id + ), +}); const PANEL_ITEMS = [ { id: "flow", label: "Flow", icon: "fas fa-diagram-project" }, @@ -335,6 +357,15 @@ const simulatorResult = ref(null); const simulatorAnswerOverrides = ref({}); const simulatorShowJson = ref(false); const simulatorDynamicImageHidden = ref(false); +const simulatorDynamicImageError = ref(""); +const simulatorCompletedTasks = ref({}); +const isSimulatorRunning = ref(false); +const simulatorRunReason = ref(""); +const simulatorRunTimer = ref(null); +const simulatorRequestSequence = ref(0); +const simulatorLastRequestKey = ref(""); +const simulatorLastError = ref(""); +const isRestoringSimulatorStorage = ref(false); const pathOutcomes = ref(null); const isLoadingPathOutcomes = ref(false); const pathOutcomesError = ref(""); @@ -344,6 +375,10 @@ const selectedPathOutcomeId = ref(null); const simulatorCustomerSearchQuery = ref(""); const selectedSimulatorCustomer = ref(null); const showSimulatorCustomerResults = ref(false); +const scopeGuideDraft = ref(scopeFiltersFromRoute()); +const isScopeGuideManuallyOpen = ref(false); +const isSyncingScopeFromRoute = ref(false); +const isWritingScopeToRoute = ref(false); const virtualHardwareForm = ref({ gateway_key: "virtual-main", gateway_label: "Virtual Studio Gateway", @@ -356,10 +391,11 @@ const selectedLaneManagementId = ref(null); const laneForm = ref({}); const lanePreviewHidden = ref(false); const inspectorLanePreviewHidden = ref(false); +const inspectorTaskDynamicImageHidden = ref(false); +const inspectorTaskDynamicImageError = ref(""); const searchTerm = ref(""); const filters = ref({ - lane_id: "", - vehicle_type_id: "", + ...scopeFiltersFromRoute(), machine_type_id: "", dynamic_image_id: "", }); @@ -573,14 +609,12 @@ const buildLaneDynamicImagePreviewUrl = (laneId, dynamicImageId) => { return null; } - const params = new URLSearchParams({ - department: String(departmentId.value), - lane: String(normalizedLaneId), - dynamic_image_id: String(normalizedDynamicImageId), - current_step: "0", + return buildSelfServeDynamicImageUrl({ + departmentId: departmentId.value, + laneId: normalizedLaneId, + dynamicImageId: normalizedDynamicImageId, + currentStep: 0, }); - - return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`); }; const laneManagementDynamicImagePreviewUrl = computed(() => ( @@ -634,11 +668,113 @@ const vehicleTypeMatchesFilter = (vehicleType, selectedValue) => { ].some((candidate) => String(candidate ?? "").trim() === normalizedSelectedValue); }; -const selectedSimulatorLaneId = computed(() => firstPositiveInt( - filters.value.lane_id, - simulatorForm.value.lane_id, - lookupRows("lanes")[0]?.id +const vehicleTypeFilterValue = (vehicleType) => normalizeScopeValue( + vehicleType?.product + ?? vehicleType?.product_id + ?? vehicleType?.id +); + +const hasLaneScopeValue = (value) => { + const normalized = normalizeScopeValue(value); + return Boolean(normalized && lookupRows("lanes").some((lane) => normalizeScopeValue(lane?.id) === normalized)); +}; + +const hasVehicleScopeValue = (value) => { + const normalized = normalizeScopeValue(value); + return Boolean(normalized && lookupRows("vehicle_types").some((vehicleType) => vehicleTypeMatchesFilter(vehicleType, normalized))); +}; + +const simulatorDefaults = computed(() => graphPayload.value?.simulator_defaults || {}); +const defaultLaneScopeValue = () => { + const current = normalizeScopeValue(filters.value.lane_id); + if (hasLaneScopeValue(current)) { + return current; + } + const fromDefaults = normalizeScopeValue(simulatorDefaults.value?.lane_id); + if (hasLaneScopeValue(fromDefaults)) { + return fromDefaults; + } + return normalizeScopeValue(lookupRows("lanes")[0]?.id); +}; +const defaultVehicleScopeValue = () => { + const current = normalizeScopeValue(filters.value.vehicle_type_id); + if (hasVehicleScopeValue(current)) { + return current; + } + const fromDefaults = normalizeScopeValue(simulatorDefaults.value?.vehicle_type_id); + const defaultVehicleType = lookupRows("vehicle_types").find((vehicleType) => vehicleTypeMatchesFilter(vehicleType, fromDefaults)); + if (defaultVehicleType) { + return vehicleTypeFilterValue(defaultVehicleType); + } + return vehicleTypeFilterValue(lookupRows("vehicle_types")[0]); +}; +const syncScopeGuideDraftDefaults = () => { + scopeGuideDraft.value = { + lane_id: defaultLaneScopeValue(), + vehicle_type_id: defaultVehicleScopeValue(), + }; +}; + +const requiredScopeComplete = computed(() => ( + hasLaneScopeValue(filters.value.lane_id) + && hasVehicleScopeValue(filters.value.vehicle_type_id) )); +const scopeGuideCanApply = computed(() => ( + hasLaneScopeValue(scopeGuideDraft.value.lane_id) + && hasVehicleScopeValue(scopeGuideDraft.value.vehicle_type_id) +)); +const showScopeGuide = computed(() => ( + Boolean(graphPayload.value) + && (!requiredScopeComplete.value || isScopeGuideManuallyOpen.value) +)); +const isScopeGuideBlocking = computed(() => !requiredScopeComplete.value); +const scopeGuideStatusText = computed(() => { + if (!lookupRows("lanes").length) { + return "Create a lane before the studio can simulate or edit a scoped flow."; + } + if (!lookupRows("vehicle_types").length) { + return "Create a vehicle type before the studio can simulate or edit a scoped flow."; + } + if (!hasLaneScopeValue(scopeGuideDraft.value.lane_id) || !hasVehicleScopeValue(scopeGuideDraft.value.vehicle_type_id)) { + return "Select one lane and one vehicle type to continue."; + } + return "This scope will be saved in the URL and restored after reload."; +}); + +const openScopeGuide = () => { + syncScopeGuideDraftDefaults(); + isScopeGuideManuallyOpen.value = true; +}; + +const closeScopeGuide = () => { + if (requiredScopeComplete.value) { + isScopeGuideManuallyOpen.value = false; + } +}; + +const applyScopeGuideSelection = () => { + if (!scopeGuideCanApply.value) { + return; + } + filters.value = { + ...filters.value, + lane_id: normalizeScopeValue(scopeGuideDraft.value.lane_id), + vehicle_type_id: normalizeScopeValue(scopeGuideDraft.value.vehicle_type_id), + }; + isScopeGuideManuallyOpen.value = false; +}; + +const selectedSimulatorLaneId = computed(() => firstPositiveInt( + filters.value.lane_id +)); +const selectedSimulatorLaneRow = computed(() => { + const laneId = parseIntOrZero(selectedSimulatorLaneId.value); + if (laneId <= 0) { + return null; + } + + return lookupRows("lanes").find((lane) => parseIntOrZero(lane?.id) === laneId) || null; +}); const selectedSimulatorVehicleTypeRow = computed(() => { const selectedValue = String(filters.value.vehicle_type_id ?? "").trim(); if (!selectedValue) { @@ -657,7 +793,7 @@ const simulatorScopeSummary = computed(() => ({ ? labelFor("lanes", selectedSimulatorLaneId.value) : "Select lane", vehicleType: selectedSimulatorVehicleTypeRow.value?.label - || (selectedSimulatorVehicleTypeId.value ? labelFor("vehicle_types", selectedSimulatorVehicleTypeId.value) : "Auto"), + || (selectedSimulatorVehicleTypeId.value ? labelFor("vehicle_types", selectedSimulatorVehicleTypeId.value) : "Select vehicle type"), })); const pathOutcomesRequestPayload = computed(() => { @@ -671,6 +807,63 @@ const pathOutcomesRequestPayload = computed(() => { include_hardware: hardwareMode !== "none", }; }); +const syncScopeFromRoute = () => { + if (isWritingScopeToRoute.value) { + return; + } + const routeScope = scopeFiltersFromRoute(); + if ( + routeScope.lane_id === normalizeScopeValue(filters.value.lane_id) + && routeScope.vehicle_type_id === normalizeScopeValue(filters.value.vehicle_type_id) + ) { + return; + } + isSyncingScopeFromRoute.value = true; + filters.value = { + ...filters.value, + lane_id: routeScope.lane_id, + vehicle_type_id: routeScope.vehicle_type_id, + }; + syncScopeGuideDraftDefaults(); + nextTick(() => { + isSyncingScopeFromRoute.value = false; + }); +}; +const writeScopeToRoute = () => { + if (isSyncingScopeFromRoute.value) { + return; + } + const laneId = normalizeScopeValue(filters.value.lane_id); + const vehicleTypeId = normalizeScopeValue(filters.value.vehicle_type_id); + const nextQuery = { ...(currentRoute?.query || {}) }; + if (laneId) { + nextQuery.lane_id = laneId; + } else { + delete nextQuery.lane_id; + } + if (vehicleTypeId) { + nextQuery.vehicle_type_id = vehicleTypeId; + } else { + delete nextQuery.vehicle_type_id; + } + delete nextQuery.lane; + delete nextQuery.vehicle_type; + delete nextQuery.product_id; + + if ( + normalizeScopeValue(currentRoute?.query?.lane_id) === normalizeScopeValue(nextQuery.lane_id) + && normalizeScopeValue(currentRoute?.query?.vehicle_type_id) === normalizeScopeValue(nextQuery.vehicle_type_id) + ) { + return; + } + + isWritingScopeToRoute.value = true; + currentRouter.replace({ query: nextQuery }) + .catch(() => {}) + .finally(() => { + isWritingScopeToRoute.value = false; + }); +}; const currentPathOutcomesRequestKey = computed(() => JSON.stringify(pathOutcomesRequestPayload.value)); const pathOutcomeList = computed(() => (Array.isArray(pathOutcomes.value?.outcomes) ? pathOutcomes.value.outcomes : [])); const pathResultList = computed(() => (Array.isArray(pathOutcomes.value?.paths) ? pathOutcomes.value.paths : [])); @@ -773,6 +966,11 @@ const loadPathOutcomes = async () => { if (!departmentId.value || isLoadingPathOutcomes.value) { return; } + if (!requiredScopeComplete.value) { + pathOutcomesError.value = "Select a lane and vehicle type before projecting path outcomes."; + openScopeGuide(); + return; + } isLoadingPathOutcomes.value = true; pathOutcomesError.value = ""; pathOutcomesAbortController.value?.abort(); @@ -898,6 +1096,74 @@ const hideSimulatorCustomerResults = () => { }, 200); }; +const simulatorStorageKey = computed(() => [ + "selfserve_studio_simulator", + "v1", + departmentId.value || 0, + normalizeScopeValue(filters.value.lane_id) || "lane", + normalizeScopeValue(filters.value.vehicle_type_id) || "vehicle", +].join(":")); + +const readSimulatorStorage = () => { + if (typeof window === "undefined") { + return null; + } + try { + const raw = window.localStorage.getItem(simulatorStorageKey.value); + return raw ? JSON.parse(raw) : null; + } catch (_error) { + return null; + } +}; + +const normalizeStoredAnswerOverrides = (answers) => { + const source = answers && typeof answers === "object" ? answers : {}; + return Object.fromEntries( + Object.entries(source) + .filter(([questionId, value]) => parseIntOrZero(questionId) > 0 && (value === true || value === false || value === null)) + .map(([questionId, value]) => [String(parseIntOrZero(questionId)), value]) + ); +}; + +const restoreSimulatorStateFromStorage = () => { + const stored = readSimulatorStorage(); + isRestoringSimulatorStorage.value = true; + try { + simulatorAnswerOverrides.value = normalizeStoredAnswerOverrides(stored?.answer_overrides); + const storedCustomer = normalizeSimulatorCustomer(stored?.customer); + const storedCustomerNumber = normalizePositiveInteger(stored?.customer_number ?? storedCustomer?.customerNumber); + if (storedCustomer) { + selectedSimulatorCustomer.value = storedCustomer; + simulatorForm.value.customer_number = storedCustomer.customerNumber; + simulatorCustomerSearchQuery.value = buildSimulatorCustomerLabel(storedCustomer); + showSimulatorCustomerResults.value = false; + searchCustomer(null); + return; + } + applySimulatorCustomerNumber(storedCustomerNumber ?? simulatorForm.value.customer_number); + } finally { + nextTick(() => { + isRestoringSimulatorStorage.value = false; + }); + } +}; + +const persistSimulatorStateToStorage = () => { + if (isRestoringSimulatorStorage.value || typeof window === "undefined" || !requiredScopeComplete.value) { + return; + } + const payload = { + customer_number: parseNullableInt(simulatorForm.value.customer_number), + customer: selectedSimulatorCustomer.value, + answer_overrides: normalizeStoredAnswerOverrides(simulatorAnswerOverrides.value), + }; + try { + window.localStorage.setItem(simulatorStorageKey.value, JSON.stringify(payload)); + } catch (_error) { + // Local simulator state is a convenience cache; storage failures should not block the studio. + } +}; + const resolvedSimulatorVehicleTypeId = computed(() => firstPositiveInt( simulatorDebug.value?.parameters?.vehicle_type_id, simulatorResult.value?.vehicle_type_id, @@ -1490,6 +1756,102 @@ const selectedTaskAttachments = computed(() => ( selectedNodeKind.value === "task" ? taskAttachmentList(inspectorForm.value) : [] )); +const inspectorTaskDynamicImageTask = computed(() => { + if (selectedNodeKind.value !== "task") { + return null; + } + + return { + ...selectedRaw.value, + ...inspectorForm.value, + services: normalizeServiceList(inspectorForm.value?.services), + buttons: normalizeButtonList(inspectorForm.value?.buttons), + }; +}); + +const inspectorTaskDynamicImageButtons = computed(() => ( + inspectorTaskDynamicImageTask.value + ? getSelfServeTaskDynamicImageButtons(inspectorTaskDynamicImageTask.value) + : [] +)); + +const inspectorTaskDynamicImageThumbPosition = computed(() => ( + inspectorTaskDynamicImageTask.value + ? getSelfServeDynamicImageThumbPosition([inspectorTaskDynamicImageTask.value]) + : null +)); + +const inspectorTaskPreviewLaneId = computed(() => firstPositiveInt( + inspectorForm.value?.lane_id, + inspectorForm.value?.lane, + selectedRaw.value?.lane_id, + selectedRaw.value?.lane, + selectedSimulatorLaneId.value +)); + +const inspectorTaskPreviewLaneRow = computed(() => laneRowById(inspectorTaskPreviewLaneId.value)); + +const inspectorTaskPreviewDynamicImageId = computed(() => ( + parseNullableInt(inspectorTaskPreviewLaneRow.value?.dynamic_image_id) +)); + +const inspectorTaskPreviewVehicleTypeId = computed(() => firstPositiveInt( + inspectorForm.value?.vehicle_type_id, + selectedRaw.value?.vehicle_type_id, + inspectorForm.value?.product, + selectedRaw.value?.product, + selectedSimulatorVehicleTypeId.value +)); + +const inspectorTaskHasDynamicImageContext = computed(() => ( + inspectorTaskDynamicImageButtons.value.length > 0 || inspectorTaskDynamicImageThumbPosition.value !== null +)); + +const inspectorTaskDynamicImageUrl = computed(() => { + if (selectedNodeKind.value !== "task" || !inspectorTaskHasDynamicImageContext.value) { + return null; + } + if (!departmentId.value || !inspectorTaskPreviewLaneId.value || !inspectorTaskPreviewDynamicImageId.value) { + return null; + } + + return buildSelfServeDynamicImageUrl({ + departmentId: departmentId.value, + laneId: inspectorTaskPreviewLaneId.value, + dynamicImageId: inspectorTaskPreviewDynamicImageId.value, + buttons: inspectorTaskDynamicImageButtons.value, + currentStep: 0, + vehicleTypeId: inspectorTaskPreviewVehicleTypeId.value, + thumbPosition: inspectorTaskDynamicImageThumbPosition.value, + }); +}); + +const displayedInspectorTaskDynamicImageUrl = computed(() => ( + inspectorTaskDynamicImageHidden.value ? null : inspectorTaskDynamicImageUrl.value +)); + +const inspectorTaskDynamicImageUnavailableReason = computed(() => { + if (inspectorTaskDynamicImageError.value) { + return inspectorTaskDynamicImageError.value; + } + if (selectedNodeKind.value !== "task") { + return "Select a task to preview dynamic image markings."; + } + if (!inspectorTaskPreviewLaneId.value) { + return "Select a lane scope before previewing task markings."; + } + if (!inspectorTaskPreviewDynamicImageId.value) { + return "The selected lane has no dynamic image configured."; + } + if (!inspectorTaskHasDynamicImageContext.value) { + return "Select a dynamic image button or PROGRAM_PICKER service to preview markings."; + } + if (inspectorTaskDynamicImageHidden.value) { + return "Dynamic image could not be rendered for the selected task markings."; + } + return "Task dynamic image preview is unavailable."; +}); + const openTaskAttachment = async (attachment, taskId = null) => { let downloadLink = String(attachment?.download_link || "").trim(); if (!downloadLink && taskId && attachment?.id) { @@ -1938,11 +2300,6 @@ const taskButtonIsSelected = (buttons, buttonId) => ( normalizeButtonList(buttons).includes(buttonId) ); -const parseDynamicImageThumbPosition = (value) => { - const parsed = parseNullableInt(value); - return parsed !== null && parsed >= 1 && parsed <= 12 ? parsed : null; -}; - const simulatorPreviewTasks = computed(() => { if (Array.isArray(simulatorResult.value?.tasks) && simulatorResult.value.tasks.length > 0) { return simulatorResult.value.tasks; @@ -1951,71 +2308,225 @@ const simulatorPreviewTasks = computed(() => { return simulatorTasks.value.filter((task) => task.active); }); -const simulatorDynamicImageButtons = computed(() => { - const buttons = new Map(); - for (const task of simulatorPreviewTasks.value) { - for (const button of normalizeButtonList(task?.buttons)) { - buttons.set(`${typeof button}:${button}`, button); - } - } - return Array.from(buttons.values()); -}); +const simulatorDynamicImageButtons = computed(() => getSelfServeDynamicImageButtonsToPress(simulatorPreviewTasks.value)); -const simulatorDynamicImageThumbPosition = computed(() => { - for (const task of simulatorPreviewTasks.value) { - const thumbPosition = parseDynamicImageThumbPosition(task?.dynamic_images_vehicle_type); - if (thumbPosition !== null) { - return thumbPosition; - } - } - return null; -}); +const simulatorDynamicImageThumbPosition = computed(() => getSelfServeDynamicImageThumbPosition(simulatorPreviewTasks.value)); + +const simulatorDynamicImageCurrentStep = computed(() => getSelfServeCompletedDynamicImageStep( + simulatorPreviewTasks.value, + simulatorCompletedTasks.value +)); const hasSimulatorDynamicImageContext = computed(() => ( simulatorDynamicImageButtons.value.length > 0 || simulatorDynamicImageThumbPosition.value !== null )); +const simulatorResolvedLaneId = computed(() => parseIntOrZero( + selectedSimulatorLaneId.value + || simulatorResult.value?.lane?.id + || simulatorDebug.value?.parameters?.lane_id +)); + +const simulatorResolvedVehicleTypeId = computed(() => parseNullableInt( + selectedSimulatorVehicleTypeId.value + ?? resolvedSimulatorVehicleTypeId.value + ?? simulatorResult.value?.vehicle?.type + ?? simulatorDebug.value?.parameters?.vehicle_type_id +)); + const simulatorDynamicImageUrl = computed(() => { if (!simulatorResult.value || !hasSimulatorDynamicImageContext.value) { return null; } - const laneId = parseIntOrZero( - selectedSimulatorLaneId.value - || simulatorResult.value?.lane?.id - || simulatorDebug.value?.parameters?.lane_id - ); + const laneId = simulatorResolvedLaneId.value; if (!departmentId.value || laneId <= 0) { return null; } - const params = new URLSearchParams({ - department: String(departmentId.value), - lane: String(laneId), - current_step: "0", - buttons: JSON.stringify(simulatorDynamicImageButtons.value), + return buildSelfServeDynamicImageUrl({ + departmentId: departmentId.value, + laneId, + buttons: simulatorDynamicImageButtons.value, + currentStep: simulatorDynamicImageCurrentStep.value, + vehicleTypeId: simulatorResolvedVehicleTypeId.value, + thumbPosition: simulatorDynamicImageThumbPosition.value, }); - - const vehicleTypeId = parseNullableInt( - selectedSimulatorVehicleTypeId.value - ?? simulatorResult.value?.vehicle?.type - ?? simulatorDebug.value?.parameters?.vehicle_type_id - ); - if (vehicleTypeId !== null && vehicleTypeId > 0) { - params.set("vehicle_type", String(vehicleTypeId)); - } - - if (simulatorDynamicImageThumbPosition.value !== null) { - params.set("thumb_position", String(simulatorDynamicImageThumbPosition.value)); - } - - return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`); }); const displayedSimulatorDynamicImageUrl = computed(() => ( simulatorDynamicImageHidden.value ? null : simulatorDynamicImageUrl.value )); +const simulatorDynamicImageUnavailableReason = computed(() => { + if (simulatorDynamicImageError.value) { + return simulatorDynamicImageError.value; + } + if (!simulatorResult.value) { + return "Run the simulator to resolve the dynamic image state."; + } + if (simulatorResolvedLaneId.value <= 0) { + return "Dynamic image hidden because no lane is selected."; + } + if (selectedSimulatorLaneRow.value && (parseNullableInt(selectedSimulatorLaneRow.value.dynamic_image_id) ?? 0) <= 0) { + return "Dynamic image hidden because the selected lane has no dynamic image configured."; + } + if (simulatorResult.value?.all_visible_questions_answered === false) { + return "Dynamic image hidden because visible questions are still missing answers."; + } + if (!hasSimulatorDynamicImageContext.value) { + return "Dynamic image hidden because no active task exposes dynamic image buttons or thumb position."; + } + if (simulatorDynamicImageHidden.value) { + return "Dynamic image could not be rendered for the selected lane, buttons, and vehicle type."; + } + return ""; +}); + +const simulatorDecisions = computed(() => ( + Array.isArray(simulatorDebug.value?.decisions) ? simulatorDebug.value.decisions : [] +)); + +const simulatorDynamicImageButtonDecisions = computed(() => { + if (Array.isArray(simulatorDebug.value?.dynamic_image_buttons)) { + return simulatorDebug.value.dynamic_image_buttons; + } + return simulatorDecisions.value.filter((decision) => decision.kind === "dynamic_image_button"); +}); + +const formatDecisionValue = (value) => { + if (value === true) { + return "true"; + } + if (value === false) { + return "false"; + } + if (value === null || typeof value === "undefined") { + return "not set"; + } + return String(value); +}; + +const formatDecisionCause = (cause) => { + if (!cause || typeof cause !== "object") { + return ""; + } + if (cause.description) { + return String(cause.description); + } + const label = cause.label || cause.kind || "Source"; + const expected = Object.prototype.hasOwnProperty.call(cause, "expected") + ? ` expected ${formatDecisionValue(cause.expected)}` + : ""; + const actual = Object.prototype.hasOwnProperty.call(cause, "actual") + ? `, actual ${formatDecisionValue(cause.actual)}` + : ""; + const reason = cause.reason ? ` (${cause.reason})` : ""; + return `${label}${expected}${actual}${reason}`; +}; + +const decisionCauses = (entry) => ( + Array.isArray(entry?.causes) ? entry.causes.map(formatDecisionCause).filter(Boolean) : [] +); + +const simulatorFlowSteps = computed(() => { + const hasResult = Boolean(simulatorResult.value); + const hasRegistration = String(simulatorForm.value.reg || "").trim() !== ""; + const laneId = simulatorResolvedLaneId.value; + const resolvedVehicleType = simulatorResolvedVehicleTypeId.value; + const visibleQuestions = simulatorQuestions.value.filter((question) => question.visible !== false); + const missingQuestions = visibleQuestions.filter((question) => question.state === "missing" || question.answer === null); + const allQuestionsAnswered = simulatorResult.value?.all_visible_questions_answered === true + || (hasResult && visibleQuestions.length > 0 && missingQuestions.length === 0); + const activeTasks = simulatorPreviewTasks.value; + const machineServiceAllowed = (simulatorResult.value?.allowed_services || []).includes("MACHINE"); + const machineAvailable = simulatorResult.value?.machine_available === true; + const allowed = simulatorResult.value?.allowed === true; + + const steps = [ + { + id: "vehicle", + title: "Vehicle", + icon: "fas fa-car-side", + state: hasRegistration ? (hasResult ? "complete" : "active") : "disabled", + reason: hasRegistration + ? (resolvedVehicleType ? `Registration ${simulatorForm.value.reg} resolves to ${labelFor("vehicle_types", resolvedVehicleType)}.` : "Registration is set; vehicle type will be resolved by the simulator.") + : "Vehicle step disabled because registration is empty.", + node_ids: resolvedVehicleType ? [`vehicle_type:${resolvedVehicleType}`] : [], + }, + { + id: "questions", + title: "Questions", + icon: "fas fa-circle-question", + state: !hasResult ? "disabled" : (visibleQuestions.length === 0 ? "skipped" : (allQuestionsAnswered ? "complete" : "active")), + reason: !hasResult + ? "Questions disabled until the simulator has a result." + : (visibleQuestions.length === 0 + ? "Questions skipped because no questions are visible for the selected lane, vehicle type, and answers." + : (allQuestionsAnswered + ? "Questions complete because every visible question has an answer." + : `Questions active because ${missingQuestions.length} visible question(s) are missing answers.`)), + node_ids: visibleQuestions.map((question) => question.node_id).filter(Boolean), + }, + { + id: "lane", + title: "Select lane", + icon: "fas fa-road", + state: laneId > 0 ? "complete" : "disabled", + reason: laneId > 0 + ? `Lane selected: ${labelFor("lanes", laneId)}.` + : "Lane step disabled because no lane is selected.", + node_ids: laneId > 0 ? [`lane:${laneId}`] : [], + }, + { + id: "tasks", + title: "Tasks", + icon: "fas fa-list-check", + state: !hasResult || !allQuestionsAnswered ? "disabled" : (activeTasks.length > 0 ? "active" : "skipped"), + reason: !hasResult + ? "Tasks disabled until the simulator has a result." + : (!allQuestionsAnswered + ? "Tasks disabled because visible questions are still missing answers." + : (activeTasks.length > 0 + ? `${activeTasks.length} task(s) will be shown to the end user.` + : "Tasks skipped because no active tasks remain after gates and service filtering.")), + node_ids: activeTasks + .map((task) => parseIntOrZero(task?.id || task?.task_id)) + .filter((taskId) => taskId > 0) + .map((taskId) => `task:${taskId}`), + }, + { + id: "wash", + title: "Wash in progress", + icon: "fas fa-soap", + state: allowed ? "active" : "disabled", + reason: allowed + ? "Wash in progress is reachable because MACHINE service is allowed and the lane machine relay is available." + : (!machineServiceAllowed + ? "Wash in progress disabled because MACHINE service is not allowed by the active tasks." + : (!machineAvailable + ? "Wash in progress disabled because the lane machine relay is not configured." + : "Wash in progress disabled because eligibility is blocked.")), + node_ids: ["checkpoint:eligible"], + }, + { + id: "completed", + title: "Completed", + icon: "fas fa-check", + state: allowed ? "complete" : "disabled", + reason: allowed + ? "Completed is reachable after the simulated wash can start." + : "Completed disabled because the wash cannot reach the in-progress step.", + node_ids: ["checkpoint:finish"], + }, + ]; + + return steps.map((step) => ({ + ...step, + causes: decisionCauses(step), + })); +}); + const bindingServiceLabel = (data) => { const services = bindingServiceList(data); return services.length > 0 ? services.join(", ") : "Unassigned"; @@ -2330,16 +2841,22 @@ const hydrateFromGraph = (payload) => { gatewayWorkspace.value = payload?.gateway_workspace || { gateways: [], relays: [], lanes: [], issues: [], actions: [] }; versions.value = Array.isArray(payload?.versions) ? payload.versions : []; simulatorForm.value = { - lane_id: payload?.simulator_defaults?.lane_id || lookupRows("lanes")[0]?.id || null, + lane_id: null, reg: payload?.simulator_defaults?.reg || "TEST123", customer_number: payload?.simulator_defaults?.customer_number || null, config_source: simulatorForm.value?.config_source || "draft", include_hardware: simulatorForm.value?.include_hardware !== false, hardware_mode: simulatorForm.value?.hardware_mode || payload?.simulator_defaults?.hardware_mode || (payload?.gateway_workspace?.virtual?.has_virtual_hardware ? "studio" : "real"), }; - applySimulatorCustomerNumber(simulatorForm.value.customer_number); simulatorResult.value = null; - simulatorAnswerOverrides.value = {}; + simulatorCompletedTasks.value = {}; + simulatorDynamicImageError.value = ""; + syncScopeFromRoute(); + syncScopeGuideDraftDefaults(); + restoreSimulatorStateFromStorage(); + if (activePanel.value === "simulator") { + scheduleSimulatorRun("load", 0); + } syncLaneManagementSelection(); suppressLayoutSave.value = true; @@ -2580,14 +3097,27 @@ const selectNode = (nodeId, openInspector = true) => { setSelectedNode(nodeId, openInspector); }; -const clearCanvasFilters = () => { +const requiredScopeForNodeIds = (nodeIds = []) => { + const nodes = (Array.isArray(nodeIds) ? nodeIds : []) + .map((nodeId) => flowNodes.value.find((node) => node.id === nodeId)) + .filter(Boolean); + const laneId = nodes.map((node) => nodeScopeFilterValue(node, "lane")).find(hasLaneScopeValue); + const vehicleTypeId = nodes.map((node) => nodeScopeFilterValue(node, "vehicle_type")).find(hasVehicleScopeValue); + return { + lane_id: laneId || (hasLaneScopeValue(filters.value.lane_id) ? normalizeScopeValue(filters.value.lane_id) : defaultLaneScopeValue()), + vehicle_type_id: vehicleTypeId || (hasVehicleScopeValue(filters.value.vehicle_type_id) ? normalizeScopeValue(filters.value.vehicle_type_id) : defaultVehicleScopeValue()), + }; +}; + +const clearCanvasFilters = (nodeIds = []) => { if (!hasActiveCanvasFilters.value) { return false; } + const scope = requiredScopeForNodeIds(nodeIds); searchTerm.value = ""; filters.value = { - lane_id: "", - vehicle_type_id: "", + lane_id: scope.lane_id, + vehicle_type_id: scope.vehicle_type_id, machine_type_id: "", dynamic_image_id: "", }; @@ -2615,7 +3145,7 @@ const focusDebugNodes = async (nodeIds = [], openInspector = false, options = {} if (ids.length === 0) { return; } - const clearedFilters = options.clearFilters === true && clearCanvasFilters(); + const clearedFilters = options.clearFilters === true && clearCanvasFilters(ids); if (clearedFilters) { await nextTick(); await waitForCanvasFilterReset(); @@ -2635,6 +3165,16 @@ const focusDebugNodes = async (nodeIds = [], openInspector = false, options = {} }).catch(() => {}); }; +const focusSimulatorNodes = async (nodeIds = [], openInspector = false) => { + const ids = [...new Set((Array.isArray(nodeIds) ? nodeIds : []).filter(Boolean))]; + if (ids.length === 0) { + return; + } + activePanel.value = openInspector ? "inspector" : "flow"; + await nextTick(); + await focusDebugNodes(ids, openInspector); +}; + const selectValidationItem = async (item) => { const targetNodeIds = Array.isArray(item?.target_node_ids) ? item.target_node_ids @@ -2660,6 +3200,8 @@ const refreshInspectorForm = () => { inspectorForm.value.gate_type ||= "ALWAYS"; inspectorForm.value.services = normalizeServiceList(inspectorForm.value.services); inspectorForm.value.buttons = normalizeButtonList(inspectorForm.value.buttons); + inspectorTaskDynamicImageHidden.value = false; + inspectorTaskDynamicImageError.value = ""; } if (selectedNode.value?.data?.kind === "action") { inspectorForm.value = normalizeActionForm(inspectorForm.value); @@ -2974,37 +3516,104 @@ const useVirtualRelaySlot = (slotId) => { }; }; -const runSimulator = async () => { +const buildSimulatorPayload = () => { + const answerOverrides = Object.entries(simulatorAnswerOverrides.value) + .filter(([, value]) => value === true || value === false || value === null) + .map(([questionId, value]) => ({ question_id: parseIntOrZero(questionId), value })); + const hardwareMode = simulatorForm.value.hardware_mode || (hasVirtualHardware.value ? "studio" : "real"); + const laneId = parseIntOrZero(selectedSimulatorLaneId.value); + const vehicleTypeId = selectedSimulatorVehicleTypeId.value; + if (laneId <= 0 || !vehicleTypeId) { + return null; + } + + return { + department: departmentId.value, + lane_id: laneId, + reg: simulatorForm.value.reg, + customer_number: parseNullableInt(simulatorForm.value.customer_number), + vehicle_type_id: vehicleTypeId, + config_source: simulatorForm.value.config_source || "draft", + include_hardware: hardwareMode !== "none", + hardware_mode: hardwareMode, + mode: "full_dry_run", + answer_overrides: answerOverrides, + }; +}; + +const simulatorErrorMessage = (error, fallbackMessage = "Simulation failed.") => ( + error?.response?.data?.message + || error?.response?.data?.data?.message + || error?.message + || fallbackMessage +); + +const runSimulator = async ({ manual = false, reason = "manual" } = {}) => { + let sequence = null; try { - const answerOverrides = Object.entries(simulatorAnswerOverrides.value) - .filter(([, value]) => value === true || value === false || value === null) - .map(([questionId, value]) => ({ question_id: parseIntOrZero(questionId), value })); - const hardwareMode = simulatorForm.value.hardware_mode || (hasVirtualHardware.value ? "studio" : "real"); - const laneId = parseIntOrZero(selectedSimulatorLaneId.value); - if (laneId <= 0) { - toast.error("Select a lane before running the simulator."); + const payload = buildSimulatorPayload(); + if (!payload) { + simulatorLastError.value = "Select a lane and vehicle type before running the simulator."; + openScopeGuide(); + if (manual) { + toast.error(simulatorLastError.value); + } return; } - simulatorResult.value = await requestPost("/department/selfserve/studio/simulate", { - department: departmentId.value, - lane_id: laneId, - reg: simulatorForm.value.reg, - customer_number: parseNullableInt(simulatorForm.value.customer_number), - vehicle_type_id: selectedSimulatorVehicleTypeId.value, - config_source: simulatorForm.value.config_source || "draft", - include_hardware: hardwareMode !== "none", - hardware_mode: hardwareMode, - mode: "full_dry_run", - answer_overrides: answerOverrides, - }); + + const requestKey = JSON.stringify(payload); + sequence = simulatorRequestSequence.value + 1; + simulatorRequestSequence.value = sequence; + simulatorLastRequestKey.value = requestKey; + simulatorRunReason.value = reason; + isSimulatorRunning.value = true; + simulatorLastError.value = ""; + + const result = await requestPost("/department/selfserve/studio/simulate", payload); + if (sequence !== simulatorRequestSequence.value || requestKey !== simulatorLastRequestKey.value) { + return; + } + + simulatorResult.value = result; + simulatorDynamicImageError.value = ""; applyCanvasFilters(); } catch (error) { - withErrorToast(error, "Simulation failed."); + const message = simulatorErrorMessage(error); + simulatorLastError.value = message; + if (manual) { + toast.error(message); + } + } finally { + if (sequence === simulatorRequestSequence.value) { + isSimulatorRunning.value = false; + } } }; +const scheduleSimulatorRun = (reason = "change", delayMs = 250) => { + if (simulatorRunTimer.value) { + window.clearTimeout(simulatorRunTimer.value); + simulatorRunTimer.value = null; + } + if (activePanel.value !== "simulator" || !graphPayload.value) { + return; + } + const run = () => runSimulator({ reason }); + if (delayMs <= 0) { + run(); + return; + } + simulatorRunTimer.value = window.setTimeout(run, delayMs); +}; + const onSimulatorDynamicImageError = () => { simulatorDynamicImageHidden.value = true; + simulatorDynamicImageError.value = "Dynamic image endpoint could not render the selected lane, buttons, vehicle type, or thumb position."; +}; + +const onInspectorTaskDynamicImageError = () => { + inspectorTaskDynamicImageHidden.value = true; + inspectorTaskDynamicImageError.value = "Dynamic image endpoint could not render the selected task buttons, lane, or thumb position."; }; const setSimulatorAnswerOverride = (questionId, value) => { @@ -3018,6 +3627,19 @@ const clearSimulatorAnswerOverrides = () => { simulatorAnswerOverrides.value = {}; }; +const setSimulatorTaskCompleted = (taskId, value) => { + simulatorCompletedTasks.value = { + ...simulatorCompletedTasks.value, + [taskId]: value, + }; +}; + +const openSimulatorTaskAttachment = (taskId, _attachmentId, attachment = null) => { + if (attachment) { + openTaskAttachment(attachment, taskId); + } +}; + const simulatorAnswerButtonClass = (questionId, value) => ({ "is-primary": simulatorAnswerOverrides.value?.[questionId] === value, "is-light": simulatorAnswerOverrides.value?.[questionId] !== value, @@ -3308,7 +3930,10 @@ watch(selectedLaneManagementRow, (lane) => { }); watch(flowNodes, scheduleLayoutSave, { deep: true }); watch(departmentId, loadStudioGraph); -watch(() => currentRoute?.fullPath, syncPanelFromRoute, { immediate: true }); +watch(() => currentRoute?.fullPath, () => { + syncPanelFromRoute(); + syncScopeFromRoute(); +}, { immediate: true }); watch(activePanel, (panel) => { if (panel === "paths" && !pathOutcomes.value && !pathOutcomesError.value) { loadPathOutcomes(); @@ -3318,12 +3943,53 @@ watch(isStudioFullscreen, refitStudioCanvas); watch(filters, applyCanvasFilters, { deep: true }); watch(searchTerm, applyCanvasFilters); watch(() => [filters.value.lane_id, filters.value.vehicle_type_id], () => { - simulatorResult.value = null; - simulatorAnswerOverrides.value = {}; + writeScopeToRoute(); + syncScopeGuideDraftDefaults(); + restoreSimulatorStateFromStorage(); + simulatorCompletedTasks.value = {}; simulatorDynamicImageHidden.value = false; + simulatorDynamicImageError.value = ""; + scheduleSimulatorRun("scope", 150); }); watch(simulatorDynamicImageUrl, () => { simulatorDynamicImageHidden.value = false; + simulatorDynamicImageError.value = ""; +}); +watch(inspectorTaskDynamicImageUrl, () => { + inspectorTaskDynamicImageHidden.value = false; + inspectorTaskDynamicImageError.value = ""; +}); +watch(() => [ + simulatorForm.value.reg, + simulatorForm.value.customer_number, + simulatorForm.value.config_source, + simulatorForm.value.hardware_mode, +], () => { + scheduleSimulatorRun("inputs", 300); +}); +watch(simulatorAnswerOverrides, () => { + simulatorCompletedTasks.value = {}; + persistSimulatorStateToStorage(); + scheduleSimulatorRun("answers", 0); +}, { deep: true }); +watch(() => [ + simulatorForm.value.customer_number, + selectedSimulatorCustomer.value?.customerNumber, + selectedSimulatorCustomer.value?.name, + selectedSimulatorCustomer.value?.city, +], persistSimulatorStateToStorage); +watch(activePanel, (panel) => { + if (panel === "simulator") { + scheduleSimulatorRun("panel", simulatorResult.value ? 150 : 0); + } +}); +watch(simulatorPreviewTasks, (tasks) => { + const validTaskIds = new Set((Array.isArray(tasks) ? tasks : []) + .map((task) => String(task?.id ?? task?.task_id ?? "")) + .filter((id) => id !== "")); + simulatorCompletedTasks.value = Object.fromEntries( + Object.entries(simulatorCompletedTasks.value).filter(([taskId]) => validTaskIds.has(String(taskId))) + ); }); watch(laneManagementDynamicImagePreviewUrl, () => { lanePreviewHidden.value = false; @@ -3344,6 +4010,9 @@ onBeforeUnmount(() => { document.removeEventListener("keydown", handleStudioFullscreenKeydown); pathOutcomesAbortController.value?.abort(); searchCustomer(null); + if (simulatorRunTimer.value) { + window.clearTimeout(simulatorRunTimer.value); + } if (layoutSaveTimer.value) { window.clearTimeout(layoutSaveTimer.value); } @@ -3360,7 +4029,7 @@ onBeforeUnmount(() => {
      @@ -3394,6 +4063,108 @@ onBeforeUnmount(() => { + +
      + + -
{{ shortValue(session.trace_id) }}{{ userLabel(session) }}{{ deviceLabel(session) }}{{ session.channel_slug || "--" }}{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }} - {{ session.event_count || 0 }} - {{ session.error_count }} - - {{ session.error_report_count }} - - {{ formatDate(session.last_event_at || session.last_seen_at) }} - - - +
+
+ + {{ trFallback("loading.timeline_sessions", "Loading release timeline sessions...") }} +
{{ tr("replay.no_timeline_events") }}