fix(deploy): reuse shared server n8n

This commit is contained in:
NuklearRabbit
2026-08-02 03:56:17 +02:00
parent 686b62f592
commit 1f292e14bb
12 changed files with 234 additions and 61 deletions
+24 -15
View File
@@ -6,13 +6,15 @@
`/mnt/user/appdata/mobilityops`, Compose project `mobilityops`. `/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
- Deployment config commits: `07ab7a3`, `847cd05`, `e1a1c67`, `1e13943`. The accepted - Deployment config commits: `07ab7a3`, `847cd05`, `e1a1c67`, `1e13943`. The accepted
baseline `4bf9afbeff44088864e0844769d4dd0e4089d85b` remains intact. baseline `4bf9afbeff44088864e0844769d4dd0e4089d85b` remains intact.
- All four services are healthy with zero restarts. Only web port 1236 is exposed to the - MobilityOps PostgreSQL, API and web services are healthy. Only web port 1236 is exposed
LAN; n8n is loopback-only on host port 15678; API and PostgreSQL are internal. by the MobilityOps Compose project; API and PostgreSQL remain internal. Automation uses
the server's existing shared n8n at `http://192.168.10.150:5678`; no second MobilityOps
n8n container is running.
- Migrations are at `e7b08389f47f (head)` and deterministic seed counts match final - Migrations are at `e7b08389f47f (head)` and deterministic seed counts match final
acceptance. A Chrome smoke test covered every requested page and a real return; its n8n acceptance. A Chrome smoke test covered every requested page and a real return; its n8n
event succeeded on attempt 1. Browser console and recent service log scans were clean. event succeeded on attempt 1. Browser console and recent service log scans were clean.
- RAGcore is disabled in favor of the honest local demo provider. MCP Hub registration is - RAGcore is disabled in favor of the honest local demo provider. MCP Hub registration is
disabled. n8n is initialized, published, healthy, and live-verified. disabled. The MobilityOps workflow is published in the existing n8n and live-verified.
- Local post-change gates: 66 backend tests, Ruff, mypy (44 files), and frontend production - Local post-change gates: 66 backend tests, Ruff, mypy (44 files), and frontend production
build all pass. Evidence is in `artifacts/deployment/unraid-summary.md`. build all pass. Evidence is in `artifacts/deployment/unraid-summary.md`.
- Published to the private Gitea repository - Published to the private Gitea repository
@@ -294,20 +296,27 @@ None. External service credentials may be absent; use the documented demo/degrad
- `docker compose run --rm api ruff check .`**All checks passed**. - `docker compose run --rm api ruff check .`**All checks passed**.
- `docker compose run --rm api mypy app`**0 issues in 44 files**. - `docker compose run --rm api mypy app`**0 issues in 44 files**.
- `cd frontend && npm run lint` — clean TypeScript check. - `cd frontend && npm run lint` — clean TypeScript check.
- `cd frontend && npm run build` — production build succeeded (59 modules; 238.66 kB JS, - `cd frontend && npm run build` — production build succeeded (59 modules; 240.24 kB JS,
36.54 kB CSS before gzip). 36.63 kB CSS before gzip).
- `cd frontend && playwright test --reporter=line`**18 passed** including the full - `cd frontend && playwright test --reporter=line`**19 passed** including the full
five-minute demo, every interactive route, return review semantics and four viewport five-minute demo, every interactive route, return review semantics and four viewport
overflow checks. overflow checks.
- Review deployment updated at `http://192.168.10.150:1236` with persistent PostgreSQL - Review deployment updated at `http://192.168.10.150:1236` with persistent PostgreSQL
and n8n volumes preserved. Deployed smoke: all ten authenticated routes plus login at data preserved. Deployed smoke: all ten authenticated routes plus login at
desktop and mobile sizes rendered without alert state or horizontal overflow; browser console had zero desktop and mobile sizes rendered without alert state or horizontal overflow; browser console had zero
warnings/errors; seven authenticated API paths returned 200; PostgreSQL/API were warnings/errors; seven authenticated API paths returned 200; PostgreSQL/API were
healthy and n8n `/healthz` returned `{"status":"ok"}`. healthy and the shared n8n `/healthz` returned `{"status":"ok"}`.
- The repository compose file uses developer ports, so the existing review deployment's - Corrected the review topology after confirming the host already runs n8n on port 5678:
established bindings were restored after archive extraction: web `1236:80`, n8n the temporary `mobilityops-n8n-1` container was removed without deleting its retained
`127.0.0.1:15678:5678`, and no host API port. This is deployment configuration only; volume; the bundled service is now opt-in through the `bundled-n8n` profile; the API
no unrelated container was altered. points to the shared n8n; and the return workflow is imported and published there.
- Exact next action after this record: commit and push the deployed evidence, update the - The existing n8n's previously empty `N8N_HOST` and `N8N_EDITOR_BASE_URL` values were
deployed source-revision marker to that final documentation commit without rebuilding persistently set in its Unraid template. A synthetic return then completed the full
unchanged images, then hand the branch off for review. Do not merge master automatically. MobilityOps → shared n8n → callback round trip as `succeeded` on attempt 1, after which
deterministic demo state was restored (`BK-DEMO-RETURN` is `active`).
- Global search is now live for Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
references, including Ctrl/Cmd+K focus and a tested not-found announcement. Final local
Playwright result is **19 passed**.
- Exact next action after this record: commit, push and deploy these final topology/search
corrections, update the deployed source-revision marker, then hand the branch off for
review. Do not merge master automatically.
+20 -17
View File
@@ -22,11 +22,13 @@
| `db` | running, 0 restarts | healthy | none (`5432/tcp` internal) | | `db` | running, 0 restarts | healthy | none (`5432/tcp` internal) |
| `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) | | `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) |
| `web` | running, 0 restarts | healthy | `1236:80` on LAN | | `web` | running, 0 restarts | healthy | `1236:80` on LAN |
| `n8n` | running, 0 restarts | healthy | `127.0.0.1:15678:5678` only | | shared host `n8n` | running | healthy | `5678:5678` on LAN; outside MobilityOps Compose |
No unrelated container was stopped, recreated, or modified. Port `1236` was confirmed The final review topology reuses the n8n container that was already running on the host.
unused by listeners, Docker containers, and existing Compose projects before deployment. Its empty public-host/editor URL settings were corrected in the persistent Unraid template
The requested directory did not exist and was created specifically for MobilityOps. so workflow execution URLs are valid. The temporary Compose-owned n8n container was
removed without deleting its retained volume. Port `1236` was confirmed unused before the
original deployment; the application directory was created specifically for MobilityOps.
## Deployment commands ## Deployment commands
@@ -54,15 +56,13 @@ ssh unraid
cd /mnt/user/appdata/mobilityops cd /mnt/user/appdata/mobilityops
tar -xzf .deploy/source.tar.gz tar -xzf .deploy/source.tar.gz
./deploy/unraid/configure-env.sh http://192.168.10.150:1236 ./deploy/unraid/configure-env.sh http://192.168.10.150:1236
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api \ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api \
python -m app.cli seed --reset python -m app.cli seed --reset
./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678 ./deploy/unraid/setup-existing-n8n.sh \
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \ n8n \
n8n import:workflow --input=//imports/mobilityops-return-processing.json http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
n8n publish:workflow --id=mobilityops-return-processing
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n
``` ```
The server `.env` was created from `.env.example`, is mode `0600`, and contains generated The server `.env` was created from `.env.example`, is mode `0600`, and contains generated
@@ -88,8 +88,9 @@ included here or in Git.
- Return workflow: `BK-DEMO-RETURN` accepted 54,700 km, created `INSP-0076` and - Return workflow: `BK-DEMO-RETURN` accepted 54,700 km, created `INSP-0076` and
`DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to `DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to
returned. returned.
- n8n round trip: event prefix `250f6c0e` reached `succeeded` on attempt 1; the callback - Shared-n8n round trip: final verification event `b32a593e-c946-4782-a180-518385b66b50`
and `return_registered` audit events were visible in the UI. reached `succeeded` on attempt 1 with no last error; the deterministic reset afterwards
restored `BK-DEMO-RETURN` to `active`.
- Data quality: `DQ-RET-0076` displayed the persisted regression evidence and related - Data quality: `DQ-RET-0076` displayed the persisted regression evidence and related
booking/inspection references. booking/inspection references.
- Knowledge: UI truthfully showed `Provider: demo · available · 10 procedures indexed`; - Knowledge: UI truthfully showed `Provider: demo · available · 10 procedures indexed`;
@@ -102,8 +103,9 @@ included here or in Git.
- ITWorx MCP Hub: registration disabled with `MCP_HUB_REGISTRATION_ENABLED=false`; the - ITWorx MCP Hub: registration disabled with `MCP_HUB_REGISTRATION_ENABLED=false`; the
independently authenticated provider endpoints remain available internally to the web independently authenticated provider endpoints remain available internally to the web
proxy/API boundary, but no live Hub connection is claimed. proxy/API boundary, but no live Hub connection is claimed.
- n8n: deployed, healthy, editor loopback-only, owner initialized, workflow imported and - n8n: the existing server instance at `http://192.168.10.150:5678` is healthy; the
published, and a real return delivery succeeded. MobilityOps workflow is imported/published there and a real return delivery succeeded.
The bundled MobilityOps service is disabled by default in the Unraid overlay.
## Known limitations ## Known limitations
@@ -119,7 +121,7 @@ to `.deploy/source.tar.gz`. On Unraid, preserve `.env` and the named volumes, th
```bash ```bash
cd /mnt/user/appdata/mobilityops cd /mnt/user/appdata/mobilityops
tar -xzf .deploy/source.tar.gz tar -xzf .deploy/source.tar.gz
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current
curl -fsS http://127.0.0.1:1236/health curl -fsS http://127.0.0.1:1236/health
``` ```
@@ -130,7 +132,8 @@ curl -fsS http://127.0.0.1:1236/health
cd /mnt/user/appdata/mobilityops cd /mnt/user/appdata/mobilityops
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200 docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web n8n docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web
docker logs -f n8n
``` ```
## Safe rollback ## Safe rollback
@@ -3,7 +3,7 @@
Date: 2026-08-02 Date: 2026-08-02
Branch: `design/mobilityops-premium-ui` Branch: `design/mobilityops-premium-ui`
Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b` Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b`
Final design revision: `b3946af4e0e14f2a3acecd212e9f0079e3028fca` Revision: final branch head reported in the review handoff
Review URL: `http://192.168.10.150:1236` Review URL: `http://192.168.10.150:1236`
## Outcome ## Outcome
@@ -45,8 +45,8 @@ return registration gained an evidence-based review boundary before commit.
| Backend tests | 66 passed | | Backend tests | 66 passed |
| Backend lint | ruff passed | | Backend lint | ruff passed |
| Backend types | mypy: 0 issues in 44 files | | Backend types | mypy: 0 issues in 44 files |
| Frontend types/build | passed; 59 modules; 238.66 kB JS and 36.54 kB CSS before gzip | | Frontend types/build | passed; 59 modules; 240.24 kB JS and 36.63 kB CSS before gzip |
| Browser journeys | 18 passed locally | | Browser journeys | 19 passed locally |
| Horizontal overflow | none at 390/768/1280/1440 px | | Horizontal overflow | none at 390/768/1280/1440 px |
| Accessibility | named landmarks, skip link, visible focus, text-plus-shape status, labelled mobile rows, reduced-motion support | | Accessibility | named landmarks, skip link, visible focus, text-plus-shape status, labelled mobile rows, reduced-motion support |
| Deployed browser smoke | passed on all 10 authenticated routes plus login at desktop and mobile sizes | | Deployed browser smoke | passed on all 10 authenticated routes plus login at desktop and mobile sizes |
@@ -57,7 +57,9 @@ Synthetic-data labelling is persistent on login and authenticated surfaces.
Deployed evidence is stored in `artifacts/design-validation/implementation/deployed/`. Deployed evidence is stored in `artifacts/design-validation/implementation/deployed/`.
The review stack reports healthy PostgreSQL/API state, HTTP 200 from the web application, The review stack reports healthy PostgreSQL/API state, HTTP 200 from the web application,
and `{"status":"ok"}` from n8n's internal health endpoint. and healthy state from the server's existing n8n at port 5678. A synthetic return reached
`succeeded` on attempt 1 through that shared n8n and its MobilityOps callback; the demo was
then reset to its deterministic state.
## Performance observations ## Performance observations
@@ -66,9 +68,12 @@ and CSS visuals, and the production bundle remains appropriate for this internal
## Known limitations ## Known limitations
- The global search visual is a non-submitting orientation cue because no cross-entity - Global search resolves Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
query API exists; all route-level search and filters are live. It is intentionally not references. It intentionally does not offer customer lookup because the locked PoC has
presented as an action button. no customer detail route or cross-entity search API.
- The repository retains a bundled n8n service for standalone local clean-checkout demos.
The Unraid overlay keeps it behind the opt-in `bundled-n8n` profile; the live review
deployment uses the server's existing shared n8n instead.
- The MCP Hub state is correctly shown as not configured in the current PoC rather than - The MCP Hub state is correctly shown as not configured in the current PoC rather than
simulated as healthy. simulated as healthy.
- Live RAGcore and MCP Hub round trips remain subject to the existing environment limits - Live RAGcore and MCP Hub round trips remain subject to the existing environment limits
+3
View File
@@ -17,6 +17,9 @@ services:
retries: 10 retries: 10
n8n: n8n:
# The review server already has a shared n8n instance. Keep the bundled service
# available for an explicit fallback without starting a second instance by default.
profiles: ["bundled-n8n"]
restart: unless-stopped restart: unless-stopped
ports: !override ports: !override
- "127.0.0.1:15678:5678" - "127.0.0.1:15678:5678"
+17 -14
View File
@@ -1,8 +1,9 @@
# Unraid deployment # Unraid deployment
MobilityOps is deployed from a committed source archive; the server does not need Gitea MobilityOps is deployed from a committed source archive; the server does not need Gitea
credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml` so that credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml`; of the
only the web application is reachable from the LAN. MobilityOps-owned services, only the web application is reachable from the LAN. The host's
existing shared n8n remains available on its established port 5678.
## Server layout ## Server layout
@@ -10,7 +11,7 @@ only the web application is reachable from the LAN.
- Compose project: `mobilityops` - Compose project: `mobilityops`
- Web: `http://192.168.10.150:1236` (`1236` on the host to `80` in `web`) - Web: `http://192.168.10.150:1236` (`1236` on the host to `80` in `web`)
- API and PostgreSQL: Compose network only - API and PostgreSQL: Compose network only
- n8n editor: server loopback `127.0.0.1:15678` only; use an SSH tunnel for setup - Shared n8n: `http://192.168.10.150:5678` (outside the MobilityOps Compose project)
## Deploy ## Deploy
@@ -21,25 +22,26 @@ available. Keep `MCP_HUB_REGISTRATION_ENABLED=false` until the central Hub is re
```bash ```bash
cd /mnt/user/appdata/mobilityops cd /mnt/user/appdata/mobilityops
./deploy/unraid/configure-env.sh http://192.168.10.150:1236 ./deploy/unraid/configure-env.sh \
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d http://192.168.10.150:1236 \
http://192.168.10.150:5678/webhook/mobilityops-return
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \
python -m app.cli seed --reset python -m app.cli seed --reset
``` ```
Migrations run automatically in the API entrypoint. Complete the one-time n8n owner Migrations run automatically in the API entrypoint. Import and publish the MobilityOps
setup without exposing its editor to the LAN, then import and publish the workflow: workflow into the existing n8n container:
```bash ```bash
./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678 ./deploy/unraid/setup-existing-n8n.sh \
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \ n8n \
n8n import:workflow --input=//imports/mobilityops-return-processing.json http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \
n8n publish:workflow --id=mobilityops-return-processing
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n
``` ```
The generated n8n owner credentials remain only in the mode-`0600` server `.env`. The callback token remains server-side and is never written to the repository. The
bundled n8n service is retained only as a standalone fallback behind the explicit
`bundled-n8n` Compose profile; it is not started in this deployment.
## Operate ## Operate
@@ -48,4 +50,5 @@ cd /mnt/user/appdata/mobilityops
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200 docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current
docker logs --tail=200 n8n
``` ```
+3 -1
View File
@@ -2,6 +2,7 @@
set -eu set -eu
public_url="${1:-http://192.168.10.150:1236}" public_url="${1:-http://192.168.10.150:1236}"
n8n_webhook_url="${2:-http://192.168.10.150:5678/webhook/mobilityops-return}"
if [ -e .env ]; then if [ -e .env ]; then
echo "Refusing to overwrite existing .env" >&2 echo "Refusing to overwrite existing .env" >&2
@@ -25,6 +26,7 @@ sed -i \
-e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \ -e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \ -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \
-e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \ -e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \
-e "s|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=${n8n_webhook_url}|" \
-e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \ -e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \
-e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \ -e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \
-e "s|^MOBILITYOPS_CALLBACK_TOKEN=.*|MOBILITYOPS_CALLBACK_TOKEN=${callback_token}|" \ -e "s|^MOBILITYOPS_CALLBACK_TOKEN=.*|MOBILITYOPS_CALLBACK_TOKEN=${callback_token}|" \
@@ -35,4 +37,4 @@ sed -i \
unset db_password app_secret n8n_key n8n_password callback_token mcp_token unset db_password app_secret n8n_key n8n_password callback_token mcp_token
echo "Created server .env with generated secrets for ${public_url}" echo "Created server .env for ${public_url} using n8n webhook ${n8n_webhook_url}"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
exit 1
fi
if [ ! -f "$source_workflow" ]; then
echo "Missing workflow export: $source_workflow" >&2
exit 1
fi
if ! docker inspect "$container_name" >/dev/null 2>&1; then
echo "Existing n8n container not found: $container_name" >&2
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() {
rm -f "$temporary_workflow"
docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}"
+23
View File
@@ -62,6 +62,29 @@ curl -b cookies.txt http://localhost:8128/api/v1/workflows | grep succeeded
A failed/offline n8n does not roll back the return — the outbox event simply stays A failed/offline n8n does not roll back the return — the outbox event simply stays
`pending`/`failed` and is safely retryable from the Automation page. `pending`/`failed` and is safely retryable from the Automation page.
### Existing shared n8n on the Unraid review server
The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not
start MobilityOps's bundled n8n service. `compose.unraid.yaml` places that fallback behind
the opt-in `bundled-n8n` profile. Configure the API target and publish the workflow with:
```bash
sed -i \
's|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return|' \
.env
./deploy/unraid/setup-existing-n8n.sh \
n8n \
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
```
The setup script reads the callback token from the mode-0600 deployment `.env`, builds and
removes a temporary server-side import without writing the token to Git, and restarts the
existing n8n so the production webhook is registered. The imported configuration remains
inside n8n's protected application data. The callback travels through the MobilityOps web
proxy, so the shared n8n container does not need direct database access or membership of
the MobilityOps Docker network.
## Required operational checks ## Required operational checks
- API and web health (`GET /health`, web root `200`); - API and web health (`GET /health`, web root `200`);
+4 -2
View File
@@ -66,8 +66,10 @@ The same widths are automated in `frontend/e2e/ui-redesign.spec.ts`.
- Authenticated network verification through the deployed web proxy returned HTTP 200 - Authenticated network verification through the deployed web proxy returned HTTP 200
for dashboard, vehicles, active booking, open quality issues, knowledge status, for dashboard, vehicles, active booking, open quality issues, knowledge status,
workflows and audit. workflows and audit.
- Container verification: PostgreSQL and API healthy, web HTTP 200, n8n `/healthz` - Container verification: PostgreSQL and API healthy, web HTTP 200, and the server's
returned `{"status":"ok"}`. Existing database and n8n volumes were preserved. existing n8n on port 5678 returned healthy. No second MobilityOps n8n container is
running. A synthetic return completed the shared-n8n callback on attempt 1 and the demo
was reset to its deterministic start state.
## Performance choices ## Performance choices
+10
View File
@@ -22,6 +22,16 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page"); await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page");
}); });
test("global search supports its keyboard shortcut and public references", async ({ page }) => {
await page.keyboard.press("Control+k");
const search = page.getByRole("searchbox", { name: "Search MobilityOps" });
await expect(search).toBeFocused();
await search.fill("MO-024");
await search.press("Enter");
await expect(page).toHaveURL(/\/vehicles\/MO-024$/);
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible();
});
test("return review separates capture from irreversible commit", async ({ page }) => { test("return review separates capture from irreversible commit", async ({ page }) => {
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("60000"); await page.getByLabel("End odometer (km)").fill("60000");
+71 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { FormEvent, useEffect, useRef, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { BrandMark, Icon, type IconName } from "./Icons"; import { BrandMark, Icon, type IconName } from "./Icons";
@@ -25,16 +25,70 @@ const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: strin
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5); const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
const SEARCH_DESTINATIONS = [
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
{ to: "/bookings", terms: ["booking", "bookings", "rental"] },
{ to: "/data-quality", terms: ["quality", "data quality", "issues"] },
{ to: "/knowledge", terms: ["knowledge", "procedures"] },
{ to: "/automation", terms: ["automation", "integrations", "systems", "n8n"] },
{ to: "/audit", terms: ["audit", "history"] },
];
export function Layout() { export function Layout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchStatus, setSearchStatus] = useState("");
const searchInput = useRef<HTMLInputElement>(null);
useEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.current?.focus();
}
}
window.addEventListener("keydown", focusGlobalSearch);
return () => window.removeEventListener("keydown", focusGlobalSearch);
}, []);
function handleLogout() { function handleLogout() {
logout(); logout();
navigate("/login"); navigate("/login");
} }
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const query = searchQuery.trim();
if (!query) {
setSearchStatus("Enter a section or a vehicle, booking or issue reference.");
return;
}
const publicRef = query.toUpperCase();
let destination: string | undefined;
if (/^MO-\d+$/.test(publicRef)) destination = `/vehicles/${publicRef}`;
else if (/^BK-[A-Z0-9-]+$/.test(publicRef)) destination = `/bookings/${publicRef}`;
else if (/^DQ-[A-Z0-9-]+$/.test(publicRef)) destination = `/data-quality/${publicRef}`;
else {
const normalized = query.toLowerCase();
destination = SEARCH_DESTINATIONS.find(({ terms }) =>
terms.some((term) => term.includes(normalized) || normalized.includes(term)),
)?.to;
}
if (destination) {
setSearchStatus("");
navigate(destination);
return;
}
setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`);
}
return ( return (
<div className="app-shell"> <div className="app-shell">
<a className="skip-link" href="#main-content">Skip to main content</a> <a className="skip-link" href="#main-content">Skip to main content</a>
@@ -74,12 +128,24 @@ export function Layout() {
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation"> <button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" /> <Icon name="menu" />
</button> </button>
<label className="global-search"> <form className="global-search" role="search" onSubmit={handleSearch}>
<Icon name="search" /> <Icon name="search" />
<span className="visually-hidden">Search MobilityOps</span> <label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
<input type="search" placeholder="Search fleet, booking or customer…" /> <input
id="global-search-input"
ref={searchInput}
type="search"
value={searchQuery}
placeholder="Search fleet, booking or section…"
aria-describedby="global-search-status"
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchStatus("");
}}
/>
<kbd>Ctrl K</kbd> <kbd>Ctrl K</kbd>
</label> <span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
</form>
<div className="topbar-meta"> <div className="topbar-meta">
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span> <span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
{user && ( {user && (
+1
View File
@@ -77,6 +77,7 @@ a:hover { color: var(--teal); }
.global-search { width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); } .global-search { width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); }
.global-search svg { width: 16px; } .global-search svg { width: 16px; }
.global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .8rem; } .global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .8rem; }
.global-search:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(18, 132, 126, .14); }
.global-search kbd { padding: 2px 5px; border: 1px solid var(--line); background: white; color: var(--muted); font-size: .64rem; border-radius: 3px; } .global-search kbd { padding: 2px 5px; border: 1px solid var(--line); background: white; color: var(--muted); font-size: .64rem; border-radius: 3px; }
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; } .topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; }
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; } .timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }