diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index cb364e2..bffebb7 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -2459,3 +2459,24 @@ evidence yet."
regenerated. E2E coverage now includes bulk queue assignment and full user editing.
- Exact next action: split the frontend bundle, harden mobile layout and operational
backup/deployment controls, then run clean full acceptance and redeploy.
+
+## M19 — performance and recoverable operations (2026-08-10)
+
+- Route-level React lazy loading reduced the initial production JavaScript chunk from
+ about **572 kB to 212 kB**; every operational page now ships as a separate bounded
+ chunk and the previous Vite large-chunk warning is gone. Both npm audits report zero
+ vulnerabilities.
+- Reflowed the five dashboard readiness metrics into a readable 3+2 mobile grid instead
+ of an overflowing horizontal strip at 390 px.
+- Compose now gates dependants on database-backed `/health/ready`. The optional bundled
+ n8n fallback is pinned to `n8nio/n8n:2.33.7`, matching the central server n8n version;
+ Unraid still starts no second n8n instance.
+- Added guarded Unraid PostgreSQL backup/restore scripts. Backups use custom format and
+ are structurally verified; restore requires an explicit confirmation, makes a safety
+ backup, stops API writes, recreates only the configured database and checks Alembic.
+ A disposable dump/restore drill recovered all **50 vehicles** into a second database.
+- Evidence: merged Compose configurations and shell syntax pass; frontend production
+ build and audits pass; full isolated PostgreSQL suite **225 passed, 1 warning**; ruff
+ and mypy are clean.
+- Exact next action: commit and deploy this milestone, run complete Playwright and visual
+ acceptance against Unraid, refresh final evidence, push and verify the live hash.
diff --git a/README.md b/README.md
index 37149a5..3296269 100644
--- a/README.md
+++ b/README.md
@@ -38,13 +38,14 @@ rollback).
The PoC implements:
- operations dashboard with a truthful aggregate n8n/MCP integration-status card;
-- vehicle and booking views with working search, filters and pagination;
+- vehicle and booking workspaces with planning windows, location filters, service/next-
+ booking context, operational ordering and pagination;
- server-backed session lifecycle (refresh-safe, central 401 handling);
- a role matrix enforced server-side and mirrored in the UI (see
`docs/12-security-and-audit.md`);
- vehicle return capture → authoritative server-evaluated review → commit → result;
-- five deterministic data-quality checks, each with a bounded resolution flow, plus a
- manual scan action;
+- five deterministic data-quality checks with SLA deadlines, assignment, overdue/bulk
+ queue controls, bounded resolution flows and a manual scan action;
- human review and customer merge;
- audit trail with human-readable before/after evidence and safe entity links;
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
@@ -55,6 +56,7 @@ The PoC implements:
heartbeat evidence and crash-recoverable outbox delivery leases;
- four read-only MCP tools through ITWorx MCP Hub;
- deterministic demo reset and five-minute showcase.
+- audited operational user creation, role/name editing, password reset and activation.
It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent.
@@ -118,7 +120,7 @@ required for the automation demo, and the full operational runbook.
Endpoints:
- Web: `http://localhost:1228`
-- API health: `http://localhost:8128/health`
+- API readiness: `http://localhost:8128/health/ready` (liveness: `/health/live`)
- n8n: `http://localhost:5678`
All defaults are configurable via `.env` (see `.env.example`).
diff --git a/backend/tests/test_data_quality.py b/backend/tests/test_data_quality.py
index 797fdea..64c3bcd 100644
--- a/backend/tests/test_data_quality.py
+++ b/backend/tests/test_data_quality.py
@@ -354,10 +354,21 @@ def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
def test_apply_recommended_status_resolves_conflict(ops_client):
- target = _first_open(ops_client, "vehicle_status_conflict")
- preview = ops_client.post(
- f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
+ targets = ops_client.get(
+ "/api/v1/data-quality/issues",
+ params={"rule_type": "vehicle_status_conflict", "status": "open"},
).json()
+ target = None
+ preview = None
+ for candidate in targets:
+ candidate_preview = ops_client.post(
+ f"/api/v1/data-quality/issues/{candidate['public_ref']}/status-recommendation"
+ ).json()
+ if candidate_preview["safe_to_apply"]:
+ target = candidate
+ preview = candidate_preview
+ break
+ assert target is not None and preview is not None
assert preview["safe_to_apply"] is True
assert preview["manual_review_required"] is False
diff --git a/compose.yaml b/compose.yaml
index 7cc09c5..5930e80 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -47,7 +47,7 @@ services:
db:
condition: service_healthy
healthcheck:
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready')"]
interval: 10s
timeout: 5s
retries: 10
@@ -65,7 +65,8 @@ services:
networks: [mobilityops]
n8n:
- image: n8nio/n8n:latest
+ # Explicit fallback only; production reuses the server's existing central n8n.
+ image: n8nio/n8n:2.33.7
environment:
TZ: ${TZ:-Europe/Brussels}
GENERIC_TIMEZONE: ${TZ:-Europe/Brussels}
diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md
index 7f441d2..cddf81b 100644
--- a/deploy/unraid/README.md
+++ b/deploy/unraid/README.md
@@ -52,3 +52,24 @@ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current
docker logs --tail=200 n8n
```
+
+## Backup and restore
+
+Create and structurally verify a timestamped PostgreSQL custom-format backup:
+
+```bash
+./deploy/unraid/backup-postgres.sh
+```
+
+Copy backups off the server according to the host backup policy. A restore is deliberately
+guarded and creates an additional safety backup before replacing the database:
+
+```bash
+./deploy/unraid/restore-postgres.sh \
+ backups/postgres/mobilityops-YYYYMMDDTHHMMSSZ.dump \
+ RESTORE-MOBILITYOPS
+```
+
+The restore stops the API, recreates only the configured MobilityOps database, restarts
+API/web and verifies the active Alembic revision. Test restores in a disposable environment
+before using a production backup for incident recovery.
diff --git a/deploy/unraid/backup-postgres.sh b/deploy/unraid/backup-postgres.sh
new file mode 100755
index 0000000..32f51a3
--- /dev/null
+++ b/deploy/unraid/backup-postgres.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+set -eu
+
+cd "$(dirname "$0")/../.."
+project="${COMPOSE_PROJECT_NAME:-mobilityops}"
+destination="${1:-backups/postgres}"
+timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
+backup_file="${destination}/mobilityops-${timestamp}.dump"
+temporary_file="${backup_file}.partial"
+compose_files="-f compose.yaml -f compose.unraid.yaml"
+
+mkdir -p "$destination"
+trap 'rm -f "$temporary_file"' EXIT INT TERM
+
+docker compose -p "$project" $compose_files exec -T db sh -eu -c \
+ 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"' \
+ > "$temporary_file"
+
+docker compose -p "$project" $compose_files exec -T db pg_restore --list \
+ < "$temporary_file" > /dev/null
+mv "$temporary_file" "$backup_file"
+trap - EXIT INT TERM
+printf '%s\n' "$backup_file"
diff --git a/deploy/unraid/restore-postgres.sh b/deploy/unraid/restore-postgres.sh
new file mode 100755
index 0000000..b847569
--- /dev/null
+++ b/deploy/unraid/restore-postgres.sh
@@ -0,0 +1,37 @@
+#!/bin/sh
+set -eu
+
+cd "$(dirname "$0")/../.."
+backup_file="${1:-}"
+confirmation="${2:-}"
+project="${COMPOSE_PROJECT_NAME:-mobilityops}"
+compose_files="-f compose.yaml -f compose.unraid.yaml"
+
+if [ ! -f "$backup_file" ]; then
+ echo "Backup file not found: $backup_file" >&2
+ exit 1
+fi
+if [ "$confirmation" != "RESTORE-MOBILITYOPS" ]; then
+ echo "Refusing destructive restore; pass RESTORE-MOBILITYOPS as the second argument" >&2
+ exit 1
+fi
+
+docker compose -p "$project" $compose_files exec -T db pg_restore --list \
+ < "$backup_file" > /dev/null
+safety_backup="$(./deploy/unraid/backup-postgres.sh)"
+echo "Pre-restore safety backup: $safety_backup"
+
+restart_api() {
+ docker compose -p "$project" $compose_files up -d api web > /dev/null
+}
+trap restart_api EXIT INT TERM
+docker compose -p "$project" $compose_files stop api > /dev/null
+docker compose -p "$project" $compose_files exec -T db sh -eu -c \
+ 'dropdb --force -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
+docker compose -p "$project" $compose_files exec -T db sh -eu -c \
+ 'pg_restore --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ < "$backup_file"
+restart_api
+trap - EXIT INT TERM
+docker compose -p "$project" $compose_files exec -T api alembic current
+echo "Restore completed from $backup_file"
diff --git a/docs/17-runbook.md b/docs/17-runbook.md
index f3de161..3dcf116 100644
--- a/docs/17-runbook.md
+++ b/docs/17-runbook.md
@@ -24,7 +24,7 @@ docker compose exec api python -m app.cli seed --reset
Verify:
```bash
-curl http://localhost:8128/health # {"status":"ok",...}
+curl http://localhost:8128/health/ready # database-backed readiness
curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200
make test # isolated test project/database, all tests pass
docker compose run --rm api ruff check . # clean
@@ -52,7 +52,7 @@ invalidates its next request.
## n8n automation (one-time per environment)
-The n8n image used here (n8nio/n8n:latest, 2.x) requires an owner account before any
+The optional bundled fallback image (`n8nio/n8n:2.33.7`) requires an owner account before any
workflow — including webhook registration — works reliably; `N8N_BASIC_AUTH_ACTIVE` no
longer gates this. This is a one-time step per fresh `docker compose down -v`:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ca85aeb..156f38e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,24 +1,29 @@
+import { lazy, Suspense, type ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { DemoManifestProvider } from "./context/DemoManifestContext";
import { DemoGuideProvider } from "./context/DemoGuideContext";
import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
-import { Login } from "./pages/Login";
-import { Dashboard } from "./pages/Dashboard";
-import { Vehicles } from "./pages/Vehicles";
-import { VehicleDetail } from "./pages/VehicleDetail";
-import { Bookings } from "./pages/Bookings";
-import { BookingDetail } from "./pages/BookingDetail";
-import { BookingCreate } from "./pages/BookingCreate";
-import { DataQuality } from "./pages/DataQuality";
-import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
-import { Automation } from "./pages/Automation";
-import { Knowledge } from "./pages/Knowledge";
-import { Audit } from "./pages/Audit";
-import { AboutDemo } from "./pages/AboutDemo";
-import { Scenarios } from "./pages/Scenarios";
-import { Users } from "./pages/Users";
+const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login })));
+const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard })));
+const Vehicles = lazy(() => import("./pages/Vehicles").then((module) => ({ default: module.Vehicles })));
+const VehicleDetail = lazy(() => import("./pages/VehicleDetail").then((module) => ({ default: module.VehicleDetail })));
+const Bookings = lazy(() => import("./pages/Bookings").then((module) => ({ default: module.Bookings })));
+const BookingDetail = lazy(() => import("./pages/BookingDetail").then((module) => ({ default: module.BookingDetail })));
+const BookingCreate = lazy(() => import("./pages/BookingCreate").then((module) => ({ default: module.BookingCreate })));
+const DataQuality = lazy(() => import("./pages/DataQuality").then((module) => ({ default: module.DataQuality })));
+const DataQualityIssueDetail = lazy(() => import("./pages/DataQualityIssueDetail").then((module) => ({ default: module.DataQualityIssueDetail })));
+const Automation = lazy(() => import("./pages/Automation").then((module) => ({ default: module.Automation })));
+const Knowledge = lazy(() => import("./pages/Knowledge").then((module) => ({ default: module.Knowledge })));
+const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: module.Audit })));
+const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
+const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
+const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
+
+function deferredPage(element: ReactNode) {
+ return }>{element};
+}
export function App() {
return (
@@ -26,7 +31,7 @@ export function App() {
- } />
+ )} />
@@ -34,20 +39,20 @@ export function App() {
}
>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
+ )} />
} />
} />
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 5f15949..3c3539f 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -419,6 +419,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
+.route-loading { min-height: 40vh; display: grid; place-items: center; }
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
.api-error-notice strong { display: block; font-size: .78rem; }
@@ -512,7 +513,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
}
@media (max-width: 700px) {
- #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
+ #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }.metric-cell { min-width: 0; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; white-space: normal; overflow-wrap: anywhere; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }