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; }