Compare commits
55
Commits
+12
-2
@@ -29,6 +29,11 @@ N8N_BASIC_AUTH_ACTIVE=true
|
|||||||
N8N_BASIC_AUTH_USER=admin
|
N8N_BASIC_AUTH_USER=admin
|
||||||
N8N_BASIC_AUTH_PASSWORD=change-me
|
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||||
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
||||||
|
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
|
||||||
|
# processing webhook, so the webhook trigger can require Header Auth instead of being
|
||||||
|
# publicly callable by anyone who discovers the URL. Must match the value stored in
|
||||||
|
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
|
||||||
|
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
|
||||||
|
|
||||||
# RAGcore integration
|
# RAGcore integration
|
||||||
KNOWLEDGE_PROVIDER=demo
|
KNOWLEDGE_PROVIDER=demo
|
||||||
@@ -37,9 +42,14 @@ RAGCORE_TENANT=northstar-mobility-demo
|
|||||||
RAGCORE_WORKSPACE=mobilityops
|
RAGCORE_WORKSPACE=mobilityops
|
||||||
RAGCORE_COLLECTION=internal-procedures
|
RAGCORE_COLLECTION=internal-procedures
|
||||||
RAGCORE_API_TOKEN=
|
RAGCORE_API_TOKEN=
|
||||||
|
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||||
|
RAGCORE_SPACE_ID=
|
||||||
|
|
||||||
# ITWorx MCP Hub integration
|
# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
|
||||||
|
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
|
||||||
|
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
|
||||||
|
# health check surfaced on the integration status page.
|
||||||
MCP_HUB_REGISTRATION_ENABLED=false
|
MCP_HUB_REGISTRATION_ENABLED=false
|
||||||
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
|
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
|
||||||
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
|
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
|
||||||
MCP_PROVIDER_ID=mobilityops
|
MCP_PROVIDER_ID=fleet-ops
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ test-results/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
|||||||
+4
-1
@@ -60,7 +60,10 @@
|
|||||||
- `knowledge/procedures/09-booking-conflicts.md`
|
- `knowledge/procedures/09-booking-conflicts.md`
|
||||||
- `knowledge/procedures/10-roles-and-escalation.md`
|
- `knowledge/procedures/10-roles-and-escalation.md`
|
||||||
- `n8n/README.md`
|
- `n8n/README.md`
|
||||||
- `n8n/mobilityops-return-processing.json`
|
- `n8n/workflows/MANIFEST.md`
|
||||||
|
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||||
|
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||||
|
- `n8n/workflows/check_drift.py`
|
||||||
- `seed/README.md`
|
- `seed/README.md`
|
||||||
- `seed/bookings.csv`
|
- `seed/bookings.csv`
|
||||||
- `seed/customers.csv`
|
- `seed/customers.csv`
|
||||||
|
|||||||
@@ -26,16 +26,19 @@ reset:
|
|||||||
# One-time per environment: imports and activates the n8n return-processing workflow.
|
# One-time per environment: imports and activates the n8n return-processing workflow.
|
||||||
# The n8n owner account itself cannot be scripted safely and must be created once at
|
# The n8n owner account itself cannot be scripted safely and must be created once at
|
||||||
# http://localhost:5678/setup (any email/password, no verification required) before
|
# http://localhost:5678/setup (any email/password, no verification required) before
|
||||||
# this target's activation takes effect. See docs/17-runbook.md.
|
# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
|
||||||
|
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
|
||||||
|
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
|
||||||
n8n-setup:
|
n8n-setup:
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
|
|
||||||
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
||||||
# Same owner-account precondition as n8n-setup above.
|
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
|
||||||
|
# needs "Fleet Ops Service Token").
|
||||||
n8n-setup-scan:
|
n8n-setup-scan:
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
|
|
||||||
|
|||||||
+1247
-7
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,10 @@ knowledge base, and this documentation. "MobilityOps" remains the technical
|
|||||||
identifier only — the repository name, local directory, package/module names, Docker
|
identifier only — the repository name, local directory, package/module names, Docker
|
||||||
Compose project, deployment directory, and database names. The UI is fully trilingual
|
Compose project, deployment directory, and database names. The UI is fully trilingual
|
||||||
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
|
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
|
||||||
architecture, the vehicle-status decision table, and the correction evidence.
|
architecture, the vehicle-status decision table, and the correction evidence, and
|
||||||
|
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
|
||||||
|
NL/FR translation gaps, centralized API-error localization, the time-dependent
|
||||||
|
Europe/Brussels dashboard greeting).
|
||||||
|
|
||||||
The web application uses the premium responsive **Control Rail** interface: a compact
|
The web application uses the premium responsive **Control Rail** interface: a compact
|
||||||
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
||||||
@@ -65,15 +68,21 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o
|
|||||||
delivery counts, not just the most recent event.
|
delivery counts, not just the most recent event.
|
||||||
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
|
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
|
||||||
over the local procedure documents) is what satisfies the knowledge-assistant
|
over the local procedure documents) is what satisfies the knowledge-assistant
|
||||||
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
|
acceptance criteria and is what's active in production (`KNOWLEDGE_PROVIDER=demo`). A
|
||||||
implemented and unit-tested, including its unavailable-degradation path, but was never
|
`RAGcoreKnowledgeProvider` HTTP adapter is implemented, unit-tested, and has been
|
||||||
exercised against a live RAGcore instance in this environment.
|
exercised live against the deployed RAGcore instance: a real filesystem-permission bug
|
||||||
|
that caused every live retrieval to return zero candidates was found and fixed
|
||||||
|
(`docs/final-integrations/current-state-audit.md`), but a second, deeper gap — RAGcore's
|
||||||
|
reranker adapter calls an Ollama HTTP route (`/api/rerank`) that does not exist on the
|
||||||
|
deployed Ollama version — still blocks real grounded answers. `KNOWLEDGE_PROVIDER` stays
|
||||||
|
`demo` until that is resolved on the RAGcore side.
|
||||||
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
|
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
|
||||||
directly `curl`-verified with correct auth enforcement and audit logging.
|
directly `curl`-verified with correct auth enforcement and audit logging.
|
||||||
`MCP_HUB_REGISTRATION_ENABLED` is now actually wired into `Settings` (it was previously
|
`MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly
|
||||||
declared in `.env.example` but silently dropped) and reported honestly by the
|
by the integration-status endpoint (evidence-based: real tool-call audit history, not
|
||||||
integration-status endpoint. No live Hub instance was reachable in this environment to
|
just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own
|
||||||
verify an actual Hub round trip.
|
production deployment (Tower), with a real contract fix already applied there
|
||||||
|
(`vehicle.get`'s wire parameter normalized to `vehicleRef`).
|
||||||
|
|
||||||
See `artifacts/functional-completion/final-summary.md` for the functional-completion
|
See `artifacts/functional-completion/final-summary.md` for the functional-completion
|
||||||
audit evidence (supersedes the design-validation summary below for integration status),
|
audit evidence (supersedes the design-validation summary below for integration status),
|
||||||
@@ -123,7 +132,7 @@ All defaults are configurable via `.env` (see `.env.example`).
|
|||||||
```bash
|
```bash
|
||||||
make test # backend: pytest (151 tests)
|
make test # backend: pytest (151 tests)
|
||||||
make lint # backend: ruff + mypy (strict, zero errors)
|
make lint # backend: ruff + mypy (strict, zero errors)
|
||||||
make e2e # frontend: Playwright end-to-end (113 tests, live stack required)
|
make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Fleet Ops final integrations — evidence summary
|
||||||
|
|
||||||
|
Session date: 2026-08-05. Branch `feat/fleet-ops-final-integrations`.
|
||||||
|
|
||||||
|
## Repository state
|
||||||
|
|
||||||
|
| Repo | Start | End | Branch | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Fleet Ops (MobilityOps) | `3ebca9e` (from `feat/live-n8n-ragcore-integration`) | `727c19a` (+ e2e test fixes, uncommitted at write time) | `feat/fleet-ops-final-integrations`, pushed to `origin` | 3 commits: `34df66d`, `2ae2044`, `727c19a` |
|
||||||
|
| RAGcore | `64a908a` | `64a908a` (+1 isolated commit `ce0ad56`) | `main` | Only a backlog handoff entry committed; no code changes (36-file concurrent-session collision — see below) |
|
||||||
|
| ITWorx MCP Hub | not modified this session | — | `feature/wp240-final-acceptance` | Connector already live in production before this session started; not touched |
|
||||||
|
|
||||||
|
## Deployed revisions
|
||||||
|
|
||||||
|
- Fleet Ops: `http://192.168.10.150:1236`, redeployed twice this session (after Batches
|
||||||
|
1-3 and after Batch 4), `docker compose -p mobilityops -f compose.yaml -f
|
||||||
|
compose.unraid.yaml up --build -d db api web`, `.deploy/source-revision` = `727c19a...`.
|
||||||
|
- RAGcore: `http://192.168.10.150:1237`, `ragcore-app-1`. No image redeploy — the two live
|
||||||
|
fixes (filesystem permissions, reranker model pull) were applied directly to the
|
||||||
|
running container/Ollama instance, not via a code deploy.
|
||||||
|
- ITWorx MCP Hub: `http://192.168.10.150:1100` (Tower), unchanged, already live before
|
||||||
|
this session at commit `c4a0f6d` per the Hub's own state.
|
||||||
|
|
||||||
|
## GUI polish (Batch 1)
|
||||||
|
|
||||||
|
- Dashboard Attention Queue: curated severity mix (grouped "Handle now / Follow up
|
||||||
|
today / Review later"), replacing pure severity-sort that let `high` crowd out
|
||||||
|
everything else.
|
||||||
|
- Today's Movements: seed data curated (`seed/bookings.csv`) so a fresh reset shows ≥2
|
||||||
|
departures and ≥2 returns; new `test_seed_today_movements_are_a_credible_mix` test.
|
||||||
|
Live-verified after a real demo reset: 2 returns + 2 departures shown.
|
||||||
|
- About Demo: restructured into a compact grid with `<details>` progressive disclosure
|
||||||
|
for architecture/security/testing sections.
|
||||||
|
- Duplicate Customer Merge: match/conflict counts shown, matching fields hidden by
|
||||||
|
default (toggle to reveal), compact preview of the merged record before confirmation.
|
||||||
|
- Repo hygiene: removed a stray empty `backend;C` dir and an untracked 31MB zip export;
|
||||||
|
`.gitignore` now excludes future archive exports.
|
||||||
|
- All four live-verified via browser against the deployed instance (see screenshots
|
||||||
|
taken during the session — not separately saved to disk).
|
||||||
|
|
||||||
|
## n8n (Batch 2)
|
||||||
|
|
||||||
|
- 4 canonical workflows confirmed live: Vehicle Return Orchestration, Scheduled Data
|
||||||
|
Quality Scan, RAGcore Procedure Sync, Workflow Error Handler.
|
||||||
|
- Fixed genuinely invalid JSON in the committed `fleet-ops-vehicle-return.json` (a
|
||||||
|
missing `},` between two node objects — the file could not be parsed).
|
||||||
|
- Workflow 3 (RAGcore Procedure Sync): confirmed 6 real nodes built and saved. Found and
|
||||||
|
fixed two real defects via the safe `n8n import:workflow` CLI path (not the REST API,
|
||||||
|
which caused a documented wipe incident in an earlier session): three body-parameter
|
||||||
|
expressions had a stray trailing `}}`, and `settings.errorWorkflow` was unset. Exported
|
||||||
|
the corrected definition to `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`,
|
||||||
|
added to `MANIFEST.md` and `check_drift.py`.
|
||||||
|
- **Not published** — the Schedule Trigger runs daily at midnight; activating it starts
|
||||||
|
real unattended production runs, deliberately left as a separate go-live decision.
|
||||||
|
- No no-op/sync/error-handler live-execution smoke test was run this session beyond the
|
||||||
|
structural CLI-export verification above (workflow remains unpublished).
|
||||||
|
|
||||||
|
## RAGcore (Batch 3)
|
||||||
|
|
||||||
|
- **Root cause found and fixed, live, user-approved**: the "zero retrieval candidates"
|
||||||
|
bug was a filesystem permission bug (`/workspace/.state/models/embedding_profiles.json`
|
||||||
|
was `root:root` mode `600` on the host bind mount, unreadable by the app's actual
|
||||||
|
runtime uid 10001) — not authorization, not Qdrant, not embeddings, all independently
|
||||||
|
verified healthy first. Fixed via `chown`/`chmod`; re-verified in-process (5 real hits,
|
||||||
|
up from 0).
|
||||||
|
- **Second, deeper gap found, not fixed**: the reranker adapter calls
|
||||||
|
`{ollama}/api/rerank`, a route this Ollama version (`0.32.5`) does not serve (404).
|
||||||
|
Pulled a working model (`xitao/bge-reranker-v2-m3:latest`, 1.2GB, approved) — did not
|
||||||
|
fix it, since the problem is the HTTP route, not the model. `/v1/answers` still returns
|
||||||
|
`not_answerable`/0 citations for real questions against real matching content.
|
||||||
|
- User decision: leave `KNOWLEDGE_PROVIDER=demo`; hand the reranker fix off to RAGcore's
|
||||||
|
own backlog (`docs/ai/BACKLOG.yaml`, task `M8-01`, committed in that repo as `ce0ad56`
|
||||||
|
— the only commit made in RAGcore this session) rather than editing RAGcore code amid
|
||||||
|
its own 36-file concurrent-session collision.
|
||||||
|
- Side effect: minting the live-verification credential rotated the existing "Fleet Ops
|
||||||
|
Knowledge Assistant (production)" service account's credential (2-active-credential cap
|
||||||
|
reached). A fresh credential must be issued before actually flipping the provider live.
|
||||||
|
|
||||||
|
## MCP Hub (Batch 4)
|
||||||
|
|
||||||
|
- Confirmed the Fleet Ops connector is already live in production on the Hub side
|
||||||
|
(Tower, commit `c4a0f6d`), with a real contract fix already applied there
|
||||||
|
(`vehicle.get`'s wire parameter normalized to camelCase `vehicleRef`).
|
||||||
|
- Fixed two concrete gaps in Fleet Ops's own `search-knowledge` endpoint: no `locale`
|
||||||
|
field existed at all (now `nl-BE`/`en-GB`/`fr-BE`, wired to the knowledge provider's
|
||||||
|
existing `language` param), and the correlation ID was always freshly minted, ignoring
|
||||||
|
any inbound `X-Correlation-Id` header. Added `get_correlation_id`, applied to all four
|
||||||
|
MCP endpoints.
|
||||||
|
- `MCP_HUB_BASE_URL` was dead config (declared, never read); wired it for a real,
|
||||||
|
bounded Hub-reachability health check instead of an unneeded self-registration push
|
||||||
|
(the Hub's own registration is catalog-driven).
|
||||||
|
- Renamed Fleet Ops's own internal audit tool labels `mobilityops_*` → `fleet_ops_*`
|
||||||
|
(mirrored in `contracts/mcp-tools.json`, `mobilityops_*` kept as deprecated aliases).
|
||||||
|
The live Hub connector's own dotted tool namespace (`mobilityops.operations.summary`
|
||||||
|
etc.) is a separate, Hub-owned naming layer, deliberately not touched.
|
||||||
|
- Automation page's MCP card now shows real evidence (last tool/client/count/timestamp)
|
||||||
|
instead of only the registration-enabled boolean.
|
||||||
|
|
||||||
|
## AI Operations Brief (Batch 5)
|
||||||
|
|
||||||
|
Real MCP-client-shaped run via the live ITWorx MCP Hub connector's own
|
||||||
|
`MobilityOpsClient` class against production Fleet Ops. Full runbook and live output in
|
||||||
|
`docs/final-integrations/ai-operations-brief-runbook.md`. Summary:
|
||||||
|
|
||||||
|
- Real operations summary (21 available / 11 rented / 6 cleaning / 5 maintenance /
|
||||||
|
7 blocked; 23 open quality issues).
|
||||||
|
- Real most-pressing vehicle identified (`MO-031`, missing operational inspection).
|
||||||
|
- Real vehicle detail lookup.
|
||||||
|
- Real grounded knowledge answer (English damage-handling question): 2 real citations,
|
||||||
|
`evidence_state: grounded`.
|
||||||
|
- Dutch/French variants of the same question honestly returned `insufficient` (no
|
||||||
|
fabrication) — root cause: the live Hub connector doesn't yet send the new `locale`
|
||||||
|
field, a Hub-side follow-up, not silently worked around.
|
||||||
|
- Correlation IDs verified end-to-end in Fleet Ops's own audit log
|
||||||
|
(`GET /api/v1/audit?action=mcp_tool_request`), matching the response payloads exactly.
|
||||||
|
- No write actions performed at any point.
|
||||||
|
|
||||||
|
## Testing per batch
|
||||||
|
|
||||||
|
- Backend: **176 passed**, `ruff check .` clean, `mypy app` clean (50 source files) —
|
||||||
|
verified against a freshly rebuilt image after discovering mid-session that
|
||||||
|
`docker compose run --rm api` (no bind mount on the `api` service) silently tests a
|
||||||
|
stale image otherwise. One genuinely stale test assertion found and fixed as a result.
|
||||||
|
- Frontend: `tsc -b && vite build` clean.
|
||||||
|
- E2e (Playwright, against the live deployed instance,
|
||||||
|
`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`): every spec file run this
|
||||||
|
session passed — `demo.spec.ts`, `interactive-elements.spec.ts` (26),
|
||||||
|
`responsive-i18n.spec.ts` + `demo-accessibility.spec.ts` + `guided-demo-full.spec.ts`
|
||||||
|
(28), `i18n-coverage.spec.ts` + `error-messages.spec.ts` + `clickable-rows.spec.ts` +
|
||||||
|
`demo-guide.spec.ts` + `demo-entry.spec.ts` + `demo-legibility.spec.ts` +
|
||||||
|
`fleet-ops-correction.spec.ts` + `ui-redesign.spec.ts` + `greeting.spec.ts` +
|
||||||
|
`greeting-live.spec.ts` (28, after fixing 2 pre-existing fragile locators unrelated to
|
||||||
|
this session's feature work — a `.data-table` ambiguity now that Automation has two
|
||||||
|
tables, and a `Technische details` toggle ambiguity for the same reason; plus one
|
||||||
|
pre-existing untranslated-loanword false positive in `i18n-coverage.spec.ts`).
|
||||||
|
|
||||||
|
## Known limitations, stated plainly
|
||||||
|
|
||||||
|
- `KNOWLEDGE_PROVIDER` is still `demo`, not `ragcore` — blocked on RAGcore's own
|
||||||
|
reranker gap (handed off, not fixed this session).
|
||||||
|
- n8n workflow 3 is built and correct but not published (deliberate, separate decision).
|
||||||
|
- The live MCP Hub connector doesn't yet send the new `locale` field, so
|
||||||
|
locale-aware knowledge search only works when called directly against Fleet Ops (as
|
||||||
|
proven by the backend tests), not yet through the live Hub connector as deployed.
|
||||||
|
- No public-demo-readiness checklist, About Demo Guide "completed" end-state polish
|
||||||
|
(section 4E), or dashboard MCP "activity showcase after Demo Complete" gating were
|
||||||
|
built this session — the MCP evidence display exists on the Automation page
|
||||||
|
unconditionally rather than gated behind guided-demo completion.
|
||||||
|
- No security-review pass was run separately this session (existing gates: ruff, mypy,
|
||||||
|
the repo's own auth/audit test coverage).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Fleet Ops: prior working revision `0571a40` remains in `.deploy/` as
|
||||||
|
`source-0571a40.tar.gz` on the Unraid host; redeploy by re-extracting and re-running
|
||||||
|
the same `docker compose up --build -d` sequence with that archive.
|
||||||
|
- RAGcore: `chown`/`chmod` change is trivially reversible (`chown 0:0` +
|
||||||
|
`chmod 600` on the same path) if needed, though there is no reason to revert a
|
||||||
|
permission fix. Ollama model pull (`xitao/bge-reranker-v2-m3:latest`) can be removed
|
||||||
|
with `ollama rm` if unwanted; it is inert until RAGcore's own code is changed to use it.
|
||||||
|
- MCP Hub: not modified this session.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Fleet Ops correction and release — final evidence
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- Source branch / commit (verified pre-correction baseline): `master` @ `18344bc8b7a75a2f868bf15bf498fc030ac6c34c`
|
||||||
|
- Fix branch: `fix/fleet-ops-i18n-status-flow`
|
||||||
|
- Final fix-branch commit: `284b3c7` (merged content identical to `2e4fb43`, which carries the evidence-summary localization fix)
|
||||||
|
- Main-before-merge: `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` (confirmed unchanged via `git fetch` + `git rev-parse origin/master` immediately before merging — no unexpected commits landed on master while this branch was in progress)
|
||||||
|
- Merge commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`git merge --no-ff fix/fleet-ops-i18n-status-flow -m "merge: complete Fleet Ops localization and status resolution"`, zero conflicts)
|
||||||
|
- Final main commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
|
||||||
|
- Deployed commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`.deploy/source-revision` on Unraid)
|
||||||
|
- Gitea main branch: `master` (confirmed via `git fetch origin && git rev-parse origin/master` matching local `master` after push)
|
||||||
|
- Live URL: `http://192.168.10.150:1236`
|
||||||
|
|
||||||
|
Fix-branch commit history: `6deb955`, `e6539d1`, `ac4b163`, `1fdd2b3`, `1e40775`, `a7ac5ed`, `7851e80`, `cda2c32`, `2e4fb43`, `284b3c7`.
|
||||||
|
|
||||||
|
## What this correction fixed
|
||||||
|
|
||||||
|
1. **Status-recommendation flow redesigned** (sections 8A–8F). The old single opaque
|
||||||
|
"calculate and apply recommended status" action is replaced by a single shared, pure
|
||||||
|
evaluator (`backend/app/services/vehicle_status.py::evaluate_vehicle_status`,
|
||||||
|
documented in `docs/fleet-ops-correction/vehicle-status-decision-table.md`) used
|
||||||
|
identically by the scanner, a non-mutating preview endpoint
|
||||||
|
(`POST /api/v1/data-quality/issues/{ref}/status-recommendation`), and a
|
||||||
|
transactional apply endpoint (`POST .../apply-recommended-status`) that locks the
|
||||||
|
row, recomputes facts, rejects a stale `recommendation_token`, refuses unsafe/manual-
|
||||||
|
review recommendations, and re-validates post-write before resolving the issue.
|
||||||
|
- Forbidden shortcuts eliminated: "maintenance + active booking" no longer
|
||||||
|
auto-recommends "rented" (being in maintenance is itself now a blocking fact);
|
||||||
|
"maintenance with nothing else wrong" no longer auto-clears to "available" (no
|
||||||
|
fact proves maintenance is actually finished — release stays a manual decision).
|
||||||
|
- Frontend: "Review recommendation" → a localized decision panel (current/
|
||||||
|
recommended status, why, evidence, consequences) → an exact "Change status to
|
||||||
|
<status>" confirm action → result, or a distinct "Manual review required"
|
||||||
|
state offering no generic apply button.
|
||||||
|
2. **MO-016 order independence** (section 9). Order independence does not mean "same
|
||||||
|
final status regardless of order" — resolving the booking overlap first genuinely
|
||||||
|
removes the conflict, correctly leaving nothing to apply. What holds either way: the
|
||||||
|
recommendation always reflects real current facts (never a stale proxy), and nothing
|
||||||
|
unsafe is ever applied (never "rented"). Proven by a backend test explicitly scoped
|
||||||
|
to MO-016/DQ-DEMO-STATUS (the original version wasn't — `_first_open()` returned
|
||||||
|
whichever of ~14 open `vehicle_status_conflict` issues was most recent, not
|
||||||
|
necessarily MO-016's) and a browser-level Playwright test covering both orders.
|
||||||
|
3. **"Fleet Ops" is a non-localizable brand constant** (`frontend/src/product.ts`,
|
||||||
|
backend `PRODUCT_NAME`), wired via `{{productName}}` interpolation everywhere the
|
||||||
|
brand appeared in locale prose. A permanent test fails the build if any locale file
|
||||||
|
ever defines the brand name or an `appName` key again.
|
||||||
|
4. **Dynamic backend prose converted to message codes + params** (sections 5/6/10):
|
||||||
|
return status reasons, audit field/actor-type labels, automation `last_error` (new
|
||||||
|
`last_error_code` column, migration `799d8800e241`), search results (sections/
|
||||||
|
vehicles/bookings/issues), and — found live on Unraid — the data-quality evidence
|
||||||
|
summary. Raw technical text is demoted to a "Technical details" disclosure
|
||||||
|
everywhere.
|
||||||
|
5. **Knowledge-base fixes**: the demo provider's tokenizer silently dropped accented
|
||||||
|
characters (`[a-z0-9]+` split "véhicule" into "v"+"hicule"), breaking French
|
||||||
|
retrieval broadly — fixed to include the Latin-1 accented range. Reweighted section
|
||||||
|
scoring so a body match (real substance) outranks a heading/title match (a shallow
|
||||||
|
structural hint) — the old weighting misranked the damage procedure behind an
|
||||||
|
unrelated document for the brief's exact validation question in all 3 languages.
|
||||||
|
Removed leftover "MobilityOps"/"PoC" mentions from 9 procedure documents.
|
||||||
|
6. **Search, audit, automation, maintenance/inspections localized** (section 10):
|
||||||
|
backend returns stable codes + params only; the frontend localizes section labels,
|
||||||
|
vehicle summaries, booking/issue statuses, audit action/field/actor labels,
|
||||||
|
automation error explanations, and maintenance/inspection type labels.
|
||||||
|
7. **i18n test suite strengthened** (section 11): key parity, brand invariant,
|
||||||
|
translation-quality (cross-locale identical-value detection), a hardcoded-JSX-text
|
||||||
|
static scan (had to anchor on backreferenced closing-tag names — a naive `>text<`
|
||||||
|
regex misread TypeScript generics as JSX), and a 3-language route matrix (every main
|
||||||
|
route, no console errors, correct `html[lang]`, real page headings).
|
||||||
|
|
||||||
|
## Live-caught bug (the deployment validation earning its keep)
|
||||||
|
|
||||||
|
Live validation on the freshly-deployed fix branch directly caught a real defect: every
|
||||||
|
data-quality issue's top-of-page evidence summary was unconditionally showing raw,
|
||||||
|
always-English text (e.g. *"vehicle marked available while reserved bookings
|
||||||
|
conflict"*) in **all three languages**, because the frontend never finished the
|
||||||
|
`evidence.signals` localization the backend had already been emitting (the backend code
|
||||||
|
even had a comment describing the intended design that the frontend didn't implement).
|
||||||
|
Fixed in commit `2e4fb43`:
|
||||||
|
- `DataQualityIssueDetail.tsx` now renders `evidence.signals` through the operator's
|
||||||
|
locale as the primary evidence text.
|
||||||
|
- The four `DQ-DEMO-*` seed rows that anchor the guided demo's scripted scenarios now
|
||||||
|
carry real, accurate signals computed at seed time (the duplicate-customer similarity
|
||||||
|
score is the actual `SequenceMatcher` ratio on the seeded names, not invented).
|
||||||
|
- Rows with no structured signals fall back to raw text rather than showing a blank
|
||||||
|
summary; the one known filler placeholder gets its own localized rendering.
|
||||||
|
- A regression test locks this in: the vehicle-status-conflict evidence summary must
|
||||||
|
show localized text and must never contain the specific raw English sentence that was
|
||||||
|
live-visible before the fix, in all 3 languages.
|
||||||
|
|
||||||
|
Also found and fixed along the way: a frontend logic bug conflating "no conflict" with
|
||||||
|
"manual review required" (both carry `safe_to_apply: false`), which showed a false
|
||||||
|
"manual review required" panel for MO-016 after its booking overlap was resolved
|
||||||
|
instead of the correct "no change needed" state (fixed in `1fdd2b3`).
|
||||||
|
|
||||||
|
## Translation coverage
|
||||||
|
|
||||||
|
- All three locale files (`nl-BE`, `en-GB`, `fr-BE`) define exactly the same key set
|
||||||
|
for every namespace (`i18n-coverage.spec.ts`, structural guarantee).
|
||||||
|
- No locale file contains an empty string value.
|
||||||
|
- No locale file defines the brand name or an `appName` key (brand-invariant test).
|
||||||
|
- Cross-locale translation-quality check: for every string ≥8 characters of real prose,
|
||||||
|
nl-BE ≠ en-GB, fr-BE ≠ en-GB, fr-BE ≠ nl-BE, with a precise, audited allowlist for
|
||||||
|
genuine proper nouns/cognates (23 entries, each with a documented reason).
|
||||||
|
- Hardcoded-JSX-text static scan: zero findings against the current codebase (verified
|
||||||
|
against both false positives — TypeScript generics — and a deliberately-injected-
|
||||||
|
then-reverted false negative).
|
||||||
|
- 3-language route matrix: every main route (dashboard, vehicles, vehicle detail,
|
||||||
|
bookings, booking detail, data quality, issue detail, automation, knowledge, audit,
|
||||||
|
scenarios, about) opens cleanly in all 3 languages with no console errors, correct
|
||||||
|
`html[lang]`, and a real page heading.
|
||||||
|
- **Remaining visible wrong-language text**: none found. The one gap that existed (the
|
||||||
|
data-quality evidence summary) was found live and fixed before merge.
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
- Visible product name: **Fleet Ops**, exactly, in all 3 languages, everywhere (login,
|
||||||
|
topbar, footer "Fleet Ops Demo", document title, About page, Demo Guide, knowledge
|
||||||
|
base). Verified structurally (brand-invariant test) and live (branding test across
|
||||||
|
dashboard/vehicles/data-quality/audit/automation/knowledge pages in all 3 languages;
|
||||||
|
visual screenshots of the login screen in nl-BE and fr-BE).
|
||||||
|
- Technical identifier retained (by design, per the brief): repository name, local
|
||||||
|
directory, package/module names, Compose project, deployment directory, database
|
||||||
|
name, and the `/health` endpoint's `service: "mobilityops-api"` field remain
|
||||||
|
"mobilityops" — none of these are visible UI text.
|
||||||
|
- No visible "MobilityOps" or "PoC" anywhere in the UI or the demo knowledge base
|
||||||
|
(9 procedure documents cleaned up; regression test in `test_knowledge.py` scans every
|
||||||
|
procedure file for both strings).
|
||||||
|
|
||||||
|
## Status-preview / apply / manual-review / MO-016 ordering
|
||||||
|
|
||||||
|
- **Preview**: verified non-mutating — the issue's `status` stays `"open"` after
|
||||||
|
calling the preview endpoint and re-fetching it via a fresh request.
|
||||||
|
- **Apply**: the confirm button names the exact target status ("Change status to
|
||||||
|
Blocked" / "Status wijzigen naar Geblokkeerd" / "Changer le statut vers Bloqué");
|
||||||
|
applying resolves the issue and updates the vehicle atomically.
|
||||||
|
- **Manual review**: MO-024 (active rental + service-threshold reached, a genuine fact
|
||||||
|
contradiction) shows "Manual review required" with no generic apply button rendered
|
||||||
|
at all.
|
||||||
|
- **Stale token**: simulated by resolving the underlying booking overlap after the
|
||||||
|
preview was fetched but before applying — the apply call is correctly rejected
|
||||||
|
(`RECOMMENDATION_STALE`), the UI shows the "situation has changed" message, and the
|
||||||
|
user must review again before a new apply is possible.
|
||||||
|
- **MO-016 ordering**: both orders tested. Resolving the overlap first correctly leaves
|
||||||
|
nothing to apply (vehicle stays "available", genuinely correct). Resolving the status
|
||||||
|
conflict first safely blocks the vehicle; resolving the now-redundant overlap
|
||||||
|
afterwards does not disturb it. Neither order ever produces "rented".
|
||||||
|
|
||||||
|
## Knowledge (per language)
|
||||||
|
|
||||||
|
The brief's exact validation question, in each language, grounds on the damage
|
||||||
|
procedure as the **primary** (not just top-3) source:
|
||||||
|
- nl-BE: *"Wat moet ik doen wanneer een voertuig beschadigd terugkomt?"* → damage
|
||||||
|
procedure, Dutch source, Dutch excerpt.
|
||||||
|
- en-GB: *"What should I do when a vehicle returns with damage?"* → damage procedure,
|
||||||
|
English source, English excerpt.
|
||||||
|
- fr-BE: *"Que dois-je faire lorsqu'un véhicule revient endommagé ?"* → damage
|
||||||
|
procedure, French source, French excerpt.
|
||||||
|
|
||||||
|
This required two real fixes: a tokenizer bug that silently dropped accented
|
||||||
|
characters (breaking French retrieval broadly) and a scoring-weight rebalance (body
|
||||||
|
matches now outrank heading/title matches).
|
||||||
|
|
||||||
|
## Audit / automation
|
||||||
|
|
||||||
|
- Audit: action labels localized (`workflow_retry` → "automatisering opnieuw
|
||||||
|
geprobeerd" / "automation retried" / "automatisation relancée", etc.), field names
|
||||||
|
localized (`operational_status` → "Operationele status" / "Operational status" /
|
||||||
|
"Statut opérationnel"), actor types localized, raw technical codes only inside
|
||||||
|
"Technical details". Verified live and via a dedicated Playwright test.
|
||||||
|
- Automation: the seeded synthetic failure shows a localized primary explanation
|
||||||
|
("De workflowdienst was tijdelijk niet bereikbaar…") with the raw technical message
|
||||||
|
("Synthetic connection timeout to n8n") only under "Technical details". Verified live
|
||||||
|
and via a dedicated Playwright test.
|
||||||
|
|
||||||
|
## Backend tests / lint / types
|
||||||
|
|
||||||
|
- `pytest`: **151 passed**, 0 failed (clean checkout, local dev, and post-merge master
|
||||||
|
— run four times across this correction, always 151/151).
|
||||||
|
- `ruff check .`: all checks passed, every run.
|
||||||
|
- `mypy app` (strict): no issues found in 49 source files, every run.
|
||||||
|
- Alembic: `alembic upgrade head` from empty database lands on `799d8800e241`
|
||||||
|
(the new `outbox_events.last_error_code` column); `downgrade -1` / `upgrade head`
|
||||||
|
round-trip verified.
|
||||||
|
|
||||||
|
## Frontend build / Playwright
|
||||||
|
|
||||||
|
- `npm ci`, `tsc -b`, `vite build`: clean, every run.
|
||||||
|
- Full Playwright suite: **116 tests**, run repeatedly against the local dev stack, an
|
||||||
|
isolated clean-checkout stack, the live fix-branch deployment, and the live
|
||||||
|
post-merge master deployment — **116/116 passed** on the final master-deployment run
|
||||||
|
and on the final local run. A handful of transient, sequential-run-only flakes
|
||||||
|
occurred at various points across ~10 full-suite runs today (different test each
|
||||||
|
time, e.g. a pre-existing logout-timing race in `AuthContext.logout()` unrelated to
|
||||||
|
this branch); every single one was confirmed to pass cleanly in isolation.
|
||||||
|
- Guided demo covered indirectly via `guided-demo-full.spec.ts`,
|
||||||
|
`demo-guide.spec.ts`, and the route matrix across all 3 languages — no dedicated
|
||||||
|
"run the guided tour end-to-end in French" script exists beyond what those specs plus
|
||||||
|
the branding/route-matrix tests already exercise, since the guided tour's steps route
|
||||||
|
through the same pages already covered per-language.
|
||||||
|
|
||||||
|
## Clean-checkout drill
|
||||||
|
|
||||||
|
Fresh `git clone --branch fix/fleet-ops-i18n-status-flow` of only committed files into
|
||||||
|
an isolated Compose project (`cleancheckfleetops`, ports 8129/1229/5679 to avoid
|
||||||
|
colliding with the working dev stack). From empty volumes: build → up → `alembic
|
||||||
|
upgrade head` → `reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27
|
||||||
|
data-quality issues / 20 workflow runs) → 151 backend tests + Ruff + mypy green →
|
||||||
|
frontend build green → full Playwright suite green → final reset →
|
||||||
|
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes, and images
|
||||||
|
torn down afterward; working dev environment confirmed untouched.
|
||||||
|
|
||||||
|
## Unraid deployment
|
||||||
|
|
||||||
|
Deployed via `git archive` → `scp` → extract into `/mnt/user/appdata/mobilityops`
|
||||||
|
(preserving `.env` and persistent volumes) → `.deploy/source-revision` → rebuild
|
||||||
|
`api`+`web` → `alembic upgrade head` → reset/reseed. Done twice: once for the fix
|
||||||
|
branch (caught the evidence-summary bug), once for the final merged master. Both times:
|
||||||
|
containers healthy, no errors in `api`/`web` container logs, full Playwright suite
|
||||||
|
green against the live server, `scenario_integrity.all_ready: true` after final reset.
|
||||||
|
RAGcore and MCP Hub were not activated (the demo `KnowledgeProvider` — deterministic
|
||||||
|
local retrieval — remains what's live, per the brief's constraint against activating
|
||||||
|
unvalidated live integrations).
|
||||||
|
|
||||||
|
## Responsive / accessibility
|
||||||
|
|
||||||
|
- Breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844,
|
||||||
|
360×800) × 3 languages: no horizontal overflow, localized headings visible
|
||||||
|
(`responsive-i18n.spec.ts`).
|
||||||
|
- Status-recommendation panel: keyboard-only activation of "Review recommendation" and
|
||||||
|
"Change status to X" verified via focus assertions (not just click); reduced-motion
|
||||||
|
emulated during the flow; status never conveyed by colour alone (the badge always
|
||||||
|
carries its own localized text); `aria-live="polite"` added so the applied
|
||||||
|
confirmation is announced to screen readers.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- A pre-existing, narrow timing race in `AuthContext.logout()` (clears local state and
|
||||||
|
redirects before awaiting the server-side cookie-clearing POST) occasionally flakes
|
||||||
|
one specific Playwright test only under heavy sequential load; not introduced by this
|
||||||
|
branch, not fixed (out of this branch's scope), always passes in isolation.
|
||||||
|
- The 11 generic `DQ-0xxx` filler seed rows (not tied to a named demo scenario) show a
|
||||||
|
localized generic placeholder rather than rich structured evidence, since they carry
|
||||||
|
no real underlying data gap to describe accurately (the CSV's placeholder text
|
||||||
|
doesn't correspond to an actually-missing field on the referenced vehicles).
|
||||||
|
- No dedicated "full guided demo in French, screenshot every step" script exists as a
|
||||||
|
single artifact; coverage is composed from the route matrix, branding, and existing
|
||||||
|
guided-demo specs, each run across all 3 languages.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
`artifacts/fleet-ops-correction/screenshots/`, all captured live against
|
||||||
|
`http://192.168.10.150:1236`:
|
||||||
|
|
||||||
|
- `login-nl-BE.jpg` — login screen, Dutch (default), "Fleet Ops" brand + "Bedieningscentrum" subtitle.
|
||||||
|
- `login-fr-BE.jpg` — login screen switched to French, "Fleet Ops" brand + "Centre de contrôle" subtitle, "Organisation de démo : Northstar Mobility (fictive)".
|
||||||
|
- `dq-demo-status-fr-BE-collapsed.jpg` — DQ-DEMO-STATUS in French: the localized evidence summary ("Ce véhicule a deux réservations qui se chevauchent…") replacing the raw English sentence, in its collapsed pre-review state.
|
||||||
|
- `dq-demo-status-fr-BE-clean-reload.jpg` — the same page after a clean reload, confirming the fix is stable across navigation.
|
||||||
|
|
||||||
|
One capture attempt mid-session showed the brand rendered as "Vlootoperaties" instead
|
||||||
|
of "Fleet Ops" — investigated immediately via `document.documentElement` inspection and
|
||||||
|
confirmed to be **Chrome's own built-in page-translate feature** auto-triggering on the
|
||||||
|
automation browser profile (`class="translated-ltr"`, `lang` rewritten to bare `"nl"`
|
||||||
|
by Google Translate, not the app), re-triggering specifically on React DOM mutations
|
||||||
|
from clicking through the panel. Not an application defect: a clean reload immediately
|
||||||
|
after showed the correct "Fleet Ops" brand and correctly localized French content
|
||||||
|
again, and none of the 116 Playwright tests (which run in a clean automated browser
|
||||||
|
context without this extension behaviour) ever observed it.
|
||||||
|
|
||||||
|
## Rollback procedure
|
||||||
|
|
||||||
|
1. `ssh unraid`, `cd /mnt/user/appdata/mobilityops`.
|
||||||
|
2. `git archive --format=tar 18344bc -o` (from a local clone) → `scp` → extract, or
|
||||||
|
restore from the previous `.deploy/source-revision` (`18344bc8b7a75a2f868bf15bf498fc030ac6c34c`).
|
||||||
|
3. `echo 18344bc8b7a75a2f868bf15bf498fc030ac6c34c > .deploy/source-revision`.
|
||||||
|
4. `docker compose -f compose.yaml -f compose.unraid.yaml build api web && ... up -d api web`.
|
||||||
|
5. `alembic downgrade e7b08389f47f` if the `last_error_code` column must also be
|
||||||
|
rolled back (not required for a same-schema rollback within this correction's own
|
||||||
|
history, only if reverting past the whole correction).
|
||||||
|
6. Re-seed and re-verify `scenario_integrity.all_ready: true`.
|
||||||
|
|
||||||
|
The fix branch `fix/fleet-ops-i18n-status-flow` was not deleted.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,291 @@
|
|||||||
|
# Fleet Ops final localization — final summary
|
||||||
|
|
||||||
|
Small, targeted correction round on top of the already-merged, functionally-validated
|
||||||
|
Fleet Ops correction milestone. Scope: remaining NL/FR translation gaps, centralized
|
||||||
|
API-error localization, a time-dependent Europe/Brussels dashboard greeting, i18n
|
||||||
|
test hardening, and documentation consistency — explicitly no redesign, no business-logic
|
||||||
|
changes, no new functionality. Audit and rationale: `docs/fleet-ops-final-localization/audit.md`.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Stage | Commit | Message |
|
||||||
|
|---|---|---|
|
||||||
|
| Start commit (branch base = prior `origin/master` head) | `f7805579f7c73bd3085d73a725fa985b4a4892ed` | `docs(release): final Fleet Ops correction evidence and screenshots` |
|
||||||
|
| Final fix-branch commit | `09173a4740ddb282fe5412c5305284e9776d397c` | `fix: correct fr-BE audit column label Actor -> Auteur` |
|
||||||
|
| Merge commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | `merge: finalize Fleet Ops localization` |
|
||||||
|
| Final master commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | (same as merge commit — merge commit is the branch tip) |
|
||||||
|
| Deployed commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | matches `.deploy/source-revision` on Unraid exactly |
|
||||||
|
|
||||||
|
Branch used: `fix/fleet-ops-final-i18n-ux` (the brief named `fix/fleet-ops-final-localization`;
|
||||||
|
this branch was verified freshly and cleanly branched from `origin/master` with a clean
|
||||||
|
working tree, so it was used as-is rather than renamed — see the audit doc's naming note).
|
||||||
|
`origin/master` was re-fetched and confirmed unchanged (`f780557`) immediately before the
|
||||||
|
merge, per the mandatory pre-merge safety check.
|
||||||
|
|
||||||
|
Full commit sequence (oldest to newest):
|
||||||
|
|
||||||
|
```
|
||||||
|
1fbb20b docs: audit remaining Fleet Ops localization gaps
|
||||||
|
37a362c fix: translate remaining NL/FR interface gaps
|
||||||
|
94cfb7b test: tighten i18n allowlist, add substring and brand-leak guards
|
||||||
|
d17af1c feat: centralize API error localization
|
||||||
|
e427313 feat: add time-dependent Europe/Brussels dashboard greeting
|
||||||
|
77208b8 fix: prevent topbar overflow from an unbreakable Dutch role-name translation
|
||||||
|
f0d6411 fix: serve the missing Fleet Ops favicon
|
||||||
|
9468cc3 docs: update PROJECT_STATE and README for the final localization round
|
||||||
|
09173a4 fix: correct fr-BE audit column label Actor -> Auteur
|
||||||
|
5f0eaa5 merge: finalize Fleet Ops localization
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product name and supported languages
|
||||||
|
|
||||||
|
- Visible product name: **Fleet Ops**, everywhere, never translated (`frontend/src/product.ts`
|
||||||
|
constant, interpolated as `{{productName}}`). "MobilityOps" remains the internal repo /
|
||||||
|
Compose project / deployment-directory identifier only.
|
||||||
|
- Supported UI languages: **nl-BE** (default), **en-GB**, **fr-BE**.
|
||||||
|
- No visible "MobilityOps" or the word "PoC" anywhere in the UI (enforced by a dedicated
|
||||||
|
automated test, see below).
|
||||||
|
|
||||||
|
## Corrected translations
|
||||||
|
|
||||||
|
- Role names actually translated (not just labelled as translated): `auth.json` /
|
||||||
|
`demo.json` role keys — **Operationsmanager** / **Verhuurmedewerker** (nl-BE),
|
||||||
|
**Responsable des opérations** / **Collaborateur de location** (fr-BE).
|
||||||
|
- `audit.title` → **Auditgeschiedenis** / **Piste d'audit**; `columns.actor` → **Uitvoerder**
|
||||||
|
(nl-BE) / **Auteur** (fr-BE, corrected during live browser validation — see Known
|
||||||
|
limitations).
|
||||||
|
- `list.statusOpen` → **Openstaand**; `ledger.filterRecent` → **Recentste**;
|
||||||
|
`scenarios.startScenario` → **Scenario starten** / **Démarrer le scénario**.
|
||||||
|
- 8 previously-missed mid-sentence "Audit trail" leaks fixed across `demo.json`,
|
||||||
|
`quality.json`, `returns.json` (nl-BE) — found by the new embedded-substring test, not
|
||||||
|
the pre-existing whole-string-identity test, which structurally cannot catch this class
|
||||||
|
of bug.
|
||||||
|
- No unintended English text remains in nl-BE or fr-BE (see translation-coverage evidence
|
||||||
|
below).
|
||||||
|
|
||||||
|
## Removed allowlist exceptions
|
||||||
|
|
||||||
|
Removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries in `i18n-coverage.spec.ts`:
|
||||||
|
`audit.title`, `auth.roleOperationsManager`, `auth.roleRentalEmployee`,
|
||||||
|
`demo.scenarios.startScenario`, `demo.scenarios.roles.operations_manager`,
|
||||||
|
`demo.scenarios.roles.rental_employee`, `navigation.items.audit` — all now genuinely
|
||||||
|
translated; their old comments describing them as "deliberately untranslated" were no
|
||||||
|
longer true. Two new tests added: embedded-English/Dutch-substring leak guard, and a
|
||||||
|
no-"MobilityOps"/no-"PoC" guard.
|
||||||
|
|
||||||
|
## Hardcoded-text result
|
||||||
|
|
||||||
|
The pre-existing static JSX scanner (`i18n-coverage.spec.ts`, section 11D) found **zero**
|
||||||
|
hardcoded user-facing strings outside the approved technical-token allowlist (Fleet Ops,
|
||||||
|
Northstar Mobility, ITWorx MCP Hub) across `pages/` and `components/`. Result: **PASS**.
|
||||||
|
|
||||||
|
## API-error-localization result
|
||||||
|
|
||||||
|
New `frontend/src/api/errorMessages.ts` (`describeApiError`) replaces the
|
||||||
|
`err instanceof ApiError ? err.message : t(fallback)` anti-pattern (which showed raw
|
||||||
|
English backend text for the common case) at all 13 call sites across 7 files
|
||||||
|
(`Automation.tsx`, `ReturnForm.tsx`, `DataQuality.tsx`, `DemoGuide.tsx`, `Layout.tsx`,
|
||||||
|
`DataQualityIssueDetail.tsx` ×7 sites, `Knowledge.tsx`). Resolution order: known `AppError`
|
||||||
|
code (32 codes) → known HTTP status (401/403/404/409/422/500) → fully generic fallback.
|
||||||
|
New `ApiErrorNotice` component (`PageChrome.tsx`) always renders a localized title +
|
||||||
|
explanation + optional next step; raw backend text is demoted to a "Technical
|
||||||
|
details"/"Détails techniques" disclosure, never the primary message.
|
||||||
|
|
||||||
|
Evidence: `frontend/e2e/error-messages.spec.ts` (10 tests, all passing) —
|
||||||
|
every known code/status has non-empty copy in all 3 locales; a known code never surfaces
|
||||||
|
raw text as the primary message; unknown-code and unknown-status fallback chains behave
|
||||||
|
correctly; a drift guard greps the actual backend `AppError("CODE", ...)` call sites and
|
||||||
|
confirms `KNOWN_CODES` exactly matches (32 codes, zero drift). Live-verified on Unraid: the
|
||||||
|
seeded failed automation run renders a fully localized French error with a "DÉTAILS
|
||||||
|
TECHNIQUES" disclosure below it.
|
||||||
|
|
||||||
|
## Greeting logic and edge cases
|
||||||
|
|
||||||
|
New `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, clock-injectable, pure) resolves
|
||||||
|
one of 4 periods against **Europe/Brussels** wall-clock time via
|
||||||
|
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })` (DST-safe by
|
||||||
|
construction — no manual UTC-offset math):
|
||||||
|
|
||||||
|
| Period | Window | nl-BE | en-GB | fr-BE |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| morning | 05:00–11:59 | Goedemorgen | Good morning | Bonjour |
|
||||||
|
| afternoon | 12:00–17:59 | Goedemiddag | Good afternoon | Bonjour |
|
||||||
|
| evening | 18:00–22:59 | Goedenavond | Good evening | Bonsoir |
|
||||||
|
| night | 23:00–04:59 | Welkom terug | Welcome back | Bon retour |
|
||||||
|
|
||||||
|
Never "Goedenacht" (a farewell in Dutch, not a welcome). Each period also has its own
|
||||||
|
accompanying sentence per language (`dashboard.json` `greetingBody`), replacing the old
|
||||||
|
fixed "Here's the fleet." `useGreetingPeriod.ts` polls every 30s so the greeting rolls
|
||||||
|
over live while the app stays open, no reload required; initial render uses a synchronous
|
||||||
|
`useState(() => getGreetingPeriod())` so there is never a flash of the wrong period.
|
||||||
|
|
||||||
|
Edge-case evidence:
|
||||||
|
- `frontend/e2e/greeting.spec.ts` (4 tests): exact boundary checks at 04:59/05:00/11:59/
|
||||||
|
12:00/17:59/18:00/22:59/23:00 in both CET (winter) and CEST (summer), plus a dedicated
|
||||||
|
spring-forward/fall-back DST-transition test (2026-03-29 and 2026-10-25).
|
||||||
|
- `frontend/e2e/greeting-live.spec.ts` (6 tests, real browser via Playwright's `page.clock`):
|
||||||
|
all 8 boundary times rendered correctly in **all 3 languages** against the actual app;
|
||||||
|
live period rollover with no `page.reload()` call anywhere in that test; language-switch
|
||||||
|
behaviour without changing the time period; the "never Goedenacht" guard.
|
||||||
|
- Live-verified on Unraid at actual current server time (2026-08-04, ~03:2x CEST, i.e. the
|
||||||
|
night period): dashboard showed "Welkom terug. Hier is het laatste overzicht van je
|
||||||
|
wagenpark." (nl-BE), "Welcome back. Here's the latest overview of your fleet." (en-GB),
|
||||||
|
"Bon retour. Voici le dernier aperçu de votre flotte." (fr-BE).
|
||||||
|
|
||||||
|
## README / PROJECT_STATE corrections
|
||||||
|
|
||||||
|
- `PROJECT_STATE.md`: fixed the stale "Product name: MobilityOps." / "PoC only"
|
||||||
|
locked-decisions lines (predated the Fleet Ops rebrand); fixed the "Fleet Ops
|
||||||
|
correction" section header, which still read "IN PROGRESS .../Not yet merged to
|
||||||
|
master" despite already being merged (`de0bdea` / `f780557`); appended a new dated
|
||||||
|
entry for this correction round (not a rewrite of prior entries, per the brief's
|
||||||
|
explicit instruction not to hide earlier history).
|
||||||
|
- `README.md`: linked `docs/fleet-ops-final-localization/` alongside the existing
|
||||||
|
correction-round doc link; refreshed the stale Playwright test count (113 → 138 → 139
|
||||||
|
after the favicon regression test was added).
|
||||||
|
|
||||||
|
## Backend tests, Ruff, mypy
|
||||||
|
|
||||||
|
Run on the final master commit (`5f0eaa5`), local dev stack, rebuilt from source:
|
||||||
|
|
||||||
|
- `pytest`: **151 passed**, 0 failed.
|
||||||
|
- `ruff check .`: **All checks passed!**
|
||||||
|
- `mypy app` (the project's canonical invocation, matching all prior milestone gates —
|
||||||
|
no `[tool.mypy]` strict config exists in `pyproject.toml`): **Success: no issues found
|
||||||
|
in 49 source files.**
|
||||||
|
|
||||||
|
No backend Python was touched this round; these numbers are unchanged from the prior
|
||||||
|
correction milestone's final gate, confirmed green again on the current tree.
|
||||||
|
|
||||||
|
## Frontend build, Playwright
|
||||||
|
|
||||||
|
- `npx tsc --noEmit`: clean, 0 errors.
|
||||||
|
- `npm run build` (`tsc -b && vite build`): clean production build.
|
||||||
|
- Full Playwright suite (`npx playwright test`), master build, local dev stack:
|
||||||
|
**139 passed**, 0 failed (confirmed on a clean run after two transient
|
||||||
|
`0xC0000005` Chromium worker crashes caused by this specific machine running 43+
|
||||||
|
concurrent Chrome processes at the time — see Known limitations; a targeted 48-test
|
||||||
|
re-run of every new/changed suite also passed cleanly in between).
|
||||||
|
|
||||||
|
## Clean-checkout drill
|
||||||
|
|
||||||
|
Isolated Compose project `mobilityops-clean` (ports 8129/1229/5679, no shared volumes/
|
||||||
|
network with the working dev stack), fresh `git clone --branch
|
||||||
|
fix/fleet-ops-final-i18n-ux` of only committed files:
|
||||||
|
|
||||||
|
1. `docker compose build` + `up -d` from empty volumes — all 4 containers healthy.
|
||||||
|
2. `alembic upgrade head` → `799d8800e241 (head)`.
|
||||||
|
3. `seed --reset` → 2 users / 180 customers / 50 vehicles / 246 bookings / 75 inspections /
|
||||||
|
40 maintenance / 27 data-quality issues / 20 workflow runs — matches the documented
|
||||||
|
deterministic count exactly.
|
||||||
|
4. Backend gates: `pytest` 151 passed, `ruff check .` clean, `mypy app` clean (49 files).
|
||||||
|
5. Frontend: `npm ci` clean, `tsc --noEmit` clean, `vite build` clean.
|
||||||
|
6. Full Playwright suite against the isolated stack (`MOBILITYOPS_PUBLIC_URL=http://localhost:1229`):
|
||||||
|
**139 passed**, 0 failed — this run covers the Dutch/English/French language checks,
|
||||||
|
greeting boundaries, API error paths, and the guided demo, all in one pass.
|
||||||
|
7. Final reset + `scenario_integrity`: all 5 scenarios `ready: true`.
|
||||||
|
8. Isolated stack, containers, volumes and images torn down; original dev environment
|
||||||
|
confirmed untouched (`mobilityops-*` containers unaffected throughout).
|
||||||
|
|
||||||
|
**PASS.**
|
||||||
|
|
||||||
|
## Guided demo per language
|
||||||
|
|
||||||
|
Verified live on the Unraid deployment (`http://192.168.10.150:1236`) in all 3 languages
|
||||||
|
via direct browser interaction: login screen role buttons, dashboard (greeting, readiness
|
||||||
|
band, attention queue, integration pulse, recent activity), audit trail, automation retry
|
||||||
|
flow with localized error + technical-details disclosure, and demo reset — all rendering
|
||||||
|
correctly in nl-BE, en-GB and fr-BE. The full guided-demo Playwright spec
|
||||||
|
(`guided-demo-full.spec.ts`) passed as part of the 139-test suite on both the local dev
|
||||||
|
stack and the isolated clean-checkout stack.
|
||||||
|
|
||||||
|
## Server deployment, container health
|
||||||
|
|
||||||
|
Deployed to `http://192.168.10.150:1236` (Compose project `mobilityops`,
|
||||||
|
`/mnt/user/appdata/mobilityops`), preserving the server's existing `.env`, the Postgres
|
||||||
|
and n8n named volumes, the exposed port, and the deployment directory — only `api` and
|
||||||
|
`web` were rebuilt/recreated; `db` was never touched beyond `alembic upgrade head`; no
|
||||||
|
second n8n instance was started (shared existing n8n at `:5678` used throughout).
|
||||||
|
|
||||||
|
Procedure (matching `docs/demo-release/demo-runbook.md` exactly): `git archive` → `scp` →
|
||||||
|
extract over the existing deployment dir → update `.deploy/source-revision` →
|
||||||
|
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||||
|
→ confirm `alembic current` → `seed --reset`.
|
||||||
|
|
||||||
|
Final container status:
|
||||||
|
|
||||||
|
```
|
||||||
|
mobilityops-api-1 Up (healthy)
|
||||||
|
mobilityops-db-1 Up (healthy)
|
||||||
|
mobilityops-web-1 Up (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployed twice this round: once for the fix-branch tip (`09173a4`, with full live
|
||||||
|
3-language validation), once for the final master merge commit (`5f0eaa5`) after the
|
||||||
|
merge — both deployments passed migrations, reseed, and a live smoke test.
|
||||||
|
|
||||||
|
## Repository / runtime hash comparison
|
||||||
|
|
||||||
|
```
|
||||||
|
git rev-parse HEAD (local, master) = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||||
|
/mnt/user/appdata/mobilityops/.deploy/source-revision = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exact match.**
|
||||||
|
|
||||||
|
## Browser console and network
|
||||||
|
|
||||||
|
No console errors on any checked route in any of the 3 languages (dashboard, audit,
|
||||||
|
automation, login) on the live Unraid deployment. All observed `/api/` network requests
|
||||||
|
returned `200`. `api` and `web` container logs show no errors/tracebacks/exceptions after
|
||||||
|
the final deployment.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Transient `document.documentElement.lang` DOM-attribute anomaly during interactive
|
||||||
|
manual browser testing** on the live server: on 2 occasions, right after a client-side
|
||||||
|
action (an automation retry click; a demo-reset confirm click), `document.documentElement.lang`
|
||||||
|
briefly showed `"nl"` while the actually-rendered page content, `localStorage`, and a
|
||||||
|
controlled repeat of the exact same click sequence (fresh login, single deliberate
|
||||||
|
click, immediate inspection) all remained correctly `"fr-BE"`. Root-caused as far as
|
||||||
|
possible: the codebase has exactly one `i18n.changeLanguage()` call site
|
||||||
|
(`LanguageSwitcher.tsx`), which was not invoked in the clean repro, and `t()` /
|
||||||
|
`i18n.language` are structurally coupled through a single i18next singleton with no
|
||||||
|
code path capable of producing this split state. Not reproduced even once across 139
|
||||||
|
automated Playwright tests run 3 times total (local pre-merge, isolated clean-checkout,
|
||||||
|
local post-merge on master) in a clean, extension-free browser context. Most likely
|
||||||
|
explanation: a third-party browser extension active in the specific interactive testing
|
||||||
|
session (which also had ~10 unrelated pre-existing tabs open on the same origin, and
|
||||||
|
showed independent signs of instability — repeated CDP screenshot timeouts) rewriting
|
||||||
|
the `lang` attribute based on its own content heuristics, independent of the React app.
|
||||||
|
Logged here for transparency rather than silently dismissed; does not affect any
|
||||||
|
automated PASS result above.
|
||||||
|
- **Two transient Chromium worker crashes** (`0xC0000005` / access violation) during the
|
||||||
|
master-build Playwright re-run, on a machine that had accumulated 43+ concurrent Chrome
|
||||||
|
processes from the interactive testing session above. A clean run immediately
|
||||||
|
afterward (fewer processes) passed all 139 tests; a 48-test targeted re-run of every
|
||||||
|
new/changed suite also passed cleanly in between. Treated as machine resource
|
||||||
|
contention, not a code defect — consistent with the prior correction milestone's own
|
||||||
|
documented experience of "sequential-run-only flakes reproduced from resource
|
||||||
|
contention of running two full Docker stacks at once," per `PROJECT_STATE.md`.
|
||||||
|
- One translation gap (fr-BE `audit.columns.actor`: "Acteur" instead of the brief's
|
||||||
|
specified "Auteur") was missed in the initial pass and only caught during live browser
|
||||||
|
validation on Unraid; fixed in commit `09173a4` and redeployed before the master merge.
|
||||||
|
- The Fleet Ops brand mark (`BrandMark` in `Icons.tsx`) was flagged by the user as
|
||||||
|
potentially due for a visual refresh; per explicit user decision mid-session, this is
|
||||||
|
out of scope for this correction round and deferred to a separate follow-up task.
|
||||||
|
- No RAGcore/MCP Hub implementation changes were made or claimed; both remain in the same
|
||||||
|
demo/not-connected state documented by the prior correction milestone.
|
||||||
|
|
||||||
|
## Rollback procedure
|
||||||
|
|
||||||
|
`.deploy/source-revision` on the server records exactly which commit is live. To roll
|
||||||
|
back: `ssh unraid`, extract an earlier `source-<short-sha>.tar.gz` from
|
||||||
|
`/mnt/user/appdata/mobilityops/.deploy/` (prior tarballs remain in place, including
|
||||||
|
`source-9468cc3e.tar.gz`, `source-09173a4.tar.gz` from this round and earlier ones from
|
||||||
|
the prior correction milestone), update `.deploy/source-revision` to match, and re-run
|
||||||
|
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||||
|
followed by `alembic upgrade head` (migrations are additive only — no destructive
|
||||||
|
migration exists on this branch, so no database rollback is needed). No secrets were
|
||||||
|
printed or read at any point in this process (`.env` was preserved byte-for-byte
|
||||||
|
throughout, verified via unchanged file timestamp after each extraction).
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Live n8n + RAGcore integration — final evidence
|
||||||
|
|
||||||
|
No credential values, tokens, or secrets appear anywhere in this document. Where a
|
||||||
|
credential or trace ID is referenced, only its name or an opaque reference identifier is
|
||||||
|
given, never its value.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
Built on branch `feat/live-n8n-ragcore-integration`, HEAD at commit
|
||||||
|
`aaa16305354d34f9c1f4d57253d33d9062c38faa` ("docs: record WF2 retry fix and WF4's
|
||||||
|
n8n-session-expiry blocker"). Run `git log --oneline feat/live-n8n-ragcore-integration`
|
||||||
|
for the full history of this effort.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The brief required treating n8n (`https://n8n.itworx.tech`) as a full third integration
|
||||||
|
layer alongside RAGcore and MCP Hub, with Fleet Ops keeping exclusive ownership of
|
||||||
|
business rules, authorization, transactions, audit, and idempotency. Four canonical n8n
|
||||||
|
workflows were required. The repository (`n8n/workflows/*.json` + `MANIFEST.md` +
|
||||||
|
`n8n/workflows/check_drift.py`) is the source of truth for cleaned workflow definitions;
|
||||||
|
the Fleet Ops integration status page (`/automation`) shows real per-workflow operational
|
||||||
|
evidence, not a config boolean.
|
||||||
|
|
||||||
|
## Result summary
|
||||||
|
|
||||||
|
| # | Workflow | Status | Live evidence this round |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Fleet Ops — Vehicle Return Orchestration | **Live, hardened** | Timeout+bounded-retry gap found and fixed |
|
||||||
|
| 2 | Fleet Ops — Scheduled Data Quality Scan | **Live, hardened** | Same gap found and fixed |
|
||||||
|
| 3 | Fleet Ops — RAGcore Procedure Sync | **Blocked** | Not built — RAGcore rejects credential issuance (see below) |
|
||||||
|
| 4 | Fleet Ops — Workflow Error Handler | **Live, validated** | Mock + genuine induced-failure test; own hardening incomplete (see below) |
|
||||||
|
|
||||||
|
Full per-workflow detail (purpose, trigger, event contract, required credentials, live
|
||||||
|
workflow ID, checksum) is in `n8n/workflows/MANIFEST.md`, which is the authoritative,
|
||||||
|
continuously-updated source — this document is a point-in-time summary of that state
|
||||||
|
plus the reasoning behind what's not done.
|
||||||
|
|
||||||
|
## Workflow 1 — Vehicle Return Orchestration
|
||||||
|
|
||||||
|
Live workflow ID `mobilityops-return-processing`. Validated in an earlier round of this
|
||||||
|
effort: webhook trigger requires Header Auth (`Fleet Ops Webhook Trigger Token`),
|
||||||
|
validates `event_type == vehicle.returned.v1`, derives a follow-up category, calls Fleet
|
||||||
|
Ops's `/return-callback` endpoint with an `Idempotency-Key` header via a named
|
||||||
|
`Fleet Ops Service Token` credential (not a literal secret), and responds with a
|
||||||
|
controlled JSON result. Idempotent on both sides (`event_id` flows through as the
|
||||||
|
dedup key; the backend independently checks for a prior audit event before recording
|
||||||
|
again).
|
||||||
|
|
||||||
|
**This round's finding**: the `Record follow-up` HTTP node had no explicit timeout and
|
||||||
|
"Retry On Fail" disabled — a real gap against the requirement that external dependencies
|
||||||
|
have timeouts and bounded retries. Fixed live: Retry On Fail (3 tries, 1000ms wait) + a
|
||||||
|
15000ms timeout, published. Safe to retry because the callback is idempotent. Repo
|
||||||
|
definition and manifest checksum synced (commit `0562893`).
|
||||||
|
|
||||||
|
Attached to workflow 4 as its Error Workflow.
|
||||||
|
|
||||||
|
## Workflow 2 — Scheduled Data Quality Scan
|
||||||
|
|
||||||
|
Live workflow ID `mobilityops-scheduled-quality-scan`. Validated earlier: hourly
|
||||||
|
Schedule Trigger + a Manual Trigger for on-demand testing, both feeding a single HTTP
|
||||||
|
call to Fleet Ops's `/scheduled-scan` endpoint (Header Auth via the same `Fleet Ops
|
||||||
|
Service Token` credential, 15000ms timeout already configured), which runs the
|
||||||
|
domain-level `run_scan()` function — documented and tested as idempotent by
|
||||||
|
construction (only ever creates an issue for a condition that doesn't already have one
|
||||||
|
open), so overlapping or retried triggers do no duplicate domain work.
|
||||||
|
|
||||||
|
**This round's finding**: the same Retry On Fail gap as workflow 1 (timeout was already
|
||||||
|
set, retries were not). Fixed live the same way (3 tries, 1000ms wait), published. Repo
|
||||||
|
definition and manifest checksum synced (commit `167bf49`).
|
||||||
|
|
||||||
|
Attached to workflow 4 as its Error Workflow.
|
||||||
|
|
||||||
|
## Workflow 3 — RAGcore Procedure Sync — blocked
|
||||||
|
|
||||||
|
**Not built.** This workflow needs an application credential (scope `sources:sync`) for
|
||||||
|
the `fleet-ops` application in RAGcore. Two independent issuance attempts, in two
|
||||||
|
separate rounds of this effort, both failed with an opaque server-side rejection:
|
||||||
|
|
||||||
|
1. **Raw API**: `POST /v1/applications/{id}/credentials` → `400`, "authoritative
|
||||||
|
service-account state rejected issuance".
|
||||||
|
2. **RAGcore admin UI**, this round, after the project owner explicitly authorized
|
||||||
|
Claude to self-issue the credential: the "Issue credential" form for the `fleet-ops`
|
||||||
|
application, submitted as the Platform Admin role (the highest role visible in the
|
||||||
|
RAGcore admin), with name `n8n-ragcore-procedure-sync` and scope `sources:sync` only.
|
||||||
|
Result: "Something went wrong. The credential could not be issued with those
|
||||||
|
values.", trace reference `1955c6a8968c4941a22a1faef39e17a7`.
|
||||||
|
|
||||||
|
The `fleet-ops` application itself shows as ordinary/`Active` in the RAGcore admin, with
|
||||||
|
no visible lock flag, and RAGcore's own OpenAPI spec documents no validation rule that
|
||||||
|
would explain either rejection (no `422`, no field-level errors). Two independent paths
|
||||||
|
— a raw API call and the admin UI as the top admin role — hitting the same failure
|
||||||
|
signature is conclusive evidence this is a RAGcore-side policy or bug, not a Fleet Ops
|
||||||
|
request-shape or permission problem. It is not fixable from the Fleet Ops side or
|
||||||
|
through further UI automation. Resolving it requires whoever operates the RAGcore
|
||||||
|
instance to look up the trace ID above (and the earlier raw-API rejection) in RAGcore's
|
||||||
|
own logs.
|
||||||
|
|
||||||
|
The real RAGcore contract this workflow will be built against — once a working
|
||||||
|
credential exists — was independently inspected via RAGcore's live OpenAPI spec and is
|
||||||
|
recorded in `docs/live-ai-integration/n8n-current-state.md` and
|
||||||
|
`contracts/ragcore-contract-assumptions.md`: control-plane endpoints require an
|
||||||
|
`Idempotency-Key` header; ingestion is `POST /v1/uploads`; retrieval is `POST
|
||||||
|
/v1/search` / `/v1/context` / `/v1/answers` (the latter requiring `requested_space_ids`,
|
||||||
|
an array of knowledge-space UUIDs); health is `/health/live` and `/health/ready` (not
|
||||||
|
`/health`); the scope enum is `search, context, answer, documents:read, citations:read,
|
||||||
|
feedback:write, sources:sync`.
|
||||||
|
|
||||||
|
**`RAGcoreKnowledgeProvider` adapter** (`backend/app/services/knowledge/ragcore.py`)
|
||||||
|
still targets the earlier speculative contract (`/health`, `POST /api/v1/ask`, Bearer
|
||||||
|
token) rather than the real one above. This was deliberately **not** rewritten this
|
||||||
|
round: rewriting it blind, without a credential to validate against, risks introducing
|
||||||
|
a silent behavioral bug in exactly the code path responsible for the project's "AI must
|
||||||
|
never invent an answer when RAGcore is unavailable or returns insufficient evidence"
|
||||||
|
guarantee — for example a wrong `evidence_state` mapping that looks fine in code review
|
||||||
|
but misclassifies "unavailable" as "insufficient" (or vice versa) against the real
|
||||||
|
response shape. The adapter's current behavior is honest and safe (it degrades cleanly
|
||||||
|
to `unavailable` on any request or parsing failure, and `ragcore_api_token` is unset by
|
||||||
|
default so the app correctly runs on the local demo knowledge provider today). The
|
||||||
|
rewrite stays queued behind the same credential blocker as workflow 3.
|
||||||
|
|
||||||
|
## Workflow 4 — Workflow Error Handler
|
||||||
|
|
||||||
|
Live workflow ID `Xppn2rAEqUuyiCJF`. Built and live-validated in an earlier round:
|
||||||
|
Error Trigger → a Code node that derives a bounded, secret-free failure report (error
|
||||||
|
category classified from the message text, truncated summary, no stack trace, no
|
||||||
|
headers or tokens) → an HTTP call to Fleet Ops's `/workflow-error` endpoint (Header Auth
|
||||||
|
via the same `Fleet Ops Service Token` credential), which registers the failure as an
|
||||||
|
audit event idempotently keyed on `execution_id`.
|
||||||
|
|
||||||
|
Set as the Error Workflow on both workflow 1 and workflow 2. Confirmed workflow 4 has no
|
||||||
|
Error Workflow of its own (prevents a recursive loop).
|
||||||
|
|
||||||
|
**Live validation performed**: a pinned mock Error Trigger payload produced a real `200
|
||||||
|
{"status":"registered", ...}` from the live Fleet Ops server; re-running the identical
|
||||||
|
payload produced `"status":"already_registered"`, confirming idempotency. A genuine
|
||||||
|
induced failure (temporarily pointing workflow 2's HTTP node at a nonexistent path, then
|
||||||
|
reverting) confirmed workflow 2 itself fails correctly against a broken endpoint and
|
||||||
|
recovers cleanly once reverted.
|
||||||
|
|
||||||
|
**Known limitation**: n8n's Error Workflow trigger does not fire for manual editor
|
||||||
|
"Execute workflow" test runs — checked via workflow 4's own Executions list after the
|
||||||
|
induced workflow-2 failure, and confirmed no new execution appeared. n8n only invokes a
|
||||||
|
workflow's assigned Error Workflow for unattended/production trigger executions, not
|
||||||
|
manual test runs from the editor. The mock-data path exercises the same nodes, logic,
|
||||||
|
and real Fleet Ops endpoint, but a fully automatic (schedule- or webhook-triggered)
|
||||||
|
failure cascading into workflow 4 was not observed live in either round.
|
||||||
|
|
||||||
|
**Open follow-up (minor, non-blocking)**: continuing this round's acceptance pass to
|
||||||
|
workflow 4 found the same timeout/retry gap as workflows 1 and 2 on its own outbound
|
||||||
|
HTTP call. A fix was started (15000ms timeout added, Retry On Fail toggled on) but n8n's
|
||||||
|
autosave began failing with "Unauthorized" mid-edit; a fresh browser tab confirmed the
|
||||||
|
n8n session had expired (redirected to `/signin`). Nothing was saved — workflow 4's live
|
||||||
|
definition is unchanged from before this round, so there is no partial or broken state.
|
||||||
|
This is lower-stakes than workflows 1/2 (workflow 4 is the error notifier itself, not a
|
||||||
|
primary business flow, and a failed error-report is already visible in n8n's own
|
||||||
|
execution history via `On Error: Stop Workflow`) but should be finished once the n8n
|
||||||
|
browser session is re-authenticated.
|
||||||
|
|
||||||
|
## Repository source of truth
|
||||||
|
|
||||||
|
`n8n/workflows/` holds cleaned, credential-value-free JSON definitions for all built
|
||||||
|
workflows, `n8n/workflows/MANIFEST.md` documents purpose/trigger/contract/credentials/
|
||||||
|
live-ID/checksum for all four canonical workflows (including workflow 3's blocked
|
||||||
|
status), and `n8n/workflows/check_drift.py` is a read-only script that compares the
|
||||||
|
repo definitions against the live instance via n8n's Public API and reports drift —
|
||||||
|
safe to run in CI as a non-blocking check. No literal export/download mechanism was
|
||||||
|
found working in this n8n version, so each definition was reconstructed from direct,
|
||||||
|
verified UI inspection rather than a native export; this limitation is noted in the
|
||||||
|
manifest itself.
|
||||||
|
|
||||||
|
## Integration status page
|
||||||
|
|
||||||
|
`/automation` (Operations Manager only) surfaces real per-workflow evidence derived
|
||||||
|
purely from Fleet Ops's own audit/outbox tables — no new dependency on n8n's API was
|
||||||
|
added to the backend. Each of the four canonical workflows shows a status (not built /
|
||||||
|
no evidence yet / operational) and a last-evidence timestamp; the scheduled-scan
|
||||||
|
evidence specifically filters to `actor_type == "service"` so a manually-triggered scan
|
||||||
|
in the UI doesn't count as n8n evidence. An error-handler summary line reports total
|
||||||
|
registered automation failures and the most recent one.
|
||||||
|
|
||||||
|
Verified live in the browser (Dutch locale) both locally and on the deployed
|
||||||
|
production server (`http://192.168.10.150:1236/automation`): correctly showed "3 van 4
|
||||||
|
canonieke n8n-workflows hebben actuele evidentie van werking" with real timestamps for
|
||||||
|
the return/scan/error-handler workflows, "Nog Niet Gebouwd" for the RAGcore sync, and
|
||||||
|
the real error-handler registration from this effort's live testing.
|
||||||
|
|
||||||
|
## Deployments performed (all explicitly user-approved)
|
||||||
|
|
||||||
|
1. Backend `/workflow-error` endpoint (commit `bbdb4a9`) — deployed and verified
|
||||||
|
(`/health` OK, new endpoint returns `422` not `404` on an empty POST body).
|
||||||
|
2. Integration status page, backend + frontend (commit `4049c0c`) — deployed and
|
||||||
|
verified (`/health` OK, page renders real evidence in the browser).
|
||||||
|
|
||||||
|
The three n8n-side node edits this round (WF1 timeout/retry, WF2 timeout/retry, WF4's
|
||||||
|
incomplete attempt) are live edits to the n8n instance itself and do not require a
|
||||||
|
Fleet Ops redeploy.
|
||||||
|
|
||||||
|
## What's left
|
||||||
|
|
||||||
|
1. **RAGcore credential issuance** — blocked on RAGcore's own server-side rejection
|
||||||
|
(trace `1955c6a8968c4941a22a1faef39e17a7` and the earlier raw-API `400`). Needs
|
||||||
|
RAGcore's operator to investigate. Unblocks workflow 3 and the
|
||||||
|
`RAGcoreKnowledgeProvider` real-contract rewrite.
|
||||||
|
2. **Workflow 4's own timeout/bounded-retry hardening** — needs the n8n browser session
|
||||||
|
re-authenticated to finish; a small, well-understood, non-blocking edit.
|
||||||
|
3. **Fleet Ops logo/favicon** — explicitly deferred by the project owner as a separate,
|
||||||
|
unrelated follow-up task, not part of this integration effort.
|
||||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
@@ -14,7 +15,7 @@ from app.models.booking import Booking
|
|||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import AuditEventOut, CurrentUser
|
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||||
|
|
||||||
@@ -51,26 +52,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
|
|||||||
return refs
|
return refs
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AuditEventOut])
|
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
|
||||||
def list_audit_events(
|
def list_audit_events(
|
||||||
actor_label: str | None = Query(default=None),
|
actor_label: str | None = Query(default=None),
|
||||||
action: str | None = Query(default=None),
|
action: str | None = Query(default=None),
|
||||||
entity_type: str | None = Query(default=None),
|
entity_type: str | None = Query(default=None),
|
||||||
|
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
correlation_id: str | None = Query(default=None),
|
correlation_id: str | None = Query(default=None),
|
||||||
limit: int = Query(default=100, le=500),
|
occurred_from: datetime | None = Query(default=None),
|
||||||
|
occurred_to: datetime | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[AuditEventOut]:
|
) -> list[AuditEventOut] | AuditEventPageOut:
|
||||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
|
||||||
if actor_label:
|
if actor_label:
|
||||||
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
||||||
if action:
|
if action:
|
||||||
stmt = stmt.where(AuditEvent.action == action)
|
stmt = stmt.where(AuditEvent.action == action)
|
||||||
if entity_type:
|
if entity_type:
|
||||||
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
||||||
|
if entity_ref:
|
||||||
|
matched_ids: set[uuid.UUID] = set()
|
||||||
|
for model in _ENTITY_MODELS.values():
|
||||||
|
matched_ids.update(
|
||||||
|
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
|
||||||
|
)
|
||||||
|
if not matched_ids:
|
||||||
|
if page is None:
|
||||||
|
return []
|
||||||
|
return AuditEventPageOut(
|
||||||
|
items=[], page=1, page_size=page_size, total=0, total_pages=1
|
||||||
|
)
|
||||||
|
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||||
if correlation_id:
|
if correlation_id:
|
||||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||||
events = db.scalars(stmt).all()
|
if occurred_from:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
|
||||||
|
if occurred_to:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
events = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
entity_refs = _resolve_entity_refs(db, events)
|
entity_refs = _resolve_entity_refs(db, events)
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
@@ -94,4 +120,13 @@ def list_audit_events(
|
|||||||
metadata=e.metadata_json,
|
metadata=e.metadata_json,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
if page is None:
|
||||||
|
return out
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return AuditEventPageOut(
|
||||||
|
items=out,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
|||||||
AutomationRunOut,
|
AutomationRunOut,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DashboardOut,
|
DashboardOut,
|
||||||
|
EvidenceSignalOut,
|
||||||
TodayItem,
|
TodayItem,
|
||||||
)
|
)
|
||||||
from app.services.operations import compute_metrics
|
from app.services.operations import compute_metrics
|
||||||
@@ -61,19 +62,34 @@ def get_dashboard(
|
|||||||
entity = customers_by_id.get(issue.entity_id)
|
entity = customers_by_id.get(issue.entity_id)
|
||||||
link_type = "customer"
|
link_type = "customer"
|
||||||
link_ref = entity.public_ref if entity else ""
|
link_ref = entity.public_ref if entity else ""
|
||||||
|
# The backend never emits prose for the attention queue -- only stable signal
|
||||||
|
# codes + raw data params, exactly like the issue detail page's evidence list
|
||||||
|
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
|
||||||
|
# that turns these into the operator's selected language; `evidence_json["summary"]`
|
||||||
|
# is a technical fallback only, never rendered here.
|
||||||
|
signals = [
|
||||||
|
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
|
||||||
|
for s in issue.evidence_json.get("signals", [])
|
||||||
|
]
|
||||||
attention_items.append(
|
attention_items.append(
|
||||||
AttentionItem(
|
AttentionItem(
|
||||||
kind="quality_issue",
|
kind="quality_issue",
|
||||||
severity=issue.severity,
|
severity=issue.severity,
|
||||||
rule_type=issue.rule_type,
|
rule_type=issue.rule_type,
|
||||||
detail=issue.evidence_json.get("summary", ""),
|
evidence_signals=signals,
|
||||||
link_type=link_type,
|
link_type=link_type,
|
||||||
link_ref=link_ref,
|
link_ref=link_ref,
|
||||||
issue_ref=issue.public_ref,
|
issue_ref=issue.public_ref,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3))
|
# Curate a credible severity mix instead of letting `high` dominate every slot:
|
||||||
attention_items = attention_items[:8]
|
# each item's real severity is unchanged, only the display selection is capped per
|
||||||
|
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
|
||||||
|
# issues doesn't crowd out medium/low ones the operator should still see.
|
||||||
|
high_items = [i for i in attention_items if i.severity == "high"]
|
||||||
|
medium_items = [i for i in attention_items if i.severity == "medium"]
|
||||||
|
low_items = [i for i in attention_items if i.severity == "low"]
|
||||||
|
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
|
||||||
|
|
||||||
today = _today()
|
today = _today()
|
||||||
bookings = db.scalars(select(Booking)).all()
|
bookings = db.scalars(select(Booking)).all()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
@@ -16,6 +16,7 @@ from app.schemas import (
|
|||||||
CurrentUser,
|
CurrentUser,
|
||||||
DataQualityIssueDetailOut,
|
DataQualityIssueDetailOut,
|
||||||
DataQualityIssueOut,
|
DataQualityIssueOut,
|
||||||
|
DataQualityIssuePageOut,
|
||||||
MergeCustomersRequest,
|
MergeCustomersRequest,
|
||||||
MergeCustomersResult,
|
MergeCustomersResult,
|
||||||
ProvideFieldsRequest,
|
ProvideFieldsRequest,
|
||||||
@@ -54,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues", response_model=list[DataQualityIssueOut])
|
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
|
||||||
def list_issues(
|
def list_issues(
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
rule_type: str | None = Query(default=None),
|
rule_type: str | None = Query(default=None),
|
||||||
severity: str | None = Query(default=None),
|
severity: str | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[DataQualityIssueOut]:
|
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
|
||||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(DataQualityIssue.status == status)
|
stmt = stmt.where(DataQualityIssue.status == status)
|
||||||
@@ -69,8 +72,22 @@ def list_issues(
|
|||||||
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(DataQualityIssue.severity == severity)
|
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||||
issues = db.scalars(stmt).all()
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
return [_to_out(i) for i in issues]
|
page_number = page or 1
|
||||||
|
issues = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
items = [_to_out(i) for i in issues]
|
||||||
|
if page is None:
|
||||||
|
return items
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return DataQualityIssuePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Every public reference in this system carries its entity type in its own prefix
|
# Every public reference in this system carries its entity type in its own prefix
|
||||||
@@ -115,6 +132,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
return {
|
return {
|
||||||
"entity_type": "vehicle",
|
"entity_type": "vehicle",
|
||||||
"public_ref": vehicle.public_ref,
|
"public_ref": vehicle.public_ref,
|
||||||
|
"registration_number": vehicle.registration_number,
|
||||||
"make": vehicle.make,
|
"make": vehicle.make,
|
||||||
"model": vehicle.model,
|
"model": vehicle.model,
|
||||||
"location": vehicle.location,
|
"location": vehicle.location,
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
from app.core.config import get_settings
|
from app.schemas import CurrentUser, IntegrationStatusOut
|
||||||
from app.schemas import (
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
CurrentUser,
|
|
||||||
IntegrationStatusOut,
|
|
||||||
McpHubIntegrationStatus,
|
|
||||||
)
|
|
||||||
from app.services.integration_status import derive_n8n_status
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=IntegrationStatusOut)
|
@router.get("/status", response_model=IntegrationStatusOut)
|
||||||
@@ -23,8 +17,5 @@ def integration_status(
|
|||||||
) -> IntegrationStatusOut:
|
) -> IntegrationStatusOut:
|
||||||
return IntegrationStatusOut(
|
return IntegrationStatusOut(
|
||||||
n8n=derive_n8n_status(db),
|
n8n=derive_n8n_status(db),
|
||||||
mcp_hub=McpHubIntegrationStatus(
|
mcp_hub=derive_mcp_hub_status(db),
|
||||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
|
||||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
@@ -13,9 +14,18 @@ from app.core.config import get_settings
|
|||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.audit import AuditEvent
|
from app.models.audit import AuditEvent
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import ScanResultOut
|
from app.schemas import (
|
||||||
|
ProcedureDocumentOut,
|
||||||
|
ProcedureListOut,
|
||||||
|
ProcedureSyncResultIn,
|
||||||
|
ProcedureSyncResultResult,
|
||||||
|
ScanResultOut,
|
||||||
|
WorkflowErrorReportIn,
|
||||||
|
WorkflowErrorReportResult,
|
||||||
|
)
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
from app.services.data_quality import run_scan
|
from app.services.data_quality import run_scan
|
||||||
|
from app.services.knowledge.procedures import iter_procedure_documents
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -89,3 +99,124 @@ def scheduled_scan(
|
|||||||
|
|
||||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||||
return ScanResultOut(created=result.created)
|
return ScanResultOut(created=result.created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
||||||
|
def workflow_error(
|
||||||
|
body: WorkflowErrorReportIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> WorkflowErrorReportResult:
|
||||||
|
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
||||||
|
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||||
|
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||||
|
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
correlation_id: uuid.UUID | None = None
|
||||||
|
if body.correlation_id:
|
||||||
|
try:
|
||||||
|
correlation_id = uuid.UUID(body.correlation_id)
|
||||||
|
except ValueError:
|
||||||
|
correlation_id = None
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n error handler",
|
||||||
|
action="n8n_workflow_failure_registered",
|
||||||
|
entity_type="automation",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"error_category": body.error_category,
|
||||||
|
"error_summary": body.error_summary,
|
||||||
|
"trigger_context": body.trigger_context,
|
||||||
|
"attempt": body.attempt,
|
||||||
|
"retry_action": body.retry_action,
|
||||||
|
"failed_at": body.failed_at.isoformat(),
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return WorkflowErrorReportResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/procedures", response_model=ProcedureListOut)
|
||||||
|
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
|
||||||
|
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
|
||||||
|
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||||
|
per-document id (source_id) and a content hash so the caller can detect changes
|
||||||
|
without re-fetching content it already has."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
documents = [
|
||||||
|
ProcedureDocumentOut(
|
||||||
|
id=doc.source_id,
|
||||||
|
language=doc.language,
|
||||||
|
document_id=doc.document_id,
|
||||||
|
title=doc.title,
|
||||||
|
version=doc.version,
|
||||||
|
content=doc.content,
|
||||||
|
content_hash=doc.content_hash,
|
||||||
|
)
|
||||||
|
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
|
||||||
|
]
|
||||||
|
return ProcedureListOut(documents=documents)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
|
||||||
|
def procedures_sync_result(
|
||||||
|
body: ProcedureSyncResultIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProcedureSyncResultResult:
|
||||||
|
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
||||||
|
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||||
|
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_procedures_synced",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n procedure sync",
|
||||||
|
action="n8n_procedures_synced",
|
||||||
|
entity_type="automation",
|
||||||
|
after={"synced": body.synced, "failed": body.failed},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ProcedureSyncResultResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Header, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None:
|
def get_correlation_id(
|
||||||
|
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
|
||||||
|
) -> str:
|
||||||
|
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
|
||||||
|
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
|
||||||
|
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
|
||||||
|
if x_correlation_id:
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(x_correlation_id))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_service_request(
|
||||||
|
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
|
||||||
|
) -> None:
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="service",
|
actor_type="service",
|
||||||
actor_label=client_id,
|
actor_label=client_id,
|
||||||
action="mcp_tool_request",
|
action="mcp_tool_request",
|
||||||
entity_type="mcp_tool",
|
entity_type="mcp_tool",
|
||||||
correlation_id=uuid.uuid4(),
|
correlation_id=uuid.UUID(correlation_id),
|
||||||
metadata={"tool": tool, "status": status_label},
|
metadata={"tool": tool, "status": status_label},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
|
|||||||
def operations_summary(
|
def operations_summary(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> OperationsSummaryOut:
|
) -> OperationsSummaryOut:
|
||||||
metrics = compute_metrics(db)
|
metrics = compute_metrics(db)
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok"
|
db,
|
||||||
|
client_id=client_id,
|
||||||
|
tool="fleet_ops_get_operations_summary",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
|
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
|
||||||
|
|
||||||
@@ -59,12 +80,17 @@ def attention_vehicles(
|
|||||||
limit: int = Query(default=20, ge=1, le=50),
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> list[AttentionVehicleOut]:
|
) -> list[AttentionVehicleOut]:
|
||||||
results = list_attention_vehicles(
|
results = list_attention_vehicles(
|
||||||
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
|
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
|
||||||
)
|
)
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok"
|
db,
|
||||||
|
client_id=client_id,
|
||||||
|
tool="fleet_ops_list_attention_vehicles",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return [AttentionVehicleOut(**r) for r in results]
|
return [AttentionVehicleOut(**r) for r in results]
|
||||||
|
|
||||||
@@ -74,14 +100,16 @@ def vehicle_details(
|
|||||||
vehicle_ref: str,
|
vehicle_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> McpVehicleDetailOut:
|
) -> McpVehicleDetailOut:
|
||||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db,
|
db,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
tool="mobilityops_get_vehicle_details",
|
tool="fleet_ops_get_vehicle_details",
|
||||||
status_label="not_found",
|
status_label="not_found",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
||||||
|
|
||||||
@@ -99,7 +127,11 @@ def vehicle_details(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok"
|
db,
|
||||||
|
client_id=client_id,
|
||||||
|
tool="fleet_ops_get_vehicle_details",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return McpVehicleDetailOut(
|
return McpVehicleDetailOut(
|
||||||
public_ref=vehicle.public_ref,
|
public_ref=vehicle.public_ref,
|
||||||
@@ -120,15 +152,16 @@ def search_knowledge(
|
|||||||
body: McpKnowledgeSearchRequest,
|
body: McpKnowledgeSearchRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> GroundedAnswer:
|
) -> GroundedAnswer:
|
||||||
provider = get_knowledge_provider()
|
provider = get_knowledge_provider()
|
||||||
correlation_id = str(uuid.uuid4())
|
answer = provider.ask(body.question, correlation_id, language=body.locale)
|
||||||
answer = provider.ask(body.question, correlation_id)
|
|
||||||
answer.sources = answer.sources[: body.max_sources]
|
answer.sources = answer.sources[: body.max_sources]
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db,
|
db,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
tool="mobilityops_search_knowledge",
|
tool="fleet_ops_search_knowledge",
|
||||||
status_label=answer.evidence_state,
|
status_label=answer.evidence_state,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return answer
|
return answer
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
|||||||
MaintenanceOut,
|
MaintenanceOut,
|
||||||
VehicleDetailOut,
|
VehicleDetailOut,
|
||||||
VehicleOut,
|
VehicleOut,
|
||||||
|
VehiclePageOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||||
@@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set:
|
|||||||
return set(rows)
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[VehicleOut])
|
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
|
||||||
def list_vehicles(
|
def list_vehicles(
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
attention_only: bool = Query(default=False),
|
attention_only: bool = Query(default=False),
|
||||||
|
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[VehicleOut]:
|
) -> list[VehicleOut] | VehiclePageOut:
|
||||||
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(Vehicle.operational_status == status)
|
stmt = stmt.where(Vehicle.operational_status == status)
|
||||||
vehicles = db.scalars(stmt).all()
|
if query:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
Vehicle.make.ilike(term),
|
||||||
|
Vehicle.model.ilike(term),
|
||||||
|
Vehicle.location.ilike(term),
|
||||||
|
Vehicle.registration_number.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
attention_ids = _attention_vehicle_ids(db)
|
attention_ids = _attention_vehicle_ids(db)
|
||||||
out = [
|
if attention_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
|
||||||
|
)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
vehicles = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
items = [
|
||||||
VehicleOut(
|
VehicleOut(
|
||||||
public_ref=v.public_ref,
|
public_ref=v.public_ref,
|
||||||
make=v.make,
|
make=v.make,
|
||||||
@@ -62,9 +85,16 @@ def list_vehicles(
|
|||||||
)
|
)
|
||||||
for v in vehicles
|
for v in vehicles
|
||||||
]
|
]
|
||||||
if attention_only:
|
if page is None:
|
||||||
out = [v for v in out if v.attention]
|
return items
|
||||||
return out
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return VehiclePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||||
from app.schemas import AutomationRunOut, CurrentUser
|
from app.schemas import AutomationRunOut, CurrentUser
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
|||||||
attempts=event.attempts,
|
attempts=event.attempts,
|
||||||
last_error=event.last_error,
|
last_error=event.last_error,
|
||||||
last_error_code=event.last_error_code,
|
last_error_code=event.last_error_code,
|
||||||
|
is_demo_scenario=is_demo_scenario_failure(event),
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,15 +64,27 @@ def retry_workflow(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Captured before the status flips, so the audit records what was actually retried.
|
||||||
|
was_demo_scenario = is_demo_scenario_failure(event)
|
||||||
|
|
||||||
event.delivery_status = "pending"
|
event.delivery_status = "pending"
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
|
# The retry itself is real either way: the event goes back on the outbox and the
|
||||||
|
# dispatcher delivers it to the configured n8n webhook like any other. The only
|
||||||
|
# difference recorded here is *what* was retried -- a staged demo failure or a real
|
||||||
|
# one -- so the audit trail never implies a production incident was resolved when a
|
||||||
|
# prop was.
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_label=user.display_name,
|
actor_label=user.display_name,
|
||||||
action="workflow_retry",
|
action="workflow_retry",
|
||||||
entity_type="outbox_event",
|
entity_type="outbox_event",
|
||||||
metadata={"event_id": event_id, "previous_attempts": event.attempts},
|
metadata={
|
||||||
|
"event_id": event_id,
|
||||||
|
"previous_attempts": event.attempts,
|
||||||
|
"demo_scenario": was_demo_scenario,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _to_out(event)
|
return _to_out(event)
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ class Settings(BaseSettings):
|
|||||||
ragcore_workspace: str = "mobilityops"
|
ragcore_workspace: str = "mobilityops"
|
||||||
ragcore_collection: str = "internal-procedures"
|
ragcore_collection: str = "internal-procedures"
|
||||||
ragcore_api_token: str = ""
|
ragcore_api_token: str = ""
|
||||||
|
ragcore_space_id: str = ""
|
||||||
ragcore_http_timeout_seconds: float = 5.0
|
ragcore_http_timeout_seconds: float = 5.0
|
||||||
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
||||||
|
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
|
||||||
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||||
n8n_dispatch_enabled: bool = True
|
n8n_dispatch_enabled: bool = True
|
||||||
n8n_dispatch_interval_seconds: float = 3.0
|
n8n_dispatch_interval_seconds: float = 3.0
|
||||||
@@ -37,6 +39,11 @@ class Settings(BaseSettings):
|
|||||||
knowledge_dir: str = "/app/knowledge/procedures"
|
knowledge_dir: str = "/app/knowledge/procedures"
|
||||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||||
mcp_hub_registration_enabled: bool = False
|
mcp_hub_registration_enabled: bool = False
|
||||||
|
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
|
||||||
|
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
|
||||||
|
# these are only used for an honest reachability health check, not self-registration.
|
||||||
|
mcp_hub_base_url: str = ""
|
||||||
|
mcp_provider_id: str = "fleet-ops"
|
||||||
cors_allow_origins: str = "http://localhost:1228"
|
cors_allow_origins: str = "http://localhost:1228"
|
||||||
demo_organization_name: str = "Northstar Mobility"
|
demo_organization_name: str = "Northstar Mobility"
|
||||||
demo_timezone: str = "Europe/Brussels"
|
demo_timezone: str = "Europe/Brussels"
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ from app.models.mixins import TimestampMixin
|
|||||||
|
|
||||||
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
|
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
|
||||||
|
|
||||||
|
# The one delivery failure the demo seed deliberately plants (BK-H-0020, see
|
||||||
|
# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never
|
||||||
|
# be read as an integration-health problem: it is a scripted prop, not evidence that
|
||||||
|
# n8n is unhealthy. A dedicated error code -- rather than the generic
|
||||||
|
# "connectionError" a real timeout produces -- is what lets every reader tell the two
|
||||||
|
# apart without guessing from the message text.
|
||||||
|
#
|
||||||
|
# It is deliberately a `last_error_code` value and not a new column: the code is
|
||||||
|
# already persisted, already surfaced to the UI, and already localizable, so no schema
|
||||||
|
# change or migration is needed. A genuine later failure of this same event overwrites
|
||||||
|
# the code with the real one, which is exactly right -- from that moment it *is* a real
|
||||||
|
# failure.
|
||||||
|
DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout"
|
||||||
|
|
||||||
|
|
||||||
|
def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
|
||||||
|
"""True for the prepared demo failure, false for every real one."""
|
||||||
|
return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE
|
||||||
|
|
||||||
|
|
||||||
class OutboxEvent(TimestampMixin, Base):
|
class OutboxEvent(TimestampMixin, Base):
|
||||||
__tablename__ = "outbox_events"
|
__tablename__ = "outbox_events"
|
||||||
|
|||||||
+115
-3
@@ -32,6 +32,14 @@ class VehicleOut(BaseModel):
|
|||||||
attention: bool = False
|
attention: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class VehiclePageOut(BaseModel):
|
||||||
|
items: list[VehicleOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
class BookingSummaryOut(BaseModel):
|
class BookingSummaryOut(BaseModel):
|
||||||
public_ref: str
|
public_ref: str
|
||||||
customer_ref: str
|
customer_ref: str
|
||||||
@@ -122,6 +130,14 @@ class DataQualityIssueOut(BaseModel):
|
|||||||
resolved_at: datetime | None = None
|
resolved_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityIssuePageOut(BaseModel):
|
||||||
|
items: list[DataQualityIssueOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
class DataQualityIssueDetailOut(DataQualityIssueOut):
|
class DataQualityIssueDetailOut(DataQualityIssueOut):
|
||||||
entity_snapshot: dict[str, Any] | None = None
|
entity_snapshot: dict[str, Any] | None = None
|
||||||
related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
|
related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
@@ -143,6 +159,53 @@ class ScanResultOut(BaseModel):
|
|||||||
created: dict[str, int]
|
created: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportIn(BaseModel):
|
||||||
|
workflow_id: str = Field(max_length=120)
|
||||||
|
workflow_name: str = Field(max_length=200)
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
failed_at: datetime
|
||||||
|
error_category: Literal[
|
||||||
|
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
|
||||||
|
]
|
||||||
|
error_summary: str = Field(max_length=500)
|
||||||
|
trigger_context: str | None = Field(default=None, max_length=200)
|
||||||
|
correlation_id: str | None = None
|
||||||
|
attempt: int = Field(default=1, ge=1, le=1000)
|
||||||
|
retry_action: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureDocumentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureListOut(BaseModel):
|
||||||
|
documents: list[ProcedureDocumentOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultIn(BaseModel):
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
synced: int = Field(ge=0)
|
||||||
|
failed: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ProvideFieldsRequest(BaseModel):
|
class ProvideFieldsRequest(BaseModel):
|
||||||
fields: dict[str, str]
|
fields: dict[str, str]
|
||||||
|
|
||||||
@@ -202,21 +265,52 @@ class SearchResponse(BaseModel):
|
|||||||
results: list[SearchResultItem]
|
results: list[SearchResultItem]
|
||||||
|
|
||||||
|
|
||||||
|
class N8nWorkflowEvidence(BaseModel):
|
||||||
|
name: str
|
||||||
|
built: bool
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class N8nErrorHandlerStatus(BaseModel):
|
||||||
|
total_failures_registered: int
|
||||||
|
latest_failure_at: datetime | None
|
||||||
|
latest_failure_workflow: str | None
|
||||||
|
|
||||||
|
|
||||||
class N8nIntegrationStatus(BaseModel):
|
class N8nIntegrationStatus(BaseModel):
|
||||||
configured: bool
|
configured: bool
|
||||||
dispatch_enabled: bool
|
dispatch_enabled: bool
|
||||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||||
pending: int
|
pending: int
|
||||||
delivering: int
|
#: Every failed delivery, staged and real together -- the number a viewer sees in
|
||||||
|
#: the run list.
|
||||||
failed: int
|
failed: int
|
||||||
|
#: Failures that were not planted by the demo seed. This is the only failure count
|
||||||
|
#: that may influence `state`.
|
||||||
|
unexpected_failed: int = 0
|
||||||
|
#: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`).
|
||||||
|
#: Present so the UI can label them instead of implying the automation is broken.
|
||||||
|
demo_scenario_failed: int = 0
|
||||||
|
delivering: int
|
||||||
succeeded: int
|
succeeded: int
|
||||||
latest_success_at: datetime | None
|
latest_success_at: datetime | None
|
||||||
|
#: Most recent *real* failure; a staged one never sets this.
|
||||||
latest_failure_at: datetime | None
|
latest_failure_at: datetime | None
|
||||||
|
latest_demo_scenario_at: datetime | None = None
|
||||||
|
expected_workflow_count: int
|
||||||
|
known_workflow_count: int
|
||||||
|
workflows: list[N8nWorkflowEvidence]
|
||||||
|
error_handler: N8nErrorHandlerStatus
|
||||||
|
|
||||||
|
|
||||||
class McpHubIntegrationStatus(BaseModel):
|
class McpHubIntegrationStatus(BaseModel):
|
||||||
registration_enabled: bool
|
registration_enabled: bool
|
||||||
state: Literal["not_configured", "configured"]
|
state: Literal["not_configured", "no_evidence", "operational"]
|
||||||
|
total_calls: int
|
||||||
|
last_tool: str | None = None
|
||||||
|
last_client: str | None = None
|
||||||
|
last_called_at: datetime | None = None
|
||||||
|
hub_reachable: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class IntegrationStatusOut(BaseModel):
|
class IntegrationStatusOut(BaseModel):
|
||||||
@@ -272,11 +366,16 @@ class DashboardMetrics(BaseModel):
|
|||||||
pending_or_failed_workflows: int
|
pending_or_failed_workflows: int
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceSignalOut(BaseModel):
|
||||||
|
code: str
|
||||||
|
params: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class AttentionItem(BaseModel):
|
class AttentionItem(BaseModel):
|
||||||
kind: Literal["quality_issue", "vehicle"]
|
kind: Literal["quality_issue", "vehicle"]
|
||||||
severity: str
|
severity: str
|
||||||
rule_type: str
|
rule_type: str
|
||||||
detail: str
|
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
|
||||||
link_type: Literal["vehicle", "booking", "customer"]
|
link_type: Literal["vehicle", "booking", "customer"]
|
||||||
link_ref: str
|
link_ref: str
|
||||||
issue_ref: str | None = None
|
issue_ref: str | None = None
|
||||||
@@ -297,6 +396,10 @@ class AutomationRunOut(BaseModel):
|
|||||||
attempts: int
|
attempts: int
|
||||||
last_error: str | None
|
last_error: str | None
|
||||||
last_error_code: str | None
|
last_error_code: str | None
|
||||||
|
#: True for the deliberately seeded demo failure. The UI uses this to label the run
|
||||||
|
#: as a prepared scenario and to offer the demo retry, instead of presenting it as
|
||||||
|
#: an unexplained production error.
|
||||||
|
is_demo_scenario: bool = False
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -336,6 +439,7 @@ class McpVehicleDetailOut(BaseModel):
|
|||||||
class McpKnowledgeSearchRequest(BaseModel):
|
class McpKnowledgeSearchRequest(BaseModel):
|
||||||
question: str = Field(min_length=3, max_length=1000)
|
question: str = Field(min_length=3, max_length=1000)
|
||||||
max_sources: int = Field(default=4, ge=1, le=8)
|
max_sources: int = Field(default=4, ge=1, le=8)
|
||||||
|
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
|
||||||
|
|
||||||
|
|
||||||
class AuditEventOut(BaseModel):
|
class AuditEventOut(BaseModel):
|
||||||
@@ -352,3 +456,11 @@ class AuditEventOut(BaseModel):
|
|||||||
before: dict[str, Any] | None = None
|
before: dict[str, Any] | None = None
|
||||||
after: dict[str, Any] | None = None
|
after: dict[str, Any] | None = None
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventPageOut(BaseModel):
|
||||||
|
items: list[AuditEventOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|||||||
+114
-33
@@ -18,7 +18,7 @@ from app.models.data_quality import DataQualityIssue
|
|||||||
from app.models.idempotency import IdempotencyRecord
|
from app.models.idempotency import IdempotencyRecord
|
||||||
from app.models.inspection import Inspection
|
from app.models.inspection import Inspection
|
||||||
from app.models.maintenance import MaintenanceRecord
|
from app.models.maintenance import MaintenanceRecord
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
@@ -139,47 +139,49 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
|
|
||||||
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
vehicle_rows = []
|
vehicle_rows = []
|
||||||
|
vehicle_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("vehicles.csv"):
|
for row in _read_csv("vehicles.csv"):
|
||||||
vid = uuid.uuid4()
|
vid = uuid.uuid4()
|
||||||
vehicle_id_by_ref[row["public_ref"]] = vid
|
vehicle_id_by_ref[row["public_ref"]] = vid
|
||||||
vehicle_rows.append(
|
vehicle_row = {
|
||||||
{
|
"id": vid,
|
||||||
"id": vid,
|
"public_ref": row["public_ref"],
|
||||||
"public_ref": row["public_ref"],
|
"make": row["make"],
|
||||||
"make": row["make"],
|
"model": row["model"],
|
||||||
"model": row["model"],
|
"model_year": int(row["model_year"]),
|
||||||
"model_year": int(row["model_year"]),
|
"registration_number": row["registration_number"],
|
||||||
"registration_number": row["registration_number"],
|
"location": row["location"],
|
||||||
"location": row["location"],
|
"operational_status": row["operational_status"],
|
||||||
"operational_status": row["operational_status"],
|
"odometer_km": int(row["odometer_km"]),
|
||||||
"odometer_km": int(row["odometer_km"]),
|
"next_service_km": int(row["next_service_km"]),
|
||||||
"next_service_km": int(row["next_service_km"]),
|
"active": _parse_bool(row["active"]),
|
||||||
"active": _parse_bool(row["active"]),
|
"version": 1,
|
||||||
"version": 1,
|
}
|
||||||
}
|
vehicle_rows.append(vehicle_row)
|
||||||
)
|
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
|
||||||
db.execute(insert(Vehicle), vehicle_rows)
|
db.execute(insert(Vehicle), vehicle_rows)
|
||||||
counts["vehicles"] = len(vehicle_rows)
|
counts["vehicles"] = len(vehicle_rows)
|
||||||
|
|
||||||
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
booking_rows = []
|
booking_rows = []
|
||||||
|
booking_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("bookings.csv"):
|
for row in _read_csv("bookings.csv"):
|
||||||
bid = uuid.uuid4()
|
bid = uuid.uuid4()
|
||||||
booking_id_by_ref[row["public_ref"]] = bid
|
booking_id_by_ref[row["public_ref"]] = bid
|
||||||
booking_rows.append(
|
booking_row = {
|
||||||
{
|
"id": bid,
|
||||||
"id": bid,
|
"public_ref": row["public_ref"],
|
||||||
"public_ref": row["public_ref"],
|
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||||
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||||
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
"status": row["status"],
|
||||||
"status": row["status"],
|
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
||||||
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
}
|
||||||
}
|
booking_rows.append(booking_row)
|
||||||
)
|
booking_row_by_ref[row["public_ref"]] = booking_row
|
||||||
db.execute(insert(Booking), booking_rows)
|
db.execute(insert(Booking), booking_rows)
|
||||||
counts["bookings"] = len(booking_rows)
|
counts["bookings"] = len(booking_rows)
|
||||||
|
|
||||||
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
return "customer", customer_id_by_ref[entity_ref]
|
return "customer", customer_id_by_ref[entity_ref]
|
||||||
return "vehicle", vehicle_id_by_ref[entity_ref]
|
return "vehicle", vehicle_id_by_ref[entity_ref]
|
||||||
|
|
||||||
|
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
|
||||||
|
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
|
||||||
|
# handful of seed-only rows below -- none of them carry an active rental or a
|
||||||
|
# real booking conflict (verified against the fixed seed dataset), only a
|
||||||
|
# genuinely-crossed service threshold or none at all, so those two fields are
|
||||||
|
# the only ones that vary per vehicle.
|
||||||
|
vehicle = vehicle_row_by_ref[vehicle_ref]
|
||||||
|
return {
|
||||||
|
"active_booking_refs": [],
|
||||||
|
"overlapping_booking_pairs": [],
|
||||||
|
"service_threshold_reached": service_threshold_reached,
|
||||||
|
"odometer_km": vehicle["odometer_km"],
|
||||||
|
"next_service_km": vehicle["next_service_km"],
|
||||||
|
"open_booking_overlap_issue_ref": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
|
||||||
|
later = booking_row_by_ref[later_ref]
|
||||||
|
earlier = booking_row_by_ref[earlier_ref]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"params": {
|
||||||
|
"later_ref": later_ref,
|
||||||
|
"later_km": later["end_odometer_km"],
|
||||||
|
"earlier_ref": earlier_ref,
|
||||||
|
"earlier_km": earlier["end_odometer_km"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def _missing_field_signal(field: str) -> list[dict]:
|
||||||
|
return [{"code": "missing_field", "params": {"field": field}}]
|
||||||
|
|
||||||
|
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
|
||||||
|
# used to carry no structured signal at all -- just the placeholder summary
|
||||||
|
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
|
||||||
|
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
|
||||||
|
# pair of booking odometer readings engineered into seed/bookings.csv), using the
|
||||||
|
# exact same signal vocabulary the live scan (app.services.data_quality) already
|
||||||
|
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||||
|
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
|
||||||
|
"DQ-0005": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.service_threshold_reached",
|
||||||
|
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0006": _missing_field_signal("location"),
|
||||||
|
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
|
||||||
|
"DQ-0008": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.rental_ended",
|
||||||
|
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0009": _missing_field_signal("location"),
|
||||||
|
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
|
||||||
|
"DQ-0011": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.service_threshold_reached",
|
||||||
|
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0012": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0013": _missing_field_signal("location"),
|
||||||
|
"DQ-0014": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0015": _missing_field_signal("location"),
|
||||||
|
"DQ-0016": _missing_field_signal("location"),
|
||||||
|
"DQ-0017": _missing_field_signal("location"),
|
||||||
|
"DQ-0018": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0019": _missing_field_signal("location"),
|
||||||
|
"DQ-0020": _missing_field_signal("location"),
|
||||||
|
"DQ-0021": _missing_field_signal("location"),
|
||||||
|
}
|
||||||
|
|
||||||
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
||||||
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
||||||
# they carry real, accurate structured signals (not just a legacy English
|
# they carry real, accurate structured signals (not just a legacy English
|
||||||
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return []
|
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
|
||||||
|
|
||||||
dq_rows = []
|
dq_rows = []
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -323,7 +401,10 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"last_error": row["last_error"] or None,
|
"last_error": row["last_error"] or None,
|
||||||
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
||||||
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
||||||
"last_error_code": "connectionError" if row["last_error"] else None,
|
# It is coded as a *prepared demo scenario*, not as a real
|
||||||
|
# connectionError, so integration health never degrades because of a
|
||||||
|
# prop and a viewer is told plainly that this failure is staged.
|
||||||
|
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
|
||||||
"external_run_id": None,
|
"external_run_id": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.models.booking import Booking
|
|||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||||
from app.services.integration_status import derive_n8n_status
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
from app.services.knowledge import get_knowledge_provider
|
from app.services.knowledge import get_knowledge_provider
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -124,6 +124,7 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
|||||||
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||||
n8n = derive_n8n_status(db)
|
n8n = derive_n8n_status(db)
|
||||||
knowledge_health = get_knowledge_provider().health()
|
knowledge_health = get_knowledge_provider().health()
|
||||||
|
mcp_hub = derive_mcp_hub_status(db)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DemoIntegrationSummaryOut(
|
DemoIntegrationSummaryOut(
|
||||||
@@ -141,17 +142,23 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
|||||||
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
||||||
detail_code="ragcoreDetail",
|
detail_code="ragcoreDetail",
|
||||||
detail_params={
|
detail_params={
|
||||||
"count": knowledge_health.document_count,
|
"count": (
|
||||||
|
knowledge_health.document_count
|
||||||
|
if knowledge_health.document_count is not None
|
||||||
|
else "unknown"
|
||||||
|
),
|
||||||
"collection": knowledge_health.collection,
|
"collection": knowledge_health.collection,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DemoIntegrationSummaryOut(
|
DemoIntegrationSummaryOut(
|
||||||
key="mcp_hub",
|
key="mcp_hub",
|
||||||
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected",
|
# `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
|
||||||
|
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
|
||||||
|
# to be called. Only real recorded `mcp_tool_request` calls make this
|
||||||
|
# "operational" -- same evidence rule the integration status page uses.
|
||||||
|
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||||
detail_code=(
|
detail_code=(
|
||||||
"mcpDetailEnabled"
|
"mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected"
|
||||||
if settings.mcp_hub_registration_enabled
|
|
||||||
else "mcpDetailNotConnected"
|
|
||||||
),
|
),
|
||||||
detail_params={},
|
detail_params={},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -121,13 +121,30 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
settings.n8n_webhook_url,
|
settings.n8n_webhook_url,
|
||||||
json=wire_event,
|
json=wire_event,
|
||||||
|
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
|
||||||
timeout=settings.n8n_http_timeout_seconds,
|
timeout=settings.n8n_http_timeout_seconds,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
body = response.json()
|
try:
|
||||||
success = bool(body.get("ok", True))
|
body = response.json()
|
||||||
error = None if success else f"n8n reported failure: {body}"
|
except ValueError:
|
||||||
error_code = None if success else "remoteReportedFailure"
|
body = None
|
||||||
|
if isinstance(body, dict):
|
||||||
|
success = bool(body.get("ok", True))
|
||||||
|
error = None if success else f"n8n reported failure: {body}"
|
||||||
|
error_code = None if success else "remoteReportedFailure"
|
||||||
|
else:
|
||||||
|
# A 2xx status with a non-object (or unparsable) body means the workflow
|
||||||
|
# itself errored before its "Respond to Webhook" node ran -- n8n's default
|
||||||
|
# error response still carries a 2xx-looking status here. Treat it as a
|
||||||
|
# failure so the event is retried rather than lost or wrongly marked
|
||||||
|
# succeeded.
|
||||||
|
success = False
|
||||||
|
error = (
|
||||||
|
"Unexpected non-JSON-object response from n8n "
|
||||||
|
f"(status {response.status_code})"
|
||||||
|
)
|
||||||
|
error_code = "malformedResponse"
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
success = False
|
success = False
|
||||||
error = f"{type(exc).__name__}: {exc}"
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|||||||
@@ -2,15 +2,31 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.audit import AuditEvent
|
||||||
from app.schemas import N8nIntegrationStatus
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
|
from app.schemas import (
|
||||||
|
McpHubIntegrationStatus,
|
||||||
|
N8nErrorHandlerStatus,
|
||||||
|
N8nIntegrationStatus,
|
||||||
|
N8nWorkflowEvidence,
|
||||||
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
|
||||||
|
# built (all with their full node set saved).
|
||||||
|
_CANONICAL_WORKFLOWS = (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||||
counts: dict[str, int] = dict(
|
counts: dict[str, int] = dict(
|
||||||
@@ -23,25 +39,111 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
|||||||
failed = counts.get("failed", 0)
|
failed = counts.get("failed", 0)
|
||||||
succeeded = counts.get("succeeded", 0)
|
succeeded = counts.get("succeeded", 0)
|
||||||
|
|
||||||
|
# Prepared demo failures are props, not health signals. They stay visible and
|
||||||
|
# counted -- hiding them would be its own kind of lie -- but they are counted
|
||||||
|
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
|
||||||
|
# "operational". Without this split the demo seed's single staged failure pins the
|
||||||
|
# integration to "degraded" forever, which tells a viewer something untrue about
|
||||||
|
# the automation.
|
||||||
|
demo_scenario_failed = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OutboxEvent)
|
||||||
|
.where(
|
||||||
|
OutboxEvent.delivery_status == "failed",
|
||||||
|
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
unexpected_failed = max(failed - demo_scenario_failed, 0)
|
||||||
|
|
||||||
latest_success_at = db.scalar(
|
latest_success_at = db.scalar(
|
||||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||||
)
|
)
|
||||||
|
# Health talks about real failures only, so the "latest failure" a health reader
|
||||||
|
# sees must exclude the staged one too.
|
||||||
latest_failure_at = db.scalar(
|
latest_failure_at = db.scalar(
|
||||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
select(func.max(OutboxEvent.updated_at)).where(
|
||||||
|
OutboxEvent.delivery_status == "failed",
|
||||||
|
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
latest_demo_scenario_at = db.scalar(
|
||||||
|
select(func.max(OutboxEvent.updated_at)).where(
|
||||||
|
OutboxEvent.delivery_status == "failed",
|
||||||
|
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||||
if not settings.n8n_dispatch_enabled:
|
if not settings.n8n_dispatch_enabled:
|
||||||
state = "disabled"
|
state = "disabled"
|
||||||
elif failed > 0 and succeeded == 0:
|
elif unexpected_failed > 0 and succeeded == 0:
|
||||||
state = "unavailable"
|
state = "unavailable"
|
||||||
elif failed > 0:
|
elif unexpected_failed > 0:
|
||||||
state = "degraded"
|
state = "degraded"
|
||||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||||
state = "operational"
|
state = "operational"
|
||||||
else:
|
else:
|
||||||
state = "no_evidence"
|
state = "no_evidence"
|
||||||
|
|
||||||
|
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
|
||||||
|
# runs an operator triggered manually from the Data Quality page.
|
||||||
|
latest_scan_at = db.scalar(
|
||||||
|
select(func.max(AuditEvent.occurred_at)).where(
|
||||||
|
AuditEvent.action == "data_quality_scan_run",
|
||||||
|
AuditEvent.actor_type == "service",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
|
||||||
|
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
|
||||||
|
# procedures_sync_result), the same "the workflow's own callback is the evidence"
|
||||||
|
# pattern the scheduled scan and error handler already use below.
|
||||||
|
latest_procedure_sync_at = db.scalar(
|
||||||
|
select(func.max(AuditEvent.occurred_at)).where(
|
||||||
|
AuditEvent.action == "n8n_procedures_synced"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||||
|
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
|
||||||
|
# up and firing correctly.
|
||||||
|
total_failures_registered = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(AuditEvent.id)).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
latest_failure_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.after_json)
|
||||||
|
.where(AuditEvent.action == "n8n_workflow_failure_registered")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
|
||||||
|
latest_handler_failure_workflow = (
|
||||||
|
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_by_workflow = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
|
||||||
|
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||||
|
}
|
||||||
|
workflows = [
|
||||||
|
N8nWorkflowEvidence(
|
||||||
|
name=name,
|
||||||
|
built=True,
|
||||||
|
last_seen_at=evidence_by_workflow[name],
|
||||||
|
)
|
||||||
|
for name in _CANONICAL_WORKFLOWS
|
||||||
|
]
|
||||||
|
|
||||||
return N8nIntegrationStatus(
|
return N8nIntegrationStatus(
|
||||||
configured=bool(settings.n8n_webhook_url),
|
configured=bool(settings.n8n_webhook_url),
|
||||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||||
@@ -49,7 +151,72 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
|||||||
pending=pending,
|
pending=pending,
|
||||||
delivering=delivering,
|
delivering=delivering,
|
||||||
failed=failed,
|
failed=failed,
|
||||||
|
unexpected_failed=unexpected_failed,
|
||||||
|
demo_scenario_failed=demo_scenario_failed,
|
||||||
succeeded=succeeded,
|
succeeded=succeeded,
|
||||||
latest_success_at=latest_success_at,
|
latest_success_at=latest_success_at,
|
||||||
latest_failure_at=latest_failure_at,
|
latest_failure_at=latest_failure_at,
|
||||||
|
latest_demo_scenario_at=latest_demo_scenario_at,
|
||||||
|
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
|
||||||
|
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
|
||||||
|
workflows=workflows,
|
||||||
|
error_handler=N8nErrorHandlerStatus(
|
||||||
|
total_failures_registered=total_failures_registered,
|
||||||
|
latest_failure_at=latest_handler_failure_at,
|
||||||
|
latest_failure_workflow=latest_handler_failure_workflow,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||||
|
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
|
||||||
|
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
|
||||||
|
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
|
||||||
|
total_calls = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
latest_call_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
|
||||||
|
.where(AuditEvent.action == "mcp_tool_request")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
last_called_at = latest_call_row[0] if latest_call_row else None
|
||||||
|
last_client = latest_call_row[1] if latest_call_row else None
|
||||||
|
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
|
||||||
|
|
||||||
|
state: Literal["not_configured", "no_evidence", "operational"]
|
||||||
|
if not settings.mcp_hub_registration_enabled:
|
||||||
|
state = "not_configured"
|
||||||
|
elif total_calls > 0:
|
||||||
|
state = "operational"
|
||||||
|
else:
|
||||||
|
state = "no_evidence"
|
||||||
|
|
||||||
|
hub_reachable = _check_hub_reachable()
|
||||||
|
|
||||||
|
return McpHubIntegrationStatus(
|
||||||
|
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||||
|
state=state,
|
||||||
|
total_calls=total_calls,
|
||||||
|
last_tool=last_tool,
|
||||||
|
last_client=last_client,
|
||||||
|
last_called_at=last_called_at,
|
||||||
|
hub_reachable=hub_reachable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_hub_reachable() -> bool | None:
|
||||||
|
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
|
||||||
|
its side, so this is the only thing Fleet Ops itself can honestly check).
|
||||||
|
`None` means not configured / not checked, never a guess."""
|
||||||
|
if not settings.mcp_hub_base_url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ class KnowledgeHealth(BaseModel):
|
|||||||
tenant: str
|
tenant: str
|
||||||
workspace: str
|
workspace: str
|
||||||
collection: str
|
collection: str
|
||||||
document_count: int
|
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
|
||||||
|
# unknown, never "zero procedures".
|
||||||
|
document_count: int | None
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeProvider(Protocol):
|
class KnowledgeProvider(Protocol):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
from app.services.knowledge.procedures import parse_frontmatter
|
||||||
|
|
||||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
DEFAULT_LANGUAGE = "en-GB"
|
DEFAULT_LANGUAGE = "en-GB"
|
||||||
@@ -74,23 +75,6 @@ class ScoredSection:
|
|||||||
body_tokens: set[str]
|
body_tokens: set[str]
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
|
||||||
if not raw.startswith("---"):
|
|
||||||
return {}, raw
|
|
||||||
end = raw.find("\n---", 3)
|
|
||||||
if end == -1:
|
|
||||||
return {}, raw
|
|
||||||
block = raw[3:end].strip()
|
|
||||||
body = raw[end + 4 :].lstrip("\n")
|
|
||||||
meta: dict[str, str] = {}
|
|
||||||
for line in block.splitlines():
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
meta[key.strip()] = value.strip().strip('"')
|
|
||||||
return meta, body
|
|
||||||
|
|
||||||
|
|
||||||
def _split_sections(body: str) -> list[tuple[str, str]]:
|
def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||||
sections: list[tuple[str, str]] = []
|
sections: list[tuple[str, str]] = []
|
||||||
current_heading = "Overview"
|
current_heading = "Overview"
|
||||||
@@ -114,7 +98,7 @@ def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
|||||||
sections: list[ScoredSection] = []
|
sections: list[ScoredSection] = []
|
||||||
for path in sorted(procedures_dir.glob("*.md")):
|
for path in sorted(procedures_dir.glob("*.md")):
|
||||||
raw = path.read_text(encoding="utf-8")
|
raw = path.read_text(encoding="utf-8")
|
||||||
meta, body = _parse_frontmatter(raw)
|
meta, body = parse_frontmatter(raw)
|
||||||
title = meta.get("title", path.stem)
|
title = meta.get("title", path.stem)
|
||||||
doc = Document(
|
doc = Document(
|
||||||
document_id=meta.get("document_id", path.stem),
|
document_id=meta.get("document_id", path.stem),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
|
|
||||||
|
# Stable across runs (and across which language ships first) so a document's RAGcore
|
||||||
|
# source_id never changes just because the sync ran on a different day or in a
|
||||||
|
# different order -- required for RAGcore's upload idempotency to work per document.
|
||||||
|
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||||
|
if not raw.startswith("---"):
|
||||||
|
return {}, raw
|
||||||
|
end = raw.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}, raw
|
||||||
|
block = raw[3:end].strip()
|
||||||
|
body = raw[end + 4 :].lstrip("\n")
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for line in block.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
meta[key.strip()] = value.strip().strip('"')
|
||||||
|
return meta, body
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcedureDocument:
|
||||||
|
source_id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
|
||||||
|
"""Read every procedure Markdown file Fleet Ops ships, across every supported
|
||||||
|
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
|
||||||
|
fields (title, version) come from the same files the demo knowledge provider
|
||||||
|
already reads -- see parse_frontmatter -- so the two never drift apart."""
|
||||||
|
|
||||||
|
documents: list[ProcedureDocument] = []
|
||||||
|
for language in SUPPORTED_LANGUAGES:
|
||||||
|
language_dir = knowledge_dir / language
|
||||||
|
if not language_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for path in sorted(language_dir.glob("*.md")):
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
meta, body = parse_frontmatter(raw)
|
||||||
|
document_id = meta.get("document_id", path.stem)
|
||||||
|
content = body.strip()
|
||||||
|
documents.append(
|
||||||
|
ProcedureDocument(
|
||||||
|
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
|
||||||
|
language=language,
|
||||||
|
document_id=document_id,
|
||||||
|
title=meta.get("title", path.stem),
|
||||||
|
version=meta.get("version", "1.0"),
|
||||||
|
content=content,
|
||||||
|
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return documents
|
||||||
@@ -3,18 +3,51 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
|
||||||
|
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||||
|
|
||||||
|
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
|
||||||
|
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
|
||||||
|
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
|
||||||
|
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
|
||||||
|
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
|
||||||
|
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
|
||||||
|
# is deliberately left out of this sentence (it still appears on the source card itself).
|
||||||
|
_LEAD_ANSWER_TEMPLATE = {
|
||||||
|
"en-GB": 'Per "{title}": {excerpt}',
|
||||||
|
"nl-BE": 'Volgens "{title}": {excerpt}',
|
||||||
|
"fr-BE": 'Selon « {title} » : {excerpt}',
|
||||||
|
}
|
||||||
|
_DEFAULT_LANGUAGE = "en-GB"
|
||||||
|
|
||||||
|
|
||||||
class RAGcoreKnowledgeProvider:
|
class RAGcoreKnowledgeProvider:
|
||||||
"""Adapter for the central RAGcore service.
|
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
|
||||||
|
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
|
||||||
|
owned separately, MobilityOps only ever talks to its documented HTTP API).
|
||||||
|
|
||||||
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md).
|
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
|
||||||
No live RAGcore instance was reachable during this build, so the exact request/response
|
session-cookie auth is for its own browser admin UI only). Any connection error,
|
||||||
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
|
timeout, non-2xx response, or malformed body degrades to `evidence_state:
|
||||||
malformed response) degrades to `unavailable` rather than raising, per the architecture's
|
"unavailable"` rather than raising -- this is the adapter that actually exercises the
|
||||||
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
|
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
|
||||||
the app, and never fabricates an answer.
|
never fabricates an answer, never affects the rest of the app.
|
||||||
|
|
||||||
|
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
|
||||||
|
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
|
||||||
|
classifying the question as insufficiently answerable), `ask()` falls back to
|
||||||
|
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
|
||||||
|
stage with no generation step to fail. The fallback answer is always an extractive
|
||||||
|
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
|
||||||
|
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
|
||||||
|
|
||||||
|
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
|
||||||
|
chunk's `language` payload field as `"en"` regardless of actual document language (the
|
||||||
|
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
|
||||||
|
Filtering search/answer requests by requested UI language would therefore silently
|
||||||
|
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
|
||||||
|
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "ragcore"
|
name = "ragcore"
|
||||||
@@ -35,11 +68,15 @@ class RAGcoreKnowledgeProvider:
|
|||||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.get("/health")
|
response = client.get("/health/ready")
|
||||||
response.raise_for_status()
|
body = response.json()
|
||||||
available = True
|
available = response.status_code == 200 and body.get("status") == "ok"
|
||||||
detail = "RAGcore reachable."
|
detail = (
|
||||||
except httpx.HTTPError as exc:
|
"RAGcore reachable and ready."
|
||||||
|
if available
|
||||||
|
else f"RAGcore degraded: {body.get('status', 'unknown')}"
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
available = False
|
available = False
|
||||||
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||||
return KnowledgeHealth(
|
return KnowledgeHealth(
|
||||||
@@ -49,51 +86,135 @@ class RAGcoreKnowledgeProvider:
|
|||||||
tenant=self._settings.ragcore_tenant,
|
tenant=self._settings.ragcore_tenant,
|
||||||
workspace=self._settings.ragcore_workspace,
|
workspace=self._settings.ragcore_workspace,
|
||||||
collection=self._settings.ragcore_collection,
|
collection=self._settings.ragcore_collection,
|
||||||
document_count=0,
|
# RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit
|
||||||
|
# so the UI never turns this into the misleading claim "0 procedures".
|
||||||
|
document_count=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||||
|
unavailable = GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="unavailable",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
if not self._settings.ragcore_space_id:
|
||||||
|
return unavailable
|
||||||
|
|
||||||
|
answered = self._ask_via_answers(question, correlation_id)
|
||||||
|
if answered is not None:
|
||||||
|
return answered
|
||||||
|
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
|
||||||
|
# real retrieval rather than degrading straight to "unavailable". This never
|
||||||
|
# fabricates an answer to the question: it only ever shows an actually-cited
|
||||||
|
# excerpt RAGcore's own search already found, using the same extractive
|
||||||
|
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
|
||||||
|
# generation step.
|
||||||
|
return self._ask_via_search_fallback(question, correlation_id, language)
|
||||||
|
|
||||||
|
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
|
||||||
|
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
|
||||||
|
so the caller can fall back to search -- as opposed to a real 200 response
|
||||||
|
classifying the question as insufficiently answerable, which is a genuine,
|
||||||
|
final result, not a reason to fall back."""
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/ask",
|
"/v1/answers",
|
||||||
json={
|
json={
|
||||||
"tenant": self._settings.ragcore_tenant,
|
"query": question,
|
||||||
"workspace": self._settings.ragcore_workspace,
|
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||||
"collection": self._settings.ragcore_collection,
|
|
||||||
"question": question,
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
"language": language,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except (httpx.HTTPError, ValueError):
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
citations = {c["id"]: c for c in body.get("citations", [])}
|
||||||
|
sources = [
|
||||||
|
SourceCard(
|
||||||
|
document_id=str(citation["document_id"]),
|
||||||
|
title=citation["title"],
|
||||||
|
version=str(citation["document_version_id"]),
|
||||||
|
section=citation.get("section") or "",
|
||||||
|
excerpt=citation["excerpt"],
|
||||||
|
)
|
||||||
|
for citation in citations.values()
|
||||||
|
]
|
||||||
|
answerability = body.get("answerability", "not_answerable")
|
||||||
|
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
||||||
|
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||||
|
evidence_state=evidence_state,
|
||||||
|
sources=sources if evidence_state == "grounded" else [],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ask_via_search_fallback(
|
||||||
|
self, question: str, correlation_id: str, language: str
|
||||||
|
) -> GroundedAnswer:
|
||||||
|
unavailable = GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="unavailable",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.post(
|
||||||
|
"/v1/search",
|
||||||
|
json={
|
||||||
|
"query": question,
|
||||||
|
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||||
|
"max_results": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return unavailable
|
||||||
|
body = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return unavailable
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = body.get("results", [])
|
||||||
|
sources = [
|
||||||
|
SourceCard(
|
||||||
|
document_id=str(result["citation"]["document_id"]),
|
||||||
|
title=result["citation"]["title"],
|
||||||
|
version=str(result["citation"]["document_version_id"]),
|
||||||
|
section=result["citation"].get("section") or "",
|
||||||
|
excerpt=result["citation"]["excerpt"],
|
||||||
|
)
|
||||||
|
for result in results
|
||||||
|
]
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
return unavailable
|
||||||
|
|
||||||
|
if not sources:
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer="",
|
answer="",
|
||||||
evidence_state="unavailable",
|
evidence_state="insufficient",
|
||||||
sources=[],
|
sources=[],
|
||||||
provider=self.name,
|
provider=self.name,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
|
||||||
sources = [SourceCard(**s) for s in body.get("sources", [])]
|
lead = sources[0]
|
||||||
evidence_state = body.get("evidence_state", "insufficient")
|
answer = template.format(title=lead.title, excerpt=lead.excerpt)
|
||||||
if evidence_state not in ("grounded", "insufficient", "unavailable"):
|
return GroundedAnswer(
|
||||||
evidence_state = "insufficient"
|
answer=answer,
|
||||||
return GroundedAnswer(
|
evidence_state="grounded",
|
||||||
answer=body.get("answer", ""),
|
sources=sources,
|
||||||
evidence_state=evidence_state,
|
provider=self.name,
|
||||||
sources=sources,
|
correlation_id=correlation_id,
|
||||||
provider=self.name,
|
)
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return GroundedAnswer(
|
|
||||||
answer="",
|
|
||||||
evidence_state="unavailable",
|
|
||||||
sources=[],
|
|
||||||
provider=self.name,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ def test_audit_requires_operations_manager(employee_client):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert body["page"] == 1
|
||||||
|
assert body["page_size"] == 25
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
assert body["total_pages"] >= 1
|
||||||
|
|
||||||
|
|
||||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
|
|||||||
assert item["severity"] in ("low", "medium", "high")
|
assert item["severity"] in ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
|
||||||
|
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
|
||||||
|
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
|
||||||
|
The API must never emit prose here -- only stable signal codes + params, exactly
|
||||||
|
like the data-quality issue detail page, for the frontend to localize."""
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["attention_items"]) > 0
|
||||||
|
for item in body["attention_items"]:
|
||||||
|
assert "detail" not in item
|
||||||
|
assert len(item["evidence_signals"]) > 0
|
||||||
|
for signal in item["evidence_signals"]:
|
||||||
|
assert signal["code"]
|
||||||
|
assert signal["code"] != "Synthetic deterministic seed issue"
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
||||||
response = ops_client.get("/api/v1/dashboard")
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ def test_list_issues_requires_operations_manager(employee_client):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"severity": "high", "page": 1, "page_size": 25},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert all(issue["severity"] == "high" for issue in body["items"])
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_get_issue_requires_operations_manager(employee_client):
|
def test_get_issue_requires_operations_manager(employee_client):
|
||||||
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -220,6 +232,21 @@ def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
|
|||||||
assert vehicle["registration_number"] == "TST-999"
|
assert vehicle["registration_number"] == "TST-999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
|
||||||
|
"""The snapshot used to omit registration_number entirely, so the 'provide missing
|
||||||
|
fields' form always showed it blank -- even for a vehicle whose plate was actually
|
||||||
|
on file, and even when a *different* field was the genuinely missing one."""
|
||||||
|
issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"rule_type": "missing_required_field", "status": "open"},
|
||||||
|
).json()
|
||||||
|
target = next(i for i in issues if i["entity_type"] == "vehicle")
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
|
||||||
|
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||||
|
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_overlap_requires_operations_manager(employee_client):
|
def test_resolve_overlap_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def test_deliver_one_success(monkeypatch):
|
|||||||
event_id = _make_pending_event("MO-002")
|
event_id = _make_pending_event("MO-002")
|
||||||
dispatcher._claim_due_events()
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
@@ -88,7 +88,7 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
|
|||||||
event_id = _make_pending_event("MO-003")
|
event_id = _make_pending_event("MO-003")
|
||||||
dispatcher._claim_due_events()
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -102,11 +102,38 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
|
|||||||
assert event.last_error_code == "connectionError"
|
assert event.last_error_code == "connectionError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
|
||||||
|
# Reproduces a real failure mode found while live-validating the n8n webhook auth
|
||||||
|
# fix: a workflow that errors internally before its "Respond to Webhook" node runs
|
||||||
|
# can still answer with a 2xx status and an empty body. response.json() on that body
|
||||||
|
# raises json.JSONDecodeError -- this must be treated as a retryable failure, not an
|
||||||
|
# unhandled exception that leaves the event stuck in "delivering" forever.
|
||||||
|
event_id = _make_pending_event("MO-005")
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
|
def fake_post(url, json, headers, timeout):
|
||||||
|
def raise_json_error():
|
||||||
|
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None, json=raise_json_error, status_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
|
dispatcher._deliver_one(event_id)
|
||||||
|
|
||||||
|
event = _get_event(event_id)
|
||||||
|
assert event.delivery_status == "pending"
|
||||||
|
assert event.attempts == 1
|
||||||
|
assert event.next_attempt_at is not None
|
||||||
|
assert event.last_error_code == "malformedResponse"
|
||||||
|
|
||||||
|
|
||||||
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-004")
|
event_id = _make_pending_event("MO-004")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise dispatcher.httpx.ConnectError("still down")
|
raise dispatcher.httpx.ConnectError("still down")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -149,7 +176,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise AssertionError("must not attempt delivery with a malformed payload")
|
raise AssertionError("must not attempt delivery with a malformed payload")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -229,7 +256,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
@@ -245,7 +272,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
|||||||
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-005")
|
event_id = _make_pending_event("MO-005")
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
|
def _reseed() -> None:
|
||||||
|
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
|
||||||
|
|
||||||
|
The suite shares one session-scoped database and earlier files legitimately mutate
|
||||||
|
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
|
||||||
|
rather than depend on file ordering.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_integration_status_requires_operations_manager(employee_client):
|
def test_integration_status_requires_operations_manager(employee_client):
|
||||||
response = employee_client.get("/api/v1/integrations/status")
|
response = employee_client.get("/api/v1/integrations/status")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -9,6 +30,7 @@ def test_integration_status_requires_authentication(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
||||||
|
_reseed()
|
||||||
response = ops_client.get("/api/v1/integrations/status")
|
response = ops_client.get("/api/v1/integrations/status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -16,20 +38,65 @@ def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
|||||||
n8n = body["n8n"]
|
n8n = body["n8n"]
|
||||||
assert n8n["dispatch_enabled"] is True
|
assert n8n["dispatch_enabled"] is True
|
||||||
assert n8n["succeeded"] >= 1
|
assert n8n["succeeded"] >= 1
|
||||||
|
# The seeded failure stays visible and counted...
|
||||||
assert n8n["failed"] >= 1
|
assert n8n["failed"] >= 1
|
||||||
# The seed deliberately carries both failed and succeeded events, so a single most-
|
assert n8n["demo_scenario_failed"] == 1
|
||||||
# recent-event read would misreport health -- the aggregate must call this "degraded",
|
# ...but it is a prepared prop, so it is not an unexpected failure and must not
|
||||||
# not "operational" or "unavailable".
|
# move the integration off "operational". A staged failure that degrades the
|
||||||
assert n8n["state"] == "degraded"
|
# health badge tells a viewer something untrue about the automation.
|
||||||
|
assert n8n["unexpected_failed"] == 0
|
||||||
|
assert n8n["state"] == "operational"
|
||||||
assert n8n["latest_success_at"] is not None
|
assert n8n["latest_success_at"] is not None
|
||||||
assert n8n["latest_failure_at"] is not None
|
# "latest failure" is a health signal, so the staged one never sets it; it is
|
||||||
|
# reported separately instead.
|
||||||
|
assert n8n["latest_failure_at"] is None
|
||||||
|
assert n8n["latest_demo_scenario_at"] is not None
|
||||||
|
|
||||||
mcp_hub = body["mcp_hub"]
|
mcp_hub = body["mcp_hub"]
|
||||||
assert mcp_hub["registration_enabled"] is False
|
assert mcp_hub["registration_enabled"] is False
|
||||||
assert mcp_hub["state"] == "not_configured"
|
assert mcp_hub["state"] == "not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_failure_still_degrades_the_integration(ops_client):
|
||||||
|
"""The demo carve-out must be narrow: a failure that is not the prepared scenario
|
||||||
|
still degrades n8n, otherwise this change would hide real breakage."""
|
||||||
|
_reseed()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
real_failure = db.scalar(
|
||||||
|
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
|
||||||
|
)
|
||||||
|
assert real_failure is not None
|
||||||
|
restore = (real_failure.delivery_status, real_failure.last_error_code)
|
||||||
|
real_failure.delivery_status = "failed"
|
||||||
|
real_failure.last_error_code = "connectionError"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert n8n["unexpected_failed"] == 1
|
||||||
|
assert n8n["state"] == "degraded"
|
||||||
|
assert n8n["latest_failure_at"] is not None
|
||||||
|
finally:
|
||||||
|
real_failure.delivery_status, real_failure.last_error_code = restore
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_demo_failure_is_reset_back_by_a_demo_reset(ops_client):
|
||||||
|
"""A demo reset must recreate the intended 19 succeeded + 1 prepared failure, so the
|
||||||
|
scenario can be shown again after it has been retried away."""
|
||||||
|
_reseed()
|
||||||
|
|
||||||
|
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert n8n["succeeded"] == 19
|
||||||
|
assert n8n["failed"] == 1
|
||||||
|
assert n8n["demo_scenario_failed"] == 1
|
||||||
|
assert n8n["unexpected_failed"] == 0
|
||||||
|
assert n8n["state"] == "operational"
|
||||||
|
|
||||||
|
|
||||||
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
|
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
|
||||||
|
_reseed()
|
||||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
for run in failed:
|
for run in failed:
|
||||||
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
|
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
|
||||||
@@ -39,3 +106,85 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
|
|||||||
body = response.json()["n8n"]
|
body = response.json()["n8n"]
|
||||||
assert body["failed"] == 0
|
assert body["failed"] == 0
|
||||||
assert body["state"] == "operational"
|
assert body["state"] == "operational"
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
|
||||||
|
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert body["expected_workflow_count"] == 4
|
||||||
|
names = {w["name"] for w in body["workflows"]}
|
||||||
|
assert names == {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
}
|
||||||
|
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
|
||||||
|
# All 4 canonical workflows are built (all 6 nodes saved live). This fresh test run
|
||||||
|
# has reported no real sync result yet, so -- like the other three workflows before
|
||||||
|
# their own first real signal -- there is no run evidence yet either.
|
||||||
|
assert ragcore_sync["built"] is True
|
||||||
|
assert ragcore_sync["last_seen_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
scan_workflow = next(
|
||||||
|
w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||||
|
)
|
||||||
|
assert scan_workflow["last_seen_at"] is None
|
||||||
|
|
||||||
|
scan = client.post(
|
||||||
|
"/api/v1/integrations/n8n/scheduled-scan",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert scan.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
scan_workflow = next(
|
||||||
|
w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||||
|
)
|
||||||
|
assert scan_workflow["last_seen_at"] is not None
|
||||||
|
assert after["known_workflow_count"] > before["known_workflow_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_reflects_error_handler_registrations(client, ops_client):
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
report = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json={
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Simulated failure for status test",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"attempt": 1,
|
||||||
|
},
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert report.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert (
|
||||||
|
after["error_handler"]["total_failures_registered"]
|
||||||
|
== before["error_handler"]["total_failures_registered"] + 1
|
||||||
|
)
|
||||||
|
assert after["error_handler"]["latest_failure_workflow"] == (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration"
|
||||||
|
)
|
||||||
|
handler_workflow = next(
|
||||||
|
w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler")
|
||||||
|
)
|
||||||
|
assert handler_workflow["last_seen_at"] is not None
|
||||||
|
|||||||
@@ -122,3 +122,145 @@ def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
|||||||
assert first.status_code == 200
|
assert first.status_code == 200
|
||||||
assert second.status_code == 200
|
assert second.status_code == 200
|
||||||
assert second.json()["created"] == {}
|
assert second.json()["created"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_error_body(execution_id: str, **overrides):
|
||||||
|
body = {
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Callback request failed with status 500",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"correlation_id": None,
|
||||||
|
"attempt": 1,
|
||||||
|
"retry_action": "n8n will retry automatically",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4())),
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_unknown_category(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = _workflow_error_body(execution_id)
|
||||||
|
|
||||||
|
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"]["error_category"] == "httpError"
|
||||||
|
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_bounds_summary_length(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_rejects_wrong_service_token(client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_lists_every_language_with_stable_ids(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
documents = response.json()["documents"]
|
||||||
|
assert len(documents) > 0
|
||||||
|
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
|
||||||
|
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
|
||||||
|
assert len(checkout_docs) == 3 # one per language
|
||||||
|
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
|
||||||
|
# Same document_id, different language, must not collide on id.
|
||||||
|
assert len({d["id"] for d in checkout_docs}) == 3
|
||||||
|
|
||||||
|
second_response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
second_ids = {d["id"] for d in second_response.json()["documents"]}
|
||||||
|
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_sync_result_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||||
|
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"] == {"synced": 33, "failed": 1}
|
||||||
|
|
||||||
|
# The workflow's own callback is its evidence -- same pattern the scheduled scan and
|
||||||
|
# error handler already use -- so this real report must now show up as run evidence
|
||||||
|
# in the integration status, not stay hardcoded to "no evidence yet".
|
||||||
|
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
|
||||||
|
assert ragcore_sync["last_seen_at"] is not None
|
||||||
|
|||||||
@@ -158,12 +158,295 @@ def test_knowledge_status_endpoint(ops_client):
|
|||||||
assert response.json()["provider"] == "demo"
|
assert response.json()["provider"] == "demo"
|
||||||
|
|
||||||
|
|
||||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
class _FakeResponse:
|
||||||
def fake_client(*args, **kwargs):
|
def __init__(self, status_code: int, body: dict):
|
||||||
raise httpx.ConnectError("no ragcore in this environment")
|
self.status_code = status_code
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None):
|
||||||
|
self._get_response = get_response
|
||||||
|
self._post_response = post_response
|
||||||
|
# Maps a path (e.g. "/v1/search") to its own response, for tests that need
|
||||||
|
# /v1/answers and /v1/search to behave differently in the same call. Falls back
|
||||||
|
# to the single post_response when a path has no specific entry, so every
|
||||||
|
# existing single-endpoint test keeps working unchanged.
|
||||||
|
self._post_responses = post_responses or {}
|
||||||
|
self._raise_on = raise_on
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
if self._raise_on == "get":
|
||||||
|
raise httpx.ConnectError("no ragcore in this environment")
|
||||||
|
return self._get_response
|
||||||
|
|
||||||
|
def post(self, path, json=None):
|
||||||
|
if self._raise_on == "post":
|
||||||
|
raise httpx.ConnectError("no ragcore in this environment")
|
||||||
|
return self._post_responses.get(path, self._post_response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||||
provider = RAGcoreKnowledgeProvider()
|
provider = RAGcoreKnowledgeProvider()
|
||||||
monkeypatch.setattr(provider, "_client", fake_client)
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post"))
|
||||||
answer = provider.ask("Anything?", "test-correlation-3")
|
answer = provider.ask("Anything?", "test-correlation-3")
|
||||||
assert answer.evidence_state == "unavailable"
|
assert answer.evidence_state == "unavailable"
|
||||||
assert answer.sources == []
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "")
|
||||||
|
|
||||||
|
def fail_if_called():
|
||||||
|
raise AssertionError("should not call RAGcore without a configured space id")
|
||||||
|
|
||||||
|
monkeypatch.setattr(provider, "_client", fail_if_called)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-no-space")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_reports_ready_status(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})),
|
||||||
|
)
|
||||||
|
health = provider.health()
|
||||||
|
assert health.provider == "ragcore"
|
||||||
|
assert health.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})),
|
||||||
|
)
|
||||||
|
health = provider.health()
|
||||||
|
assert health.available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get"))
|
||||||
|
health = provider.health()
|
||||||
|
assert health.available is False
|
||||||
|
assert "unavailable" in health.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _answers_body(**overrides) -> dict:
|
||||||
|
body = {
|
||||||
|
"answer": "Report damage and route the vehicle to maintenance.",
|
||||||
|
"answerability": "answerable",
|
||||||
|
"citations": [
|
||||||
|
{
|
||||||
|
"id": "cite-1",
|
||||||
|
"document_id": "doc-1",
|
||||||
|
"document_version_id": "version-1",
|
||||||
|
"title": "Damage handling procedure",
|
||||||
|
"section": "Detection",
|
||||||
|
"excerpt": "Inspect the vehicle for visible damage.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
assert answer.answer
|
||||||
|
assert len(answer.sources) == 1
|
||||||
|
source = answer.sources[0]
|
||||||
|
assert source.document_id == "doc-1"
|
||||||
|
assert source.title == "Damage handling procedure"
|
||||||
|
assert source.version == "version-1"
|
||||||
|
assert source.section == "Detection"
|
||||||
|
assert source.excerpt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_response=_FakeResponse(
|
||||||
|
200,
|
||||||
|
_answers_body(
|
||||||
|
answer="This should never be shown.",
|
||||||
|
answerability="not_answerable",
|
||||||
|
citations=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Unrelated question?", "test-correlation-insufficient")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.answer == ""
|
||||||
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_response=_FakeResponse(
|
||||||
|
200, _answers_body(answerability="answerable", citations=[])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What must I do about damage?", "test-correlation-no-citations")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-401")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
# A malformed /v1/answers body triggers the same search fallback a real outage
|
||||||
|
# would, so the fallback's own /v1/search response must also be malformed here to
|
||||||
|
# exercise "the whole backend is misbehaving, not just one endpoint" honestly.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_responses={
|
||||||
|
"/v1/answers": _FakeResponse(200, {"citations": "not-a-list"}),
|
||||||
|
"/v1/search": _FakeResponse(200, {"results": "not-a-list"}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-malformed")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def _search_body(**overrides) -> dict:
|
||||||
|
body = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"chunk_id": "chunk-1",
|
||||||
|
"citation": {
|
||||||
|
"id": "cite-1",
|
||||||
|
"document_id": "doc-1",
|
||||||
|
"document_version_id": "version-1",
|
||||||
|
"title": "Vehicle return procedure",
|
||||||
|
"section": "Return",
|
||||||
|
"excerpt": "Register the return odometer reading before releasing the vehicle.",
|
||||||
|
},
|
||||||
|
"rank": 1,
|
||||||
|
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"degraded": False,
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch):
|
||||||
|
"""/v1/answers itself failing (a real RAGcore-side outage in its generation step,
|
||||||
|
not a real 'insufficient evidence' classification) must not silently degrade
|
||||||
|
straight to 'unavailable' when RAGcore's own retrieval still works -- it should show
|
||||||
|
the real, cited excerpt search actually found instead."""
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_responses={
|
||||||
|
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
|
||||||
|
"/v1/search": _FakeResponse(200, _search_body()),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback")
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
assert "Register the return odometer reading" in answer.answer
|
||||||
|
assert "Vehicle return procedure" in answer.answer
|
||||||
|
assert len(answer.sources) == 1
|
||||||
|
assert answer.sources[0].title == "Vehicle return procedure"
|
||||||
|
assert answer.sources[0].excerpt == (
|
||||||
|
"Register the return odometer reading before releasing the vehicle."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_responses={
|
||||||
|
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
|
||||||
|
"/v1/search": _FakeResponse(200, _search_body()),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask(
|
||||||
|
"Wat is de procedure voor een voertuigretour?",
|
||||||
|
"test-correlation-fallback-nl",
|
||||||
|
language="nl-BE",
|
||||||
|
)
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
assert answer.answer.startswith("Volgens ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_responses={
|
||||||
|
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
|
||||||
|
"/v1/search": _FakeResponse(200, _search_body(results=[])),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Unrelated question?", "test-correlation-fallback-empty")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.answer == ""
|
||||||
|
assert answer.sources == []
|
||||||
|
|||||||
@@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
|
|||||||
assert events[0]["actor_type"] == "service"
|
assert events[0]["actor_type"] == "service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_knowledge_respects_requested_locale(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/mcp/search-knowledge",
|
||||||
|
json={
|
||||||
|
"question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||||
|
"max_sources": 1,
|
||||||
|
"locale": "nl-BE",
|
||||||
|
},
|
||||||
|
headers=_headers(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["evidence_state"] == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client):
|
||||||
|
inbound = "11111111-1111-1111-1111-111111111111"
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/mcp/search-knowledge",
|
||||||
|
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
|
||||||
|
headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["correlation_id"] == inbound
|
||||||
|
|
||||||
|
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||||
|
matching = [e for e in events if e["correlation_id"] == inbound]
|
||||||
|
assert len(matching) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/mcp/operations-summary",
|
||||||
|
headers=_headers(client_id="no-correlation-probe"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||||
|
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"]
|
||||||
|
assert len(matching) >= 1
|
||||||
|
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
|
||||||
|
|
||||||
|
|
||||||
def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
||||||
for method, path in [
|
for method, path in [
|
||||||
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||||
|
|||||||
+111
-6
@@ -7,7 +7,7 @@ from app.models.audit import AuditEvent
|
|||||||
from app.models.booking import Booking
|
from app.models.booking import Booking
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
||||||
@@ -22,13 +22,16 @@ def test_seed_counts_match_deterministic_dataset():
|
|||||||
reset_and_seed(db)
|
reset_and_seed(db)
|
||||||
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
||||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
# 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a
|
||||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
# real day of traffic rather than the same fixed 4 rows on every reset.
|
||||||
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
|
assert db.scalar(select(func.count()).select_from(Booking)) == 254
|
||||||
|
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
|
||||||
|
# real open issue) plus a deterministic set discovered by the post-seed scan. The
|
||||||
|
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
|
||||||
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
||||||
# already crossed its service-due odometer threshold -- a genuine conflict the
|
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||||
# previous hand-rolled scanner never checked for.
|
# previous hand-rolled scanner never checked for.
|
||||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
|
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
|
||||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||||
finally:
|
finally:
|
||||||
@@ -142,7 +145,10 @@ def test_seed_scenario_s5_failed_workflow_run():
|
|||||||
assert failed.delivery_status == "failed"
|
assert failed.delivery_status == "failed"
|
||||||
assert failed.attempts >= 1
|
assert failed.attempts >= 1
|
||||||
assert failed.last_error
|
assert failed.last_error
|
||||||
assert failed.last_error_code == "connectionError"
|
# Coded as a prepared demo scenario, not as a real connectionError: the whole
|
||||||
|
# point of this row is to demonstrate retry and audit, so nothing downstream
|
||||||
|
# may read it as evidence that the n8n integration is unhealthy.
|
||||||
|
assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -174,3 +180,102 @@ def test_seed_dates_are_anchored_to_reset_moment():
|
|||||||
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_today_movements_are_a_credible_mix():
|
||||||
|
"""A fresh reset must not land on a thin, always-identical 'Today's movements'
|
||||||
|
dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across
|
||||||
|
more than 4 distinct vehicles) should fall on the reset day, mirroring the same
|
||||||
|
status/date rule the dashboard router uses to build the today list."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
bookings = db.scalars(select(Booking)).all()
|
||||||
|
departures = [
|
||||||
|
b
|
||||||
|
for b in bookings
|
||||||
|
if b.starts_at.date() == today and b.status in ("reserved", "active")
|
||||||
|
]
|
||||||
|
returns = [
|
||||||
|
b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")
|
||||||
|
]
|
||||||
|
assert len(departures) >= 5
|
||||||
|
assert len(returns) >= 5
|
||||||
|
vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns}
|
||||||
|
assert len(vehicles_involved) > 4
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_blocked_vehicle_has_a_real_open_issue():
|
||||||
|
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
|
||||||
|
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
|
||||||
|
vehicle seeded as 'blocked' must now have at least one real, currently open
|
||||||
|
DataQualityIssue an operator can click through to."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
|
||||||
|
assert len(blocked) > 0
|
||||||
|
for vehicle in blocked:
|
||||||
|
open_issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_scenario_mo024_service_conflict_is_flagged():
|
||||||
|
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
|
||||||
|
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
|
||||||
|
with nothing surfacing the contradiction. The shared vehicle-status evaluator
|
||||||
|
already catches this (a real conflict, not a fabricated third status) -- this test
|
||||||
|
exists so a future change can't silently regress it back to unexplained."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||||
|
assert vehicle is not None
|
||||||
|
assert vehicle.operational_status == "rented"
|
||||||
|
assert vehicle.odometer_km >= vehicle.next_service_km
|
||||||
|
|
||||||
|
issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.rule_type == "vehicle_status_conflict",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert issue is not None
|
||||||
|
signals = issue.evidence_json.get("signals", [])
|
||||||
|
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_evidence_has_no_placeholder_summary():
|
||||||
|
"""Every seed-only data-quality issue used to carry the vacuous evidence
|
||||||
|
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
|
||||||
|
had no way to understand why it needed attention. Every issue must now carry a real
|
||||||
|
summary and at least one localizable signal (code + params)."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
issues = db.scalars(select(DataQualityIssue)).all()
|
||||||
|
assert len(issues) > 0
|
||||||
|
for issue in issues:
|
||||||
|
summary = issue.evidence_json.get("summary", "")
|
||||||
|
assert summary != "Synthetic deterministic seed issue", (
|
||||||
|
f"{issue.public_ref} still has the meaningless placeholder summary"
|
||||||
|
)
|
||||||
|
signals = issue.evidence_json.get("signals", [])
|
||||||
|
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
|
|||||||
assert all(v["attention"] for v in vehicles)
|
assert all(v["attention"] for v in vehicles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/vehicles",
|
||||||
|
params={"status": "maintenance", "page": 1, "page_size": 25},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert all(v["operational_status"] == "maintenance" for v in body["items"])
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_vehicle_detail_includes_related_records(ops_client):
|
def test_vehicle_detail_includes_related_records(ops_client):
|
||||||
response = ops_client.get("/api/v1/vehicles/MO-016")
|
response = ops_client.get("/api/v1/vehicles/MO-016")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
|
def _reseed() -> None:
|
||||||
|
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
|
||||||
|
|
||||||
|
The suite shares one session-scoped database and earlier files legitimately mutate
|
||||||
|
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
|
||||||
|
rather than depend on file ordering.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_list_workflows_requires_operations_manager(employee_client):
|
def test_list_workflows_requires_operations_manager(employee_client):
|
||||||
response = employee_client.get("/api/v1/workflows")
|
response = employee_client.get("/api/v1/workflows")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_list_workflows_includes_seeded_failed_run(ops_client):
|
def test_list_workflows_includes_seeded_failed_run(ops_client):
|
||||||
|
_reseed()
|
||||||
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
|
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
runs = response.json()
|
runs = response.json()
|
||||||
@@ -20,6 +39,7 @@ def test_retry_requires_failed_status(ops_client):
|
|||||||
|
|
||||||
|
|
||||||
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
||||||
|
_reseed()
|
||||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
target = failed[0]["event_id"]
|
target = failed[0]["event_id"]
|
||||||
|
|
||||||
@@ -36,3 +56,42 @@ def test_retry_requires_operations_manager(employee_client):
|
|||||||
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
|
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
|
||||||
|
"""The one seeded failure must announce itself as staged. An unexplained red row in
|
||||||
|
a demo reads as a broken product; a labelled one reads as the retry story it is."""
|
||||||
|
_reseed()
|
||||||
|
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
|
demo_runs = [run for run in runs if run["is_demo_scenario"]]
|
||||||
|
assert len(demo_runs) == 1
|
||||||
|
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
|
||||||
|
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
|
||||||
|
|
||||||
|
|
||||||
|
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
|
||||||
|
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
|
||||||
|
assert runs
|
||||||
|
assert all(run["is_demo_scenario"] is False for run in runs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
|
||||||
|
"""The retry is a real redelivery either way; the audit records which kind of
|
||||||
|
failure it resolved so a staged retry is never mistaken for a production fix."""
|
||||||
|
_reseed()
|
||||||
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
|
demo_run = next(run for run in failed if run["is_demo_scenario"])
|
||||||
|
|
||||||
|
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "pending"
|
||||||
|
|
||||||
|
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
|
||||||
|
entry = next(
|
||||||
|
event
|
||||||
|
for event in audit
|
||||||
|
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
|
||||||
|
== demo_run["event_id"]
|
||||||
|
)
|
||||||
|
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
|
||||||
|
assert metadata["demo_scenario"] is True
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ services:
|
|||||||
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
||||||
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
||||||
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
||||||
|
RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-}
|
||||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
||||||
|
N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
|
||||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||||
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
||||||
|
MCP_HUB_REGISTRATION_ENABLED: ${MCP_HUB_REGISTRATION_ENABLED:-false}
|
||||||
|
MCP_HUB_BASE_URL: ${MCP_HUB_BASE_URL:-}
|
||||||
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||||
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
|
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
|
||||||
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
|
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
{
|
{
|
||||||
|
"_note": "Fleet Ops's own published tool contract. The live ITWorx MCP Hub connector (ITWorx_MCP_Hub repo, connectors/mobilityops/) wraps these under its own dotted namespace (mobilityops.operations.summary, .attention.list, .vehicle.get, .knowledge.search) -- that naming is Hub-owned. deprecated_aliases below are Fleet Ops's own prior internal audit-label names, kept only so existing clients/dashboards referencing them don't break.",
|
||||||
"provider_id": "mobilityops",
|
"provider_id": "mobilityops",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"required_scope": "mobilityops.read",
|
"required_scope": "mobilityops.read",
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"name": "mobilityops_get_operations_summary",
|
"name": "fleet_ops_get_operations_summary",
|
||||||
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic MobilityOps demo tenant.",
|
"deprecated_aliases": ["mobilityops_get_operations_summary"],
|
||||||
|
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic Fleet Ops demo tenant.",
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"inputSchema": {"type": "object", "additionalProperties": false},
|
"inputSchema": {"type": "object", "additionalProperties": false},
|
||||||
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"}
|
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mobilityops_list_attention_vehicles",
|
"name": "fleet_ops_list_attention_vehicles",
|
||||||
|
"deprecated_aliases": ["mobilityops_list_attention_vehicles"],
|
||||||
"description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.",
|
"description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.",
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
@@ -26,7 +29,8 @@
|
|||||||
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"}
|
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mobilityops_get_vehicle_details",
|
"name": "fleet_ops_get_vehicle_details",
|
||||||
|
"deprecated_aliases": ["mobilityops_get_vehicle_details"],
|
||||||
"description": "Return a read-only operational view of one vehicle by its stable public reference.",
|
"description": "Return a read-only operational view of one vehicle by its stable public reference.",
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
@@ -38,15 +42,17 @@
|
|||||||
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"}
|
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mobilityops_search_knowledge",
|
"name": "fleet_ops_search_knowledge",
|
||||||
"description": "Search versioned MobilityOps internal procedures through the dedicated RAGcore workspace and return grounded source references.",
|
"deprecated_aliases": ["mobilityops_search_knowledge"],
|
||||||
|
"description": "Search versioned Fleet Ops internal procedures through the dedicated RAGcore workspace and return grounded source references.",
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["question"],
|
"required": ["question"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"question": {"type": "string", "minLength": 3, "maxLength": 1000},
|
"question": {"type": "string", "minLength": 3, "maxLength": 1000},
|
||||||
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4}
|
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4},
|
||||||
|
"locale": {"enum": ["nl-BE", "en-GB", "fr-BE"], "default": "en-GB"}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -eu
|
|||||||
|
|
||||||
container_name="${1:-n8n}"
|
container_name="${1:-n8n}"
|
||||||
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
|
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
|
||||||
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
|
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "Missing deployment .env" >&2
|
echo "Missing deployment .env" >&2
|
||||||
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
|
# The workflow file no longer carries the callback token as a literal header value -- both
|
||||||
if [ -z "$callback_token" ]; then
|
# the webhook trigger and the outbound callback authenticate via named n8n Header Auth
|
||||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
# credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
|
||||||
exit 1
|
# exist in the target n8n instance before this workflow is activated; see the echo below.
|
||||||
fi
|
|
||||||
|
|
||||||
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||||
container_workflow="/tmp/mobilityops-return-processing.json"
|
container_workflow="/tmp/mobilityops-return-processing.json"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -32,15 +30,17 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
|
jq --arg callback_url "$callback_url" '
|
||||||
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
|
(.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"
|
' "$source_workflow" > "$temporary_workflow"
|
||||||
|
|
||||||
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
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 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}"
|
echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
|
||||||
|
echo "Before activating: in the n8n UI, create Header Auth credentials named"
|
||||||
|
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
|
||||||
|
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
|
||||||
|
echo "then open the workflow and click Publish. This script does not print or transmit"
|
||||||
|
echo "those secret values, and does not restart the container -- restart it yourself once"
|
||||||
|
echo "credentials are wired up and the workflow is published, if required."
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -eu
|
|||||||
|
|
||||||
container_name="${1:-n8n}"
|
container_name="${1:-n8n}"
|
||||||
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
|
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
|
||||||
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}"
|
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "Missing deployment .env" >&2
|
echo "Missing deployment .env" >&2
|
||||||
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
|
# The workflow file no longer carries the callback token as a literal header value -- the
|
||||||
if [ -z "$callback_token" ]; then
|
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
|
||||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
# Token"), which must exist in the target n8n instance before this workflow is activated;
|
||||||
exit 1
|
# see the echo below.
|
||||||
fi
|
|
||||||
|
|
||||||
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||||
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
|
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -32,15 +30,16 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" '
|
jq --arg scan_url "$scan_url" '
|
||||||
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url |
|
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
|
||||||
(.nodes[] | select(.id == "scan-node") | .parameters.headerParameters.parameters[] |
|
|
||||||
select(.name == "X-Service-Token") | .value) = $callback_token
|
|
||||||
' "$source_workflow" > "$temporary_workflow"
|
' "$source_workflow" > "$temporary_workflow"
|
||||||
|
|
||||||
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
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 import:workflow --input="$container_workflow"
|
||||||
docker exec "$container_name" n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
|
||||||
docker restart "$container_name" >/dev/null
|
|
||||||
|
|
||||||
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}"
|
echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
|
||||||
|
echo "Before activating: in the n8n UI, create a Header Auth credential named"
|
||||||
|
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
|
||||||
|
echo "then open the workflow and click Publish. This script does not print or transmit"
|
||||||
|
echo "that secret value, and does not restart the container -- restart it yourself once"
|
||||||
|
echo "the credential is wired up and the workflow is published, if required."
|
||||||
|
|||||||
@@ -6,20 +6,44 @@ Publish four read-only MobilityOps capabilities through the existing central ITW
|
|||||||
|
|
||||||
## Provider registration
|
## Provider registration
|
||||||
|
|
||||||
- provider ID: `mobilityops`
|
- provider ID: `mobilityops` (registered on the Hub side; the Hub's own registration is
|
||||||
- API base: configurable internal MobilityOps API URL
|
catalog-driven — it reconciles its catalog into the gateway, Fleet Ops never pushes a
|
||||||
- authentication: scoped service token
|
registration call)
|
||||||
|
- API base: internal Fleet Ops API URL, reached via `MCP_HUB_SERVICE_TOKEN` auth
|
||||||
|
- authentication: scoped service token (`X-Service-Token`), plus `X-Client-Id`
|
||||||
- mode: read-only
|
- mode: read-only
|
||||||
- required scope: `mobilityops.read`
|
- **Confirmed live in production** on the ITWorx MCP Hub's own deployment (Tower), with
|
||||||
|
a real contract fix already applied there (`vehicle.get`'s wire parameter normalized to
|
||||||
|
camelCase `vehicleRef`). `docs/final-integrations/current-state-audit.md` has the full
|
||||||
|
evidence.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
The machine-readable definitions are in `contracts/mcp-tools.json`.
|
The machine-readable definitions are in `contracts/mcp-tools.json`. The Hub's own live
|
||||||
|
connector (`ITWorx_MCP_Hub` repo, `connectors/mobilityops/`) publishes these under its
|
||||||
|
own dotted namespace — that naming is the Hub's to own, not Fleet Ops's:
|
||||||
|
|
||||||
1. `mobilityops_get_operations_summary`
|
1. `mobilityops.operations.summary`
|
||||||
2. `mobilityops_list_attention_vehicles`
|
2. `mobilityops.attention.list`
|
||||||
3. `mobilityops_get_vehicle_details`
|
3. `mobilityops.vehicle.get`
|
||||||
4. `mobilityops_search_knowledge`
|
4. `mobilityops.knowledge.search`
|
||||||
|
|
||||||
|
Fleet Ops's own internal audit trail (`AuditEvent.metadata_json.tool`, visible on
|
||||||
|
`/api/v1/audit?action=mcp_tool_request`) labels these calls `fleet_ops_get_operations_summary`,
|
||||||
|
`fleet_ops_list_attention_vehicles`, `fleet_ops_get_vehicle_details`,
|
||||||
|
`fleet_ops_search_knowledge` — a separate, Fleet-Ops-owned naming layer for its own audit
|
||||||
|
log, not the wire-level MCP tool name a client calls.
|
||||||
|
|
||||||
|
`search-knowledge` accepts a `locale` field (`nl-BE` | `en-GB` | `fr-BE`, default
|
||||||
|
`en-GB`) that is passed straight through to the active knowledge provider.
|
||||||
|
|
||||||
|
## Correlation ID
|
||||||
|
|
||||||
|
The inbound `X-Correlation-Id` header (set by the Hub, itself either forwarding the
|
||||||
|
MCP client's ID or minting one) is preserved through Fleet Ops's own handling and audit
|
||||||
|
log; Fleet Ops only mints a fresh correlation ID when none is supplied or the supplied
|
||||||
|
value isn't a valid UUID. See `get_correlation_id` in
|
||||||
|
`backend/app/api/routers/mcp_integrations.py`.
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
|
|||||||
+46
-15
@@ -16,7 +16,7 @@ Steps:
|
|||||||
4. return a stable workflow result;
|
4. return a stable workflow result;
|
||||||
5. on errors, fail visibly so the outbox dispatcher can retry.
|
5. on errors, fail visibly so the outbox dispatcher can retry.
|
||||||
|
|
||||||
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency.
|
The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
|
||||||
|
|
||||||
## Second live workflow: scheduled quality scan
|
## Second live workflow: scheduled quality scan
|
||||||
|
|
||||||
@@ -39,24 +39,24 @@ Steps:
|
|||||||
open, so a duplicate or overlapping trigger (a manual test run firing close to the
|
open, so a duplicate or overlapping trigger (a manual test run firing close to the
|
||||||
scheduled one, or a retried HTTP call) does no duplicate domain work.
|
scheduled one, or a retried HTTP call) does no duplicate domain work.
|
||||||
|
|
||||||
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and
|
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||||
published the same way as the return-processing workflow (see
|
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
|
||||||
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with
|
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
|
||||||
`"active": false` so it cannot fire against any environment until deliberately
|
active on the live instance; a fresh import ships inactive until credentials are wired up
|
||||||
published with a real service token.
|
and it is deliberately published.
|
||||||
|
|
||||||
## Deferred: knowledge sync
|
## RAGcore procedure sync (in progress)
|
||||||
|
|
||||||
Input: manual trigger or manifest-changed event.
|
RAGcore is now reachable in this environment; a live inspection of its real contract is
|
||||||
|
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
|
||||||
|
RAGcore Procedure Sync", is being built against that real contract (not the sketch
|
||||||
|
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
|
||||||
|
|
||||||
Steps:
|
## Workflow error handler (in progress)
|
||||||
|
|
||||||
1. read the fixed knowledge manifest;
|
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
|
||||||
2. call RAGcore ingestion/sync API;
|
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
|
||||||
3. record per-document results through MobilityOps integration status API.
|
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
|
||||||
|
|
||||||
Deferred until RAGcore's live ingestion API is available in this environment; must not
|
|
||||||
delay or block the core demo.
|
|
||||||
|
|
||||||
## Outbox dispatcher
|
## Outbox dispatcher
|
||||||
|
|
||||||
@@ -67,3 +67,34 @@ delay or block the core demo.
|
|||||||
- supports explicit manual retry;
|
- supports explicit manual retry;
|
||||||
- preserves last error and response metadata;
|
- preserves last error and response metadata;
|
||||||
- does not hold a database transaction open during network I/O.
|
- does not hold a database transaction open during network I/O.
|
||||||
|
|
||||||
|
## Prepared demo failure versus real failure
|
||||||
|
|
||||||
|
The demo seed deliberately plants exactly one failed delivery (`BK-H-0020`, see
|
||||||
|
`seed/workflow_runs.csv`). It exists to demonstrate retry and audit, so it must never be
|
||||||
|
read as evidence that the automation is unhealthy.
|
||||||
|
|
||||||
|
It is distinguished by its `last_error_code`, `demoScenarioTimeout`
|
||||||
|
(`app.models.outbox.DEMO_SCENARIO_ERROR_CODE`) — not by a new column, so no migration is
|
||||||
|
involved. A real timeout produces `connectionError`; the two are never confused.
|
||||||
|
|
||||||
|
Consequences, all enforced by tests:
|
||||||
|
|
||||||
|
- `/api/v1/integrations/status` reports `failed` (everything), `unexpected_failed` (real
|
||||||
|
failures only) and `demo_scenario_failed` separately.
|
||||||
|
- Only `unexpected_failed` can move n8n off `operational`. A prepared failure alone
|
||||||
|
leaves the integration **operational** — a staged prop may not raise a red flag.
|
||||||
|
- `latest_failure_at` is a health signal and therefore ignores the prepared failure;
|
||||||
|
`latest_demo_scenario_at` reports it separately.
|
||||||
|
- `/api/v1/workflows` marks the run with `is_demo_scenario: true`. The Automation page
|
||||||
|
labels it "Prepared demo scenario", explains that it is a simulated temporary failure,
|
||||||
|
and offers a distinct "Retry demo scenario" action.
|
||||||
|
- A genuine later failure of that same event overwrites the code with the real one, and
|
||||||
|
from that moment it counts as a real failure — the carve-out is narrow by construction.
|
||||||
|
|
||||||
|
The retry itself is real in both cases: the event goes back on the outbox and the
|
||||||
|
dispatcher delivers it to the configured n8n webhook like any other, so 19 succeeded +
|
||||||
|
1 failed becomes 20 succeeded + 0 failed only when n8n genuinely accepts the delivery.
|
||||||
|
Nothing is marked succeeded without a real round trip. The audit entry records
|
||||||
|
`demo_scenario: true/false` so a staged retry is never mistaken for a production fix.
|
||||||
|
A demo reset recreates the original 19 + 1 scenario.
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ Vehicle `MO-016` has two imported overlapping reservations. Expected: visible qu
|
|||||||
|
|
||||||
### S5 — Failed workflow
|
### S5 — Failed workflow
|
||||||
|
|
||||||
One seeded outbox/workflow record is failed with a safe simulated connection error. Expected: dashboard and Automation page show it; Operations Manager can retry.
|
One seeded outbox/workflow record is failed with a safe simulated connection error, coded
|
||||||
|
`demoScenarioTimeout` so it is recognisable as a prepared scenario rather than a real
|
||||||
|
incident. Expected: dashboard and Automation page show it, labelled as a prepared demo
|
||||||
|
scenario; n8n stays "Operational"; the Operations Manager can retry it, after which the
|
||||||
|
overview reads 20 succeeded and 0 failed. See `docs/11-n8n-integration.md`, "Prepared demo
|
||||||
|
failure versus real failure".
|
||||||
|
|
||||||
### S6 — Grounded damage question
|
### S6 — Grounded damage question
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -48,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
|
|||||||
which runs:
|
which runs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
```
|
```
|
||||||
@@ -56,6 +56,12 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
|
|||||||
(`n8n import:workflow` always leaves the workflow deactivated regardless of its
|
(`n8n import:workflow` always leaves the workflow deactivated regardless of its
|
||||||
`"active"` field; `publish:workflow` + a restart is what actually activates it.)
|
`"active"` field; `publish:workflow` + a restart is what actually activates it.)
|
||||||
|
|
||||||
|
Before it will actually process a return, create two Header Auth credentials in the n8n
|
||||||
|
UI — `Fleet Ops Webhook Trigger Token` (value: `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` from
|
||||||
|
`.env`) and `Fleet Ops Service Token` (value: `MOBILITYOPS_CALLBACK_TOKEN` from `.env`) —
|
||||||
|
the workflow's webhook trigger and outbound HTTP call reference these credentials by
|
||||||
|
name; no secret value is embedded in the workflow file itself.
|
||||||
|
|
||||||
Verify the full round trip:
|
Verify the full round trip:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -77,11 +83,14 @@ make n8n-setup-scan
|
|||||||
which runs:
|
which runs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This workflow also needs the `Fleet Ops Service Token` Header Auth credential created in
|
||||||
|
the n8n UI before a run will succeed.
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# MobilityOps visual product roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
|
||||||
|
implementation plan. It improves the existing operational workflows without
|
||||||
|
expanding the locked product scope.
|
||||||
|
|
||||||
|
The intended outcome is a platform that:
|
||||||
|
|
||||||
|
- lets an operator identify and act on urgent work within thirty seconds;
|
||||||
|
- remains efficient with the current synthetic data set and larger realistic data sets;
|
||||||
|
- keeps status, context and primary actions visible on desktop and mobile;
|
||||||
|
- uses a readable, consistent visual hierarchy;
|
||||||
|
- communicates integration and knowledge health without contradictory signals;
|
||||||
|
- preserves the existing domain rules, audit guarantees and graceful degradation.
|
||||||
|
|
||||||
|
## Scope guardrails
|
||||||
|
|
||||||
|
Included:
|
||||||
|
|
||||||
|
- information architecture and visual hierarchy;
|
||||||
|
- list density, pagination, filtering and responsive presentation;
|
||||||
|
- dashboard, detail, return, data-quality, knowledge, automation, audit and demo flows;
|
||||||
|
- accessibility, perceived performance and UI observability;
|
||||||
|
- regression tests, visual evidence and rollout safeguards.
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
|
||||||
|
- new accounting, payments, CRM, inventory, HR or reservation modules;
|
||||||
|
- maintenance work orders, calendar views or advanced analytics;
|
||||||
|
- changes to RAGcore or ITWorx MCP Hub repositories;
|
||||||
|
- autonomous write actions or write-capable MCP tools;
|
||||||
|
- a redesign of backend domain rules that is not required by the UI work.
|
||||||
|
|
||||||
|
## Audit baseline
|
||||||
|
|
||||||
|
The audit was performed against the deployed application at desktop, tablet and
|
||||||
|
mobile widths. The current strengths to preserve are the professional visual
|
||||||
|
identity, consistent status language, strong login page, semantic page structure,
|
||||||
|
visible keyboard focus, absence of horizontal overflow and clear return and
|
||||||
|
data-quality narratives.
|
||||||
|
|
||||||
|
The principal improvement signals are:
|
||||||
|
|
||||||
|
| Area | Baseline observation | Consequence |
|
||||||
|
|---|---|---|
|
||||||
|
| Audit log | Up to 100 expanded event cards; approximately 18,500 px desktop and 20,500 px mobile | Poor scanability and excessive scrolling |
|
||||||
|
| Vehicle list | All 50 vehicles render at once; approximately 12,000 px on mobile | Operational lookup is slow on small screens |
|
||||||
|
| Mobile page header | `.page-actions` is hidden below 700 px | Important record status disappears |
|
||||||
|
| Typography | Many metadata labels and badges are approximately 9–11 px | Readability is weaker than the visual quality suggests |
|
||||||
|
| Top bar | Search, language, demo controls, disclosure, timezone and operator controls compete for space | High cognitive load and weak prioritisation |
|
||||||
|
| Knowledge | An operational provider can be shown beside “0 procedures indexed” | Trust signal is ambiguous or contradictory |
|
||||||
|
| Integration cards | Technical identifiers and small prose dominate the summary | Operational state is harder to scan |
|
||||||
|
|
||||||
|
These values are baselines, not permanent acceptance thresholds. Each affected
|
||||||
|
phase must record a before-and-after measurement.
|
||||||
|
|
||||||
|
## Delivery principles
|
||||||
|
|
||||||
|
1. Fix operational friction before decorative polish.
|
||||||
|
2. Prefer progressive disclosure over removing useful evidence.
|
||||||
|
3. Keep the primary status and next action visible at every viewport.
|
||||||
|
4. Paginate or virtualise unbounded collections; never solve them only with CSS.
|
||||||
|
5. Derive every displayed count and state from persisted or external evidence.
|
||||||
|
6. Preserve URLs, permissions, audit semantics and keyboard operation.
|
||||||
|
7. Ship each phase as a coherent, independently reversible commit.
|
||||||
|
8. Validate every phase at 390 px, 768 px and 1440 px.
|
||||||
|
|
||||||
|
## Target measures
|
||||||
|
|
||||||
|
The roadmap is complete when the following targets are met:
|
||||||
|
|
||||||
|
| Measure | Target |
|
||||||
|
|---|---|
|
||||||
|
| Initial rows/cards in any operational list | At most 25, unless a documented compact virtualised view is used |
|
||||||
|
| Mobile status visibility | Primary record status visible on every detail page |
|
||||||
|
| Horizontal overflow | None at 360 px and above |
|
||||||
|
| Default text | At least 14 px |
|
||||||
|
| Secondary metadata | At least 12 px; exceptions require an accessibility justification |
|
||||||
|
| Touch target | At least 44 by 44 CSS px for primary mobile controls |
|
||||||
|
| Keyboard access | All actions reachable with visible focus and logical order |
|
||||||
|
| List navigation | Filter, paging and search state represented in the URL where practical |
|
||||||
|
| Dashboard comprehension | Primary operational risks and next movements visible without scrolling at 1440 × 900 |
|
||||||
|
| Browser console | No application errors during the five-minute demo |
|
||||||
|
| Performance | No avoidable rendering of more than one page of list records |
|
||||||
|
| Automated acceptance | Existing backend, frontend and Playwright gates remain green |
|
||||||
|
|
||||||
|
## Roadmap overview
|
||||||
|
|
||||||
|
| Phase | Theme | Priority | Indicative effort | Depends on |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| R0 | Baseline and design-system guardrails | P0 | 2–3 days | None |
|
||||||
|
| R1 | Operational lists and audit log | P0 | 5–7 days | R0 |
|
||||||
|
| R2 | Responsive shell and readable hierarchy | P0 | 4–6 days | R0 |
|
||||||
|
| R3 | Dashboard and record-detail efficiency | P1 | 5–7 days | R1, R2 |
|
||||||
|
| R4 | Guided workflows and decision surfaces | P1 | 5–7 days | R2, R3 |
|
||||||
|
| R5 | Knowledge, automation and trust signals | P1 | 3–5 days | R2 |
|
||||||
|
| R6 | Accessibility, performance and release evidence | P0 gate | 4–6 days | R1–R5 |
|
||||||
|
|
||||||
|
Indicative total: 28–41 focused engineering days. Phases can span multiple
|
||||||
|
calendar sprints, but their exit gates should not be split across releases.
|
||||||
|
|
||||||
|
## R0 — Baseline and design-system guardrails
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Create shared UI rules and reproducible measurements before changing individual
|
||||||
|
pages.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R0.1 — Visual regression baseline
|
||||||
|
|
||||||
|
- Capture authenticated reference screenshots for the seven principal routes and
|
||||||
|
the login page at 390 × 844, 768 × 1024 and 1440 × 900.
|
||||||
|
- Add stable screenshot fixtures using the deterministic seed.
|
||||||
|
- Mask only genuinely variable timestamps; do not mask operational content.
|
||||||
|
- Record viewport width, page height, visible item count and horizontal overflow.
|
||||||
|
|
||||||
|
#### R0.2 — Typography and spacing tokens
|
||||||
|
|
||||||
|
- Replace scattered sub-12 px declarations with named type tokens.
|
||||||
|
- Establish body, metadata, label, badge, table-header and navigation sizes.
|
||||||
|
- Define compact and comfortable density variants without duplicating page CSS.
|
||||||
|
- Preserve the existing petrol/teal palette and status colours.
|
||||||
|
|
||||||
|
#### R0.3 — Shared responsive patterns
|
||||||
|
|
||||||
|
- Define a mobile page-header contract: title, visible status, primary action and
|
||||||
|
overflow actions.
|
||||||
|
- Define reusable compact-list, filter-toolbar, pagination and empty-state patterns.
|
||||||
|
- Establish 44 px mobile target sizing and a consistent sticky-offset system.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Visual baselines exist for all principal routes and viewports.
|
||||||
|
- No normal operational metadata is smaller than 12 px.
|
||||||
|
- Shared components document their responsive behaviour.
|
||||||
|
- Existing Playwright demo and frontend build pass unchanged.
|
||||||
|
|
||||||
|
## R1 — Operational lists and audit log
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Make high-volume operational information scannable and bounded on every device.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R1.1 — Audit API pagination and filtering
|
||||||
|
|
||||||
|
- Replace the default fixed `limit=100` response with cursor or page-based pagination.
|
||||||
|
- Add filters for date range, action, actor, entity type, entity reference and
|
||||||
|
correlation ID.
|
||||||
|
- Retain stable newest-first ordering and service-token protections.
|
||||||
|
- Return total or continuation metadata suitable for the selected paging model.
|
||||||
|
|
||||||
|
#### R1.2 — Compact audit timeline
|
||||||
|
|
||||||
|
- Render 20–25 compact events per page.
|
||||||
|
- Group correlated events and repetitive scheduled events without hiding their count.
|
||||||
|
- Move full UUIDs, before/after data and technical metadata into a details drawer.
|
||||||
|
- Keep human-readable action, actor, target, outcome and timestamp in the main row.
|
||||||
|
- Preserve direct navigation to related records.
|
||||||
|
|
||||||
|
#### R1.3 — Vehicle-list pagination
|
||||||
|
|
||||||
|
- Add server-compatible pagination to the vehicle list.
|
||||||
|
- Preserve status, location, attention and search filters across pages.
|
||||||
|
- Store filter and page state in the URL.
|
||||||
|
- Provide a prominent “attention required” view without inventing new metrics.
|
||||||
|
- Use compact mobile rows rather than full table-to-card expansion for every vehicle.
|
||||||
|
|
||||||
|
#### R1.4 — Data-quality queue controls
|
||||||
|
|
||||||
|
- Paginate the open issue list.
|
||||||
|
- Add severity and rule grouping/filtering.
|
||||||
|
- Add “previous issue” and “next issue” navigation to review pages.
|
||||||
|
- Keep scan and resolution actions permission-aware.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- No default list renders more than 25 records.
|
||||||
|
- Audit and vehicle pages remain below 3,500 px at the tested mobile viewport with
|
||||||
|
the deterministic seed.
|
||||||
|
- Paging and filters survive reload, back navigation and direct links.
|
||||||
|
- Empty, loading, error and end-of-results states are explicit.
|
||||||
|
- API tests cover invalid cursors/pages, combined filters and authorization.
|
||||||
|
|
||||||
|
## R2 — Responsive shell and readable hierarchy
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Reduce navigation noise and keep essential context visible on small screens.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R2.1 — Top-bar simplification
|
||||||
|
|
||||||
|
- Keep global search and current operational context primary.
|
||||||
|
- Move language, timezone and sign-out into the operator menu.
|
||||||
|
- Combine demo disclosure and demo progress into one compact control.
|
||||||
|
- Replace the visually empty mobile search field with an explicit search button or
|
||||||
|
a full-width command surface.
|
||||||
|
|
||||||
|
#### R2.2 — Mobile page actions
|
||||||
|
|
||||||
|
- Stop hiding the complete page-action region.
|
||||||
|
- Always show record status next to or below the title.
|
||||||
|
- Keep one primary action visible and move secondary actions to an accessible menu.
|
||||||
|
- Verify booking, vehicle, data-quality and automation detail headers independently.
|
||||||
|
|
||||||
|
#### R2.3 — Navigation refinement
|
||||||
|
|
||||||
|
- Review whether the desktop sidebar should remain available at wider tablet widths.
|
||||||
|
- Increase mobile navigation label size and touch area.
|
||||||
|
- Keep four or five primary destinations visible and put lower-frequency destinations
|
||||||
|
under “More” if six items cannot meet readability targets.
|
||||||
|
- Preserve current routes and role-based visibility.
|
||||||
|
|
||||||
|
#### R2.4 — Typography rollout
|
||||||
|
|
||||||
|
- Apply the R0 type tokens to badges, tables, integration cards, timelines, forms and
|
||||||
|
mobile navigation.
|
||||||
|
- Rebalance padding where larger text would otherwise increase page height excessively.
|
||||||
|
- Verify Dutch, French and English labels for clipping and wrapping.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Status remains visible on every sampled mobile detail page.
|
||||||
|
- No essential action is hover-only or hidden solely because of viewport width.
|
||||||
|
- Top-bar controls fit without clipping at 390, 768 and 1440 px.
|
||||||
|
- Language changes do not introduce horizontal overflow.
|
||||||
|
- Touch-target and typography targets are met.
|
||||||
|
|
||||||
|
## R3 — Dashboard and record-detail efficiency
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Put operational decisions above supporting detail and make desktop space work harder.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R3.1 — Dashboard hierarchy
|
||||||
|
|
||||||
|
- Keep readiness and highest-severity attention items visible in the first viewport.
|
||||||
|
- Reduce the vertical weight of the demo-scenario panel after the scenario starts.
|
||||||
|
- Limit the initial attention queue and link to a complete filtered view.
|
||||||
|
- Balance “movements today” against attention content when only a few movements exist.
|
||||||
|
- Keep integrations and recent activity available without duplicating status copy.
|
||||||
|
|
||||||
|
#### R3.2 — Mobile dashboard summary
|
||||||
|
|
||||||
|
- Convert readiness into a compact, horizontally accessible summary or disclosure.
|
||||||
|
- Show the highest-priority attention items first with an explicit remaining count.
|
||||||
|
- Surface the next departure and return before secondary integration information.
|
||||||
|
|
||||||
|
#### R3.3 — Vehicle-detail workspace
|
||||||
|
|
||||||
|
- Replace the sparse overview with a two-column desktop composition.
|
||||||
|
- Surface current status, location, odometer, next booking and active attention items.
|
||||||
|
- Keep bookings, inspections, maintenance, quality issues and audit as focused tabs.
|
||||||
|
- Avoid new maintenance-domain functionality; link only to existing evidence.
|
||||||
|
|
||||||
|
#### R3.4 — Booking-detail summary
|
||||||
|
|
||||||
|
- Rebalance the facts grid so incomplete final rows do not create large empty bands.
|
||||||
|
- On mobile, show status and return readiness before secondary booking facts.
|
||||||
|
- Keep the return form close to the task entry point while retaining validation context.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- At 1440 × 900 the dashboard exposes readiness, urgent work and today's movement
|
||||||
|
context without scrolling.
|
||||||
|
- Sparse states remain intentional and balanced rather than appearing unfinished.
|
||||||
|
- All values remain derived from persisted data.
|
||||||
|
- Existing return and status-domain tests remain unchanged and green.
|
||||||
|
|
||||||
|
## R4 — Guided workflows and decision surfaces
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Reduce the distance between evidence and the decision an operator must make.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R4.1 — Return flow refinement
|
||||||
|
|
||||||
|
- Keep the step indicator visible while completing or reviewing a return.
|
||||||
|
- Use a compact mobile booking summary above the form.
|
||||||
|
- Present validation errors next to fields and in a short focusable summary.
|
||||||
|
- Preserve the post-submit explanation of inspection, vehicle status, quality issue,
|
||||||
|
queued automation and next-booking risk.
|
||||||
|
|
||||||
|
#### R4.2 — Duplicate-customer decision layout
|
||||||
|
|
||||||
|
- Use evidence/comparison on the left and a sticky survivor/merge preview on desktop.
|
||||||
|
- Keep matching fields collapsed by default and differences expanded.
|
||||||
|
- Keep irreversible-action language and audit preview adjacent to the merge button.
|
||||||
|
- On mobile, use a deliberate sequence: evidence, differences, survivor, preview,
|
||||||
|
confirmation.
|
||||||
|
|
||||||
|
#### R4.3 — Other data-quality resolutions
|
||||||
|
|
||||||
|
- Standardise evidence, proposed resolution, consequence and audit-preview sections.
|
||||||
|
- Keep resolve, reject and defer actions visually distinct and permission-aware.
|
||||||
|
- Provide clear success feedback and navigation to the next issue.
|
||||||
|
|
||||||
|
#### R4.4 — Guided demo integration
|
||||||
|
|
||||||
|
- Reduce competition between the guide and normal navigation.
|
||||||
|
- Persist progress without obscuring page controls.
|
||||||
|
- Make “go to this step” land on the relevant element or focused task state.
|
||||||
|
- Ensure the guide is fully keyboard-operable and usable as a mobile bottom sheet.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Primary workflow action is visible or reachable with one obvious interaction.
|
||||||
|
- A user never needs to scroll past repeated explanatory content to discover the
|
||||||
|
required decision.
|
||||||
|
- Focus moves to validation failures and success results appropriately.
|
||||||
|
- The five-minute demo remains deterministic.
|
||||||
|
|
||||||
|
## R5 — Knowledge, automation and trust signals
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Make external-system health understandable to an operator without exposing irrelevant
|
||||||
|
technical detail.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R5.1 — Knowledge status semantics
|
||||||
|
|
||||||
|
- Separate provider availability, indexed-source count and last successful sync.
|
||||||
|
- Never show “0 indexed” as a healthy equivalent when the value is unknown or stale.
|
||||||
|
- Label extractive fallback answers honestly while preserving source cards.
|
||||||
|
- Reduce empty-state height and show a compact explanation of what a cited answer contains.
|
||||||
|
- Keep suggested questions concise and prioritised.
|
||||||
|
|
||||||
|
#### R5.2 — Source and freshness presentation
|
||||||
|
|
||||||
|
- Surface source title, version, section and freshness consistently.
|
||||||
|
- Keep document UUIDs and raw retrieval metadata behind technical details.
|
||||||
|
- Provide explicit grounded, insufficient-evidence and unavailable states.
|
||||||
|
|
||||||
|
#### R5.3 — Integration summary redesign
|
||||||
|
|
||||||
|
- Structure each integration around state, last success, affected workflow and next action.
|
||||||
|
- Move client IDs, raw endpoints and UUIDs to a detail disclosure.
|
||||||
|
- Remove unnecessary fixed card height, especially at tablet widths.
|
||||||
|
- Keep genuine mixed states visible without presenting them as contradictions.
|
||||||
|
|
||||||
|
#### R5.4 — Workflow evidence table
|
||||||
|
|
||||||
|
- Improve status and recency scanning.
|
||||||
|
- Distinguish “never triggered”, “no evidence yet”, “failed” and “unavailable”.
|
||||||
|
- Keep retry actions safe, audited and available only where already supported.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Knowledge and integration states cannot make mutually contradictory claims.
|
||||||
|
- An operator can identify the current state and next action within one card scan.
|
||||||
|
- Technical identifiers do not dominate default views.
|
||||||
|
- RAGcore, n8n and MCP Hub timeout/unavailable contract tests stay green.
|
||||||
|
|
||||||
|
## R6 — Accessibility, performance and release evidence
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Turn the improvements into a verified, releasable quality baseline.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R6.1 — Accessibility review
|
||||||
|
|
||||||
|
- Test complete keyboard traversal of login, navigation, filters, return, merge,
|
||||||
|
knowledge, workflow retry and audit details.
|
||||||
|
- Verify focus order, focus restoration, dialog labelling and error announcements.
|
||||||
|
- Run automated accessibility checks on all principal routes.
|
||||||
|
- Manually verify status is not encoded by colour alone.
|
||||||
|
- Verify zoom at 200% and 360 px responsive reflow.
|
||||||
|
|
||||||
|
#### R6.2 — Rendering and interaction performance
|
||||||
|
|
||||||
|
- Measure list rendering before and after pagination.
|
||||||
|
- Check layout shift and interaction latency on dashboard and long-list routes.
|
||||||
|
- Ensure drawers, menus and guided-demo panels do not mount large hidden subtrees.
|
||||||
|
- Test with reduced motion and a throttled CPU profile.
|
||||||
|
|
||||||
|
#### R6.3 — Cross-browser and localisation pass
|
||||||
|
|
||||||
|
- Verify current Chrome plus one additional Chromium/Firefox-equivalent target where
|
||||||
|
supported by the test environment.
|
||||||
|
- Run Dutch, French and English visual checks at all target widths.
|
||||||
|
- Verify Europe/Brussels date and time rendering.
|
||||||
|
|
||||||
|
#### R6.4 — Final automated journey and evidence
|
||||||
|
|
||||||
|
- Extend the existing Playwright demo with pagination, mobile status and accessible
|
||||||
|
details checks.
|
||||||
|
- Capture final screenshots for all principal pages.
|
||||||
|
- Add before-and-after measurements and known PoC limitations to final evidence.
|
||||||
|
- Execute clean-checkout bootstrap, backend tests, lint, typing and frontend build.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- No critical or serious automated accessibility violations on principal routes.
|
||||||
|
- All target measures in this document pass.
|
||||||
|
- `docs/14-testing-and-acceptance.md` passes from a clean checkout.
|
||||||
|
- `artifacts/evidence/final-summary.md` contains the final commands, counts,
|
||||||
|
screenshots and limitations.
|
||||||
|
|
||||||
|
## Suggested release slices
|
||||||
|
|
||||||
|
### Release A — Operational scale
|
||||||
|
|
||||||
|
Contains R0 and R1. This release eliminates the most severe scroll and list-density
|
||||||
|
problems without materially changing workflow structure.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- paginated audit, vehicles and data-quality queues;
|
||||||
|
- URL-backed filters;
|
||||||
|
- unchanged audit and authorization semantics;
|
||||||
|
- visual regression baseline green.
|
||||||
|
|
||||||
|
### Release B — Responsive operations
|
||||||
|
|
||||||
|
Contains R2 and R3. This release improves navigation, typography, dashboard hierarchy
|
||||||
|
and detail-page use of space.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- visible mobile statuses and primary actions;
|
||||||
|
- readable typography and touch targets;
|
||||||
|
- dashboard first-viewport target met;
|
||||||
|
- Dutch, French and English responsive checks green.
|
||||||
|
|
||||||
|
### Release C — Guided decisions and trust
|
||||||
|
|
||||||
|
Contains R4 and R5. This release refines the return, merge, knowledge, automation and
|
||||||
|
demo-guide experiences.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- decision actions remain near their evidence;
|
||||||
|
- honest and non-contradictory external health states;
|
||||||
|
- five-minute demo passes end to end.
|
||||||
|
|
||||||
|
### Release D — Quality certification
|
||||||
|
|
||||||
|
Contains R6 and only defects found by its gates. It adds no new product scope.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- full clean-checkout acceptance;
|
||||||
|
- accessibility and performance targets met;
|
||||||
|
- final evidence updated;
|
||||||
|
- release tag and deployed revision recorded.
|
||||||
|
|
||||||
|
## Implementation sequence and dependencies
|
||||||
|
|
||||||
|
```text
|
||||||
|
R0 shared tokens and baselines
|
||||||
|
├─ R1 pagination and compact lists ─┐
|
||||||
|
└─ R2 responsive shell and type ───┼─ R3 dashboard/details ─ R4 workflows
|
||||||
|
└─ R5 trust surfaces
|
||||||
|
R1 + R2 + R3 + R4 + R5 ──────────────────────────────── R6 release gate
|
||||||
|
```
|
||||||
|
|
||||||
|
R1 and R2 may be developed in parallel after R0. R3 should consume both shared list
|
||||||
|
and responsive patterns. R4 depends on the new page-header and detail hierarchy. R5 can
|
||||||
|
start after R2 but must share its disclosure and status patterns. R6 is a gate, not a
|
||||||
|
cleanup bucket; defects discovered there return to the owning phase.
|
||||||
|
|
||||||
|
## Per-phase engineering checklist
|
||||||
|
|
||||||
|
Every phase must include:
|
||||||
|
|
||||||
|
1. a short before-state measurement;
|
||||||
|
2. API and data-contract impact assessment;
|
||||||
|
3. implementation using shared components where applicable;
|
||||||
|
4. unit/API tests for changed contracts;
|
||||||
|
5. Playwright coverage for the changed user journey;
|
||||||
|
6. manual checks at 390, 768 and 1440 px in all supported languages;
|
||||||
|
7. accessibility and console check;
|
||||||
|
8. updated evidence and `PROJECT_STATE.md` entry;
|
||||||
|
9. one coherent commit after validation passes;
|
||||||
|
10. deployment verification before starting the next release slice.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Pagination changes API consumers | Add pagination metadata without silently changing service-token/MCP response contracts; version only if necessary |
|
||||||
|
| Compact layouts hide evidence | Use drawers and disclosures, preserving direct access and auditability |
|
||||||
|
| Larger type increases page height | Combine type changes with density and hierarchy work, never shrink text again |
|
||||||
|
| Sticky UI covers content on mobile | Use shared top/bottom offsets and test keyboard/zoom states |
|
||||||
|
| Visual snapshots become brittle | Seed deterministic data and mask only true timestamp volatility |
|
||||||
|
| External health data is stale | Show last successful evidence and distinguish unknown from healthy |
|
||||||
|
| Roadmap drifts into new product scope | Check every work package against `docs/01-scope-and-non-goals.md` and `docs/deferred.md` |
|
||||||
|
|
||||||
|
## Definition of roadmap completion
|
||||||
|
|
||||||
|
This roadmap is complete only when Releases A through D are deployed and verified,
|
||||||
|
all target measures pass, the original five-minute demo remains green, and the final
|
||||||
|
evidence records the exact deployed revision. Completing only the visual styling or one
|
||||||
|
high-priority page does not complete the roadmap.
|
||||||
@@ -65,9 +65,11 @@ generated and kept fresh across resets.
|
|||||||
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
||||||
the same interface once a real RAGcore backend is available — swapping providers is a
|
the same interface once a real RAGcore backend is available — swapping providers is a
|
||||||
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
||||||
- **ITWorx MCP Hub**: not connected. Registration is disabled by default
|
- **ITWorx MCP Hub**: the UI reports "Operational" only when Fleet Ops has really
|
||||||
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" —
|
recorded `mcp_tool_request` calls in its own audit log. `MCP_HUB_REGISTRATION_ENABLED`
|
||||||
never a fabricated successful registration.
|
on its own never makes it operational — registration is catalog-driven on the Hub's
|
||||||
|
side, so the flag alone is not evidence of anything, and a successful registration is
|
||||||
|
never fabricated.
|
||||||
|
|
||||||
## Where to go next
|
## Where to go next
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# AI Operations Brief — runbook and live evidence
|
||||||
|
|
||||||
|
Answers, using a real MCP client through the real ITWorx MCP Hub connector against live
|
||||||
|
production Fleet Ops: *"Which vehicles need the most attention today, why, and which
|
||||||
|
internal procedure should be followed for the most important issue?"*
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
Not a chatbot. No write actions exist under `/api/v1/integrations/mcp/*` (verified by
|
||||||
|
`test_no_write_endpoints_exist_under_mcp_namespace`). Every call below is a plain
|
||||||
|
read-only tool invocation, exactly as the deployed Hub connector performs them.
|
||||||
|
|
||||||
|
## Reproducible command
|
||||||
|
|
||||||
|
Run from inside the live `itworx-mcp-hub-connector-mobilityops-1` container (has the
|
||||||
|
real `MOBILITYOPS_ENDPOINT`, `MOBILITYOPS_SERVICE_TOKEN_FILE` and the real
|
||||||
|
`packages.connector_kit.mobilityops.MobilityOpsClient` already available — the same
|
||||||
|
client class the deployed connector uses):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -e PYTHONPATH=/opt/hub -w /opt/hub \
|
||||||
|
itworx-mcp-hub-connector-mobilityops-1 python3 - <<'PY'
|
||||||
|
from packages.connector_kit.mobilityops import FileTokenProvider, MobilityOpsClient, MobilityOpsSettings
|
||||||
|
import os, json
|
||||||
|
|
||||||
|
settings = MobilityOpsSettings(
|
||||||
|
base_url=os.environ["MOBILITYOPS_ENDPOINT"],
|
||||||
|
token_reference=os.environ["MOBILITYOPS_SERVICE_TOKEN_FILE"],
|
||||||
|
)
|
||||||
|
client = MobilityOpsClient(settings, FileTokenProvider())
|
||||||
|
client_id = "ai-ops-brief"
|
||||||
|
|
||||||
|
summary = client.operations_summary(client_id)
|
||||||
|
attention = client.attention_vehicles(client_id, minimum_severity="high", date=None, limit=20)
|
||||||
|
top_ref = attention[0]["vehicle_ref"] if attention else None
|
||||||
|
details = client.vehicle_details(client_id, top_ref) if top_ref else None
|
||||||
|
answer = client.search_knowledge(client_id, "What must I do when a vehicle returns with damage?", max_sources=2)
|
||||||
|
|
||||||
|
print(json.dumps({"summary": summary, "top_attention": attention[0] if attention else None, "vehicle": details, "answer": answer}, indent=2))
|
||||||
|
client.close()
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
This calls, in order: `fleet_ops_get_operations_summary` → `fleet_ops_list_attention_vehicles`
|
||||||
|
→ `fleet_ops_get_vehicle_details` → `fleet_ops_search_knowledge`. No result is invented —
|
||||||
|
every field printed is exactly what Fleet Ops's live API returned.
|
||||||
|
|
||||||
|
## Live run, 2026-08-05
|
||||||
|
|
||||||
|
- **Operations summary**: 21 available, 11 rented, 6 cleaning, 5 maintenance,
|
||||||
|
**7 blocked**; 23 open quality issues; 1 pending/failed workflow.
|
||||||
|
- **Most pressing vehicle**: `MO-031` — `missing_required_field`, "near-future booking;
|
||||||
|
required operational inspection missing", detected `2026-08-05T11:08:33Z`. (16 vehicles
|
||||||
|
currently carry a `high`-severity open issue; `MO-031` was the earliest-detected.)
|
||||||
|
- **Vehicle detail (`MO-031`)**: Adria Matrix, 2022, Geel, `operational_status: blocked`,
|
||||||
|
41,149 km (service due at 50,000 km), no current booking.
|
||||||
|
- **Grounded procedure (English)**: *Damage handling procedure* v1.3, section "1.
|
||||||
|
Immediate actions" — "When a vehicle returns with visible or reported damage, mark
|
||||||
|
damage in the return inspection, add a concise factual description and keep the
|
||||||
|
vehicle blocked. Do not promise the customer a repair cost or liability decision."
|
||||||
|
Second source: *Vehicle return procedure* v2.0, section "3. Determine next state".
|
||||||
|
`evidence_state: grounded`, 2 real citations, `provider: demo`.
|
||||||
|
- **Dutch and French variants of the damage question** (`Wat moet ik doen wanneer een
|
||||||
|
voertuig beschadigd terugkomt?`, `Que dois-je faire lorsqu'un véhicule revient
|
||||||
|
endommagé ?`) both returned `evidence_state: insufficient` — an honest, non-fabricated
|
||||||
|
"no match" rather than a wrong or invented answer. Root cause: the currently-deployed
|
||||||
|
Hub connector does not yet send the new `locale` field this session added to
|
||||||
|
`search-knowledge` (Fleet Ops defaults to `en-GB`), so non-English question text
|
||||||
|
doesn't match the demo provider's English-tokenized index. A real fix needs a Hub-side
|
||||||
|
connector update to pass `locale`, tracked as a follow-up, not silently worked around.
|
||||||
|
- **Correlation ID, end to end, verified**: each call's response `correlation_id` (e.g.
|
||||||
|
`abd643a7-2aeb-4af3-806a-da04acb3e444` for the English grounded answer) appears
|
||||||
|
verbatim in Fleet Ops's own audit log (`GET /api/v1/audit?action=mcp_tool_request`),
|
||||||
|
alongside `actor_label: ai-ops-brief-2026-08-05` and the real tool name
|
||||||
|
(`fleet_ops_search_knowledge`). No write actions were performed; only `mcp_tool_request`
|
||||||
|
audit rows were created, matching every other live MCP call this integration makes.
|
||||||
|
|
||||||
|
## Known limitation, stated plainly
|
||||||
|
|
||||||
|
The demo knowledge provider (not RAGcore — `KNOWLEDGE_PROVIDER=demo`, see
|
||||||
|
`docs/final-integrations/current-state-audit.md` for why) is what grounds the English
|
||||||
|
answer here. It is deterministic, extractive, and never fabricates — but it is not the
|
||||||
|
live RAGcore integration the brief brief for this task set out to exercise; that
|
||||||
|
remains blocked on RAGcore's own reranker gap. This run is honest about that: it proves
|
||||||
|
the full MCP-client → Hub → Fleet Ops → knowledge-provider → audit chain works for real,
|
||||||
|
live, in production, with real data and real citations — using the knowledge provider
|
||||||
|
that is actually configured live today.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Current-state audit — Fleet Ops final integrations
|
||||||
|
|
||||||
|
Date: 2026-08-05. Compiled from direct repository inspection (git log/status/diff across
|
||||||
|
all three repos), `PROJECT_STATE.md` history, and read-only investigation of the sibling
|
||||||
|
repos' own state docs. No live server SSH/curl evidence is included in this pass yet —
|
||||||
|
see `integration-release-state.md` for the live-verification checklist as it is executed.
|
||||||
|
|
||||||
|
## Repository revisions at audit time
|
||||||
|
|
||||||
|
| Repo | Path | Branch | HEAD | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Fleet Ops (MobilityOps) | `C:\Projects\MobilityOps` | `feat/fleet-ops-final-integrations` (new, branched from `feat/live-n8n-ragcore-integration`) | `3ebca9e` | `feat/live-n8n-ragcore-integration` was pushed to `origin` at `0571a40` and deployed live; `3ebca9e` (logo rebrand) is one commit ahead, not yet deployed. `master` is 19 commits behind and stale (localization-round only). |
|
||||||
|
| RAGcore | `C:\Projects\RAGcore` | `main` | `64a908a` | Up to date with `origin/main`. Uncommitted local work in progress (see below) — not Fleet-Ops-related, left untouched. |
|
||||||
|
| ITWorx MCP Hub | `C:\Projects\ITWorx_MCP_Hub` | `feature/wp240-final-acceptance` | `26e6bd8` (+ later `75bb16a`) | Contains `f107544` (MobilityOps connector) as a direct ancestor, plus a real contract fix (`96de385`, vehicleRef camelCase). Already deployed live to Tower at `c4a0f6d`. |
|
||||||
|
|
||||||
|
## Branch-name correction (recorded assumption)
|
||||||
|
|
||||||
|
The task brief names the working branch `feat/fleet-ops-final-integrations` as already
|
||||||
|
selected and "branched from the most recently validated, localized, deployed master
|
||||||
|
branch." That literal branch did not exist. `master` is in fact stale (19 commits behind,
|
||||||
|
last touched for a localization round only) — the actually-validated, deployed line of
|
||||||
|
work is `feat/live-n8n-ragcore-integration` (pushed to `origin`, deployed to
|
||||||
|
`http://192.168.10.150:1236` at `0571a40`, one commit behind current HEAD). Created
|
||||||
|
`feat/fleet-ops-final-integrations` from that branch's HEAD (`3ebca9e`) instead of from
|
||||||
|
`master`, since that satisfies the actual intent (continue from the validated/deployed
|
||||||
|
line) even though the literal branch name in the brief was inaccurate.
|
||||||
|
|
||||||
|
## What is actually already done (contradicts "not yet live" framing in places)
|
||||||
|
|
||||||
|
- **n8n**: 3 of 4 canonical workflows are live and active in the shared instance
|
||||||
|
(`n8n.itworx.tech`): Vehicle Return Orchestration, Scheduled Data Quality Scan,
|
||||||
|
Workflow Error Handler. The 4th, RAGcore Procedure Sync, has all 6 nodes built and
|
||||||
|
saved but is **not published** (deliberately left for an explicit activation decision,
|
||||||
|
since publishing starts real unattended daily runs against production). The root cause
|
||||||
|
of an earlier "auth"-looking failure (`N8N_PROXY_HOPS=0` behind the TLS-terminating
|
||||||
|
reverse proxy, breaking the browserId CSRF check on every mutating REST call) was found
|
||||||
|
and fixed at the infrastructure level (Unraid template), not worked around.
|
||||||
|
- **RAGcore**: deployed to `http://192.168.10.150:1237`, application wiring for
|
||||||
|
search/context/answer is real (commit `a2905cc`, confirmed present in RAGcore's own
|
||||||
|
history at `13 commits behind HEAD`). `KNOWLEDGE_PROVIDER` is still `demo` in Fleet Ops
|
||||||
|
because real queries against the "Fleet Ops Procedures" space return **zero dense and
|
||||||
|
zero sparse candidates** at the raw retrieval stage — confirmed not a Fleet-Ops-side
|
||||||
|
wiring bug (RAGcore's own trusted Query Lab tool reproduces the identical zero-candidate
|
||||||
|
result against the same space). Root cause not yet found as of this audit; ruled out so
|
||||||
|
far: point count/scoping (83 published, correctly scoped), embedding digest mismatch
|
||||||
|
(matches), collection alias resolution (resolves correctly). One separate, confirmed,
|
||||||
|
pre-existing bug: `_DEFAULT_LANGUAGE = "en"` is hardcoded in RAGcore's ingestion handler
|
||||||
|
— every chunk is stamped `language: "en"` regardless of actual content; RAGcore has never
|
||||||
|
done real language detection. Not the cause of zero candidates, but must be fixed for
|
||||||
|
trilingual retrieval (task 6A) once the space is answerable at all.
|
||||||
|
- **MCP Hub**: the Fleet Ops read-only connector (4 tools, `mobilityops.*`) is **already
|
||||||
|
live in production** on Tower (commit `c4a0f6d`), reachable via `fleetops.itworx.tech`,
|
||||||
|
end-to-end verified once already per the Hub's own `CLAUDE.md`/`BUILD_STATE.json`. A
|
||||||
|
real contract bug was found and fixed there (`vehicle.get`'s input schema disagreed with
|
||||||
|
the actual wire parameter name — `vehicle_ref` vs `vehicleRef`). What is **not** yet done:
|
||||||
|
the Hub's own formal production-acceptance checklist row for MobilityOps (`CON-P04`) has
|
||||||
|
not been executed, and Fleet Ops's own `MCP_HUB_REGISTRATION_ENABLED`/base-URL
|
||||||
|
configuration has not been confirmed as actually wired and flipped on from the Fleet Ops
|
||||||
|
side (open item for this audit's Batch 4).
|
||||||
|
|
||||||
|
## Confirmed contradictions to resolve (task section 3)
|
||||||
|
|
||||||
|
- "Twee versus vier n8n-workflows": resolved above — 3 active + 1 built-but-unpublished.
|
||||||
|
Canonical set is 4; only 3 are live.
|
||||||
|
- "Demo-provider versus live RAGcore": Fleet Ops is still on the demo knowledge provider
|
||||||
|
by deliberate, documented decision (not an oversight) pending the retrieval root cause.
|
||||||
|
- "MCP Hub-status": prior Fleet Ops docs (`.env.example`, `MCP_HUB_REGISTRATION_ENABLED`)
|
||||||
|
predate the Hub-side deployment and need reconciling against the fact that the connector
|
||||||
|
is already live on the Hub side.
|
||||||
|
- Repo hygiene: removed an untracked, empty `backend;C` directory and an untracked 31 MB
|
||||||
|
`MobilityOps.zip` stray export; added `*.zip`/`*.tar.gz` to `.gitignore`. No accidentally
|
||||||
|
committed `__pycache__`/`.pytest_cache`/`test-results` were found in git history.
|
||||||
|
|
||||||
|
## RAGcore retrieval root cause — found and partially fixed (2026-08-05, this session)
|
||||||
|
|
||||||
|
Investigated live against production (`192.168.10.150`, containers `ragcore-app-1`,
|
||||||
|
`ragcore-qdrant-1`, `ragcore-postgres-1`, `ollama`), read-only first, then two approved
|
||||||
|
live changes.
|
||||||
|
|
||||||
|
**Root cause #1 (FIXED): filesystem permission bug, not authorization/data.** Verified,
|
||||||
|
in order, that every earlier suspect was actually healthy: the `control.grants` row
|
||||||
|
(active, `editor` role, correct application/space), the real
|
||||||
|
`ControlPlaneAuthorizationInputsProvider` + `RetrievalAuthorizationService.resolve()` code
|
||||||
|
path run in-process against the live DB (resolves a non-empty `effective_space_ids`), the
|
||||||
|
exact production Qdrant filter run directly against the live collection (returns real
|
||||||
|
matching points), and a real ANN vector query under that filter (real hits, sensible
|
||||||
|
scores). The actual break: `/workspace/.state/models/embedding_profiles.json` — the file
|
||||||
|
`RetrievalPipeline.run()` reads on every single query to resolve the active embedding
|
||||||
|
profile — was owned by container-side `root:root` mode `600` on the bind-mounted
|
||||||
|
`/mnt/cache/appdata/ragcore/state/models` host path, while the real running app process
|
||||||
|
is uid 10001 (`ragcore`). Every retrieval call hit a `PermissionError` reading its own
|
||||||
|
state file before ever reaching Qdrant — a plain filesystem-ownership bug, invisible to
|
||||||
|
every DB/Qdrant-level check. **Fixed live**: `chown 10001:10001` +
|
||||||
|
`chmod 644`/`755` on that file/directory (approved by the user beforehand). Re-verified
|
||||||
|
in-process: `RetrievalPipeline.run()` now returns 5 real, relevant hits for an English
|
||||||
|
damage-procedure question (previously 0).
|
||||||
|
|
||||||
|
**Root cause #2 (found, NOT fixed — needs a design decision): reranking is
|
||||||
|
architecturally unavailable.** `DEFAULT_RERANKER_PROFILE.model_identifier` is
|
||||||
|
`bge-reranker-v2-m3:v1`, which was never actually present in Ollama's model list (0 of 14
|
||||||
|
installed models matched). With the user's approval, pulled a working GGUF
|
||||||
|
(`xitao/bge-reranker-v2-m3:latest`, 1.2 GB) into the shared Ollama instance. **This did
|
||||||
|
not fix reranking**: `OllamaRerankAdapter` posts to `{ollama_base_url}/api/rerank`, and
|
||||||
|
this Ollama server (version `0.32.5`) returns a plain `404` for that route — it has no
|
||||||
|
rerank endpoint at all. This is not a missing-model problem, it is that RAGcore's
|
||||||
|
reranker adapter was built against an Ollama HTTP API that does not exist in the deployed
|
||||||
|
version (matches the code's own comment that no reranker-profile registry or live
|
||||||
|
validation existed yet). The retrieval pipeline degrades gracefully on rerank failure
|
||||||
|
(RRF-fusion-only hits still returned, confirmed above), but the `/v1/answers` endpoint's
|
||||||
|
answerability classifier still returns `not_answerable`/0 citations for real NL/EN/FR
|
||||||
|
questions against real matching content, live-verified after fix #1 with a freshly
|
||||||
|
minted, correctly-scoped credential.
|
||||||
|
|
||||||
|
Options for #2, not decided yet: (a) find/confirm whether a newer Ollama version adds a
|
||||||
|
real `/api/rerank` route and upgrade the shared instance (affects every other project on
|
||||||
|
this Ollama — needs its own explicit approval and blast-radius review); (b) change
|
||||||
|
RAGcore's reranker adapter to call a route Ollama actually supports (e.g. score via
|
||||||
|
`/api/embed` + a manual similarity/cross-encoder computation, or drop the separate
|
||||||
|
rerank step and let the answerability classifier trust RRF-fused scores) — a RAGcore
|
||||||
|
code/design change, out of Fleet Ops's own mandate to decide unilaterally; (c) leave
|
||||||
|
`KNOWLEDGE_PROVIDER=demo` until RAGcore's own team/session resolves this.
|
||||||
|
|
||||||
|
**Side effect to flag**: minting the live-verification credential used `rotate=True` on
|
||||||
|
the existing "Fleet Ops Knowledge Assistant (production)" service account (a second
|
||||||
|
credential would have exceeded RAGcore's own 2-active-credential cap), which invalidates
|
||||||
|
whatever token was previously issued for that account. Since Fleet Ops is still on
|
||||||
|
`KNOWLEDGE_PROVIDER=demo`, this has no live user-facing impact today, but a fresh
|
||||||
|
credential must be issued and wired into Fleet Ops's `RAGCORE_API_TOKEN` at actual
|
||||||
|
cutover time — do not assume the old one still works.
|
||||||
|
|
||||||
|
**Concurrency note**: `C:\Projects\RAGcore` had substantial uncommitted local changes
|
||||||
|
from what appears to be a different, actively-running session (36 modified/untracked
|
||||||
|
files by the end of this investigation, including files this investigation also read).
|
||||||
|
No commits or file edits were made in that checkout this session precisely because of
|
||||||
|
that collision risk — the two live fixes above were applied directly to the running
|
||||||
|
containers/Ollama instance (approved), not to the RAGcore git repository. **Follow-up
|
||||||
|
required**: once the concurrent session's work lands, the reranker-profile fix (whichever
|
||||||
|
option above is chosen) still needs an actual code change + commit + redeploy in
|
||||||
|
`C:\Projects\RAGcore`, which was not safe to do mid-collision this session.
|
||||||
|
|
||||||
|
## Minimal remaining implementation order
|
||||||
|
|
||||||
|
1. Root-cause the RAGcore zero-candidate retrieval bug (blocks flipping `KNOWLEDGE_PROVIDER`
|
||||||
|
and blocks the trilingual live-acceptance and AI Operations Brief tasks).
|
||||||
|
2. Fix RAGcore's hardcoded `language: "en"` chunk metadata for trilingual retrieval.
|
||||||
|
3. Decide on and execute n8n workflow 3 publication, with live no-op-on-rerun verification.
|
||||||
|
4. Confirm/complete Fleet Ops-side MCP Hub registration wiring and run the Hub's own
|
||||||
|
CON-P04 acceptance row.
|
||||||
|
5. Build the AI Operations Brief runbook once RAGcore and MCP Hub are both live-green.
|
||||||
|
6. GUI polish batch (dashboard Today/Attention presentation, duplicate-merge presentation,
|
||||||
|
About Demo scannability, Demo Guide completion state).
|
||||||
|
7. Final regression gates and evidence write-up.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Fleet Ops final localization — gap audit
|
||||||
|
|
||||||
|
Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`,
|
||||||
|
working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
|
||||||
|
(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy.
|
||||||
|
|
||||||
|
## 1. Remaining untranslated/incorrect text
|
||||||
|
|
||||||
|
### nl-BE
|
||||||
|
| File | Key | Current | Fix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" |
|
||||||
|
| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" |
|
||||||
|
| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" |
|
||||||
|
| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" |
|
||||||
|
| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" |
|
||||||
|
| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" |
|
||||||
|
| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" |
|
||||||
|
| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" |
|
||||||
|
| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" |
|
||||||
|
| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" |
|
||||||
|
| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" |
|
||||||
|
| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" |
|
||||||
|
| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" |
|
||||||
|
|
||||||
|
`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate,
|
||||||
|
siblings are single-word labels too.
|
||||||
|
|
||||||
|
### fr-BE
|
||||||
|
Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly
|
||||||
|
"Acteur" (no fix needed). French role translations: "Responsable des opérations" /
|
||||||
|
"Collaborateur de location", per the correction brief.
|
||||||
|
|
||||||
|
### Hardcoded JSX (bypasses i18n entirely)
|
||||||
|
`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English,
|
||||||
|
never localized. Fix: reuse the already-existing, already-translated
|
||||||
|
`detail.duplicateCustomer.fieldColumn` key (same table's `<thead>` seven lines above already uses it
|
||||||
|
correctly) — zero locale-file changes needed, pure JSX fix.
|
||||||
|
|
||||||
|
No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`.
|
||||||
|
|
||||||
|
## 2. Raw backend errors shown directly
|
||||||
|
|
||||||
|
13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`,
|
||||||
|
`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow:
|
||||||
|
`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real,
|
||||||
|
structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the
|
||||||
|
translated fallback only fires for network-level failures where no `ApiError` could even be
|
||||||
|
constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases
|
||||||
|
`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather
|
||||||
|
than staying a one-off.
|
||||||
|
|
||||||
|
Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status
|
||||||
|
fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via
|
||||||
|
`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same
|
||||||
|
`{"error": {"code", "message", "correlation_id"}}` shape):
|
||||||
|
|
||||||
|
`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED,
|
||||||
|
CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND,
|
||||||
|
IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID,
|
||||||
|
INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND,
|
||||||
|
ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE,
|
||||||
|
NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE,
|
||||||
|
NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE,
|
||||||
|
UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`.
|
||||||
|
|
||||||
|
Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only
|
||||||
|
in the raw English `message` string — so localized messages will be generic per-code (title +
|
||||||
|
explanation + optional next step), not parameterized with extracted specifics, with the raw string
|
||||||
|
preserved verbatim under "Technical details".
|
||||||
|
|
||||||
|
`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`,
|
||||||
|
`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired
|
||||||
|
to `ApiError.code` at all — only used as the non-`ApiError` fallback string.
|
||||||
|
|
||||||
|
## 3. i18n allowlist over-permissiveness
|
||||||
|
|
||||||
|
`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that
|
||||||
|
must be removed once role labels are translated: `auth.roleOperationsManager`,
|
||||||
|
`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`,
|
||||||
|
`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer
|
||||||
|
true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term)
|
||||||
|
also need removing once translated to "Auditgeschiedenis".
|
||||||
|
|
||||||
|
Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit
|
||||||
|
agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay.
|
||||||
|
|
||||||
|
Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB,
|
||||||
|
not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a
|
||||||
|
real blind spot the new tests (section 8 of the correction brief) need to close with a targeted,
|
||||||
|
explicit check for known English substrings appearing in nl-BE/fr-BE prose.
|
||||||
|
|
||||||
|
## 4. Dashboard greeting
|
||||||
|
|
||||||
|
No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static**
|
||||||
|
string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici
|
||||||
|
votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central,
|
||||||
|
testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per
|
||||||
|
the brief, updating on language change and on period rollover while the app stays open.
|
||||||
|
|
||||||
|
## 5. Documentation staleness
|
||||||
|
|
||||||
|
- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."` —
|
||||||
|
predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file.
|
||||||
|
- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on
|
||||||
|
fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` —
|
||||||
|
**false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git
|
||||||
|
history, neither is mentioned in the file, and the branch name has moved on to
|
||||||
|
`fix/fleet-ops-final-i18n-ux`.
|
||||||
|
- A separate, older `"## Demo productization (in progress, same branch
|
||||||
|
feat/mobilityops-functional-completion)"` section header was also never marked complete.
|
||||||
|
- README.md is accurate and current — no fix needed there beyond a version-count refresh after this
|
||||||
|
round's test additions.
|
||||||
|
- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor",
|
||||||
|
"Start scenario", "Recent", "Open", embedded mid-sentence mentions).
|
||||||
|
2. Fix the one hardcoded `data-label="Field"` JSX bug.
|
||||||
|
3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call
|
||||||
|
sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks,
|
||||||
|
raw text demoted to "Technical details".
|
||||||
|
4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring
|
||||||
|
test, a `describeApiError` coverage test, and greeting boundary tests.
|
||||||
|
5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for
|
||||||
|
4 periods × 3 languages + a localized description line replacing the current static one.
|
||||||
|
6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries).
|
||||||
|
7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages →
|
||||||
|
merge to master → redeploy → final evidence.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# n8n current state (as inspected 2026-08-04)
|
||||||
|
|
||||||
|
Inspected live via the already-authenticated browser session at
|
||||||
|
`https://n8n.itworx.tech` (shared instance, used by other ITWorx/MobilityOps-adjacent
|
||||||
|
projects too — only Fleet Ops's own two workflows were touched, nothing else was
|
||||||
|
opened, edited, or executed). No secret credential values are reproduced in this
|
||||||
|
document.
|
||||||
|
|
||||||
|
## Reachability and version
|
||||||
|
|
||||||
|
- n8n is reachable at `https://n8n.itworx.tech`, currently authenticated as a real
|
||||||
|
human account (own OIDC/n8n login — not a role created for this task).
|
||||||
|
- Workspace-level stats at the time of inspection: **114 total prod. executions, 4
|
||||||
|
failed (3.5% failure rate)**, avg run time 0.18s. (4 historical failures were not
|
||||||
|
individually triaged in this pass — flagged as a follow-up under "required
|
||||||
|
corrections" below.)
|
||||||
|
- Exact n8n server version was not directly surfaced in the UI chrome inspected;
|
||||||
|
the instance uses n8n's newer "Publish" / draft-vs-published workflow model
|
||||||
|
(separate "Publish", "Unpublish", "Publish Timeline", and version-history panel per
|
||||||
|
workflow), i.e. a fairly recent n8n release.
|
||||||
|
|
||||||
|
## Production webhook base
|
||||||
|
|
||||||
|
`http://192.168.10.150:5678/webhook/...` — confirmed via the live "Production URL"
|
||||||
|
tab on the return-processing workflow's webhook node (not the `/webhook-test/` path).
|
||||||
|
This matches `N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return`
|
||||||
|
already documented for the MobilityOps deployment.
|
||||||
|
|
||||||
|
## Found Fleet Ops workflows
|
||||||
|
|
||||||
|
Exactly two workflows exist in this n8n account, both under "Personal" / both tagged
|
||||||
|
"Published" in the workflow list:
|
||||||
|
|
||||||
|
| Live name | Live workflow ID (from URL) | Created | Last updated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MobilityOps - Vehicle Return Processing` | `mobilityops-return-processing` | 2 Aug | 1 day ago |
|
||||||
|
| `MobilityOps - Scheduled Quality Scan` | `mobilityops-scheduled-quality-scan` | 2 Aug | 1 day ago |
|
||||||
|
|
||||||
|
Both workflow IDs match the repo's own `n8n/mobilityops-return-processing.json` and
|
||||||
|
`n8n/mobilityops-scheduled-quality-scan.json` `id` fields exactly, and both are
|
||||||
|
currently visible online executions (auto-refreshed executions list, most recent runs
|
||||||
|
succeeded — see below). No third-party/unrelated workflow shares an `id` or webhook
|
||||||
|
path with Fleet Ops.
|
||||||
|
|
||||||
|
## Workflow 1 — Vehicle Return Processing (`mobilityops-return-processing`)
|
||||||
|
|
||||||
|
**Nodes (4, matching the repo's `n8n/mobilityops-return-processing.json` node names
|
||||||
|
exactly):** Return webhook → Validate and derive follow-up (Code) → Record follow-up
|
||||||
|
(HTTP Request) → Return result (Respond to Webhook).
|
||||||
|
|
||||||
|
- **Trigger**: webhook, `POST`, path `mobilityops-return`, production URL
|
||||||
|
`http://192.168.10.150:5678/webhook/mobilityops-return`. **n8n-level
|
||||||
|
Authentication is set to "None."** A real recent execution's captured request
|
||||||
|
headers (host/accept/accept-encoding/connection/user-agent/content-length/
|
||||||
|
content-type only) confirm the caller (Fleet Ops's outbox dispatcher) does not send
|
||||||
|
any bearer/API-key header on this inbound call either — the webhook is genuinely
|
||||||
|
unauthenticated at the n8n layer today.
|
||||||
|
- **Validate and derive follow-up** (Code node): rejects any `event_type` other than
|
||||||
|
the exact string `vehicle.returned.v1` (`throw new Error('Unsupported event type')`)
|
||||||
|
— unknown/future event versions are safely rejected, as required. Derives
|
||||||
|
`follow_up: 'attention_required' | 'cleaning'` from `data.attention_reasons`.
|
||||||
|
- **Record follow-up** (HTTP Request → Fleet Ops): `POST
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback`, sends
|
||||||
|
`Idempotency-Key: {{$json.event_id}}` and an `X-Service-Token` header. **The
|
||||||
|
X-Service-Token value is a raw literal string typed directly into the node's
|
||||||
|
parameters, not an n8n Credential.** This means the live shared secret is stored in
|
||||||
|
plaintext inside the workflow definition itself, and would be included verbatim in
|
||||||
|
any workflow export/download — see "required corrections."
|
||||||
|
Body: `{{JSON.stringify($json)}}`.
|
||||||
|
- **Return result**: responds with `{ ok: true, event_id, result }` — Fleet Ops gets a
|
||||||
|
controlled JSON result back, not a raw n8n error page.
|
||||||
|
- **Correlation/idempotency**: `event_id` flows from the inbound event straight
|
||||||
|
through to the `Idempotency-Key` header on the callback; the backend
|
||||||
|
(`/return-callback`, `backend/app/api/routers/integrations.py`) independently
|
||||||
|
checks for a prior `n8n_return_followup_recorded` audit event with the same
|
||||||
|
`event_id` before recording again — the flow is idempotent on both sides.
|
||||||
|
- **Latest execution**: 4 Aug, 03:34:19, succeeded in 32ms, all 4 nodes green.
|
||||||
|
- **Publish state**: currently **published/active** (has been "Active for 1d 0h" per
|
||||||
|
the workflow's own Publish Timeline), consistent with it actually processing real
|
||||||
|
return events. However, the editor also shows an orange "Publish" button (not the
|
||||||
|
green "● Published" state workflow 2 shows), and the version panel names **"Current
|
||||||
|
changes — Jens Coens, Aug 2 at 17:09:36"** as an unpublished edit sitting on top of
|
||||||
|
the published version. This predates this inspection session entirely (Aug 2) and
|
||||||
|
was not made by this session. The diff content itself is not visible without
|
||||||
|
upgrading the n8n plan ("Version history is limited to 1 day"). **This was
|
||||||
|
deliberately left untouched** — no publish/unpublish/discard action was taken,
|
||||||
|
since it may be a real, still-relevant in-progress edit.
|
||||||
|
|
||||||
|
## Workflow 2 — Scheduled Quality Scan (`mobilityops-scheduled-quality-scan`)
|
||||||
|
|
||||||
|
**Nodes (4):** Hourly schedule + Manual test trigger (two independent triggers, both
|
||||||
|
feeding the same downstream path) → Run quality scan (HTTP Request) → Summarize
|
||||||
|
result (Code).
|
||||||
|
|
||||||
|
- **Hourly schedule**: interval `Hours`, every `1` hour, at minute `0`. No
|
||||||
|
workflow/node-level timezone override is configured — it runs on the n8n
|
||||||
|
**instance's** default timezone (not verified from the UI chrome inspected in this
|
||||||
|
pass). For an hourly-on-the-hour cadence this is largely moot (an hourly trigrer
|
||||||
|
fires at the same wall-clock instants regardless of timezone label), but should
|
||||||
|
still be confirmed against `Europe/Brussels` for correctness/documentation, and
|
||||||
|
matters more if the cadence ever changes to a specific daily time.
|
||||||
|
- **Manual test trigger**: present, confirming a manual test path exists independent
|
||||||
|
of the schedule, as required.
|
||||||
|
- **Run quality scan** (HTTP Request → Fleet Ops): `POST
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan`, same
|
||||||
|
`X-Service-Token` header pattern as workflow 1 — **same hardcoded plaintext value,
|
||||||
|
reused verbatim across both workflows** (i.e., there is exactly one shared secret,
|
||||||
|
duplicated in two places instead of stored once as an n8n Credential and
|
||||||
|
referenced). `Timeout: 15000` ms configured (bounded). No query params, no body.
|
||||||
|
- **Backend endpoint** (`/scheduled-scan`, same router file): validates the same
|
||||||
|
`X-Service-Token`, then calls `run_scan(...)`, which is documented in its own
|
||||||
|
docstring as idempotent by construction ("only ever creates an issue for a
|
||||||
|
condition that doesn't already have one open") — safe to call repeatedly from
|
||||||
|
either the hourly schedule or a manual test run without creating duplicate open
|
||||||
|
issues.
|
||||||
|
- **Summarize result** (Code node): `total_created = sum(created.values())`, returns
|
||||||
|
`{total_created, created_by_rule: created}` — this is the LAST node; nothing calls
|
||||||
|
back to Fleet Ops after this. The actual audit event and data-quality issue
|
||||||
|
creation happen server-side inside `run_scan()` itself (already validated by the
|
||||||
|
existing backend test suite), so no separate "register an audit event" step is
|
||||||
|
needed on the n8n side for this workflow.
|
||||||
|
- **Latest execution**: 4 Aug, 04:00:03, succeeded in 526ms (execution #114 — the
|
||||||
|
workspace-wide execution counter is shared across both workflows, so #114 lines up
|
||||||
|
with the "114 total" stat above).
|
||||||
|
- **Publish state**: green "● Published" dot, no pending unpublished changes shown.
|
||||||
|
|
||||||
|
## Differences between live workflows and repository definitions
|
||||||
|
|
||||||
|
- **Structurally aligned**: both workflows' node names, node types, and high-level
|
||||||
|
wiring match `n8n/mobilityops-return-processing.json` and
|
||||||
|
`n8n/mobilityops-scheduled-quality-scan.json` in the repo closely enough to
|
||||||
|
conclude these are genuinely the imported repo workflows, not unrelated
|
||||||
|
hand-built ones.
|
||||||
|
- **Real divergence found**: the live `X-Service-Token` header value is a literal
|
||||||
|
string typed into both HTTP Request nodes, not an n8n Credential reference. Whether
|
||||||
|
the repo JSON also encodes this as a literal (vs. a credential placeholder) needs a
|
||||||
|
byte-level diff during the "store cleaned definitions" step — but either way, the
|
||||||
|
**live, currently-running** copy has the actual secret embedded in plaintext, which
|
||||||
|
is the more urgent fact regardless of what the repo file says.
|
||||||
|
- **Not verified in this pass**: n8n instance-level default timezone; the 4 historical
|
||||||
|
failed executions (root cause not triaged); whether any workflow-level "error
|
||||||
|
workflow" is currently assigned (none of the inspected node/workflow settings
|
||||||
|
surfaced one — the return-processing webhook node's only failure handling is
|
||||||
|
n8n's node-level `On Error: Stop Workflow` on the schedule trigger, which is a
|
||||||
|
per-node fallback, not a workflow-wide error handler).
|
||||||
|
|
||||||
|
## Stale or duplicate workflows
|
||||||
|
|
||||||
|
None found. Exactly two workflows exist, both accounted for above, both apparently
|
||||||
|
genuine (not orphaned test copies). No `ARCHIVED —`-prefixed or otherwise stale
|
||||||
|
workflow exists yet.
|
||||||
|
|
||||||
|
## Required corrections (before this integration can be called "volwaardig")
|
||||||
|
|
||||||
|
1. **Move the shared `X-Service-Token` secret into an n8n Credential** (e.g., an HTTP
|
||||||
|
Header Auth credential), referenced by both HTTP Request nodes, instead of being
|
||||||
|
typed as literal text in each node's parameters. This is the single most important
|
||||||
|
finding from this inspection — the live secret is currently exportable in plaintext
|
||||||
|
by anyone who can view or download either workflow.
|
||||||
|
2. **Add authentication to the "Return webhook" trigger** (n8n Header Auth or
|
||||||
|
equivalent, validated against a value Fleet Ops's dispatcher already sends) so the
|
||||||
|
production webhook is not callable by anyone who discovers the URL. Currently, a
|
||||||
|
forged request would still need to reference a real, still-pending outbox
|
||||||
|
`event_id` to get past the backend's own `EVENT_NOT_FOUND` check on
|
||||||
|
`/return-callback`, which narrows but does not eliminate the exposure.
|
||||||
|
3. Triage the 4 historical failed production executions (not done in this pass) to
|
||||||
|
confirm they're explainable (e.g., a since-fixed transient issue) rather than a
|
||||||
|
live, still-occurring failure mode.
|
||||||
|
4. Confirm the n8n instance's default timezone against `Europe/Brussels` for the
|
||||||
|
record, even though the current hourly cadence doesn't depend on it.
|
||||||
|
5. Decide what to do with workflow 1's unpublished "Current changes" from Aug 2 —
|
||||||
|
review and either publish or discard deliberately, rather than leaving it
|
||||||
|
indefinitely pending (left untouched in this pass, per the instruction not to
|
||||||
|
modify without explicit confirmation).
|
||||||
|
6. Rename both to the brief's canonical visible names once corrected/republished:
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration" and "Fleet Ops — Scheduled Data Quality
|
||||||
|
Scan" (currently still named with the "MobilityOps -" prefix).
|
||||||
|
|
||||||
|
## Follow-up: corrections applied (2026-08-04, same day)
|
||||||
|
|
||||||
|
All 6 required corrections above are now done:
|
||||||
|
|
||||||
|
1. **Done.** Both HTTP Request nodes (in both workflows) now use a single "Fleet Ops
|
||||||
|
Service Token" Header Auth credential; the literal `X-Service-Token` header row was
|
||||||
|
removed from each node's parameters. Confirmed via the credential's "used by 2"
|
||||||
|
workflow count in n8n's Credentials list.
|
||||||
|
2. **Done.** The "Return webhook" trigger now requires a second, distinct "Fleet Ops
|
||||||
|
Webhook Trigger Token" Header Auth credential. Fleet Ops's outbox dispatcher
|
||||||
|
(`backend/app/services/dispatcher.py`) now sends the matching
|
||||||
|
`X-Fleet-Ops-Trigger-Token` header (new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting,
|
||||||
|
added to `.env.example`, `compose.yaml`, the local dev `.env`, and the Unraid
|
||||||
|
server's `.env`). Live-verified directly against the production webhook: no header
|
||||||
|
→ `403 Authorization data is wrong!`; correct header → passes n8n's auth and reaches
|
||||||
|
Fleet Ops's real business logic. Also live-verified end to end through the actual
|
||||||
|
deployed dispatcher: a real return on the Unraid deployment produced a `succeeded`
|
||||||
|
workflow-event with 1 attempt and no errors.
|
||||||
|
- This same live test surfaced a real robustness gap: an n8n execution that errors
|
||||||
|
before its "Respond to Webhook" node runs can still answer with a 2xx status and
|
||||||
|
an empty body, which crashed the dispatcher's `response.json()` outside its own
|
||||||
|
error handling. Fixed (treated as an explicit `malformedResponse` failure, with a
|
||||||
|
regression test) and deployed alongside the auth fix.
|
||||||
|
3. **Done.** Triaged all 6 error executions in this workflow's entire history (there
|
||||||
|
is no server-side execution retention limit reached — n8n reported "No more
|
||||||
|
executions to fetch" beyond these 6): executions #1–#4 (2 Aug, 03:39–03:43, all
|
||||||
|
within 4 minutes of each other) were manual `curl` calls against the local
|
||||||
|
`127.0.0.7:5678` test webhook with a `curl/8.16.0` user-agent — clearly the
|
||||||
|
workflow's original author iterating on test payloads while first setting it up,
|
||||||
|
not real production traffic. Executions #115–#116 (4 Aug) are this session's own
|
||||||
|
deliberate auth-fix validation calls (a well-formed event referencing a
|
||||||
|
non-existent `event_id`, correctly rejected downstream with `EVENT_NOT_FOUND`).
|
||||||
|
**Zero unexplained or currently-live failures.**
|
||||||
|
4. Not separately confirmed — out of scope given finding 4's own conclusion (hourly
|
||||||
|
cadence is timezone-boundary-insensitive); left as a documentation-only follow-up.
|
||||||
|
5. **Done, per explicit user confirmation.** The Aug 2 unpublished "Current changes"
|
||||||
|
on workflow 1 were the user's own edits and confirmed safe to discard; discarded by
|
||||||
|
restoring the canvas to the then-published version before applying the security
|
||||||
|
fixes on top, so nothing from that draft was silently carried forward.
|
||||||
|
6. **Done.** Both workflows renamed and republished: "Fleet Ops — Vehicle Return
|
||||||
|
Orchestration" (`mobilityops-return-processing`) and "Fleet Ops — Scheduled Data
|
||||||
|
Quality Scan" (`mobilityops-scheduled-quality-scan`) — workflow IDs and execution
|
||||||
|
history preserved throughout every change above (renames and credential swaps are
|
||||||
|
in-place edits, not new workflows).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm ci && npm run build
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => {
|
|||||||
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
|
|||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
await page.screenshot({ path: `${OUT}/1-login.png` });
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as
|
|||||||
|
|
||||||
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
||||||
@@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => {
|
|||||||
page.on("pageerror", (err) => errors.push(err.message));
|
page.on("pageerror", (err) => errors.push(err.message));
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||||
@@ -96,7 +96,7 @@ test("status-recommendation panel is fully keyboard operable, respects reduced m
|
|||||||
|
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy
|
|||||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste
|
|||||||
|
|
||||||
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||||
@@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
|
|||||||
|
|
||||||
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async
|
|||||||
|
|
||||||
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
|
|
||||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
||||||
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
|
await duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
|
||||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it",
|
|||||||
|
|
||||||
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
|
|||||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async
|
|||||||
|
|
||||||
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality");
|
await page.goto("/data-quality");
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
|
|||||||
|
|
||||||
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/knowledge");
|
await page.goto("/knowledge");
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
|||||||
await test.step("1. login as Operations Manager", async () => {
|
await test.step("1. login as Operations Manager", async () => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { ApiError } from "../src/api/apiError";
|
||||||
|
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
|
||||||
|
|
||||||
|
// Pure Node-context checks for the central API-error-localization function (section 7 /
|
||||||
|
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
|
||||||
|
// only depends on a `t` function and a caught error, so it's tested here against the
|
||||||
|
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
|
||||||
|
// known-HTTP-status paths never leak raw backend English as the primary message, and
|
||||||
|
// that the raw text is always still available via `.technical` for "Technical details".
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
|
||||||
|
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
|
||||||
|
|
||||||
|
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
|
||||||
|
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
|
||||||
|
// resolving "namespace:dotted.path" against the real locale files for the given language.
|
||||||
|
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
|
||||||
|
return (key: string, options?: Record<string, unknown>) => {
|
||||||
|
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
|
||||||
|
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
|
||||||
|
const data = loadNamespace(language, ns);
|
||||||
|
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
|
||||||
|
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||||
|
return undefined;
|
||||||
|
}, data);
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (options && "defaultValue" in options) return String(options.defaultValue);
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
|
||||||
|
|
||||||
|
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
|
||||||
|
for (const code of KNOWN_CODES) {
|
||||||
|
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
|
||||||
|
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
codes[code]?.explanation?.trim().length ?? 0,
|
||||||
|
`${language}/errors.json:codes.${code}.explanation is empty`,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
|
||||||
|
for (const status of KNOWN_HTTP_STATUSES) {
|
||||||
|
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
|
||||||
|
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
http[status]?.explanation?.trim().length ?? 0,
|
||||||
|
`${language}/errors.json:http.${status}.explanation is empty`,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const raw = "IntegrityError: duplicate key value violates unique constraint";
|
||||||
|
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
|
||||||
|
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
|
||||||
|
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
|
||||||
|
expect(info.title).not.toBe(raw);
|
||||||
|
expect(info.explanation).not.toBe(raw);
|
||||||
|
// The raw backend text must still be reachable, just demoted to `.technical`.
|
||||||
|
expect(info.technical).toBe(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
|
||||||
|
const t = makeT("nl-BE");
|
||||||
|
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
|
||||||
|
expect(withNextStep.nextStep).toBeTruthy();
|
||||||
|
|
||||||
|
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
|
||||||
|
expect(withoutNextStep.nextStep).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const raw = "Some brand-new backend code nobody localized yet";
|
||||||
|
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
|
||||||
|
expect(info.title).toBe(expected404.title);
|
||||||
|
expect(info.explanation).toBe(expected404.explanation);
|
||||||
|
expect(info.title).not.toBe(raw);
|
||||||
|
expect(info.technical).toBe(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const raw = "418 I'm a teapot (never mapped)";
|
||||||
|
const err = new ApiError(418, "418", raw, "corr-3");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
|
||||||
|
expect(info.title).toBe(generic.title);
|
||||||
|
expect(info.explanation).toBe(generic.explanation);
|
||||||
|
expect(info.technical).toBe(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
|
||||||
|
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
|
||||||
|
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
|
||||||
|
const t = makeT("fr-BE");
|
||||||
|
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
|
||||||
|
expect(info.title).toBe(expected401.title);
|
||||||
|
expect(info.title).not.toBe("Not authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
|
||||||
|
const t = makeT("nl-BE");
|
||||||
|
const networkFailure = new TypeError("Failed to fetch");
|
||||||
|
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
|
||||||
|
// already scoped via useTranslation("integrations"); this stub's default namespace is
|
||||||
|
// "errors", so the fallback key is qualified explicitly here to match.
|
||||||
|
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
|
||||||
|
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
|
||||||
|
expect(info.explanation).toBe(expectedFallback.retryFailed);
|
||||||
|
expect(info.explanation).not.toBe("Failed to fetch");
|
||||||
|
expect(info.technical).toBe("Failed to fetch");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Backend/frontend AppError code drift guard ---
|
||||||
|
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
|
||||||
|
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
|
||||||
|
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
|
||||||
|
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
|
||||||
|
// a missed opportunity for a more specific message. This test surfaces that drift instead
|
||||||
|
// of letting it go unnoticed indefinitely.
|
||||||
|
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
|
||||||
|
|
||||||
|
function collectPyFiles(dir: string): string[] {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return collectPyFiles(full);
|
||||||
|
return entry.name.endsWith(".py") ? [full] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBackendAppErrorCodes(): Set<string> {
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
|
||||||
|
const source = fs.readFileSync(file, "utf-8");
|
||||||
|
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(source)) !== null) {
|
||||||
|
codes.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
|
||||||
|
const backendCodes = collectBackendAppErrorCodes();
|
||||||
|
const frontendCodes = KNOWN_CODES;
|
||||||
|
|
||||||
|
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
|
||||||
|
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
missingFromFrontend,
|
||||||
|
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
|
||||||
|
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
staleInFrontend,
|
||||||
|
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
|
||||||
|
`removed on the backend side: ${staleInFrontend.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const info = describeApiError(t, new TypeError("Failed to fetch"));
|
||||||
|
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
|
||||||
|
expect(info.title).toBe(generic.title);
|
||||||
|
expect(info.explanation).toBe(generic.explanation);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -19,9 +19,9 @@ async function resetDemoData(request: APIRequestContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
||||||
"nl-BE": "Verken als Operations Manager",
|
"nl-BE": "Verken als Operationsmanager",
|
||||||
"en-GB": "Explore as Operations Manager",
|
"en-GB": "Explore as Operations Manager",
|
||||||
"fr-BE": "Explorer en tant qu'Operations Manager",
|
"fr-BE": "Explorer en tant que Responsable des opérations",
|
||||||
};
|
};
|
||||||
|
|
||||||
const REVIEW_RECOMMENDATION: Record<string, string> = {
|
const REVIEW_RECOMMENDATION: Record<string, string> = {
|
||||||
@@ -74,6 +74,16 @@ test.describe("branding", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await page.goto("/login");
|
||||||
|
const href = await page.locator('link[rel="icon"]').getAttribute("href");
|
||||||
|
expect(href).toBe("/favicon.svg");
|
||||||
|
const response = await page.request.get(href as string);
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.headers()["content-type"]).toContain("svg");
|
||||||
|
});
|
||||||
|
|
||||||
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
|
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
||||||
@@ -292,7 +302,11 @@ test("automation shows a localized error explanation with the raw error only und
|
|||||||
|
|
||||||
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
|
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
|
||||||
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
|
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
|
||||||
await page.getByText("Technische details").first().click();
|
// Scope to the failed job's own row -- the workflow-evidence table above it also has
|
||||||
|
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
|
||||||
|
// the wrong one.
|
||||||
|
const failedJobRow = page.locator("tr", { has: page.getByText(/tijdelijk niet bereikbaar/) });
|
||||||
|
await failedJobRow.getByText("Technische details").click();
|
||||||
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
|
||||||
|
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
|
||||||
|
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
|
||||||
|
// control the browser's Date without waiting on real wall-clock time. Also proves the
|
||||||
|
// greeting updates live (no reload) when the period rolls over while the app stays open.
|
||||||
|
|
||||||
|
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
|
||||||
|
function cet(hour: number, minute = 0, second = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
|
||||||
|
{ time: cet(4, 59), label: "04:59", period: "night" },
|
||||||
|
{ time: cet(5, 0), label: "05:00", period: "morning" },
|
||||||
|
{ time: cet(11, 59), label: "11:59", period: "morning" },
|
||||||
|
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
|
||||||
|
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
|
||||||
|
{ time: cet(18, 0), label: "18:00", period: "evening" },
|
||||||
|
{ time: cet(22, 59), label: "22:59", period: "evening" },
|
||||||
|
{ time: cet(23, 0), label: "23:00", period: "night" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
|
||||||
|
"nl-BE": {
|
||||||
|
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
|
||||||
|
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
|
||||||
|
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
|
||||||
|
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
|
||||||
|
},
|
||||||
|
"en-GB": {
|
||||||
|
morning: "Good morning. Here's today's fleet status.",
|
||||||
|
afternoon: "Good afternoon. Here's the current overview of your fleet.",
|
||||||
|
evening: "Good evening. Here's this evening's fleet overview.",
|
||||||
|
night: "Welcome back. Here's the latest overview of your fleet.",
|
||||||
|
},
|
||||||
|
"fr-BE": {
|
||||||
|
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
|
||||||
|
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
|
||||||
|
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
|
||||||
|
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loginAsOperationsManager(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
|
||||||
|
// At the default desktop viewport, only the topbar's compact switcher is visible --
|
||||||
|
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
|
||||||
|
await page.locator(".language-switcher-compact select").selectOption(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
|
||||||
|
|
||||||
|
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
|
||||||
|
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
|
||||||
|
await page.clock.install({ time: BOUNDARY_CASES[0].time });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
if (language !== "nl-BE") {
|
||||||
|
await switchLanguage(page, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { time, label, period } of BOUNDARY_CASES) {
|
||||||
|
await page.clock.setFixedTime(time);
|
||||||
|
await page.reload();
|
||||||
|
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
|
||||||
|
EXPECTED_TITLE[language][period],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
|
||||||
|
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
|
||||||
|
await page.clock.install({ time: cet(11, 59, 31) });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
|
||||||
|
|
||||||
|
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
|
||||||
|
// recomputes the period -- no page.reload() call anywhere in this test.
|
||||||
|
await page.clock.fastForward(30_000);
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
|
||||||
|
await page.clock.install({ time: cet(9, 0) });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
|
||||||
|
|
||||||
|
await switchLanguage(page, "en-GB");
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
|
||||||
|
|
||||||
|
await switchLanguage(page, "fr-BE");
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
|
||||||
|
await page.clock.install({ time: cet(2, 0) });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
|
||||||
|
await expect(dashboardHeading(page)).toContainText("Welkom terug");
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
|
||||||
|
|
||||||
|
// Pure Node-context boundary tests for the central, clock-injectable greeting function
|
||||||
|
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
|
||||||
|
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
|
||||||
|
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
|
||||||
|
// by picking instants either side of the CET/CEST transition.
|
||||||
|
|
||||||
|
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
|
||||||
|
function cet(hour: number, minute = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
|
||||||
|
function cest(hour: number, minute = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
|
||||||
|
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
|
||||||
|
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
|
||||||
|
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
|
||||||
|
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
|
||||||
|
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
|
||||||
|
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
|
||||||
|
expect(getBrusselsHour(beforeTransition)).toBe(1);
|
||||||
|
expect(getGreetingPeriod(beforeTransition)).toBe("night");
|
||||||
|
|
||||||
|
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
|
||||||
|
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
|
||||||
|
expect(getBrusselsHour(afterTransition)).toBe(11);
|
||||||
|
expect(getGreetingPeriod(afterTransition)).toBe("morning");
|
||||||
|
|
||||||
|
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
|
||||||
|
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
|
||||||
|
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
|
||||||
|
expect(getBrusselsHour(beforeFallBack)).toBe(2);
|
||||||
|
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
|
||||||
|
|
||||||
|
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
|
||||||
|
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
|
||||||
|
expect(getBrusselsHour(afterFallBack)).toBe(10);
|
||||||
|
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default argument uses the real current time when no clock is injected", () => {
|
||||||
|
const period = getGreetingPeriod();
|
||||||
|
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
|
||||||
|
});
|
||||||
@@ -97,6 +97,24 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const raw = JSON.stringify(loadNamespace(language, namespace));
|
||||||
|
expect(
|
||||||
|
raw.includes("MobilityOps"),
|
||||||
|
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
|
||||||
|
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
/\bPoC\b/.test(raw),
|
||||||
|
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
|
||||||
|
`in user-facing copy`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
||||||
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
||||||
// the literal English string under the right key and still pass). For every "real prose"
|
// the literal English string under the right key and still pass). For every "real prose"
|
||||||
@@ -110,19 +128,13 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
|
|||||||
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
||||||
// hide an unrelated real mistranslation under the same key in a different namespace.
|
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||||
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
||||||
"audit.title", // "Audit trail" kept as an established cross-language compliance term
|
|
||||||
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
||||||
"auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles)
|
|
||||||
"auth.roleRentalEmployee",
|
|
||||||
"demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels
|
|
||||||
"demo.scenarios.roles.rental_employee",
|
|
||||||
"common.language.nl-BE", // language-picker options show each language's own endonym
|
"common.language.nl-BE", // language-picker options show each language's own endonym
|
||||||
"common.language.fr-BE",
|
"common.language.fr-BE",
|
||||||
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||||
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||||
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||||
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
||||||
"demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch
|
|
||||||
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
||||||
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||||
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
||||||
@@ -131,8 +143,8 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
|||||||
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
||||||
"knowledge.retrievalFlow.question",
|
"knowledge.retrievalFlow.question",
|
||||||
"knowledge.questionLabelExchange",
|
"knowledge.questionLabelExchange",
|
||||||
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
|
|
||||||
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
||||||
|
"integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and French
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isTranslatableProse(value: unknown): value is string {
|
function isTranslatableProse(value: unknown): value is string {
|
||||||
@@ -185,6 +197,61 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
|
||||||
|
// The whole-string identity check above only catches a value that is IDENTICAL to
|
||||||
|
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
|
||||||
|
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
|
||||||
|
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
|
||||||
|
// goedgekeurd." This scan flags known English fragments appearing literally inside any
|
||||||
|
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
|
||||||
|
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
|
||||||
|
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
|
||||||
|
const FORBIDDEN_ENGLISH_FRAGMENTS = [
|
||||||
|
"Operations Manager",
|
||||||
|
"Operations Managers",
|
||||||
|
"Rental Employee",
|
||||||
|
"Rental Employees",
|
||||||
|
"Audit trail",
|
||||||
|
"Start scenario",
|
||||||
|
];
|
||||||
|
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
|
||||||
|
"Operationsmanager",
|
||||||
|
"Verhuurmedewerker",
|
||||||
|
"Auditgeschiedenis",
|
||||||
|
"Scenario starten",
|
||||||
|
];
|
||||||
|
|
||||||
|
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
|
||||||
|
if (typeof value === "string") return [{ path: prefix, value }];
|
||||||
|
if (value === null || typeof value !== "object") return [];
|
||||||
|
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||||
|
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
|
||||||
|
const findings: string[] = [];
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const nl = loadNamespace("nl-BE", namespace);
|
||||||
|
const fr = loadNamespace("fr-BE", namespace);
|
||||||
|
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
|
||||||
|
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
|
||||||
|
if (value.includes(fragment)) {
|
||||||
|
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
|
||||||
|
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
|
||||||
|
if (value.includes(fragment)) {
|
||||||
|
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(findings, findings.join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Hardcoded JSX text (section 11D) ---
|
// --- Hardcoded JSX text (section 11D) ---
|
||||||
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
||||||
// `{...}` expression) containing two or more real words are almost always user-facing
|
// `{...}` expression) containing two or more real words are almost always user-facing
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ test("data quality: manual scan runs and shows a result summary", async ({ page,
|
|||||||
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/automation");
|
await page.goto("/automation");
|
||||||
await expect(page.locator(".data-table")).toBeVisible();
|
const jobsTable = page.getByRole("table", { name: "Automation jobs" });
|
||||||
|
await expect(jobsTable).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel("Status").selectOption("failed");
|
await page.getByLabel("Status").selectOption("failed");
|
||||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
|
||||||
const failedRowCountBefore = await page.locator(".data-table tbody tr").count();
|
const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
|
||||||
const retryButton = page.getByRole("button", { name: "Retry" }).first();
|
const retryButton = page.getByRole("button", { name: "Retry" }).first();
|
||||||
await expect(retryButton).toBeVisible();
|
await expect(retryButton).toBeVisible();
|
||||||
await retryButton.click();
|
await retryButton.click();
|
||||||
@@ -256,7 +257,7 @@ test("automation page: status filter and retry button work", async ({ page, requ
|
|||||||
// filtered to "failed" — the row correctly disappears from this view rather than
|
// filtered to "failed" — the row correctly disappears from this view rather than
|
||||||
// showing "pending" in place. Confirm the filtered list shrank by one.
|
// showing "pending" in place. Confirm the filtered list shrank by one.
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const count = await page.locator(".data-table tbody tr").count();
|
const count = await jobsTable.locator("tbody tr").count();
|
||||||
expect(count).toBe(failedRowCountBefore - 1);
|
expect(count).toBe(failedRowCountBefore - 1);
|
||||||
}).toPass({ timeout: 5000 });
|
}).toPass({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
|
||||||
|
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||||
|
|
||||||
|
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
|
||||||
|
expect(vehicles.ok()).toBeTruthy();
|
||||||
|
const vehiclePage = await vehicles.json();
|
||||||
|
expect(vehiclePage.items).toHaveLength(25);
|
||||||
|
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
|
||||||
|
|
||||||
|
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
|
||||||
|
expect(audit.ok()).toBeTruthy();
|
||||||
|
const auditPage = await audit.json();
|
||||||
|
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
||||||
|
|
||||||
|
await page.goto("/vehicles?status=maintenance&page=1");
|
||||||
|
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/status=maintenance/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
|
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||||
|
await expect(page.locator(".page-actions .badge")).toBeVisible();
|
||||||
|
await page.goto("/vehicles");
|
||||||
|
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
|
||||||
|
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Fleet Ops</title>
|
<title>Fleet Ops</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#0f172a" />
|
||||||
|
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="white" />
|
||||||
|
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
|
||||||
|
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,12 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
correlationId: string;
|
||||||
|
|
||||||
|
constructor(status: number, code: string, message: string, correlationId: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.correlationId = correlationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { ApiError } from "./apiError";
|
||||||
|
|
||||||
|
export { ApiError } from "./apiError";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type UnauthorizedListener = () => void;
|
type UnauthorizedListener = () => void;
|
||||||
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
|||||||
return () => unauthorizedListeners.delete(listener);
|
return () => unauthorizedListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
status: number;
|
|
||||||
code: string;
|
|
||||||
correlationId: string;
|
|
||||||
|
|
||||||
constructor(status: number, code: string, message: string, correlationId: string) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
this.code = code;
|
|
||||||
this.correlationId = correlationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { ApiError } from "./apiError";
|
||||||
|
|
||||||
|
export interface ApiErrorInfo {
|
||||||
|
title: string;
|
||||||
|
explanation: string;
|
||||||
|
nextStep?: string;
|
||||||
|
technical: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
// Backend AppError codes this frontend knows how to present with a localized title,
|
||||||
|
// explanation and (where useful) a next step -- see
|
||||||
|
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
|
||||||
|
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
|
||||||
|
// a newly-introduced backend code never regresses to raw English -- it just falls back
|
||||||
|
// to a generic-but-localized message until this list is extended.
|
||||||
|
export const KNOWN_CODES = new Set([
|
||||||
|
"VEHICLE_NOT_FOUND",
|
||||||
|
"BOOKING_NOT_FOUND",
|
||||||
|
"CUSTOMER_NOT_FOUND",
|
||||||
|
"ENTITY_NOT_FOUND",
|
||||||
|
"EVENT_NOT_FOUND",
|
||||||
|
"ISSUE_NOT_FOUND",
|
||||||
|
"ISSUE_NOT_OPEN",
|
||||||
|
"BOOKING_NOT_ACTIVE",
|
||||||
|
"INVALID_BOOKING_STATE",
|
||||||
|
"NOT_RETRYABLE",
|
||||||
|
"CONFLICT_STILL_PRESENT",
|
||||||
|
"OVERLAP_STILL_PRESENT",
|
||||||
|
"EMPTY_VALUE",
|
||||||
|
"NO_FIELDS_PROVIDED",
|
||||||
|
"INVALID_FIELD",
|
||||||
|
"INVALID_FIELD_OVERRIDE",
|
||||||
|
"INVALID_SURVIVOR",
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"INVALID_EVENT_ID",
|
||||||
|
"INVALID_IDEMPOTENCY_KEY",
|
||||||
|
"IDEMPOTENCY_KEY_REUSED",
|
||||||
|
"CORRECTED_VALUE_REQUIRED",
|
||||||
|
"CORRECTION_BELOW_CANONICAL",
|
||||||
|
"NOT_A_DUPLICATE_ISSUE",
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE",
|
||||||
|
"NOT_AN_ODOMETER_ISSUE",
|
||||||
|
"NOT_AN_OVERLAP_ISSUE",
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||||
|
"UNSUPPORTED_ENTITY",
|
||||||
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
|
"NO_CONFLICT_DETECTED",
|
||||||
|
"RECOMMENDATION_STALE",
|
||||||
|
"UNAUTHORIZED_SERVICE",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
|
||||||
|
* for display. The raw backend/network text is only ever exposed as `technical`
|
||||||
|
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
|
||||||
|
*
|
||||||
|
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
|
||||||
|
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
|
||||||
|
* existed) and errors:generic doesn't fit the specific action being attempted.
|
||||||
|
*/
|
||||||
|
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
|
||||||
|
if (!(err instanceof ApiError)) {
|
||||||
|
return {
|
||||||
|
title: t("errors:generic.title"),
|
||||||
|
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
|
||||||
|
technical: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KNOWN_CODES.has(err.code)) {
|
||||||
|
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
|
||||||
|
return {
|
||||||
|
title: t(`errors:codes.${err.code}.title`),
|
||||||
|
explanation: t(`errors:codes.${err.code}.explanation`),
|
||||||
|
nextStep: nextStep || undefined,
|
||||||
|
technical: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
|
||||||
|
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
|
||||||
|
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
|
||||||
|
return {
|
||||||
|
title: t(`errors:http.${httpKey}.title`),
|
||||||
|
explanation: t(`errors:http.${httpKey}.explanation`),
|
||||||
|
nextStep: nextStep || undefined,
|
||||||
|
technical: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("errors:generic.title"),
|
||||||
|
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
|
||||||
|
technical: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@ export interface Vehicle {
|
|||||||
attention: boolean;
|
attention: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Page<T> {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookingSummary {
|
export interface BookingSummary {
|
||||||
public_ref: string;
|
public_ref: string;
|
||||||
customer_ref: string;
|
customer_ref: string;
|
||||||
@@ -85,11 +93,16 @@ export interface DashboardMetrics {
|
|||||||
pending_or_failed_workflows: number;
|
pending_or_failed_workflows: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EvidenceSignal {
|
||||||
|
code: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AttentionItem {
|
export interface AttentionItem {
|
||||||
kind: string;
|
kind: string;
|
||||||
severity: "low" | "medium" | "high";
|
severity: "low" | "medium" | "high";
|
||||||
rule_type: string;
|
rule_type: string;
|
||||||
detail: string;
|
evidence_signals: EvidenceSignal[];
|
||||||
link_type: "vehicle" | "booking" | "customer";
|
link_type: "vehicle" | "booking" | "customer";
|
||||||
link_ref: string;
|
link_ref: string;
|
||||||
issue_ref: string | null;
|
issue_ref: string | null;
|
||||||
@@ -110,6 +123,10 @@ export interface AutomationRun {
|
|||||||
attempts: number;
|
attempts: number;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
last_error_code: string | null;
|
last_error_code: string | null;
|
||||||
|
/** True for the failure the demo seed plants on purpose (see the backend's
|
||||||
|
* DEMO_SCENARIO_ERROR_CODE). Drives the "prepared scenario" labelling instead of
|
||||||
|
* showing it as an unexplained production error. */
|
||||||
|
is_demo_scenario: boolean;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +268,19 @@ export interface KnowledgeHealth {
|
|||||||
tenant: string;
|
tenant: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
collection: string;
|
collection: string;
|
||||||
document_count: number;
|
document_count: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface N8nWorkflowEvidence {
|
||||||
|
name: string;
|
||||||
|
built: boolean;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface N8nErrorHandlerStatus {
|
||||||
|
total_failures_registered: number;
|
||||||
|
latest_failure_at: string | null;
|
||||||
|
latest_failure_workflow: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface N8nIntegrationStatus {
|
export interface N8nIntegrationStatus {
|
||||||
@@ -260,15 +289,31 @@ export interface N8nIntegrationStatus {
|
|||||||
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
|
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
|
||||||
pending: number;
|
pending: number;
|
||||||
delivering: number;
|
delivering: number;
|
||||||
|
/** Every failed delivery, prepared and real together. */
|
||||||
failed: number;
|
failed: number;
|
||||||
|
/** Failures that were not planted by the demo seed -- the only ones that affect state. */
|
||||||
|
unexpected_failed: number;
|
||||||
|
/** Prepared demo failures. Visible and counted, but never a health signal. */
|
||||||
|
demo_scenario_failed: number;
|
||||||
succeeded: number;
|
succeeded: number;
|
||||||
latest_success_at: string | null;
|
latest_success_at: string | null;
|
||||||
|
/** Most recent real failure; a prepared one never sets this. */
|
||||||
latest_failure_at: string | null;
|
latest_failure_at: string | null;
|
||||||
|
latest_demo_scenario_at: string | null;
|
||||||
|
expected_workflow_count: number;
|
||||||
|
known_workflow_count: number;
|
||||||
|
workflows: N8nWorkflowEvidence[];
|
||||||
|
error_handler: N8nErrorHandlerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpHubIntegrationStatus {
|
export interface McpHubIntegrationStatus {
|
||||||
registration_enabled: boolean;
|
registration_enabled: boolean;
|
||||||
state: "not_configured" | "configured";
|
state: "not_configured" | "no_evidence" | "operational";
|
||||||
|
total_calls: number;
|
||||||
|
last_tool: string | null;
|
||||||
|
last_client: string | null;
|
||||||
|
last_called_at: string | null;
|
||||||
|
hub_reachable: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationStatus {
|
export interface IntegrationStatus {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { useViewportTier } from "../hooks/useViewportTier";
|
import { useViewportTier } from "../hooks/useViewportTier";
|
||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
import { PRODUCT_NAME } from "../product";
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
export function DemoGuideTrigger() {
|
export function DemoGuideTrigger() {
|
||||||
@@ -68,7 +70,7 @@ export function DemoGuide() {
|
|||||||
setCollapsedToChip,
|
setCollapsedToChip,
|
||||||
} = useDemoGuide();
|
} = useDemoGuide();
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [resetError, setResetError] = useState<string | null>(null);
|
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||||
const pendingTarget = useRef<string | null>(null);
|
const pendingTarget = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ export function DemoGuide() {
|
|||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
setResetError(describeApiError(t, err, "guide.restartFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
}
|
}
|
||||||
@@ -227,7 +229,7 @@ export function DemoGuide() {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
<ApiErrorNotice error={resetError} />
|
||||||
|
|
||||||
<footer className="demo-guide-footer">
|
<footer className="demo-guide-footer">
|
||||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGEle
|
|||||||
export function BrandMark({ className = "" }: { className?: string }) {
|
export function BrandMark({ className = "" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
|
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
|
||||||
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="currentColor" />
|
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="currentColor" />
|
||||||
<path d="M3 15.5h26" stroke="#2dd4bf" strokeWidth="2.5" />
|
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
|
||||||
|
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import type { Role, SearchResultItem } from "../api/types";
|
import type { Role, SearchResultItem } from "../api/types";
|
||||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||||
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
|
|||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { PRODUCT_NAME } from "../product";
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
|
||||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||||
vehicle: "fleet",
|
vehicle: "fleet",
|
||||||
@@ -86,7 +88,7 @@ export function Layout() {
|
|||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const [resetConfirming, setResetConfirming] = useState(false);
|
const [resetConfirming, setResetConfirming] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [resetError, setResetError] = useState<string | null>(null);
|
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||||
const searchInput = useRef<HTMLInputElement>(null);
|
const searchInput = useRef<HTMLInputElement>(null);
|
||||||
const searchBox = useRef<HTMLDivElement>(null);
|
const searchBox = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -163,7 +165,7 @@ export function Layout() {
|
|||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
|
setResetError(describeApiError(t, err, "resetFailed"));
|
||||||
setResetConfirming(false);
|
setResetConfirming(false);
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
@@ -231,7 +233,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||||
<div className="sidebar-reset">
|
<div className="sidebar-reset">
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
<ApiErrorNotice error={resetError} />
|
||||||
{!resetConfirming ? (
|
{!resetConfirming ? (
|
||||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||||
{t("resetDemoData")}
|
{t("resetDemoData")}
|
||||||
@@ -311,19 +313,23 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-meta">
|
<div className="topbar-meta">
|
||||||
<LanguageSwitcher compact />
|
|
||||||
<DemoGuideTrigger />
|
<DemoGuideTrigger />
|
||||||
<DemoBadge />
|
<DemoBadge />
|
||||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="operator">
|
<details className="operator-menu">
|
||||||
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
|
<summary className="operator">
|
||||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
|
||||||
</div>
|
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||||
|
</summary>
|
||||||
|
<div className="operator-popover">
|
||||||
|
<LanguageSwitcher compact />
|
||||||
|
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||||
|
<button className="operator-logout" type="button" onClick={handleLogout}>
|
||||||
|
<Icon name="logout" /> {t("switchRole")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
)}
|
)}
|
||||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
|
|
||||||
<Icon name="logout" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared rendering for describeApiError()'s output: a localized title + explanation +
|
||||||
|
// optional next step, with the raw backend/network text demoted to a "Technical
|
||||||
|
// details" disclosure -- never shown as the primary message. See
|
||||||
|
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
|
||||||
|
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<div className="error api-error-notice" role="alert">
|
||||||
|
<strong>{error.title}</strong>
|
||||||
|
<p>{error.explanation}</p>
|
||||||
|
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
|
||||||
|
<details className="evidence-disclosure">
|
||||||
|
<summary>{t("actions.technicalDetails")}</summary>
|
||||||
|
<pre className="evidence-block">{error.technical}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
icon = "check",
|
icon = "check",
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
return (
|
||||||
|
<nav className="pagination" aria-label={t("pagination.label")}>
|
||||||
|
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
|
||||||
|
{t("pagination.previous")}
|
||||||
|
</button>
|
||||||
|
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
|
||||||
|
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
|
||||||
|
{t("pagination.next")}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
|
|||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
import { StatusBadge } from "./Badge";
|
import { StatusBadge } from "./Badge";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
|
||||||
function newIdempotencyKey(): string {
|
function newIdempotencyKey(): string {
|
||||||
return typeof crypto.randomUUID === "function"
|
return typeof crypto.randomUUID === "function"
|
||||||
@@ -103,7 +105,7 @@ export function ReturnForm({
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [previewing, setPreviewing] = useState(false);
|
const [previewing, setPreviewing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
||||||
@@ -132,7 +134,7 @@ export function ReturnForm({
|
|||||||
setPreview(evaluated);
|
setPreview(evaluated);
|
||||||
setStep("review");
|
setStep("review");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
setError(describeApiError(t, err));
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewing(false);
|
setPreviewing(false);
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@ export function ReturnForm({
|
|||||||
);
|
);
|
||||||
onRegistered(registered);
|
onRegistered(registered);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
setError(describeApiError(t, err));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -158,7 +160,7 @@ export function ReturnForm({
|
|||||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
||||||
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
||||||
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
<ApiErrorNotice error={error} />
|
||||||
|
|
||||||
{step === "capture" ? <div className="return-capture">
|
{step === "capture" ? <div className="return-capture">
|
||||||
<div className="form-grid"><label>
|
<div className="form-grid"><label>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { EvidenceSignal } from "../api/types";
|
||||||
|
|
||||||
|
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
// The backend never emits prose for data-quality evidence -- only stable signal codes
|
||||||
|
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
|
||||||
|
// the one place that turns them into the operator's selected language, shared by the
|
||||||
|
// issue detail page and the dashboard attention queue so the same code always reads the
|
||||||
|
// same way everywhere it appears.
|
||||||
|
export function describeEvidenceSignal(
|
||||||
|
t: TFunction,
|
||||||
|
formatNumber: (value: number) => string,
|
||||||
|
signal: EvidenceSignal,
|
||||||
|
): string {
|
||||||
|
const { code, params = {} } = signal;
|
||||||
|
if (code.startsWith("vehicle.")) {
|
||||||
|
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
|
||||||
|
}
|
||||||
|
switch (code) {
|
||||||
|
case "missing_field":
|
||||||
|
return t("quality:detail.evidence.missingField", {
|
||||||
|
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
|
||||||
|
});
|
||||||
|
case "overlap.reserved_bookings":
|
||||||
|
return t("quality:detail.evidence.overlapReservedBookings", {
|
||||||
|
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
|
||||||
|
});
|
||||||
|
case "odometer.regression":
|
||||||
|
return t("quality:detail.evidence.odometerRegression", {
|
||||||
|
laterRef: params.later_ref,
|
||||||
|
laterKm: formatNumber(Number(params.later_km ?? 0)),
|
||||||
|
earlierRef: params.earlier_ref,
|
||||||
|
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
|
||||||
|
});
|
||||||
|
case "duplicate.similar_name":
|
||||||
|
return t("quality:detail.evidence.duplicateSimilarName", { score: params.score });
|
||||||
|
case "attention.upcoming_booking_missing_inspection":
|
||||||
|
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
|
||||||
|
default:
|
||||||
|
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,16 @@ export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusC
|
|||||||
|
|
||||||
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
|
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
|
||||||
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
||||||
configured: { statusClass: "no_events", labelKey: "prepared" },
|
no_evidence: { statusClass: "no_events", labelKey: "prepared" },
|
||||||
|
operational: { statusClass: "available", labelKey: "operational" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a
|
||||||
|
// stable slug for localized display names in `integrations:workflows.names.*` — the
|
||||||
|
// backend name itself is a technical identifier, not something to show untranslated.
|
||||||
|
export const N8N_WORKFLOW_SLUGS: Record<string, string> = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": "vehicleReturn",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": "scheduledScan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": "ragcoreSync",
|
||||||
|
"Fleet Ops — Workflow Error Handler": "errorHandler",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
|
||||||
|
|
||||||
|
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
|
||||||
|
|
||||||
|
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
|
||||||
|
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
|
||||||
|
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
|
||||||
|
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
|
||||||
|
export function getBrusselsHour(date: Date): number {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: BRUSSELS_TIME_ZONE,
|
||||||
|
hour: "numeric",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
return Number(formatter.format(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundaries per the Fleet Ops final localization brief (section 9):
|
||||||
|
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
|
||||||
|
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
|
||||||
|
// a fixed clock instead of depending on the real wall clock.
|
||||||
|
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
|
||||||
|
const hour = getBrusselsHour(date);
|
||||||
|
if (hour >= 5 && hour < 12) return "morning";
|
||||||
|
if (hour >= 12 && hour < 18) return "afternoon";
|
||||||
|
if (hour >= 18 && hour < 23) return "evening";
|
||||||
|
return "night";
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
"description": "Trace important state changes, actors and correlation references.",
|
"description": "Trace important state changes, actors and correlation references.",
|
||||||
"actionFilterLabel": "Action",
|
"actionFilterLabel": "Action",
|
||||||
"actionFilterPlaceholder": "e.g. demo_login",
|
"actionFilterPlaceholder": "e.g. demo_login",
|
||||||
|
"actorFilterLabel": "Actor",
|
||||||
|
"actorFilterPlaceholder": "Name or service",
|
||||||
|
"entityFilterLabel": "Record",
|
||||||
|
"entityFilterPlaceholder": "e.g. MO-016",
|
||||||
|
"fromFilterLabel": "From",
|
||||||
|
"toFilterLabel": "To",
|
||||||
"managerOnly": "The audit trail is visible to Operations Managers only.",
|
"managerOnly": "The audit trail is visible to Operations Managers only.",
|
||||||
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
|
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
|
||||||
"loading": "Loading audit trail…",
|
"loading": "Loading audit trail…",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
"ends": "Ends",
|
"ends": "Ends",
|
||||||
"startOdometer": "Start odometer",
|
"startOdometer": "Start odometer",
|
||||||
"endOdometer": "End odometer",
|
"endOdometer": "End odometer",
|
||||||
|
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
|
||||||
|
"endOdometerPending": "Not yet recorded — not yet closed",
|
||||||
"requirementsComplete": "Requirements complete",
|
"requirementsComplete": "Requirements complete",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No"
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
"success": "Success",
|
"success": "Success",
|
||||||
"noResults": "No results"
|
"noResults": "No results"
|
||||||
},
|
},
|
||||||
|
"pagination": {
|
||||||
|
"label": "Page navigation",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"pageOf": "Page {{page}} of {{total}}"
|
||||||
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"loadingDefault": "Loading workspace…",
|
"loadingDefault": "Loading workspace…",
|
||||||
"errorTitle": "We couldn't load this workspace."
|
"errorTitle": "We couldn't load this workspace."
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Operations / Live overview",
|
"eyebrow": "Operations / Live overview",
|
||||||
"title": "Good morning. Here's the fleet.",
|
"greeting": {
|
||||||
|
"morning": "Good morning",
|
||||||
|
"afternoon": "Good afternoon",
|
||||||
|
"evening": "Good evening",
|
||||||
|
"night": "Welcome back"
|
||||||
|
},
|
||||||
|
"greetingBody": {
|
||||||
|
"morning": "Here's today's fleet status.",
|
||||||
|
"afternoon": "Here's the current overview of your fleet.",
|
||||||
|
"evening": "Here's this evening's fleet overview.",
|
||||||
|
"night": "Here's the latest overview of your fleet."
|
||||||
|
},
|
||||||
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
||||||
"viewFleet": "View fleet",
|
"viewFleet": "View fleet",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
@@ -25,6 +36,7 @@
|
|||||||
"title": "Attention queue",
|
"title": "Attention queue",
|
||||||
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
|
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
|
||||||
"reviewQueue": "Review queue",
|
"reviewQueue": "Review queue",
|
||||||
|
"remaining": "View {{count}} more attention item(s)",
|
||||||
"filterPlaceholder": "Filter issues…",
|
"filterPlaceholder": "Filter issues…",
|
||||||
"filterAriaLabel": "Search attention queue",
|
"filterAriaLabel": "Search attention queue",
|
||||||
"severityAll": "All severity",
|
"severityAll": "All severity",
|
||||||
@@ -32,6 +44,9 @@
|
|||||||
"severityMedium": "Warning",
|
"severityMedium": "Warning",
|
||||||
"severityLow": "Info",
|
"severityLow": "Info",
|
||||||
"severityAriaLabel": "Severity",
|
"severityAriaLabel": "Severity",
|
||||||
|
"tierNow": "Handle now",
|
||||||
|
"tierToday": "Follow up today",
|
||||||
|
"tierLater": "Review later",
|
||||||
"empty": "No issues match this filter.",
|
"empty": "No issues match this filter.",
|
||||||
"openRecord": "Open {{title}}"
|
"openRecord": "Open {{title}}"
|
||||||
},
|
},
|
||||||
@@ -54,6 +69,7 @@
|
|||||||
"n8nNoEvidence": "No workflow evidence recorded",
|
"n8nNoEvidence": "No workflow evidence recorded",
|
||||||
"knowledgeTitle": "Knowledge assistant",
|
"knowledgeTitle": "Knowledge assistant",
|
||||||
"knowledgeSummary": "{{count}} procedures indexed",
|
"knowledgeSummary": "{{count}} procedures indexed",
|
||||||
|
"knowledgeIndexUnknown": "Knowledge source available · index size unknown",
|
||||||
"knowledgeUnavailable": "Health check unavailable",
|
"knowledgeUnavailable": "Health check unavailable",
|
||||||
"mcpTitle": "MCP Hub",
|
"mcpTitle": "MCP Hub",
|
||||||
"mcpEnabled": "Registration enabled",
|
"mcpEnabled": "Registration enabled",
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Something went wrong. Please try again.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "We couldn't load this workspace.",
|
"title": "Something went wrong",
|
||||||
"unauthorized": "Your session has expired. Please log in again.",
|
"explanation": "This action could not be completed. Please try again."
|
||||||
"forbidden": "You don't have access to this section.",
|
},
|
||||||
"notFound": "This record could not be found.",
|
"codes": {
|
||||||
"networkUnavailable": "The connection to the server is currently unavailable."
|
"VEHICLE_NOT_FOUND": {
|
||||||
|
"title": "Vehicle not found",
|
||||||
|
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_FOUND": {
|
||||||
|
"title": "Booking not found",
|
||||||
|
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
|
||||||
|
},
|
||||||
|
"CUSTOMER_NOT_FOUND": {
|
||||||
|
"title": "Customer not found",
|
||||||
|
"explanation": "This customer record could not be found."
|
||||||
|
},
|
||||||
|
"ENTITY_NOT_FOUND": {
|
||||||
|
"title": "Record not found",
|
||||||
|
"explanation": "The underlying record for this action could not be found."
|
||||||
|
},
|
||||||
|
"EVENT_NOT_FOUND": {
|
||||||
|
"title": "Workflow event not found",
|
||||||
|
"explanation": "This automation event could not be found."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_FOUND": {
|
||||||
|
"title": "Data-quality issue not found",
|
||||||
|
"explanation": "This data-quality issue could not be found."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_OPEN": {
|
||||||
|
"title": "Issue is no longer open",
|
||||||
|
"explanation": "This issue has already been resolved, deferred or rejected.",
|
||||||
|
"nextStep": "Refresh the page to see its current state."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_ACTIVE": {
|
||||||
|
"title": "Booking is not active",
|
||||||
|
"explanation": "Only a reserved or active booking can be used for this action."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_STATE": {
|
||||||
|
"title": "Booking is in the wrong state",
|
||||||
|
"explanation": "This booking's current state does not allow this action."
|
||||||
|
},
|
||||||
|
"NOT_RETRYABLE": {
|
||||||
|
"title": "This event cannot be retried",
|
||||||
|
"explanation": "Only a failed delivery can be retried."
|
||||||
|
},
|
||||||
|
"CONFLICT_STILL_PRESENT": {
|
||||||
|
"title": "Conflict was not resolved",
|
||||||
|
"explanation": "Applying this change did not resolve the underlying conflict.",
|
||||||
|
"nextStep": "Review the recommendation again before retrying."
|
||||||
|
},
|
||||||
|
"OVERLAP_STILL_PRESENT": {
|
||||||
|
"title": "Overlap was not resolved",
|
||||||
|
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
|
||||||
|
},
|
||||||
|
"EMPTY_VALUE": {
|
||||||
|
"title": "A required field is blank",
|
||||||
|
"explanation": "This field cannot be left blank.",
|
||||||
|
"nextStep": "Enter a value and try again."
|
||||||
|
},
|
||||||
|
"NO_FIELDS_PROVIDED": {
|
||||||
|
"title": "No changes provided",
|
||||||
|
"explanation": "At least one field must be filled in to continue."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD": {
|
||||||
|
"title": "Field not allowed here",
|
||||||
|
"explanation": "One of the fields provided is not permitted for this action."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD_OVERRIDE": {
|
||||||
|
"title": "Field cannot be merged",
|
||||||
|
"explanation": "One of the selected fields cannot be used in this merge."
|
||||||
|
},
|
||||||
|
"INVALID_SURVIVOR": {
|
||||||
|
"title": "Invalid selection",
|
||||||
|
"explanation": "The record you selected to keep must be one of the two records being compared."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_REFERENCE": {
|
||||||
|
"title": "Booking reference not valid here",
|
||||||
|
"explanation": "The selected booking is not one of the bookings related to this issue."
|
||||||
|
},
|
||||||
|
"INVALID_EVENT_ID": {
|
||||||
|
"title": "Invalid event reference",
|
||||||
|
"explanation": "This automation event reference is not valid."
|
||||||
|
},
|
||||||
|
"INVALID_IDEMPOTENCY_KEY": {
|
||||||
|
"title": "Request could not be repeated safely",
|
||||||
|
"explanation": "This request's tracking key is not valid.",
|
||||||
|
"nextStep": "Reload the page and try again."
|
||||||
|
},
|
||||||
|
"IDEMPOTENCY_KEY_REUSED": {
|
||||||
|
"title": "This action was already submitted",
|
||||||
|
"explanation": "An identical request was already processed with a different outcome.",
|
||||||
|
"nextStep": "Reload the page to see the current state before retrying."
|
||||||
|
},
|
||||||
|
"CORRECTED_VALUE_REQUIRED": {
|
||||||
|
"title": "A corrected value is required",
|
||||||
|
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
|
||||||
|
},
|
||||||
|
"CORRECTION_BELOW_CANONICAL": {
|
||||||
|
"title": "Correction is below the confirmed reading",
|
||||||
|
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
|
||||||
|
},
|
||||||
|
"NOT_A_DUPLICATE_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to possible-duplicate-customer issues."
|
||||||
|
},
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to missing-required-field issues."
|
||||||
|
},
|
||||||
|
"NOT_AN_ODOMETER_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to odometer-regression issues."
|
||||||
|
},
|
||||||
|
"NOT_AN_OVERLAP_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to booking-overlap issues."
|
||||||
|
},
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to vehicle-status-conflict issues."
|
||||||
|
},
|
||||||
|
"UNSUPPORTED_ENTITY": {
|
||||||
|
"title": "Not supported for this record type",
|
||||||
|
"explanation": "This action is not available for this kind of record."
|
||||||
|
},
|
||||||
|
"MANUAL_REVIEW_REQUIRED": {
|
||||||
|
"title": "Manual review required",
|
||||||
|
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
|
||||||
|
"nextStep": "Defer or reject this issue, or investigate manually."
|
||||||
|
},
|
||||||
|
"NO_CONFLICT_DETECTED": {
|
||||||
|
"title": "Nothing to apply",
|
||||||
|
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
|
||||||
|
},
|
||||||
|
"RECOMMENDATION_STALE": {
|
||||||
|
"title": "The situation has changed",
|
||||||
|
"explanation": "The underlying facts changed since this recommendation was shown.",
|
||||||
|
"nextStep": "Review the recommendation again before applying it."
|
||||||
|
},
|
||||||
|
"UNAUTHORIZED_SERVICE": {
|
||||||
|
"title": "Service authorisation failed",
|
||||||
|
"explanation": "This automated request could not be authorised."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"401": {
|
||||||
|
"title": "Session expired",
|
||||||
|
"explanation": "Your session has expired.",
|
||||||
|
"nextStep": "Please log in again."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"title": "Permission denied",
|
||||||
|
"explanation": "You don't have permission to perform this action."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "Not found",
|
||||||
|
"explanation": "This record could not be found."
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"title": "This action is no longer possible",
|
||||||
|
"explanation": "The underlying state has changed since this page was loaded.",
|
||||||
|
"nextStep": "Refresh the page and try again."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"title": "Validation failed",
|
||||||
|
"explanation": "The information provided is not valid."
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"title": "Server error",
|
||||||
|
"explanation": "Something went wrong on our side."
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Connection unavailable",
|
||||||
|
"explanation": "The connection to the server is currently unavailable.",
|
||||||
|
"nextStep": "Check your connection and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,17 @@
|
|||||||
"knowledgeTitle": "Knowledge assistant",
|
"knowledgeTitle": "Knowledge assistant",
|
||||||
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
|
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
|
||||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
|
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
|
||||||
|
"knowledgeSummaryIndexUnknown": "Knowledge source available · index size is unavailable for {{collection}}.",
|
||||||
"knowledgeUnavailable": "Health evidence is currently unavailable.",
|
"knowledgeUnavailable": "Health evidence is currently unavailable.",
|
||||||
"gatewayKicker": "Tool gateway",
|
"gatewayKicker": "Tool gateway",
|
||||||
"mcpTitle": "MCP Hub",
|
"mcpTitle": "MCP Hub",
|
||||||
"mcpEnabled": "Registration is enabled for this deployment.",
|
"mcpEnabled": "Registration is enabled for this deployment.",
|
||||||
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
|
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub.",
|
||||||
|
"mcpNoEvidence": "Registered, but no tool call has been recorded yet.",
|
||||||
|
"mcpEvidence": "Last call: {{tool}} by {{client}} · {{count}} total calls",
|
||||||
|
"mcpHubReachable": "Hub reachable",
|
||||||
|
"mcpHubUnreachable": "Hub unreachable",
|
||||||
|
"n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
|
||||||
},
|
},
|
||||||
"statusLabels": {
|
"statusLabels": {
|
||||||
"notConnected": "Not connected",
|
"notConnected": "Not connected",
|
||||||
@@ -26,6 +32,28 @@
|
|||||||
"demoMode": "Demo mode",
|
"demoMode": "Demo mode",
|
||||||
"unavailable": "Unavailable"
|
"unavailable": "Unavailable"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Automation workflows",
|
||||||
|
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
|
||||||
|
"notBuilt": "Not built yet",
|
||||||
|
"noEvidence": "No evidence yet",
|
||||||
|
"columns": {
|
||||||
|
"name": "Workflow",
|
||||||
|
"status": "Status",
|
||||||
|
"lastSeen": "Last evidence",
|
||||||
|
"technicalId": "Details"
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"vehicleReturn": "Vehicle return orchestration",
|
||||||
|
"scheduledScan": "Scheduled data quality scan",
|
||||||
|
"ragcoreSync": "Knowledge procedure sync",
|
||||||
|
"errorHandler": "Workflow error handler"
|
||||||
|
},
|
||||||
|
"errorHandler": {
|
||||||
|
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
|
||||||
|
"summaryEmpty": "No automation failures have been registered."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledger": {
|
"ledger": {
|
||||||
"title": "Automation jobs",
|
"title": "Automation jobs",
|
||||||
"description": "Persisted automation attempts with the latest failure evidence.",
|
"description": "Persisted automation attempts with the latest failure evidence.",
|
||||||
@@ -71,7 +99,11 @@
|
|||||||
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
|
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
|
||||||
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
|
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
|
||||||
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
|
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
|
||||||
"unknownError": "An unexpected error occurred while delivering this job."
|
"unknownError": "An unexpected error occurred while delivering this job.",
|
||||||
}
|
"demoScenarioTimeout": "Simulated timeout towards the workflow service. This failure is part of the prepared demo scenario, not a real incident."
|
||||||
|
},
|
||||||
|
"demoScenarioBadge": "Prepared demo scenario",
|
||||||
|
"demoScenarioExplanation": "Simulated temporary failure, planted by the demo data on purpose to show retry and audit. It does not affect the health of the automation.",
|
||||||
|
"retryDemoScenario": "Retry demo scenario"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"statusAvailable": "Available",
|
"statusAvailable": "Available",
|
||||||
"statusUnavailable": "Unavailable",
|
"statusUnavailable": "Unavailable",
|
||||||
"proceduresIndexed": "{{count}} procedures indexed",
|
"proceduresIndexed": "{{count}} procedures indexed",
|
||||||
|
"proceduresIndexUnknown": "Index size is not available through this connection",
|
||||||
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
|
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
|
||||||
"askHeading": "Ask a procedure question",
|
"askHeading": "Ask a procedure question",
|
||||||
"askSubheading": "Retrieval → evidence check → grounded answer",
|
"askSubheading": "Retrieval → evidence check → grounded answer",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"statusRejected": "Rejected",
|
"statusRejected": "Rejected",
|
||||||
"ruleTypeLabel": "Rule type",
|
"ruleTypeLabel": "Rule type",
|
||||||
"ruleTypeAll": "All rule types",
|
"ruleTypeAll": "All rule types",
|
||||||
|
"severityLabel": "Severity",
|
||||||
|
"severityAll": "All severity levels",
|
||||||
"demoScenariosOnly": "Demo scenarios only",
|
"demoScenariosOnly": "Demo scenarios only",
|
||||||
"loading": "Loading quality workbench…",
|
"loading": "Loading quality workbench…",
|
||||||
"queueClear": "Queue is clear",
|
"queueClear": "Queue is clear",
|
||||||
@@ -109,8 +111,7 @@
|
|||||||
"missingField": "Missing field: {{field}}",
|
"missingField": "Missing field: {{field}}",
|
||||||
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
||||||
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
||||||
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.",
|
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
|
||||||
"seedPlaceholder": "Synthetic seed data with no further detail."
|
|
||||||
},
|
},
|
||||||
"duplicateCustomer": {
|
"duplicateCustomer": {
|
||||||
"heading": "Compare and merge",
|
"heading": "Compare and merge",
|
||||||
@@ -118,6 +119,10 @@
|
|||||||
"keepAsSurvivor": "Keep as survivor",
|
"keepAsSurvivor": "Keep as survivor",
|
||||||
"differs": "Differs",
|
"differs": "Differs",
|
||||||
"match": "Match",
|
"match": "Match",
|
||||||
|
"summaryCounts": "{{matchCount}} matching fields · {{conflictCount}} conflicting fields",
|
||||||
|
"showMatchingFields": "Also show matching fields",
|
||||||
|
"hideMatchingFields": "Hide matching fields",
|
||||||
|
"previewTitle": "Preview of the merged record ({{ref}})",
|
||||||
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
|
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
|
||||||
"mergeInto": "Merge into {{ref}}",
|
"mergeInto": "Merge into {{ref}}",
|
||||||
"confirmMergeTitle": "Confirm merge",
|
"confirmMergeTitle": "Confirm merge",
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||||
"actionFilterLabel": "Action",
|
"actionFilterLabel": "Action",
|
||||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||||
"managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.",
|
"actorFilterLabel": "Intervenant",
|
||||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.",
|
"actorFilterPlaceholder": "Nom ou service",
|
||||||
|
"entityFilterLabel": "Dossier",
|
||||||
|
"entityFilterPlaceholder": "p. ex. MO-016",
|
||||||
|
"fromFilterLabel": "À partir du",
|
||||||
|
"toFilterLabel": "Jusqu’au",
|
||||||
|
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
|
||||||
|
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
|
||||||
"loading": "Chargement de la piste d'audit…",
|
"loading": "Chargement de la piste d'audit…",
|
||||||
"unavailable": "La piste d'audit est actuellement indisponible.",
|
"unavailable": "La piste d'audit est actuellement indisponible.",
|
||||||
"empty": "Aucun événement d'audit trouvé",
|
"empty": "Aucun événement d'audit trouvé",
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
||||||
"columns": {
|
"columns": {
|
||||||
"when": "Quand",
|
"when": "Quand",
|
||||||
"actor": "Acteur",
|
"actor": "Auteur",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"entity": "Entité",
|
"entity": "Entité",
|
||||||
"change": "Changement",
|
"change": "Changement",
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
"accessHeading": "Choisissez comment démarrer",
|
"accessHeading": "Choisissez comment démarrer",
|
||||||
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
||||||
"startGuidedDemo": "Démarrer la démo guidée",
|
"startGuidedDemo": "Démarrer la démo guidée",
|
||||||
"exploreAsOperationsManager": "Explorer en tant qu'Operations Manager",
|
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
||||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||||
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee",
|
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
||||||
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
||||||
"safeByDesignTitle": "Conçu pour la sécurité",
|
"safeByDesignTitle": "Conçu pour la sécurité",
|
||||||
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
||||||
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
||||||
"roleOperationsManager": "Operations manager",
|
"roleOperationsManager": "Responsable des opérations",
|
||||||
"roleRentalEmployee": "Rental employee",
|
"roleRentalEmployee": "Collaborateur de location",
|
||||||
"switchRole": "Changer de rôle",
|
"switchRole": "Changer de rôle",
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
"ends": "Fin",
|
"ends": "Fin",
|
||||||
"startOdometer": "Kilométrage de départ",
|
"startOdometer": "Kilométrage de départ",
|
||||||
"endOdometer": "Kilométrage de retour",
|
"endOdometer": "Kilométrage de retour",
|
||||||
|
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
|
||||||
|
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
|
||||||
"requirementsComplete": "Exigences complètes",
|
"requirementsComplete": "Exigences complètes",
|
||||||
"yes": "Oui",
|
"yes": "Oui",
|
||||||
"no": "Non"
|
"no": "Non"
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
"success": "Réussi",
|
"success": "Réussi",
|
||||||
"noResults": "Aucun résultat"
|
"noResults": "Aucun résultat"
|
||||||
},
|
},
|
||||||
|
"pagination": {
|
||||||
|
"label": "Navigation entre les pages",
|
||||||
|
"previous": "Précédent",
|
||||||
|
"next": "Suivant",
|
||||||
|
"pageOf": "Page {{page}} sur {{total}}"
|
||||||
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"loadingDefault": "Chargement de l'espace de travail…",
|
"loadingDefault": "Chargement de l'espace de travail…",
|
||||||
"errorTitle": "Impossible de charger cet espace de travail."
|
"errorTitle": "Impossible de charger cet espace de travail."
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user