fix(deploy): reuse shared server n8n
This commit is contained in:
+24
-15
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Executable
+46
@@ -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}"
|
||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user