fix(deploy): reuse shared server n8n
This commit is contained in:
+24
-15
@@ -6,13 +6,15 @@
|
||||
`/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
|
||||
- Deployment config commits: `07ab7a3`, `847cd05`, `e1a1c67`, `1e13943`. The accepted
|
||||
baseline `4bf9afbeff44088864e0844769d4dd0e4089d85b` remains intact.
|
||||
- All four services are healthy with zero restarts. Only web port 1236 is exposed to the
|
||||
LAN; n8n is loopback-only on host port 15678; API and PostgreSQL are internal.
|
||||
- MobilityOps PostgreSQL, API and web services are healthy. Only web port 1236 is exposed
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
build all pass. Evidence is in `artifacts/deployment/unraid-summary.md`.
|
||||
- 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 mypy app` — **0 issues in 44 files**.
|
||||
- `cd frontend && npm run lint` — clean TypeScript check.
|
||||
- `cd frontend && npm run build` — production build succeeded (59 modules; 238.66 kB JS,
|
||||
36.54 kB CSS before gzip).
|
||||
- `cd frontend && playwright test --reporter=line` — **18 passed** including the full
|
||||
- `cd frontend && npm run build` — production build succeeded (59 modules; 240.24 kB JS,
|
||||
36.63 kB CSS before gzip).
|
||||
- `cd frontend && playwright test --reporter=line` — **19 passed** including the full
|
||||
five-minute demo, every interactive route, return review semantics and four viewport
|
||||
overflow checks.
|
||||
- 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
|
||||
warnings/errors; seven authenticated API paths returned 200; PostgreSQL/API were
|
||||
healthy and n8n `/healthz` returned `{"status":"ok"}`.
|
||||
- The repository compose file uses developer ports, so the existing review deployment's
|
||||
established bindings were restored after archive extraction: web `1236:80`, n8n
|
||||
`127.0.0.1:15678:5678`, and no host API port. This is deployment configuration only;
|
||||
no unrelated container was altered.
|
||||
- Exact next action after this record: commit and push the deployed evidence, update the
|
||||
deployed source-revision marker to that final documentation commit without rebuilding
|
||||
unchanged images, then hand the branch off for review. Do not merge master automatically.
|
||||
healthy and the shared n8n `/healthz` returned `{"status":"ok"}`.
|
||||
- Corrected the review topology after confirming the host already runs n8n on port 5678:
|
||||
the temporary `mobilityops-n8n-1` container was removed without deleting its retained
|
||||
volume; the bundled service is now opt-in through the `bundled-n8n` profile; the API
|
||||
points to the shared n8n; and the return workflow is imported and published there.
|
||||
- The existing n8n's previously empty `N8N_HOST` and `N8N_EDITOR_BASE_URL` values were
|
||||
persistently set in its Unraid template. A synthetic return then completed the full
|
||||
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) |
|
||||
| `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) |
|
||||
| `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
|
||||
unused by listeners, Docker containers, and existing Compose projects before deployment.
|
||||
The requested directory did not exist and was created specifically for MobilityOps.
|
||||
The final review topology reuses the n8n container that was already running on the host.
|
||||
Its empty public-host/editor URL settings were corrected in the persistent Unraid template
|
||||
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
|
||||
|
||||
@@ -54,15 +56,13 @@ ssh unraid
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
tar -xzf .deploy/source.tar.gz
|
||||
./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 \
|
||||
python -m app.cli seed --reset
|
||||
./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \
|
||||
n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \
|
||||
n8n publish:workflow --id=mobilityops-return-processing
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n
|
||||
./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 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
|
||||
`DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to
|
||||
returned.
|
||||
- n8n round trip: event prefix `250f6c0e` reached `succeeded` on attempt 1; the callback
|
||||
and `return_registered` audit events were visible in the UI.
|
||||
- Shared-n8n round trip: final verification event `b32a593e-c946-4782-a180-518385b66b50`
|
||||
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
|
||||
booking/inspection references.
|
||||
- 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
|
||||
independently authenticated provider endpoints remain available internally to the web
|
||||
proxy/API boundary, but no live Hub connection is claimed.
|
||||
- n8n: deployed, healthy, editor loopback-only, owner initialized, workflow imported and
|
||||
published, and a real return delivery succeeded.
|
||||
- n8n: the existing server instance at `http://192.168.10.150:5678` is healthy; the
|
||||
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
|
||||
|
||||
@@ -119,7 +121,7 @@ to `.deploy/source.tar.gz`. On Unraid, preserve `.env` and the named volumes, th
|
||||
```bash
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
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
|
||||
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
|
||||
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 -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Date: 2026-08-02
|
||||
Branch: `design/mobilityops-premium-ui`
|
||||
Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b`
|
||||
Final design revision: `b3946af4e0e14f2a3acecd212e9f0079e3028fca`
|
||||
Revision: final branch head reported in the review handoff
|
||||
Review URL: `http://192.168.10.150:1236`
|
||||
|
||||
## Outcome
|
||||
@@ -45,8 +45,8 @@ return registration gained an evidence-based review boundary before commit.
|
||||
| Backend tests | 66 passed |
|
||||
| Backend lint | ruff passed |
|
||||
| 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 |
|
||||
| Browser journeys | 18 passed locally |
|
||||
| Frontend types/build | passed; 59 modules; 240.24 kB JS and 36.63 kB CSS before gzip |
|
||||
| Browser journeys | 19 passed locally |
|
||||
| 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 |
|
||||
| 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/`.
|
||||
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
|
||||
|
||||
@@ -66,9 +68,12 @@ and CSS visuals, and the production bundle remains appropriate for this internal
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The global search visual is a non-submitting orientation cue because no cross-entity
|
||||
query API exists; all route-level search and filters are live. It is intentionally not
|
||||
presented as an action button.
|
||||
- Global search resolves Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
|
||||
references. It intentionally does not offer customer lookup because the locked PoC has
|
||||
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
|
||||
simulated as healthy.
|
||||
- Live RAGcore and MCP Hub round trips remain subject to the existing environment limits
|
||||
|
||||
@@ -17,6 +17,9 @@ services:
|
||||
retries: 10
|
||||
|
||||
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
|
||||
ports: !override
|
||||
- "127.0.0.1:15678:5678"
|
||||
|
||||
+17
-14
@@ -1,8 +1,9 @@
|
||||
# Unraid deployment
|
||||
|
||||
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
|
||||
only the web application is reachable from the LAN.
|
||||
credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml`; of the
|
||||
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
|
||||
|
||||
@@ -10,7 +11,7 @@ only the web application is reachable from the LAN.
|
||||
- Compose project: `mobilityops`
|
||||
- Web: `http://192.168.10.150:1236` (`1236` on the host to `80` in `web`)
|
||||
- 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
|
||||
|
||||
@@ -21,25 +22,26 @@ available. Keep `MCP_HUB_REGISTRATION_ENABLED=false` until the central Hub is re
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
./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
|
||||
./deploy/unraid/configure-env.sh \
|
||||
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 \
|
||||
python -m app.cli seed --reset
|
||||
```
|
||||
|
||||
Migrations run automatically in the API entrypoint. Complete the one-time n8n owner
|
||||
setup without exposing its editor to the LAN, then import and publish the workflow:
|
||||
Migrations run automatically in the API entrypoint. Import and publish the MobilityOps
|
||||
workflow into the existing n8n container:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \
|
||||
n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
||||
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
|
||||
./deploy/unraid/setup-existing-n8n.sh \
|
||||
n8n \
|
||||
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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 logs --tail=200
|
||||
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
|
||||
|
||||
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
|
||||
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|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \
|
||||
-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_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \
|
||||
-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
|
||||
|
||||
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
|
||||
`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
|
||||
|
||||
- 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
|
||||
for dashboard, vehicles, active booking, open quality issues, knowledge status,
|
||||
workflows and audit.
|
||||
- Container verification: PostgreSQL and API healthy, web HTTP 200, n8n `/healthz`
|
||||
returned `{"status":"ok"}`. Existing database and n8n volumes were preserved.
|
||||
- Container verification: PostgreSQL and API healthy, web HTTP 200, and the server's
|
||||
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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
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 { useAuth } from "../context/AuthContext";
|
||||
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 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() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
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() {
|
||||
logout();
|
||||
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 (
|
||||
<div className="app-shell">
|
||||
<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">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<label className="global-search">
|
||||
<form className="global-search" role="search" onSubmit={handleSearch}>
|
||||
<Icon name="search" />
|
||||
<span className="visually-hidden">Search MobilityOps</span>
|
||||
<input type="search" placeholder="Search fleet, booking or customer…" />
|
||||
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
|
||||
<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>
|
||||
</label>
|
||||
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
|
||||
</form>
|
||||
<div className="topbar-meta">
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
{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 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: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; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user