Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66521da82 | ||
|
|
0571a40649 | ||
|
|
4227fe4f58 | ||
|
|
c790ec99cb | ||
|
|
fd390df423 | ||
|
|
e5d8466266 | ||
|
|
0da5251524 | ||
|
|
2afceea5e4 | ||
|
|
cf4d8e3649 | ||
|
|
aaa1630535 | ||
|
|
167bf49b6e | ||
|
|
fd0c55b13b | ||
|
|
05628936ca | ||
|
|
b341436e77 | ||
|
|
4049c0c6b1 | ||
|
|
e39c0a1dd6 | ||
|
|
bbdb4a9ae8 | ||
|
|
e0c107a94a | ||
|
|
59cb4c062e | ||
|
|
b79d485ef1 | ||
|
|
c0995b762e | ||
|
|
5f0eaa59b0 | ||
|
|
09173a4740 | ||
|
|
9468cc3e21 | ||
|
|
f0d641198c | ||
|
|
77208b857a | ||
|
|
e427313bce | ||
|
|
d17af1c52a | ||
|
|
94cfb7bcbb | ||
|
|
37a362c4a0 | ||
|
|
1fbb20b1ab | ||
|
|
f7805579f7 | ||
|
|
de0bdea84f |
@@ -29,6 +29,11 @@ N8N_BASIC_AUTH_ACTIVE=true
|
||||
N8N_BASIC_AUTH_USER=admin
|
||||
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||
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
|
||||
KNOWLEDGE_PROVIDER=demo
|
||||
@@ -37,6 +42,8 @@ RAGCORE_TENANT=northstar-mobility-demo
|
||||
RAGCORE_WORKSPACE=mobilityops
|
||||
RAGCORE_COLLECTION=internal-procedures
|
||||
RAGCORE_API_TOKEN=
|
||||
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||
RAGCORE_SPACE_ID=
|
||||
|
||||
# ITWorx MCP Hub integration
|
||||
MCP_HUB_REGISTRATION_ENABLED=false
|
||||
|
||||
+4
-1
@@ -60,7 +60,10 @@
|
||||
- `knowledge/procedures/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/10-roles-and-escalation.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/bookings.csv`
|
||||
- `seed/customers.csv`
|
||||
|
||||
@@ -26,16 +26,19 @@ reset:
|
||||
# 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
|
||||
# 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:
|
||||
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 restart n8n
|
||||
|
||||
# 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:
|
||||
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 restart n8n
|
||||
|
||||
|
||||
+717
-7
@@ -29,9 +29,15 @@ M7 — complete. All milestones (M0–M7) done, plus a full post-M7 final-accept
|
||||
|
||||
## Locked decisions
|
||||
|
||||
- Product name: MobilityOps.
|
||||
- Product name: Fleet Ops (the only visible product name in the UI/copy, never translated;
|
||||
see `frontend/src/product.ts`). "MobilityOps" is the internal repo name, Compose project
|
||||
name and deployment directory only — never shown to a user. See the "Final product
|
||||
polish: Fleet Ops rebrand" and "Fleet Ops final localization" entries below.
|
||||
- Fictitious tenant: Northstar Mobility Demo.
|
||||
- PoC only; all operational and knowledge data are synthetic.
|
||||
- Synthetic demo data only; all operational and knowledge data are synthetic. The product
|
||||
itself is not described as a "PoC" in user-facing copy (see the localization entry
|
||||
below) — this document and other internal/engineering docs may still use "PoC" to
|
||||
describe the engineering scope, per `CLAUDE.md`.
|
||||
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
|
||||
- RAGcore and ITWorx MCP Hub are external central services.
|
||||
- n8n receives post-commit events through an outbox dispatcher.
|
||||
@@ -873,11 +879,15 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
|
||||
and master, responsive/accessibility results, known limitations, rollback procedure)
|
||||
plus 10 screenshots in `artifacts/fleet-ops-release/screenshots/`.
|
||||
|
||||
## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — IN PROGRESS on fix/fleet-ops-i18n-status-flow
|
||||
## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — MERGED TO MASTER
|
||||
|
||||
Branch `fix/fleet-ops-i18n-status-flow`, created from master's post-release head
|
||||
(`18344bc`). Audit and rationale in `docs/fleet-ops-correction/` (gap audit, i18n
|
||||
inventory, vehicle-status decision table). Not yet merged to master.
|
||||
inventory, vehicle-status decision table). Merged to master via `de0bdea` ("merge:
|
||||
complete Fleet Ops localization and status resolution"), with final evidence commit
|
||||
`f780557` ("docs(release): final Fleet Ops correction evidence and screenshots") —
|
||||
`f780557` is `origin/master`'s current head as of the start of the correction round
|
||||
below.
|
||||
|
||||
- **Status-recommendation flow redesigned** per the brief: the old single opaque
|
||||
"calculate and apply recommended status" action is replaced by a single shared, pure
|
||||
@@ -979,12 +989,712 @@ inventory, vehicle-status decision table). Not yet merged to master.
|
||||
the live server (`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`), no console
|
||||
errors, no errors in `api`/`web` container logs, both containers healthy, final
|
||||
reset done, `scenario_integrity.all_ready: true`.
|
||||
- **Not yet done**: the final merge to master with
|
||||
`artifacts/fleet-ops-correction/final-summary.md` evidence. Do not claim PASS on
|
||||
this correction until that's done.
|
||||
- Final evidence: `artifacts/fleet-ops-correction/final-summary.md`. Merged to master
|
||||
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
|
||||
final localization" entry below for the next (small correction) round on top of this.
|
||||
|
||||
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — MERGED TO MASTER
|
||||
|
||||
Merged to master via `5f0eaa5`; final evidence commit `c0995b7` added
|
||||
`artifacts/fleet-ops-final-localization/final-summary.md`. Master head at merge:
|
||||
`c0995b762e1cbf37172a08e03645baa6b66aa8d5`. Details below are the in-progress working log
|
||||
kept for reference.
|
||||
|
||||
Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head
|
||||
(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the
|
||||
already-checked-out branch name is used instead since it was verified freshly and
|
||||
cleanly branched from current `origin/master` with a clean working tree; see
|
||||
`docs/fleet-ops-final-localization/audit.md` for the naming note. Scope: a small,
|
||||
targeted correction round only — explicitly not touching status-flow business logic,
|
||||
the status evaluator, Data Quality resolution rules, return rules, RAGcore/MCP Hub, or
|
||||
product scope.
|
||||
|
||||
- **Audit-driven gap sweep**: `docs/fleet-ops-final-localization/audit.md` documents
|
||||
every remaining untranslated/incorrect string, raw-backend-error call site,
|
||||
over-permissive allowlist entry, the static-greeting bug, and doc staleness found by
|
||||
a dedicated Explore pass before any file was touched.
|
||||
- **Remaining NL/FR translation gaps fixed**: role names actually translated (not just
|
||||
labelled as translated) — `auth.json`/`demo.json` role keys, `audit.title` →
|
||||
"Auditgeschiedenis"/"Piste d'audit", `columns.actor` → "Uitvoerder", `list.statusOpen`
|
||||
→ "Openstaand", `ledger.filterRecent` → "Recentste", `scenarios.startScenario` →
|
||||
"Scenario starten". Also found and fixed (via the new embedded-substring test below)
|
||||
8 previously-missed mid-sentence "Audit trail" leaks across `demo.json`,
|
||||
`quality.json`, `returns.json` that the old whole-string-identity test structurally
|
||||
could not catch.
|
||||
- **Central API-error localization**: new `frontend/src/api/errorMessages.ts`
|
||||
(`describeApiError`) replaces the `err instanceof ApiError ? err.message : ...`
|
||||
anti-pattern (which showed raw English for the common case) at all 13 call sites
|
||||
across 7 files. Raw backend text is now only ever shown under a "Technical
|
||||
details"/"Détails techniques" disclosure (new `ApiErrorNotice` component in
|
||||
`PageChrome.tsx`); the primary message is always a localized title + explanation +
|
||||
optional next step, keyed on the 32 known `AppError` codes, then known HTTP statuses
|
||||
(401/403/404/409/422/500), then a fully generic fallback. `ApiError` was split out of
|
||||
`client.ts` into a standalone `api/apiError.ts` (no `import.meta.env` dependency) so
|
||||
`errorMessages.ts` is independently testable outside a Vite/browser context.
|
||||
- **i18n allowlist tightened**: 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`) now that they're genuinely translated. Added 2 new tests:
|
||||
one closing the embedded-English/Dutch-substring blind spot (mid-sentence phrase
|
||||
leaks the whole-string check misses), one asserting no locale file contains
|
||||
"MobilityOps" or the word "PoC".
|
||||
- **`describeApiError` test coverage**: new `frontend/e2e/error-messages.spec.ts` (10
|
||||
tests) — every known code/HTTP status has non-empty copy in all 3 locales, a known
|
||||
code never surfaces raw backend text as the primary message (only via `.technical`),
|
||||
unknown-code and unknown-status fallback chains behave correctly, and a drift guard
|
||||
that greps the actual backend `AppError("CODE", ...)` call sites and fails if
|
||||
`KNOWN_CODES` and the backend's real codes ever diverge (currently exactly in sync,
|
||||
32 codes).
|
||||
- **Time-dependent Europe/Brussels dashboard greeting**: new
|
||||
`frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, DST-safe via
|
||||
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })`, clock
|
||||
injectable) + `useGreetingPeriod.ts` hook (30s poll for period rollover while the app
|
||||
stays open, no reload). Replaces the previously-always-"Goedemorgen" static
|
||||
`dashboard.json` title with 4 periods × 3 languages for both the greeting word and a
|
||||
varying accompanying sentence (never "Goedenacht"). Tests: `greeting.spec.ts` (pure
|
||||
boundary/DST unit tests) + `greeting-live.spec.ts` (6 real-browser tests via
|
||||
Playwright's `page.clock` — all 8 required boundary times in all 3 languages, live
|
||||
rollover without reload, language-switch behaviour, the "never Goedenacht" guard).
|
||||
- **Found and fixed one real CSS regression along the way**: correctly translating
|
||||
`roleOperationsManager` to the single unbreakable Dutch compound word
|
||||
"Operationsmanager" (vs. the old two-word "Operations Manager", which could wrap)
|
||||
pushed the topbar's `.operator` block past 1024px width, caught by the existing
|
||||
`responsive-i18n.spec.ts` overflow test. Fixed with `overflow-wrap: anywhere` on
|
||||
`.operator strong`/`small` and `min-width: 0` on their flex-item wrapper, not by
|
||||
reverting the correct translation.
|
||||
- Gates green so far: backend `pytest` 151 passed, `ruff check .` clean, `mypy app`
|
||||
clean (49 files, unchanged — no backend Python touched this round); frontend `tsc`
|
||||
clean, production build clean, full local Playwright suite **138 passed** (rebuilt
|
||||
and restarted the local `web` container from source before this run).
|
||||
- **Not yet done**: clean-checkout drill, commit/push, Unraid deployment of this fix
|
||||
branch with live 3-language validation, the master merge (with the mandatory
|
||||
`git fetch origin` / unexpected-change check first), and
|
||||
`artifacts/fleet-ops-final-localization/final-summary.md`. Do not claim PASS on this
|
||||
correction round until all of those are done and `git rev-parse HEAD` exactly matches
|
||||
`/mnt/user/appdata/mobilityops/.deploy/source-revision`.
|
||||
- Commits so far on this branch: `6deb955` (status flow + brand constant + message
|
||||
codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new
|
||||
flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test),
|
||||
`a7ac5ed` (docs), `7851e80` (11D/11F i18n tests), `cda2c32` (clean-checkout
|
||||
evidence), `2e4fb43` (evidence-summary localization fix, found live on Unraid).
|
||||
Deployed commit: `2e4fb43f093bfbdb04c4f74eed1e6c6d9a03c069`.
|
||||
|
||||
## Live n8n + RAGcore integration (2026-08-04) — IN PROGRESS on feat/live-n8n-ragcore-integration
|
||||
|
||||
Branch `feat/live-n8n-ragcore-integration`, from master `c0995b7`. Full brief: treat n8n
|
||||
(`https://n8n.itworx.tech`, existing shared instance) as a third integration layer
|
||||
alongside RAGcore and MCP Hub, owning process orchestration only — Fleet Ops keeps all
|
||||
business rules, authorization, transactions, audit and idempotency. Four canonical
|
||||
workflows required: (1) Vehicle Return Orchestration, (2) Scheduled Data Quality Scan —
|
||||
both pre-existing and now hardened; (3) RAGcore Procedure Sync, (4) Workflow Error
|
||||
Handler — both net-new, not yet built.
|
||||
|
||||
- **Current-state audit**: `docs/live-ai-integration/n8n-current-state.md` documents the
|
||||
live instance (reachable, production webhook base
|
||||
`http://192.168.10.150:5678/webhook/mobilityops-return`), both existing workflows'
|
||||
full node structure, and the findings that drove the security fixes below (webhook
|
||||
Authentication was `None`; both HTTP nodes had `X-Service-Token` hardcoded as a literal
|
||||
header value instead of a credential).
|
||||
- **Security fixes applied and live-validated** (commits `b79d485`, `59cb4c0`): webhook
|
||||
trigger now requires Header Auth (credential `Fleet Ops Webhook Trigger Token`, a new
|
||||
token generated this round — value stored in `.env`/Unraid `.env` only, never
|
||||
printed); the outbound callback HTTP node now uses a `Fleet Ops Service Token` Header
|
||||
Auth credential instead of a literal header value (existing secret copied
|
||||
clipboard-to-clipboard, never typed/echoed). Backend: `X-Fleet-Ops-Trigger-Token`
|
||||
header added to the outbox dispatcher's POST (`backend/app/services/dispatcher.py`),
|
||||
plus a new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting/env var. Also hardened
|
||||
`_deliver_one` to treat a 2xx response with a non-JSON-object body as a retryable
|
||||
failure (`malformedResponse`) instead of an unhandled exception — a real failure mode
|
||||
hit live when a workflow errors before its "Respond to Webhook" node runs; regression
|
||||
test `test_deliver_one_treats_empty_2xx_body_as_failure` added. Live-validated: curl
|
||||
probe without the header → `403`; with the header → pass-through; one real end-to-end
|
||||
vehicle return produced one correct execution visible in both n8n and Fleet Ops
|
||||
Audit/Automation. Both workflows explicitly `Publish`ed after the fixes (the editor
|
||||
does not go live on save alone) and both canonical-renamed ("Fleet Ops — Vehicle
|
||||
Return Orchestration", "Fleet Ops — Scheduled Data Quality Scan").
|
||||
- **RAGcore real contract discovered** (not the speculative one the adapter was built
|
||||
against): OpenAPI at `/openapi.json`, health at `/health/live`/`/health/ready` (not
|
||||
`/health`), ingestion via `POST /v1/uploads`, answers via `POST /v1/answers` with
|
||||
`requested_space_ids`, control-plane endpoints require an `Idempotency-Key` header.
|
||||
Bootstrapped a `fleet-ops` application + knowledge space + grant on the real server at
|
||||
`http://192.168.10.150:1237`. **Blocked**: credential issuance for that application
|
||||
failed identically via both the raw API and the admin UI ("authoritative
|
||||
service-account state rejected issuance") — an apparent privilege boundary beyond the
|
||||
interactive admin session. User chose to issue the credential themselves via another
|
||||
mechanism and hand over the token; not yet received. `RAGcoreKnowledgeProvider`
|
||||
(`backend/app/services/knowledge/ragcore.py`) still targets the old speculative
|
||||
endpoints and needs fixing once that token arrives — approved, not started.
|
||||
- **Repository source of truth started** (task in progress): `n8n/workflows/` now holds
|
||||
cleaned definitions for workflows 1-2 — `fleet-ops-vehicle-return.json` (sha256
|
||||
`e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e`),
|
||||
`fleet-ops-data-quality-scan.json` (sha256
|
||||
`cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e`) — both credential
|
||||
auth referenced by name only, no secret values. Reconstructed from direct verified
|
||||
inspection of every live node, **not** a literal n8n export/download: the UI's "..."
|
||||
menu has no Download option in this n8n version, and clipboard-based
|
||||
copy/`navigator.clipboard.readText()` extraction timed out twice. Flagged as a known
|
||||
limitation for the final evidence doc. `n8n/workflows/MANIFEST.md` records canonical
|
||||
name/purpose/trigger/contract/credentials/live ID/active-status/checksum for all 4
|
||||
workflows (3-4 marked not-yet-built). `n8n/workflows/check_drift.py` compares a repo
|
||||
definition against the live workflow via n8n's Public API (`X-N8N-API-KEY`, read-only,
|
||||
never auto-overwrites). The old root-level `n8n/mobilityops-return-processing.json`
|
||||
and `n8n/mobilityops-scheduled-quality-scan.json` (pre-integration starters, still
|
||||
carrying the literal-token pattern) are removed; `deploy/unraid/setup-existing-n8n.sh`,
|
||||
`setup-scheduled-scan.sh`, `Makefile` (`n8n-setup`, `n8n-setup-scan`) and
|
||||
`docs/17-runbook.md` updated to import from `n8n/workflows/` and to document the
|
||||
now-required manual credential-creation step (credentials are never scripted or
|
||||
committed).
|
||||
- **Explicitly deferred/forbidden this phase** (per brief): daily AI ops brief, email,
|
||||
Slack, automatic vehicle-status changes, customer communication, billing, general
|
||||
monitoring, autonomous MCP actions. An automatic demo-reset workflow may only be
|
||||
prepared, not activated, once Fleet Ops goes public.
|
||||
- **Workflow 4 (Workflow Error Handler) built and live-validated** (commit pending):
|
||||
new backend endpoint `POST /api/v1/integrations/n8n/workflow-error`
|
||||
(`backend/app/api/routers/integrations.py`, service-token auth, Pydantic
|
||||
`WorkflowErrorReportIn`/`WorkflowErrorReportResult` in `backend/app/schemas.py`),
|
||||
idempotent on `execution_id` via the same audit-precheck pattern as
|
||||
`/return-callback`; new test coverage in `backend/tests/test_integrations.py` (all
|
||||
green, 152 tests total, ruff/mypy clean). **This endpoint had to be deployed to the
|
||||
live Unraid server** (`git archive` → `scp` → extract preserving `.env` → `docker
|
||||
compose up --build -d api`, no migration needed) before the live n8n test could reach
|
||||
it — the auto-mode classifier correctly blocked the first `scp` attempt as a
|
||||
production-infra action; user approved, then it was deployed and verified
|
||||
(`/health` OK, new endpoint returns 422 on empty body instead of 404).
|
||||
Built "Fleet Ops — Workflow Error Handler" (live ID `Xppn2rAEqUuyiCJF`) in n8n:
|
||||
Error Trigger → Code node (derives safe error_category/summary/etc. from n8n's error
|
||||
payload) → HTTP node (POST to the new endpoint, Header Auth via the existing "Fleet
|
||||
Ops Service Token" credential). Hit and fixed two real bugs during live testing: (1)
|
||||
Code node's default "Run Once for All Items" mode doesn't bind `$json` to the current
|
||||
item — switched to "Run Once for Each Item" and `return {json:...}` instead of
|
||||
`return [{json:...}]`; (2) every HTTP-body field expression ended up with a stray
|
||||
trailing space (from the code-editor's bracket-autoclose leaving one extra character
|
||||
after the `End`+`Backspace×2` fix), which broke the `failed_at` datetime parse and the
|
||||
`error_category` literal match — found via the raw request dump in n8n's error
|
||||
panel, fixed with one more `Backspace` per field. Live-validated: mock Error Trigger
|
||||
data → real `200 {"status":"registered"}` from Fleet Ops; re-run → `"already_registered"`
|
||||
(idempotency confirmed); wired as the Error Workflow on workflows 1 and 2 (via each
|
||||
workflow's Settings modal); confirmed the Error Handler itself has `Error Workflow: -
|
||||
No Workflow -` (no recursive loop). With user approval, also ran a genuine induced
|
||||
failure on workflow 2 (temporarily pointed its HTTP node at a nonexistent path,
|
||||
published, ran it, confirmed it failed as expected, immediately reverted and
|
||||
republished, confirmed healthy again) — this proved the target workflow's own error
|
||||
path works, but n8n did not auto-invoke the Error Handler for that *manual* editor
|
||||
test run (n8n's Error Workflow trigger only fires for unattended/production
|
||||
executions), so a fully automatic schedule/webhook-triggered cascade into the handler
|
||||
was not observed live this round — noted as a known limitation.
|
||||
Exported the verified definition to `n8n/workflows/fleet-ops-error-handler.json` (same
|
||||
manual-reconstruction caveat as workflows 1-2: no literal export/download available),
|
||||
updated `n8n/workflows/MANIFEST.md` (all 4 workflows, workflow 3 still not-built) and
|
||||
`check_drift.py`'s known-workflows list.
|
||||
- **Integration status page enriched with real per-workflow evidence** (commit
|
||||
`4049c0c`): `N8nIntegrationStatus` now returns `workflows: N8nWorkflowEvidence[]`
|
||||
(the 4 canonical workflows, each with real evidence — latest successful outbox
|
||||
delivery for the return workflow, latest *service*-triggered `data_quality_scan_run`
|
||||
audit event for the scan workflow so a manual UI-triggered scan doesn't fake n8n
|
||||
evidence, latest `n8n_workflow_failure_registered` for the error handler, always
|
||||
`built: false` / no evidence for the not-yet-built RAGcore sync), plus
|
||||
`expected_workflow_count`/`known_workflow_count` and an `error_handler` summary
|
||||
(total registered, latest failure + which workflow). New tests in
|
||||
`backend/tests/test_integration_status.py` (all green, 159 backend tests total,
|
||||
ruff/mypy clean). Frontend: `Automation.tsx` renders this as a localized workflow
|
||||
table (EN/NL/FR, new `integrations:workflows.*` keys, technical workflow names under
|
||||
a "Technical details" disclosure per the existing progressive-disclosure pattern).
|
||||
Verified live in the browser both locally (Dutch locale, disclosure expand/collapse
|
||||
confirmed) and **on the deployed Unraid server after this round's deploy**: correctly
|
||||
shows "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 session's live
|
||||
testing. Deployed to Unraid (commit `4049c0c6b12fef3d948cd31f21119044143320d8`,
|
||||
rebuilt both `api` and `web`, `/health` OK) — user re-approved this second deploy
|
||||
separately from the first.
|
||||
- **Operational lesson learned this round**: `docker compose run --rm api pytest` does
|
||||
**not** reliably pick up source edits without an explicit `docker compose build api`
|
||||
first — a test file edit silently kept running against the stale built image (test
|
||||
count didn't change) until rebuilt. Always `docker compose build api` (and `web` for
|
||||
frontend changes) before trusting a green result after backend/frontend edits in this
|
||||
repo.
|
||||
- **WF1 acceptance gap fixed and live**: `check_drift.py`-style re-inspection of workflow
|
||||
1 during this round's acceptance pass found the `Record follow-up` HTTP node had no
|
||||
explicit timeout and "Retry On Fail" disabled — a real gap against the brief's
|
||||
timeouts/bounded-retries requirement (WF2 already had this). Fixed live: Retry On Fail
|
||||
(3 tries, 1000ms wait) + a 15000ms Timeout option, published (version note "Add bounded
|
||||
retries (3x) and a 15s timeout to the Fleet Ops callback call"). Repo definition and
|
||||
manifest checksum synced (`n8n/workflows/fleet-ops-vehicle-return.json`,
|
||||
`MANIFEST.md`, new checksum `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609`,
|
||||
commit `0562893`).
|
||||
- **RAGcore credential issuance re-attempted and still blocked (user explicitly
|
||||
authorized Claude to self-issue this round)**: tried the RAGcore admin UI's "Issue
|
||||
credential" form for the `fleet-ops` application (logged in as Platform Admin, the
|
||||
highest visible role) with name `n8n-ragcore-procedure-sync` and scope `sources:sync`
|
||||
only. Submission failed with the same generic "Something went wrong. The credential
|
||||
could not be issued with those values." page, this time carrying a trace reference
|
||||
`1955c6a8968c4941a22a1faef39e17a7`. Inspected the RAGcore OpenAPI spec for this admin
|
||||
endpoint (`POST /admin/control/applications/{application_id}/credentials`) — no
|
||||
documented validation constraint explains the rejection (no 422, no field errors); the
|
||||
`fleet-ops` application itself lists as ordinary/`Active` with no visible lock flag in
|
||||
the applications table. This is the same failure signature as the earlier raw-API
|
||||
attempt (400 "authoritative service-account state rejected issuance"): two independent
|
||||
paths (raw API, and now the admin UI as the top admin role) both hit an opaque
|
||||
server-side rejection with a trace ID. This is conclusive evidence the block is a
|
||||
deliberate RAGcore-side policy or a RAGcore-side bug, not a Fleet Ops permission or
|
||||
request-shape problem — nothing further is fixable from the Fleet Ops side or through
|
||||
browser automation. Whoever operates the RAGcore instance needs to look up trace
|
||||
`1955c6a8968c4941a22a1faef39e17a7` (and the earlier API rejection) in RAGcore's own
|
||||
logs to find the real cause.
|
||||
- **WF2 acceptance gap fixed and live**: continuing the acceptance pass to WF2 found it
|
||||
had the *same* Retry On Fail gap as WF1 (its 15s timeout was already set, but retries
|
||||
were off — the earlier note that "WF2 already had this" was wrong on the retry half).
|
||||
Fixed live the same way (3 tries, 1000ms wait), published (version note "Add bounded
|
||||
retries (3x) to the quality-scan HTTP call"). Repo definition and manifest checksum
|
||||
synced (`n8n/workflows/fleet-ops-data-quality-scan.json`, `MANIFEST.md`, new checksum
|
||||
`c0d46e0519118e6336e35c4ea2a67edb2f14bd007909ccf9256c93733751244a`, commit `167bf49`).
|
||||
- **WF4 has the same gap on its own outbound call, but is currently un-fixable**: WF4's
|
||||
"Report failure to Fleet Ops" HTTP node also has no timeout and no Retry On Fail. Began
|
||||
the same fix (added a 15000ms Timeout option, toggled Retry On Fail on) but n8n's
|
||||
autosave started failing with "Unauthorized" mid-edit, and a fresh tab confirmed the
|
||||
n8n browser session had expired (redirected to `/signin`) — so nothing was saved and
|
||||
the live WF4 definition is unchanged from before this round (no partial/broken state).
|
||||
This is a minor, best-effort-only gap (WF4 is the error notifier itself, not a primary
|
||||
business flow, and it already reports failures with `On Error: Stop Workflow` so a
|
||||
failed error-report is visible in n8n's own execution history even without retries) —
|
||||
not blocking, but worth finishing once someone re-authenticates the n8n browser
|
||||
session.
|
||||
- **RAGcore credential-issuance blocker root-caused and fixed (in RAGcore itself, with
|
||||
explicit owner approval)**: with read access to the sibling `C:\Projects\RAGcore`
|
||||
checkout, traced "authoritative service-account state rejected issuance" to a genuine
|
||||
cross-transaction race in RAGcore's own dependency injection
|
||||
(`src/ragcore/api/v1/control/dependencies.py`). `get_control_application` and
|
||||
`get_credential_service` each independently opened their own `factory.begin()`
|
||||
database transaction. Issuing a credential for a brand-new service account does, in one
|
||||
request: (1) INSERT the service account via the first dependency's transaction, then
|
||||
(2) immediately re-read it via the second dependency's *separate, uncommitted* transaction
|
||||
— invisible under READ COMMITTED isolation until the first transaction commits, which
|
||||
only happens after the endpoint returns. This made every fresh-service-account credential
|
||||
issuance fail, 100% of the time, via both the raw API and the admin UI (explaining the
|
||||
identical failure signature on both paths). RAGcore's own tests never caught this because
|
||||
they override these dependencies with an in-memory fake that ignores transaction boundaries
|
||||
entirely. Fixed by introducing one shared, cached `get_control_session` dependency that
|
||||
both providers now depend on via `Depends(...)`, so they share one transaction per request.
|
||||
Verified: RAGcore's own test suite (64 tests across `tests/web`, `tests/contract/api/control`,
|
||||
`tests/security/identity`, `tests/unit/domain/control`, `tests/api`) passes, ruff and mypy
|
||||
clean. Deployed to the live RAGcore instance (also on the Unraid host, `ragcore-app-1` on
|
||||
port 1237 — a shared service also used by other ITWorx projects) via `docker compose build`
|
||||
+ `up -d`, with explicit owner approval before both the code change and the deploy.
|
||||
Confirmed fixed live: issuing a credential for `fleet-ops` (name
|
||||
`n8n-ragcore-procedure-sync`, scope `sources:sync`) now succeeds (prefix `rc_sa_6fc51e`).
|
||||
The plaintext token was never printed/logged — copied via RAGcore's own "Copy" button and
|
||||
pasted directly into a new n8n Header Auth credential named **"RAGcore Sync Token"**
|
||||
(header `Authorization: Bearer <token>`), ready for workflow 3.
|
||||
- **Exact next action (superseded by the entry below)**: task #86 (build workflow 3,
|
||||
RAGcore Procedure Sync) and the `RAGcoreKnowledgeProvider` adapter rewrite (to the real
|
||||
inspected contract — `/health/live`, `/health/ready`, `POST /v1/uploads`, `POST
|
||||
/v1/search`/`/v1/context`/`/v1/answers`) are now unblocked — the "RAGcore Sync Token" n8n
|
||||
credential exists and works. WF4's own timeout/retry gap is still open pending n8n browser
|
||||
re-authentication (minor, non-blocking, see above). Both #90 and #91 should be revisited
|
||||
once workflow 3 is actually built, since they currently document it as blocked.
|
||||
|
||||
- **Second RAGcore bug found, fixed and deployed (with explicit owner approval, same pattern
|
||||
as the transaction-race fix above)**: with the "RAGcore Sync Token" credential in hand,
|
||||
the n8n "Upload to RAGcore" node still returned a persistent 401 on every item. Traced via
|
||||
direct RAGcore source inspection (`C:\Projects\RAGcore`) to a genuine second, independent
|
||||
gap: no code path in RAGcore converted an incoming `Authorization: Bearer <token>` header
|
||||
into a `request.state.principal` for any `/v1/*` route — only browser session cookies were
|
||||
ever accepted, even though the credential-verification logic
|
||||
(`ServiceAccountCredentialService.verify()`) existed and was unit-tested. This blocks any
|
||||
machine caller (n8n, and eventually Fleet Ops's own `RAGcoreKnowledgeProvider` adapter)
|
||||
from ever authenticating to `/v1/uploads`. Fixed additively, scoped to `/v1/uploads` only
|
||||
per owner instruction (search/context/answers left for later): new
|
||||
`CredentialRepository.get_by_id()` (Postgres + in-memory), new
|
||||
`ServiceAccountCredentialService.authenticate()` (parallel to the existing `verify()`, not
|
||||
a refactor of it), and a new `get_upload_principal` FastAPI dependency
|
||||
(`src/ragcore/api/v1/uploads/dependencies.py`) that falls back to the Bearer header when
|
||||
there is no session principal, wired into `uploads/routes.py` in place of the session-only
|
||||
`get_principal`. New/updated tests in `tests/security/identity/test_credentials.py` and
|
||||
`tests/security/uploads/test_upload_security.py` (bearer-token accept/reject paths, the
|
||||
existing route test's stale `get_principal` override fixed to `get_upload_principal`).
|
||||
Verified: RAGcore's own test suite — 377 passed in the affected `tests/security`,
|
||||
`tests/unit`, `tests/api` trees (3 unrelated pre-existing failures: two need Windows
|
||||
symlink privileges the sandbox doesn't have, one is a git-connector fixture mismatch; a
|
||||
separate architecture-boundary failure in `application/ingestion/handler.py` belongs to
|
||||
unrelated in-progress work by a different concurrent agent on the same RAGcore checkout,
|
||||
confirmed via `git status`/`git log` — not touched by this fix). Ruff and mypy clean on
|
||||
every changed file. Deployed to the live RAGcore instance (`ragcore-app-1` on Unraid, port
|
||||
1237 internally, fronted by `rag.itworx.tech` — note the *admin UI* and the *API* share
|
||||
one process/origin, `/v1/uploads` is reachable at `https://rag.itworx.tech/v1/uploads`,
|
||||
**not** `ragcore.itworx.tech`, which only appears in RFC7807 problem-type URLs) by copying
|
||||
the 6 changed source files directly into the server checkout and `docker compose build
|
||||
app && up -d --no-deps app` (deliberately not committing to RAGcore's git history or
|
||||
touching the `worker` service, since a different agent has substantial unrelated
|
||||
uncommitted work in that same working tree). Confirmed live with a garbage token (still
|
||||
correctly 401) and then with a freshly-issued, correctly-scoped real token (403
|
||||
`UPLOAD_TARGET_FORBIDDEN` against a dummy space ID — i.e. authentication succeeded,
|
||||
authorization correctly rejected the wrong space — proving the fix end-to-end before
|
||||
touching n8n at all).
|
||||
- **Root cause of the n8n-side 401 found and fixed**: separately from the RAGcore bug above,
|
||||
the "Upload to RAGcore" HTTP node's Authentication was set to Header Auth, but **no
|
||||
credential had ever actually been attached** to that picker — so the node was sending no
|
||||
`Authorization` header at all, which produces the identical 401 to a malformed one (easy
|
||||
to conflate with the RAGcore-side bug, which is why fixing RAGcore alone didn't resolve
|
||||
the symptom). There was already an unused "RAGcore Sync Token" n8n credential sitting
|
||||
around from the earlier session (its value likely never actually got saved when it was
|
||||
first created, or was created but never selected on this node — not conclusively
|
||||
determined). Owner attached it and set Name=`Authorization`,
|
||||
Value=`Bearer <freshly-issued token, scope sources:sync>` (a new credential issued via the
|
||||
RAGcore admin UI at `/admin/control/applications/c20ac48a-d57b-4c68-9bd1-564f49c1a473/credentials/new`
|
||||
specifically for this, service account "n8n Procedure Sync (production)"; the earlier
|
||||
diagnostic-only credential used to prove the RAGcore fix was revoked afterward via direct
|
||||
SQL `UPDATE identity.service_account_credentials SET revoked_at = now() ...` since the
|
||||
admin UI has no revoke button).
|
||||
- **Workflow 3's "Upload to RAGcore" node live-validated end-to-end, real data**: ran the
|
||||
full workflow via n8n's "Execute workflow" (Schedule Trigger → List procedures → Prepare
|
||||
uploads → Upload to RAGcore). All 33 items succeeded — each output item is a real
|
||||
`AcceptedJob` (`job_id`/`status_url`), not error output. Independently confirmed at the
|
||||
database level (not just trusting the n8n UI): `select count(*) from jobs.jobs where
|
||||
operation='ingest_upload' and created_at > now() - interval '5 minutes'` → **33**, on the
|
||||
live RAGcore Postgres.
|
||||
- **n8n browser-automation notes for this environment** (worth knowing before attempting
|
||||
canvas interaction again): (1) an n8n NPS survey modal (`role=dialog`, "We've been busy")
|
||||
intermittently covers the whole canvas and silently eats every click underneath it until
|
||||
removed; (2) canvas node positions reported by `getBoundingClientRect()` drift between
|
||||
successive tool calls in a way that made coordinate-based `computer` clicks and even
|
||||
`find`-ref-based clicks land on the wrong element repeatedly this session (dozens of failed
|
||||
attempts, multiple different coordinate-math theories, none reliable) — directly setting
|
||||
`.vue-flow__transformationpane`'s inline `style.transform` to force a node into view
|
||||
**desyncs vue-flow's own internal pan/zoom state**, making the problem worse, not better;
|
||||
(3) what actually worked reliably every time: calling native `.click()` directly via JS on
|
||||
a plain `<button>` element (e.g. the toolbar's "Execute workflow" button) — canvas *node*
|
||||
interaction (double-click to open a node's parameter panel) was never reliably achieved
|
||||
via any automated method this session; the owner opened/edited the node manually instead.
|
||||
A plain synthetic `dispatchEvent(new MouseEvent(...))` sequence (pointerdown/mousedown/
|
||||
pointerup/mouseup/click/dblclick, even with correct `clientX`/`clientY`/`detail`) does
|
||||
**not** trigger vue-flow's node click handling at all — it appears to require a genuinely
|
||||
trusted (real CDP-driven) pointer event, consistent with vue-flow's drag/zoom gesture
|
||||
system depending on native pointer capture.
|
||||
- **Exact next action (superseded further below)**: build and wire the workflow's final
|
||||
"Summarize sync result" Code node (count successes/failures across the 33 items) and a
|
||||
closing HTTP node reporting to Fleet Ops's already-deployed `POST
|
||||
/api/v1/integrations/n8n/procedures-sync-result` (task #86, still in progress — the sync
|
||||
itself now works, this is the last piece). Then publish the workflow (currently still a
|
||||
draft/unpublished), update `n8n/workflows/MANIFEST.md` and add
|
||||
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` as the repo source-of-truth
|
||||
definition, and revisit `artifacts/live-ai-integration/final-summary.md` (documents WF3
|
||||
as blocked — no longer true).
|
||||
|
||||
- **Verified the 33-item sync is genuinely fully ingested, not just accepted**: checked at
|
||||
every RAGcore pipeline stage on the live instance, not just trusting the n8n "success"
|
||||
status (which can mask individual failures under `On Error: Continue`). All 33
|
||||
`ingest_upload` jobs have `status='succeeded'` in `jobs.jobs`; 33 rows exist in
|
||||
`content.documents`; 372 chunks were generated in `content.chunks`; 241 vectors are
|
||||
indexed in the `rag_dense_nomic-embed-text_v1` Qdrant collection filtered specifically to
|
||||
Fleet Ops's `space_id` (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`) — matching the expected
|
||||
leaf-chunk count (structural/parent chunks in the hierarchy aren't separately embedded).
|
||||
11 unique procedures × 3 languages = 33, all present.
|
||||
|
||||
- **`RAGcoreKnowledgeProvider` rewritten against the real contract (task #93), tested, but
|
||||
NOT switched on in production — genuinely blocked on a RAGcore-side gap, not a Fleet Ops
|
||||
problem**: rewrote `backend/app/services/knowledge/ragcore.py` end to end against
|
||||
RAGcore's actual `/v1/answers` and `/health/ready` contracts (previously a best-effort
|
||||
guess against an unreachable instance). Health now calls the real `GET /health/ready`.
|
||||
`ask()` calls `POST /v1/answers` with `Authorization: Bearer <token>` and
|
||||
`requested_space_ids: [settings.ragcore_space_id]`, maps RAGcore's `answerability` enum
|
||||
conservatively (`answerable`/`partially_answerable` *with* non-empty citations →
|
||||
`grounded`, everything else → `insufficient`, any transport/parse/non-200 failure →
|
||||
`unavailable`, matching the architecture's never-fabricate rule). Added
|
||||
`RAGCORE_SPACE_ID` setting (`.env.example`, `compose.yaml`). New tests in
|
||||
`backend/tests/test_knowledge.py` (connection-error degrade, missing-space-id short
|
||||
circuits without a network call, health ready/degraded/unreachable, grounded citation
|
||||
mapping, not-answerable and answerable-without-citations both correctly map to
|
||||
`insufficient` with an empty answer, non-200 and malformed-body both degrade to
|
||||
`unavailable`) — **23 passed** in that file, **172 passed** full suite, ruff clean, mypy
|
||||
0 issues/50 files.
|
||||
**Discovered while live-testing against RAGcore with a freshly-issued, correctly-scoped
|
||||
credential** (`Fleet Ops Knowledge Assistant (production)`, scope `answer`, space
|
||||
`f4c91e49-...`): authentication now genuinely succeeds (past the 401 stage — confirmed
|
||||
via `/health/ready` returning `200 {"status":"ok", ...}` with the same token), but **both
|
||||
`POST /v1/answers` and `POST /v1/search` return `503`
|
||||
`ANSWERS_UNAVAILABLE`/`SEARCH_UNAVAILABLE`** for every request. Root-caused by reading
|
||||
`src/ragcore/main.py`'s app-startup/lifespan code directly: it constructs and assigns
|
||||
`app.state.database_engine`, `session_factory`, `qdrant_client`,
|
||||
`query_lab_service`, `profile_activation_service`, and the OIDC session services — but
|
||||
**never constructs or assigns `app.state.search_application`, `context_application`, or
|
||||
`answer_application`** anywhere in the codebase (confirmed via a repo-wide grep — the
|
||||
three `get_*_application` dependency functions exist and correctly raise their `503
|
||||
*_UNAVAILABLE` problem when the state attribute is absent, exactly as designed, but
|
||||
nothing ever populates it). This is not a config toggle Fleet Ops is missing and not
|
||||
something introduced by today's auth fixes — the retrieval/generation subsystem's route
|
||||
handlers and dependencies are scaffolded end-to-end but were never wired into the running
|
||||
application on this RAGcore deployment. Only masked until today because every call to
|
||||
these routes previously 401'd on auth before ever reaching this check.
|
||||
**Decision: `KNOWLEDGE_PROVIDER` stays `demo` in production.** Flipping it to `ragcore`
|
||||
right now would replace the currently-working demo Knowledge Assistant with one that
|
||||
correctly, honestly, but uselessly reports "unavailable" for every question — strictly
|
||||
worse for the live demo. The adapter code itself is finished, correct, and safe to ship
|
||||
(already committed-worthy), and switching providers is a one-line env var flip
|
||||
(`KNOWLEDGE_PROVIDER=ragcore` + set `RAGCORE_API_TOKEN`/`RAGCORE_SPACE_ID`) the moment
|
||||
RAGcore's own operator wires up `search_application`/`answer_application` on their side.
|
||||
The freshly-issued credential (`Fleet Ops Knowledge Assistant (production)`, scope
|
||||
`answer`) was left active/unused in RAGcore, ready for that day.
|
||||
|
||||
- **MCP Hub registration (task #94/#95) — full new connector built and validated locally
|
||||
in the sibling `C:\Projects\ITWorx_MCP_Hub` checkout, not committed or deployed**: the
|
||||
Hub's admin UI (`mcp.itworx.tech`, real production instance, 7 pre-existing projects —
|
||||
DevRunbook, ForgeFlow×2, General Infrastructure, GeoIntel, Ludarium) has **no self-service
|
||||
"add project/connector" flow** — its own Settings page states "No writable settings are
|
||||
available in this browser until the control API publishes an authorized configuration
|
||||
schema" (Hub is on release `1.0.0-rc`, UI is read-only/observability-only today).
|
||||
Registering MobilityOps therefore required building a genuinely new connector in the
|
||||
Hub's own repo, following its `gitea` connector as the closest real template (external
|
||||
HTTPS API, shared-secret auth) rather than `knowledge` (which turned out to be an
|
||||
in-memory filesystem index, not an HTTP client, despite the name suggesting otherwise).
|
||||
Built, with explicit owner approval given the Hub's own `CLAUDE.md` restricts autonomous
|
||||
action to local-checkout work only (registry pushes and production startup are separate,
|
||||
explicitly-gated checkpoints, not done this round):
|
||||
- `packages/connector_kit/mobilityops.py` — `MobilityOpsSettings`/`MobilityOpsClient`
|
||||
(HTTPS-only, same-origin-redirect-enforced, `X-Service-Token`/`X-Client-Id` headers,
|
||||
path-safety-validated `vehicle_ref`), mirroring `gitea.py`'s hardening exactly.
|
||||
- `connectors/mobilityops/{__init__,server,fake}.py` — `MobilityOpsConnector` exposing 4
|
||||
read-only tools (`mobilityops.operations.summary`, `.attention.list`, `.vehicle.get`,
|
||||
`.knowledge.search`), each `governed_tool`-wrapped, envelope-wrapped, field-mapped from
|
||||
Fleet Ops's real `/api/v1/integrations/mcp/*` response shapes.
|
||||
- `services/runtime/connector_mobilityops.py` + `dispatch.py` registration.
|
||||
- Catalog: `catalog/connectors/mobilityops-default.yaml` (ConnectorTemplate),
|
||||
`catalog/tools/mobilityops-*.yaml` (4 ToolManifests), `catalog/capability-packs/
|
||||
mobilityops-reader.yaml` (a **new**, narrowly-scoped pack — deliberately not added to
|
||||
the shared `project-reader` pack other projects use, to avoid granting them
|
||||
MobilityOps access), `catalog/projects/mobilityops.yaml` (Project, `applicationUrl`
|
||||
set to the real internal `http://192.168.10.150:1236`, not an invented public domain).
|
||||
- Compose wiring across all three files (`docker-compose.yml`, `.prod.yml`,
|
||||
`.blueprint.yml`) plus a new `mobilityops_service_token` Docker secret.
|
||||
- Fixed ~13 regressions this surfaced in the Hub's own existing test suite — all
|
||||
legitimate guard-rail tests (hardcoded service/tool/secret allowlists, a
|
||||
`CONNECTOR_GATEWAYS`/`FakeContextForge._gateway_tools` registration gap that was a
|
||||
**real production wiring miss**, not just a test-fixture gap: without it, the reconcile
|
||||
step would have raised `"virtual server resolved to an empty tool allowlist"` against
|
||||
the live Hub too) — not scope creep, this is exactly the Hub's own established pattern
|
||||
for registering a new connector, verified by reading how `gitea`/`knowledge`/`unraid`
|
||||
each touch the same ~15 files.
|
||||
- Verified: 15 new connector tests pass; full Hub suite **383 passed, 0 failed, 5 skipped
|
||||
(pre-existing)**; `ruff check`/`ruff format --check` clean; `mypy` clean across 108
|
||||
source files; `scripts/check_boundaries.py` passes (import-direction rules respected —
|
||||
the new connector only imports from `packages/connector_kit`, never `services/*`
|
||||
directly); `scripts/validate_pack.py` schema-validates the catalog cleanly (68
|
||||
documents, up from 61 — exactly the 7 new files) with the only remaining failure being
|
||||
the Hub's own git-cleanliness gate for manifest regeneration, which requires a commit.
|
||||
- **Not done, deliberately**: no commit (the Hub repo has substantial unrelated
|
||||
in-progress work from a different concurrent agent — `WP-235` frontend redesign,
|
||||
`BUILD_STATE.json`/`CURRENT_STATE.md`/`implementation/WORK_PLAN.json` all show as
|
||||
modified by them, not by this session — committing broadly risks entangling that
|
||||
work); no registry push; no production deployment/restart of the live
|
||||
`itworx-mcp-hub-*` stack. Per the Hub's own `CLAUDE.md` approval boundaries, those are
|
||||
separate, explicitly-gated checkpoints requiring their own fresh approval, and a real
|
||||
`MOBILITYOPS_ENDPOINT`/`mobilityops_service_token`/`MOBILITYOPS_PROJECTS_JSON`
|
||||
production configuration still needs to be decided before any of that could run.
|
||||
- **MCP Hub connector — committed** as `f107544` ("Add MobilityOps (Fleet Ops) read-only
|
||||
connector", 28 files) in `C:\Projects\ITWorx_MCP_Hub`, `main` branch. Not pushed to the
|
||||
registry and not deployed to the live Hub stack — those remain separate, explicitly-gated
|
||||
checkpoints per the Hub's own `CLAUDE.md` approval boundaries.
|
||||
|
||||
- **RAGcore search/context/answer application wiring (task #96) — implemented, tested,
|
||||
committed as `a2905cc`** in `C:\Projects\RAGcore`, `main` branch (not pushed, not
|
||||
deployed to the live RAGcore instance). This closes the gap documented above:
|
||||
`search_application`/`context_application`/`answer_application` are now actually
|
||||
constructed in `create_app()` instead of being permanently absent.
|
||||
- `src/ragcore/application/retrieval/production_executor.py` (new) — `RetrievalPipeline`
|
||||
(embed via Ollama -> dense+sparse Qdrant search -> RRF fuse -> rerank via Ollama),
|
||||
reusing the same flow already proven by the Query Lab's `PipelineQueryLabExecutor`
|
||||
(left untouched). Feeds `ProductionSearchExecutor` and `ProductionContextExecutor` from
|
||||
one shared per-request run, so `SearchHit`'s `FusedCandidate` (required by
|
||||
`SearchService`'s own provenance/scope check) and `ContextCandidate`'s parent-chunk
|
||||
expansion both derive from the same reranked result set instead of drifting apart.
|
||||
- `src/ragcore/application/models/answer_generator.py` (new) — `OllamaAnswerGenerator`,
|
||||
`AnswerService`'s real generator: resolves the active `GenerationProfile` from
|
||||
`GenerationProfileRegistry` (already built, never previously constructed anywhere) and
|
||||
calls the existing hardened `OllamaGenerationAdapter`. `main.py`'s `lifespan()` now
|
||||
seeds and activates a default local-Ollama answer profile on first boot if none is
|
||||
active, since no seed data ever registered one.
|
||||
- Verified: 10 new unit tests (7 for the pipeline/executors, 3 for the answer generator)
|
||||
pass; `ruff check` clean; `mypy src` clean (the only 4 mypy errors found are in
|
||||
`infrastructure/qdrant/adapter.py`, `infrastructure/qdrant/filters.py`,
|
||||
`api/v1/control/routes.py` — files this change never touched, confirmed via `git
|
||||
status`/`git diff`, pre-existing or from other concurrent work in this checkout).
|
||||
- Full non-integration suite (`pytest -m "not integration"`, excludes tests requiring
|
||||
disposable infra per the repo's own marker convention): **593 passed, 14 failed, 1
|
||||
skipped**. All 14 failures are pre-existing and unrelated to this change: 10 are
|
||||
Windows-only (`psycopg`'s async driver rejects Windows' default `ProactorEventLoop` —
|
||||
`test_health.py`, `test_readiness.py`, `test_control_plane.py`), 2 are Windows-only
|
||||
(`WinError 1314`, no symlink privilege on this account — `test_folder_connector.py`), 1
|
||||
is an unrelated pre-existing git-security-connector assertion, and 1 is the repo's
|
||||
zero-tolerance `test_domain_and_application_respect_dependency_boundaries` test —
|
||||
already red before this change, since `application/ingestion/handler.py` (untouched,
|
||||
commit `bb75f52`) already imports `httpx`/infrastructure modules from the application
|
||||
layer, which that test forbids with no allowlist. This change's new files follow the
|
||||
same established (if already-violating) pattern to reach real adapters, adding more
|
||||
instances of the same pre-existing violation rather than introducing a new kind of one.
|
||||
- **Deployed to production** (`http://192.168.10.150:1237` via `ragcore-app-1`),
|
||||
approved by the user. `scp`'d the 4 changed/new files individually to
|
||||
`/mnt/user/appdata/ragcore/app/...`, `docker compose build app`, verified the built
|
||||
image actually contains the new code (`docker run --rm ragcore-app grep`/`test -f`),
|
||||
then `docker compose up -d --no-deps app` (only the `app` service touched). Startup
|
||||
logs are clean (`ragcore_started environment=production`, no errors); `/health/ready`
|
||||
reports `postgres`/`qdrant`/`storage` all `ok`.
|
||||
- **Live-verified the fix itself**: `POST /v1/search` now returns `401
|
||||
AUTHENTICATION_REQUIRED` for an unauthenticated call, not the old permanent `503
|
||||
SEARCH_UNAVAILABLE` — proof the endpoint reaches real request handling instead of
|
||||
hitting an absent application state, which is exactly the bug this closes.
|
||||
- **Issued a fresh scoped credential** (`answer` scope, application `Fleet Ops`, via the
|
||||
RAGcore admin UI at `rag.itworx.tech` — a separate, working OIDC session, unaffected by
|
||||
the n8n auth problem above) and made a real authenticated `POST /v1/answers` call
|
||||
against the `Fleet Ops Procedures` knowledge space (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`).
|
||||
Got a clean `200`, a real `answer_id`/`retrieval_run_id`, `degraded: false` — but
|
||||
`answerability: not_answerable`, 0 citations, for two different real questions matching
|
||||
real seeded document titles.
|
||||
- **Confirmed this is not a regression from this change**: the same query against the
|
||||
same space through RAGcore's own pre-existing, already-proven Query Lab tool (which
|
||||
uses `PipelineQueryLabExecutor`, code this session never touched) returns the identical
|
||||
`NOT_ANSWERABLE` / 0 evidence / not degraded result — down to `Dense candidates (0)` and
|
||||
`Sparse candidates (0)` at the raw retrieval stage, before fusion or rerank ever runs.
|
||||
Whatever is causing zero matches lives in shared retrieval infrastructure or the data
|
||||
itself, not in the new production executors.
|
||||
- **Ruled out the obvious causes via direct Qdrant/Postgres checks** (not yet root-caused
|
||||
further — flagging as a separate follow-up, not blocking this task): the space
|
||||
genuinely has 83 published, correctly-scoped points in `rag_dense_nomic-embed-text_v1`
|
||||
(right `workspace_id`, right `space_id`, `status: published`, real Dutch-language
|
||||
procedure text); the points' `embedding_model_digest` exactly matches the currently
|
||||
active embedding profile's digest (no stale-embedding mismatch); the collection alias
|
||||
(`rag_dense_nomic-embed-text_active`) correctly resolves to that same collection. One
|
||||
oddity noted in passing, unrelated to retrieval: at least one sampled chunk has
|
||||
`language: "en"` in its payload despite the actual text being Dutch — worth a look if
|
||||
language-filtered queries matter for the demo.
|
||||
- **Decision on `KNOWLEDGE_PROVIDER=ragcore` in Fleet Ops**: still deliberately `demo`.
|
||||
The application is deployed, correctly wired, and behaves identically to RAGcore's own
|
||||
trusted reference implementation — but real questions against real seeded content
|
||||
aren't returning grounded answers yet for a reason that traces to shared
|
||||
retrieval/data, not to this wiring. Flip only after that's root-caused.
|
||||
|
||||
- **n8n workflow 3 (task #86) — incident, recovered; final 2 nodes still blocked on a
|
||||
live n8n auth problem, not a code problem.** Returning to finish the "Summarize sync
|
||||
result" + report-to-Fleet-Ops nodes found the live "Fleet Ops — RAGcore Procedure Sync"
|
||||
workflow's canvas at **zero nodes** — the earlier session's abandoned attempt to call
|
||||
n8n's REST API directly (the "unexplained 401" noted above) had gone through far enough
|
||||
to wipe the live workflow before the 401 stopped it, leaving an empty, unsaved "Current
|
||||
changes" draft on top of the last good save.
|
||||
- **Recovered**: n8n's own Version History (`/workflow/.../history`) still had the last
|
||||
good save, version `260b38b5` (Aug 4, 21:08), with all 4 real nodes intact (Schedule
|
||||
Trigger -> List procedures -> Prepare uploads -> Upload to RAGcore) plus the build
|
||||
sticky note. Used the history panel's own **Restore version** action (not a manual
|
||||
rebuild) to bring the live workflow back to that exact state. Confirmed via the page's
|
||||
DOM (`[data-test-id="canvas-node"]` count) both before (0) and after (4) — the canvas
|
||||
itself render fully off-screen (nodes positioned at negative Y coordinates, a separate
|
||||
display-only vue-flow pan bug worked around by directly setting the transform pane's
|
||||
CSS transform; harmless, doesn't touch saved data).
|
||||
- **Then blocked again attempting the 2 new nodes**: adding a Code node via the UI's own
|
||||
"What happens next?" panel (a real, first-party n8n interaction, not the REST API)
|
||||
immediately surfaced n8n's own autosave toast: **"Problem saving workflow — Autosave
|
||||
failed: Unauthorized."** A full sign-out/sign-in cycle right before this (fresh
|
||||
credentials, fresh page load) did not fix it — immediately after a successful
|
||||
interactive "Sign in", `fetch('/rest/workflows/...')` from that same authenticated tab
|
||||
still returned 401. This is not the ordinary "your session expired, log in again"
|
||||
friction seen earlier in the session; it's the live n8n instance's own save path
|
||||
rejecting a request made moments after a successful login, which is exactly the
|
||||
failure mode that caused the wipe above. Continuing to add nodes under this condition
|
||||
risks losing work again with no guarantee the next failure is as recoverable, so this
|
||||
was stopped deliberately rather than retried. The workflow was left in its safely
|
||||
restored, 4-node, unpublished state — verified via the DOM node count immediately
|
||||
before closing the session — nothing was added or changed beyond the restore.
|
||||
- **Not a code/workflow-design problem**: the 2 remaining nodes (a Code node
|
||||
summarizing the sync result, and an HTTP node reporting to Fleet Ops's already-live
|
||||
`POST /api/v1/integrations/n8n/procedures-sync-result`) were never built — this
|
||||
blocked before any node configuration happened. The design itself (mirroring WF2/WF4's
|
||||
existing report-result pattern) is unchanged from earlier planning.
|
||||
|
||||
- **n8n workflow 3 — root cause found and fixed; both remaining nodes now built and
|
||||
saved.** The user pushed back on the "auth problem" framing (n8n visibly showed signed
|
||||
in), which was the right call — dug into the live n8n container's own logs
|
||||
(`docker logs n8n`) rather than continuing to guess from the browser side, and found the
|
||||
real cause: `browserId check failed on /rest/workflows/:workflowId`, alongside an
|
||||
express-rate-limit warning about `X-Forwarded-For` being present while Express `trust
|
||||
proxy` is `false`. n8n's Unraid template
|
||||
(`/boot/config/plugins/dockerMan/templates-user/my-n8n.xml`) had `N8N_PROXY_HOPS=0`
|
||||
despite genuinely running behind the TLS-terminating reverse proxy at
|
||||
`n8n.itworx.tech` — with proxy trust disabled, n8n couldn't correctly resolve the
|
||||
request as HTTPS, which broke its CSRF-style browserId cookie check on every
|
||||
workflow-mutating REST call (autosave, but not plain page loads, which explains why the
|
||||
UI looked fully logged in the whole time).
|
||||
- **Fix, approved by the user beforehand** (shared instance, brief restart): backed up
|
||||
the template to a timestamped `.bak`, changed `N8N_PROXY_HOPS` `0` -> `1`, then
|
||||
recreated the `n8n` container on the Unraid host (stop, rename to
|
||||
`n8n_pre_proxyhops_fix` as an instant rollback, `docker run` with every existing env
|
||||
var/volume/port/label preserved exactly plus the one corrected value). All 3
|
||||
previously-active workflows (Vehicle Return Orchestration, Scheduled Data Quality
|
||||
Scan, Workflow Error Handler) re-activated cleanly on the new container — nothing
|
||||
lost. Verified the fix by reproducing the exact save action that used to fail
|
||||
(adding a node via n8n's own UI): no more "Unauthorized" toast, and the added node
|
||||
survived a full page reload. `docker logs n8n` shows zero `browserId`/401 entries
|
||||
since the fix.
|
||||
- **Mid-fix-verification, a real node got deleted by an errant `Ctrl+A`** (browser
|
||||
focus landed on the canvas instead of a text field during cleanup, selecting and then
|
||||
deleting the "List procedures" node). Caught it via a node-count check, used n8n's own
|
||||
Version History **Restore version** to the last good save (not a manual rebuild), then
|
||||
redid the 2 new nodes carefully (verifying focus before every `Ctrl+A` this time). No
|
||||
data lost, just redone.
|
||||
- **Built**: `Summarize sync result` (Code node, JS) — counts `synced`/`failed` from
|
||||
`$input.all()` by checking each item's `json.error` (matches "Upload to RAGcore"'s own
|
||||
`On Error: Continue` setting, confirmed by inspecting that node's Settings tab, so
|
||||
failed uploads land in the same output stream with an `error` field rather than a
|
||||
separate branch). `Report sync result to Fleet Ops` (HTTP Request, POST) — targets the
|
||||
already-live `/api/v1/integrations/n8n/procedures-sync-result`, reusing the existing
|
||||
"Fleet Ops Service Token" Header Auth credential (same one WF4 already uses against
|
||||
this same backend), JSON body `{execution_id, synced, failed}` matching
|
||||
`ProcedureSyncResultIn` exactly.
|
||||
- **Saved, not published.** All 6 nodes (Schedule Trigger -> List procedures -> Prepare
|
||||
uploads -> Upload to RAGcore -> Summarize sync result -> Report sync result to Fleet
|
||||
Ops) confirmed present after a full page reload. The Schedule Trigger is configured
|
||||
for once daily at midnight -- publishing activates real, live, unattended runs against
|
||||
production RAGcore and Fleet Ops, so this was deliberately left for a separate,
|
||||
explicit approval rather than done automatically.
|
||||
|
||||
- **Exact next action**: (1) RAGcore search/answer wiring (`a2905cc`) is deployed and
|
||||
proven correctly wired (matches Query Lab's trusted behavior exactly) — the open item is
|
||||
now root-causing why `Fleet Ops Procedures` returns zero dense/sparse candidates for
|
||||
real questions despite having genuinely matching, correctly-scoped, correctly-embedded
|
||||
indexed content (see evidence above); this is retrieval/data, not application wiring.
|
||||
Only flip `KNOWLEDGE_PROVIDER=ragcore` once that's fixed and a real grounded answer comes
|
||||
back. (2) n8n workflow 3 is fully built and saved (all 6 nodes) but **not published** —
|
||||
decide whether to publish it (starts real daily runs against production) and, once
|
||||
live-verified end to end, add its file to `n8n/workflows/` and `MANIFEST.md` as the
|
||||
source of truth alongside the other 3 workflows. (3) MCP Hub: registry push + production
|
||||
deployment of the now-committed connector remain separate, explicitly-gated checkpoints.
|
||||
WF4's own timeout/retry gap remains open, deferred, non-blocking.
|
||||
|
||||
## Branch push and Unraid redeploy (2026-08-05)
|
||||
|
||||
- Pushed `feat/live-n8n-ragcore-integration` to `origin` (Gitea on the same Unraid host),
|
||||
now tracking `origin/feat/live-n8n-ragcore-integration` (`0571a40`).
|
||||
- **Redeployed the live Fleet Ops instance** (`http://192.168.10.150:1236`) from this
|
||||
branch, bringing it from the previously-deployed `0da5251` up to `0571a40` — 5 commits,
|
||||
the meaningful one being the `RAGcoreKnowledgeProvider` rewrite (`e5d8466`); the rest
|
||||
are `PROJECT_STATE.md`-only evidence commits.
|
||||
- Followed the deployment directory's own established archive convention
|
||||
(`deploy/unraid/README.md`: "deployed from a committed source archive"): `git archive`
|
||||
of `HEAD` as `source-0571a40.tar.gz`, matching the naming pattern of the existing
|
||||
archives already in `/mnt/user/appdata/mobilityops/.deploy/`; `scp`'d it there;
|
||||
extracted over the deployment directory excluding `.env` and `.deploy` itself (so the
|
||||
live secrets file and archive history were never touched); updated
|
||||
`.deploy/source-revision` to the new full commit hash, matching its existing format.
|
||||
- `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db
|
||||
api web` — `api` image rebuilt and the container recreated (picked up the new code);
|
||||
`web` rebuilt too but didn't need recreating (no frontend changes this branch); `db`
|
||||
untouched. Migrations ran automatically in the `api` entrypoint with no errors.
|
||||
- **Verified live**: `/health` returns `200` from inside the `api` container; the new
|
||||
`/api/v1/integrations/n8n/procedures` endpoint (added this branch, needed for n8n
|
||||
workflow 3) is reachable through the public web proxy and correctly enforces auth
|
||||
(`422`, missing `X-Service-Token`, not a `404` — proves the route exists and is live,
|
||||
not just that the proxy responds). `KNOWLEDGE_PROVIDER=demo` confirmed unchanged in the
|
||||
live `.env` (only extracted new source files, never touched it) — the demo Knowledge
|
||||
Assistant is still what's live, exactly as intended.
|
||||
- No seed reset run (this was an update to a running instance with real accumulated
|
||||
demo data, not the initial deploy — re-seeding would have been destructive and wasn't
|
||||
warranted by anything in this branch's changes).
|
||||
- **Not done**: no PR opened/merged to `master` — the user asked for commit, push, and
|
||||
redeploy, not a merge; `master` is untouched and still 19 commits behind this branch.
|
||||
|
||||
@@ -9,7 +9,10 @@ knowledge base, and this documentation. "MobilityOps" remains the technical
|
||||
identifier only — the repository name, local directory, package/module names, Docker
|
||||
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
|
||||
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
|
||||
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
||||
@@ -123,7 +126,7 @@ All defaults are configurable via `.env` (see `.env.example`).
|
||||
```bash
|
||||
make test # backend: pytest (151 tests)
|
||||
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`).
|
||||
|
||||
@@ -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,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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.models.audit import AuditEvent
|
||||
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.data_quality import run_scan
|
||||
from app.services.knowledge.procedures import iter_procedure_documents
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
@@ -89,3 +99,124 @@ def scheduled_scan(
|
||||
|
||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -21,8 +21,10 @@ class Settings(BaseSettings):
|
||||
ragcore_workspace: str = "mobilityops"
|
||||
ragcore_collection: str = "internal-procedures"
|
||||
ragcore_api_token: str = ""
|
||||
ragcore_space_id: str = ""
|
||||
ragcore_http_timeout_seconds: float = 5.0
|
||||
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_dispatch_enabled: bool = True
|
||||
n8n_dispatch_interval_seconds: float = 3.0
|
||||
|
||||
@@ -143,6 +143,53 @@ class ScanResultOut(BaseModel):
|
||||
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):
|
||||
fields: dict[str, str]
|
||||
|
||||
@@ -202,6 +249,18 @@ class SearchResponse(BaseModel):
|
||||
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):
|
||||
configured: bool
|
||||
dispatch_enabled: bool
|
||||
@@ -212,6 +271,10 @@ class N8nIntegrationStatus(BaseModel):
|
||||
succeeded: int
|
||||
latest_success_at: datetime | None
|
||||
latest_failure_at: datetime | None
|
||||
expected_workflow_count: int
|
||||
known_workflow_count: int
|
||||
workflows: list[N8nWorkflowEvidence]
|
||||
error_handler: N8nErrorHandlerStatus
|
||||
|
||||
|
||||
class McpHubIntegrationStatus(BaseModel):
|
||||
|
||||
@@ -121,13 +121,30 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
response = httpx.post(
|
||||
settings.n8n_webhook_url,
|
||||
json=wire_event,
|
||||
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
|
||||
timeout=settings.n8n_http_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
success = bool(body.get("ok", True))
|
||||
error = None if success else f"n8n reported failure: {body}"
|
||||
error_code = None if success else "remoteReportedFailure"
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
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:
|
||||
success = False
|
||||
error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
@@ -6,11 +6,21 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import N8nIntegrationStatus
|
||||
from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). Workflow 3
|
||||
# (RAGcore Procedure Sync) is not built yet, so it always reports no evidence.
|
||||
_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:
|
||||
counts: dict[str, int] = dict(
|
||||
@@ -42,6 +52,52 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
else:
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
# 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": None,
|
||||
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||
}
|
||||
workflows = [
|
||||
N8nWorkflowEvidence(
|
||||
name=name,
|
||||
built=name != "Fleet Ops — RAGcore Procedure Sync",
|
||||
last_seen_at=evidence_by_workflow[name],
|
||||
)
|
||||
for name in _CANONICAL_WORKFLOWS
|
||||
]
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
@@ -52,4 +108,12 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
from app.services.knowledge.procedures import parse_frontmatter
|
||||
|
||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||
DEFAULT_LANGUAGE = "en-GB"
|
||||
@@ -74,23 +75,6 @@ class ScoredSection:
|
||||
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]]:
|
||||
sections: list[tuple[str, str]] = []
|
||||
current_heading = "Overview"
|
||||
@@ -114,7 +98,7 @@ def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
||||
sections: list[ScoredSection] = []
|
||||
for path in sorted(procedures_dir.glob("*.md")):
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
meta, body = _parse_frontmatter(raw)
|
||||
meta, body = parse_frontmatter(raw)
|
||||
title = meta.get("title", path.stem)
|
||||
doc = Document(
|
||||
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,29 @@ from __future__ import annotations
|
||||
import httpx
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
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).
|
||||
No live RAGcore instance was reachable during this build, so the exact request/response
|
||||
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
|
||||
malformed response) degrades to `unavailable` rather than raising, per the architecture's
|
||||
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
|
||||
the app, and never fabricates an answer.
|
||||
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
|
||||
session-cookie auth is for its own browser admin UI only). Any connection error,
|
||||
timeout, non-2xx response, or malformed body degrades to `evidence_state:
|
||||
"unavailable"` rather than raising -- this is the adapter that actually exercises the
|
||||
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
|
||||
never fabricates an answer, never affects the rest of the app.
|
||||
|
||||
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"
|
||||
@@ -35,11 +46,15 @@ class RAGcoreKnowledgeProvider:
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.get("/health")
|
||||
response.raise_for_status()
|
||||
available = True
|
||||
detail = "RAGcore reachable."
|
||||
except httpx.HTTPError as exc:
|
||||
response = client.get("/health/ready")
|
||||
body = response.json()
|
||||
available = response.status_code == 200 and body.get("status") == "ok"
|
||||
detail = (
|
||||
"RAGcore reachable and ready."
|
||||
if available
|
||||
else f"RAGcore degraded: {body.get('status', 'unknown')}"
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
available = False
|
||||
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||
return KnowledgeHealth(
|
||||
@@ -49,51 +64,58 @@ class RAGcoreKnowledgeProvider:
|
||||
tenant=self._settings.ragcore_tenant,
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
# RAGcore's retrieval API has no corpus-size endpoint to query honestly from
|
||||
# here; left at 0 rather than approximated from a capped search result count.
|
||||
document_count=0,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
"/api/v1/ask",
|
||||
"/v1/answers",
|
||||
json={
|
||||
"tenant": self._settings.ragcore_tenant,
|
||||
"workspace": self._settings.ragcore_workspace,
|
||||
"collection": self._settings.ragcore_collection,
|
||||
"question": question,
|
||||
"correlation_id": correlation_id,
|
||||
"language": language,
|
||||
"query": question,
|
||||
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code != 200:
|
||||
return unavailable
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="unavailable",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return unavailable
|
||||
|
||||
try:
|
||||
sources = [SourceCard(**s) for s in body.get("sources", [])]
|
||||
evidence_state = body.get("evidence_state", "insufficient")
|
||||
if evidence_state not in ("grounded", "insufficient", "unavailable"):
|
||||
evidence_state = "insufficient"
|
||||
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", ""),
|
||||
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||
evidence_state=evidence_state,
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="unavailable",
|
||||
sources=[],
|
||||
sources=sources if evidence_state == "grounded" else [],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
except (TypeError, KeyError, ValueError):
|
||||
return unavailable
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_deliver_one_success(monkeypatch):
|
||||
event_id = _make_pending_event("MO-002")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
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")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
event_id = _make_pending_event("MO-004")
|
||||
settings = get_settings()
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
raise dispatcher.httpx.ConnectError("still down")
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
@@ -149,7 +176,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
||||
finally:
|
||||
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")
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
@@ -229,7 +256,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
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):
|
||||
event_id = _make_pending_event("MO-005")
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||
|
||||
@@ -39,3 +39,82 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
|
||||
body = response.json()["n8n"]
|
||||
assert body["failed"] == 0
|
||||
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"])
|
||||
assert ragcore_sync["built"] is False
|
||||
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,138 @@ def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
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}
|
||||
|
||||
@@ -158,12 +158,190 @@ def test_knowledge_status_endpoint(ops_client):
|
||||
assert response.json()["provider"] == "demo"
|
||||
|
||||
|
||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||
def fake_client(*args, **kwargs):
|
||||
raise httpx.ConnectError("no ragcore in this environment")
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict):
|
||||
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, raise_on=None):
|
||||
self._get_response = get_response
|
||||
self._post_response = post_response
|
||||
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_response
|
||||
|
||||
|
||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||
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")
|
||||
assert answer.evidence_state == "unavailable"
|
||||
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")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})),
|
||||
)
|
||||
answer = provider.ask("Anything?", "test-correlation-malformed")
|
||||
assert answer.evidence_state == "unavailable"
|
||||
|
||||
@@ -31,7 +31,9 @@ services:
|
||||
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
||||
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
||||
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_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-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}
|
||||
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||
|
||||
@@ -3,7 +3,7 @@ set -eu
|
||||
|
||||
container_name="${1:-n8n}"
|
||||
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
|
||||
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
|
||||
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Missing deployment .env" >&2
|
||||
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
|
||||
if [ -z "$callback_token" ]; then
|
||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The workflow file no longer carries the callback token as a literal header value -- both
|
||||
# the webhook trigger and the outbound callback authenticate via named n8n Header Auth
|
||||
# credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
|
||||
# exist in the target n8n instance before this workflow is activated; see the echo below.
|
||||
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||
container_workflow="/tmp/mobilityops-return-processing.json"
|
||||
cleanup() {
|
||||
@@ -32,15 +30,17 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
|
||||
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
|
||||
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
|
||||
select(.name == "X-Service-Token") | .value) = $callback_token
|
||||
jq --arg callback_url "$callback_url" '
|
||||
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
|
||||
' "$source_workflow" > "$temporary_workflow"
|
||||
|
||||
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
||||
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
|
||||
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
|
||||
docker restart "$container_name" >/dev/null
|
||||
|
||||
echo "Published MobilityOps return workflow to existing container ${container_name}"
|
||||
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}"
|
||||
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
|
||||
echo "Missing deployment .env" >&2
|
||||
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
|
||||
if [ -z "$callback_token" ]; then
|
||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The workflow file no longer carries the callback token as a literal header value -- the
|
||||
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
|
||||
# Token"), which must exist in the target n8n instance before this workflow is activated;
|
||||
# see the echo below.
|
||||
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
|
||||
cleanup() {
|
||||
@@ -32,15 +30,16 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" '
|
||||
(.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
|
||||
jq --arg scan_url "$scan_url" '
|
||||
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
|
||||
' "$source_workflow" > "$temporary_workflow"
|
||||
|
||||
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
||||
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
|
||||
docker exec "$container_name" n8n publish:workflow --id=mobilityops-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."
|
||||
|
||||
+15
-15
@@ -16,7 +16,7 @@ Steps:
|
||||
4. return a stable workflow result;
|
||||
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
|
||||
|
||||
@@ -39,24 +39,24 @@ Steps:
|
||||
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.
|
||||
|
||||
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and
|
||||
published the same way as the return-processing workflow (see
|
||||
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with
|
||||
`"active": false` so it cannot fire against any environment until deliberately
|
||||
published with a real service token.
|
||||
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
|
||||
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
|
||||
active on the live instance; a fresh import ships inactive until credentials are wired up
|
||||
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;
|
||||
2. call RAGcore ingestion/sync API;
|
||||
3. record per-document results through MobilityOps integration status API.
|
||||
|
||||
Deferred until RAGcore's live ingestion API is available in this environment; must not
|
||||
delay or block the core demo.
|
||||
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
|
||||
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
|
||||
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
|
||||
|
||||
## Outbox dispatcher
|
||||
|
||||
|
||||
+11
-2
@@ -48,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
|
||||
which runs:
|
||||
|
||||
```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 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
|
||||
`"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:
|
||||
|
||||
```bash
|
||||
@@ -77,11 +83,14 @@ make n8n-setup-scan
|
||||
which runs:
|
||||
|
||||
```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 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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
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.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.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||
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.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 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 }) => {
|
||||
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$/);
|
||||
|
||||
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));
|
||||
|
||||
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 page.goto("/scenarios");
|
||||
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.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 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(/Synthetische 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 Rental Employee" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||
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 }) => {
|
||||
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$/);
|
||||
|
||||
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 }) => {
|
||||
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$/);
|
||||
|
||||
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 }) => {
|
||||
await resetDemoData(request);
|
||||
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 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 }) => {
|
||||
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 page.goto("/scenarios");
|
||||
|
||||
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$/);
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
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.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 }) => {
|
||||
await resetDemoData(request);
|
||||
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 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 expect(page.getByRole("heading", { name: "Retour geregistreerd" })).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 }) => {
|
||||
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 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 }) => {
|
||||
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 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 }) => {
|
||||
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 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 page.goto("/login");
|
||||
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$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
"nl-BE": "Verken als Operations Manager",
|
||||
"nl-BE": "Verken als Operationsmanager",
|
||||
"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> = {
|
||||
@@ -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 }) => {
|
||||
await resetDemoData(request);
|
||||
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
||||
|
||||
@@ -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 ---
|
||||
// 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"
|
||||
@@ -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
|
||||
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||
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
|
||||
"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.fr-BE",
|
||||
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||
"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.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
||||
@@ -131,7 +143,6 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
||||
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
||||
"knowledge.retrievalFlow.question",
|
||||
"knowledge.questionLabelExchange",
|
||||
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
|
||||
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
||||
]);
|
||||
|
||||
@@ -185,6 +196,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) ---
|
||||
// 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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Fleet Ops</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0f172a" />
|
||||
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="white" />
|
||||
<path d="M3 15.5h26" stroke="#2dd4bf" stroke-width="2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 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 ?? "";
|
||||
|
||||
type UnauthorizedListener = () => void;
|
||||
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||
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> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...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,
|
||||
};
|
||||
}
|
||||
@@ -254,6 +254,18 @@ export interface KnowledgeHealth {
|
||||
document_count: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
configured: boolean;
|
||||
dispatch_enabled: boolean;
|
||||
@@ -264,6 +276,10 @@ export interface N8nIntegrationStatus {
|
||||
succeeded: number;
|
||||
latest_success_at: string | null;
|
||||
latest_failure_at: string | null;
|
||||
expected_workflow_count: number;
|
||||
known_workflow_count: number;
|
||||
workflows: N8nWorkflowEvidence[];
|
||||
error_handler: N8nErrorHandlerStatus;
|
||||
}
|
||||
|
||||
export interface McpHubIntegrationStatus {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useViewportTier } from "../hooks/useViewportTier";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
export function DemoGuideTrigger() {
|
||||
@@ -68,7 +70,7 @@ export function DemoGuide() {
|
||||
setCollapsedToChip,
|
||||
} = useDemoGuide();
|
||||
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 pendingTarget = useRef<string | null>(null);
|
||||
|
||||
@@ -119,7 +121,7 @@ export function DemoGuide() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
||||
setResetError(describeApiError(t, err, "guide.restartFailed"));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
@@ -227,7 +229,7 @@ export function DemoGuide() {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
<ApiErrorNotice error={resetError} />
|
||||
|
||||
<footer className="demo-guide-footer">
|
||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
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 type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
@@ -86,7 +88,7 @@ export function Layout() {
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [resetConfirming, setResetConfirming] = 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 searchBox = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -163,7 +165,7 @@ export function Layout() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
|
||||
setResetError(describeApiError(t, err, "resetFailed"));
|
||||
setResetConfirming(false);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
@@ -231,7 +233,7 @@ export function Layout() {
|
||||
</div>
|
||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
<div className="sidebar-reset">
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
<ApiErrorNotice error={resetError} />
|
||||
{!resetConfirming ? (
|
||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||
{t("resetDemoData")}
|
||||
|
||||
@@ -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({
|
||||
icon = "check",
|
||||
title,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
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 { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { StatusBadge } from "./Badge";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
|
||||
function newIdempotencyKey(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
@@ -103,7 +105,7 @@ export function ReturnForm({
|
||||
const [notes, setNotes] = useState("");
|
||||
const [submitting, setSubmitting] = 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 [step, setStep] = useState<"capture" | "review">("capture");
|
||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
||||
@@ -132,7 +134,7 @@ export function ReturnForm({
|
||||
setPreview(evaluated);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
||||
setError(describeApiError(t, err));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
@@ -148,7 +150,7 @@ export function ReturnForm({
|
||||
);
|
||||
onRegistered(registered);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
||||
setError(describeApiError(t, err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -158,7 +160,7 @@ export function ReturnForm({
|
||||
<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="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">
|
||||
<div className="form-grid"><label>
|
||||
|
||||
@@ -17,3 +17,13 @@ export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { sta
|
||||
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
||||
configured: { statusClass: "no_events", labelKey: "prepared" },
|
||||
};
|
||||
|
||||
// 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";
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"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.",
|
||||
"viewFleet": "View fleet",
|
||||
"demoStart": {
|
||||
|
||||
@@ -1,8 +1,180 @@
|
||||
{
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"workspaceLoadFailed": "We couldn't load this workspace.",
|
||||
"unauthorized": "Your session has expired. Please log in again.",
|
||||
"forbidden": "You don't have access to this section.",
|
||||
"notFound": "This record could not be found.",
|
||||
"networkUnavailable": "The connection to the server is currently unavailable."
|
||||
"generic": {
|
||||
"title": "Something went wrong",
|
||||
"explanation": "This action could not be completed. Please try again."
|
||||
},
|
||||
"codes": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,28 @@
|
||||
"demoMode": "Demo mode",
|
||||
"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": {
|
||||
"title": "Automation jobs",
|
||||
"description": "Persisted automation attempts with the latest failure evidence.",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||
"managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.",
|
||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.",
|
||||
"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…",
|
||||
"unavailable": "La piste d'audit est actuellement indisponible.",
|
||||
"empty": "Aucun événement d'audit trouvé",
|
||||
@@ -16,7 +16,7 @@
|
||||
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
||||
"columns": {
|
||||
"when": "Quand",
|
||||
"actor": "Acteur",
|
||||
"actor": "Auteur",
|
||||
"action": "Action",
|
||||
"entity": "Entité",
|
||||
"change": "Changement",
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
"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.",
|
||||
"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",
|
||||
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee",
|
||||
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
||||
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
||||
"safeByDesignTitle": "Conçu pour la sécurité",
|
||||
"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.",
|
||||
"roleOperationsManager": "Operations manager",
|
||||
"roleRentalEmployee": "Rental employee",
|
||||
"roleOperationsManager": "Responsable des opérations",
|
||||
"roleRentalEmployee": "Collaborateur de location",
|
||||
"switchRole": "Changer de rôle",
|
||||
"logout": "Déconnexion"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Exploitation / Aperçu en direct",
|
||||
"title": "Bonjour. Voici votre flotte.",
|
||||
"greeting": {
|
||||
"morning": "Bonjour",
|
||||
"afternoon": "Bonjour",
|
||||
"evening": "Bonsoir",
|
||||
"night": "Bon retour"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Voici l'état de votre flotte pour aujourd'hui.",
|
||||
"afternoon": "Voici l'aperçu actuel de votre flotte.",
|
||||
"evening": "Voici l'aperçu de votre flotte pour ce soir.",
|
||||
"night": "Voici le dernier aperçu de votre flotte."
|
||||
},
|
||||
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
|
||||
"viewFleet": "Voir la flotte",
|
||||
"demoStart": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"understand-state": {
|
||||
"title": "1. Comprendre l'état opérationnel",
|
||||
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
|
||||
"whyItMatters": "Un Operations Manager commence chaque journée par cet aperçu pour décider où intervenir.",
|
||||
"whyItMatters": "Un Responsable des opérations commence chaque journée par cet aperçu pour décider où intervenir.",
|
||||
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
|
||||
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
|
||||
},
|
||||
@@ -102,8 +102,8 @@
|
||||
"startScenario": "Démarrer le scénario",
|
||||
"roleOr": "{{a}} ou {{b}}",
|
||||
"roles": {
|
||||
"operations_manager": "Operations Manager",
|
||||
"rental_employee": "Rental Employee"
|
||||
"operations_manager": "Responsable des opérations",
|
||||
"rental_employee": "Collaborateur de location"
|
||||
},
|
||||
"items": {
|
||||
"return-anomaly": {
|
||||
@@ -155,7 +155,7 @@
|
||||
"problemTitle": "Le problème fictif",
|
||||
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
||||
"scopeTitle": "Pour qui et avec quelle portée",
|
||||
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
||||
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Responsables des opérations et Collaborateurs de location, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
||||
"realTitle": "Ce qui fonctionne réellement",
|
||||
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
|
||||
"syntheticTitle": "Ce qui est synthétique",
|
||||
@@ -170,7 +170,7 @@
|
||||
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
|
||||
"resetTitle": "Restaurer l'environnement de démo",
|
||||
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
||||
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Operations Manager peut réinitialiser l'environnement de démo via la barre latérale.",
|
||||
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
|
||||
"limitationsTitle": "Limitations",
|
||||
"limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.",
|
||||
"unknown": "inconnu"
|
||||
|
||||
@@ -1,8 +1,180 @@
|
||||
{
|
||||
"generic": "Une erreur est survenue. Veuillez réessayer.",
|
||||
"workspaceLoadFailed": "Nous n'avons pas pu charger cet espace de travail.",
|
||||
"unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
|
||||
"forbidden": "Vous n'avez pas accès à cette section.",
|
||||
"notFound": "Cette fiche est introuvable.",
|
||||
"networkUnavailable": "La connexion au serveur est actuellement indisponible."
|
||||
"generic": {
|
||||
"title": "Une erreur s'est produite",
|
||||
"explanation": "Cette action n'a pas pu être terminée. Veuillez réessayer."
|
||||
},
|
||||
"codes": {
|
||||
"VEHICLE_NOT_FOUND": {
|
||||
"title": "Véhicule introuvable",
|
||||
"explanation": "Ce véhicule est introuvable. Il a peut-être été supprimé, ou la référence est incorrecte."
|
||||
},
|
||||
"BOOKING_NOT_FOUND": {
|
||||
"title": "Réservation introuvable",
|
||||
"explanation": "Cette réservation est introuvable. Elle a peut-être été supprimée, ou la référence est incorrecte."
|
||||
},
|
||||
"CUSTOMER_NOT_FOUND": {
|
||||
"title": "Client introuvable",
|
||||
"explanation": "Cette fiche client est introuvable."
|
||||
},
|
||||
"ENTITY_NOT_FOUND": {
|
||||
"title": "Fiche introuvable",
|
||||
"explanation": "La fiche sous-jacente à cette action est introuvable."
|
||||
},
|
||||
"EVENT_NOT_FOUND": {
|
||||
"title": "Tâche d'automatisation introuvable",
|
||||
"explanation": "Cet événement d'automatisation est introuvable."
|
||||
},
|
||||
"ISSUE_NOT_FOUND": {
|
||||
"title": "Problème de qualité des données introuvable",
|
||||
"explanation": "Ce problème de qualité des données est introuvable."
|
||||
},
|
||||
"ISSUE_NOT_OPEN": {
|
||||
"title": "Le problème n'est plus ouvert",
|
||||
"explanation": "Ce problème a déjà été résolu, reporté ou rejeté.",
|
||||
"nextStep": "Actualisez la page pour voir son état actuel."
|
||||
},
|
||||
"BOOKING_NOT_ACTIVE": {
|
||||
"title": "La réservation n'est pas active",
|
||||
"explanation": "Seule une réservation réservée ou active peut être utilisée pour cette action."
|
||||
},
|
||||
"INVALID_BOOKING_STATE": {
|
||||
"title": "La réservation est dans le mauvais état",
|
||||
"explanation": "L'état actuel de cette réservation ne permet pas cette action."
|
||||
},
|
||||
"NOT_RETRYABLE": {
|
||||
"title": "Cette tâche ne peut pas être relancée",
|
||||
"explanation": "Seule une livraison échouée peut être relancée."
|
||||
},
|
||||
"CONFLICT_STILL_PRESENT": {
|
||||
"title": "Le conflit n'a pas été résolu",
|
||||
"explanation": "L'application de ce changement n'a pas résolu le conflit sous-jacent.",
|
||||
"nextStep": "Consultez à nouveau la recommandation avant de réessayer."
|
||||
},
|
||||
"OVERLAP_STILL_PRESENT": {
|
||||
"title": "Le chevauchement n'a pas été résolu",
|
||||
"explanation": "Le blocage de cette réservation n'a pas supprimé le chevauchement ; un autre engagement subsiste."
|
||||
},
|
||||
"EMPTY_VALUE": {
|
||||
"title": "Un champ requis est vide",
|
||||
"explanation": "Ce champ ne peut pas rester vide.",
|
||||
"nextStep": "Saisissez une valeur et réessayez."
|
||||
},
|
||||
"NO_FIELDS_PROVIDED": {
|
||||
"title": "Aucune modification fournie",
|
||||
"explanation": "Au moins un champ doit être complété pour continuer."
|
||||
},
|
||||
"INVALID_FIELD": {
|
||||
"title": "Champ non autorisé ici",
|
||||
"explanation": "L'un des champs fournis n'est pas autorisé pour cette action."
|
||||
},
|
||||
"INVALID_FIELD_OVERRIDE": {
|
||||
"title": "Ce champ ne peut pas être fusionné",
|
||||
"explanation": "L'un des champs sélectionnés ne peut pas être utilisé dans cette fusion."
|
||||
},
|
||||
"INVALID_SURVIVOR": {
|
||||
"title": "Sélection non valide",
|
||||
"explanation": "La fiche que vous souhaitez conserver doit être l'une des deux fiches comparées."
|
||||
},
|
||||
"INVALID_BOOKING_REFERENCE": {
|
||||
"title": "Référence de réservation non valide ici",
|
||||
"explanation": "La réservation sélectionnée ne fait pas partie des réservations liées à ce problème."
|
||||
},
|
||||
"INVALID_EVENT_ID": {
|
||||
"title": "Référence d'événement non valide",
|
||||
"explanation": "Cette référence d'événement d'automatisation n'est pas valide."
|
||||
},
|
||||
"INVALID_IDEMPOTENCY_KEY": {
|
||||
"title": "La demande n'a pas pu être répétée en toute sécurité",
|
||||
"explanation": "La clé de suivi de cette demande n'est pas valide.",
|
||||
"nextStep": "Rechargez la page et réessayez."
|
||||
},
|
||||
"IDEMPOTENCY_KEY_REUSED": {
|
||||
"title": "Cette action a déjà été soumise",
|
||||
"explanation": "Une demande identique a déjà été traitée, avec un résultat différent.",
|
||||
"nextStep": "Rechargez la page pour voir l'état actuel avant de réessayer."
|
||||
},
|
||||
"CORRECTED_VALUE_REQUIRED": {
|
||||
"title": "Une valeur corrigée est requise",
|
||||
"explanation": "Le choix « corriger le relevé » nécessite de saisir la valeur corrigée."
|
||||
},
|
||||
"CORRECTION_BELOW_CANONICAL": {
|
||||
"title": "La correction est inférieure au relevé confirmé",
|
||||
"explanation": "Un kilométrage corrigé ne peut jamais être inférieur au dernier relevé confirmé."
|
||||
},
|
||||
"NOT_A_DUPLICATE_ISSUE": {
|
||||
"title": "Type de problème incorrect",
|
||||
"explanation": "Cette action s'applique uniquement aux problèmes de client potentiellement en double."
|
||||
},
|
||||
"NOT_A_MISSING_FIELD_ISSUE": {
|
||||
"title": "Type de problème incorrect",
|
||||
"explanation": "Cette action s'applique uniquement aux problèmes de champ obligatoire manquant."
|
||||
},
|
||||
"NOT_AN_ODOMETER_ISSUE": {
|
||||
"title": "Type de problème incorrect",
|
||||
"explanation": "Cette action s'applique uniquement aux problèmes d'anomalie de kilométrage."
|
||||
},
|
||||
"NOT_AN_OVERLAP_ISSUE": {
|
||||
"title": "Type de problème incorrect",
|
||||
"explanation": "Cette action s'applique uniquement aux problèmes de chevauchement de réservations."
|
||||
},
|
||||
"NOT_A_STATUS_CONFLICT_ISSUE": {
|
||||
"title": "Type de problème incorrect",
|
||||
"explanation": "Cette action s'applique uniquement aux problèmes de conflit de statut du véhicule."
|
||||
},
|
||||
"UNSUPPORTED_ENTITY": {
|
||||
"title": "Non pris en charge pour ce type de fiche",
|
||||
"explanation": "Cette action n'est pas disponible pour ce type de fiche."
|
||||
},
|
||||
"MANUAL_REVIEW_REQUIRED": {
|
||||
"title": "Évaluation manuelle requise",
|
||||
"explanation": "Les faits concernant ce véhicule se contredisent, ce qui rend tout changement automatique non sûr.",
|
||||
"nextStep": "Reportez ou rejetez ce problème, ou examinez-le manuellement."
|
||||
},
|
||||
"NO_CONFLICT_DETECTED": {
|
||||
"title": "Rien à appliquer",
|
||||
"explanation": "Le statut actuel correspond déjà aux faits, il n'y a donc plus rien à appliquer."
|
||||
},
|
||||
"RECOMMENDATION_STALE": {
|
||||
"title": "La situation a changé entre-temps",
|
||||
"explanation": "Les faits sous-jacents ont changé depuis l'affichage de cette recommandation.",
|
||||
"nextStep": "Consultez à nouveau la recommandation avant de l'appliquer."
|
||||
},
|
||||
"UNAUTHORIZED_SERVICE": {
|
||||
"title": "Échec de l'autorisation du service",
|
||||
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"401": {
|
||||
"title": "Session expirée",
|
||||
"explanation": "Votre session a expiré.",
|
||||
"nextStep": "Veuillez vous reconnecter."
|
||||
},
|
||||
"403": {
|
||||
"title": "Accès refusé",
|
||||
"explanation": "Vous n'avez pas la permission d'effectuer cette action."
|
||||
},
|
||||
"404": {
|
||||
"title": "Introuvable",
|
||||
"explanation": "Cette fiche est introuvable."
|
||||
},
|
||||
"409": {
|
||||
"title": "Cette action n'est plus possible",
|
||||
"explanation": "L'état sous-jacent a changé depuis le chargement de cette page.",
|
||||
"nextStep": "Actualisez la page et réessayez."
|
||||
},
|
||||
"422": {
|
||||
"title": "Échec de la validation",
|
||||
"explanation": "Les informations fournies ne sont pas valides."
|
||||
},
|
||||
"500": {
|
||||
"title": "Erreur serveur",
|
||||
"explanation": "Un problème est survenu de notre côté."
|
||||
},
|
||||
"network": {
|
||||
"title": "Connexion indisponible",
|
||||
"explanation": "La connexion au serveur est actuellement indisponible.",
|
||||
"nextStep": "Vérifiez votre connexion et réessayez."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,28 @@
|
||||
"demoMode": "Mode démo",
|
||||
"unavailable": "Indisponible"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows d'automatisation",
|
||||
"description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.",
|
||||
"notBuilt": "Pas encore créé",
|
||||
"noEvidence": "Aucune preuve pour l'instant",
|
||||
"columns": {
|
||||
"name": "Workflow",
|
||||
"status": "Statut",
|
||||
"lastSeen": "Dernière preuve",
|
||||
"technicalId": "Détails"
|
||||
},
|
||||
"names": {
|
||||
"vehicleReturn": "Orchestration du retour de véhicule",
|
||||
"scheduledScan": "Analyse planifiée de la qualité des données",
|
||||
"ragcoreSync": "Synchronisation des procédures de connaissances",
|
||||
"errorHandler": "Gestionnaire d'erreurs de workflow"
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.",
|
||||
"summaryEmpty": "Aucun échec d'automatisation n'a été enregistré."
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Tâches d'automatisation",
|
||||
"description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.",
|
||||
@@ -41,7 +63,7 @@
|
||||
"statusSucceeded": "Réussi",
|
||||
"statusFailed": "Échoué",
|
||||
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
|
||||
"managerOnly": "L'automatisation est visible uniquement pour les Operations Managers.",
|
||||
"managerOnly": "L'automatisation est visible uniquement pour les Responsables des opérations.",
|
||||
"loading": "Chargement des tâches d'automatisation…",
|
||||
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
|
||||
"count": "{{count}} événements de workflow",
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
|
||||
"notFound": "Ce problème est introuvable.",
|
||||
"loading": "Chargement des preuves du problème…",
|
||||
"managerOnly": "L'atelier qualité est visible uniquement pour les Operations Managers.",
|
||||
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Operations Managers.",
|
||||
"managerOnly": "L'atelier qualité est visible uniquement pour les Responsables des opérations.",
|
||||
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.",
|
||||
"summary": {
|
||||
"rule": "Règle",
|
||||
"entity": "Entité",
|
||||
@@ -212,7 +212,7 @@
|
||||
"reviewAgain": "Revoir la recommandation",
|
||||
"manualReview": {
|
||||
"heading": "Évaluation manuelle requise",
|
||||
"body": "Les faits concernant ce véhicule se contredisent. Un Operations Manager doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
|
||||
"body": "Les faits concernant ce véhicule se contredisent. Un Responsable des opérations doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
|
||||
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
|
||||
},
|
||||
"noConflict": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
|
||||
"title": "Audit trail",
|
||||
"title": "Auditgeschiedenis",
|
||||
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
||||
"actionFilterLabel": "Actie",
|
||||
"actionFilterPlaceholder": "bv. demo-login",
|
||||
"managerOnly": "De audit trail is enkel zichtbaar voor Operations Managers.",
|
||||
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operations Managers.",
|
||||
"loading": "Audit trail laden…",
|
||||
"unavailable": "Audit trail is momenteel niet beschikbaar.",
|
||||
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||
"loading": "Auditgeschiedenis laden…",
|
||||
"unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.",
|
||||
"empty": "Geen auditgebeurtenissen gevonden",
|
||||
"emptyDetail": "Pas het actiefilter aan.",
|
||||
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
|
||||
@@ -16,7 +16,7 @@
|
||||
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
|
||||
"columns": {
|
||||
"when": "Wanneer",
|
||||
"actor": "Actor",
|
||||
"actor": "Uitvoerder",
|
||||
"action": "Actie",
|
||||
"entity": "Entiteit",
|
||||
"change": "Wijziging",
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
"accessHeading": "Kies hoe je wil starten",
|
||||
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
||||
"startGuidedDemo": "Start begeleide demo",
|
||||
"exploreAsOperationsManager": "Verken als Operations Manager",
|
||||
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
||||
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
||||
"exploreAsRentalEmployee": "Verken als Rental Employee",
|
||||
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
||||
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
|
||||
"safeByDesignTitle": "Veilig ontworpen",
|
||||
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
|
||||
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
|
||||
"roleOperationsManager": "Operations manager",
|
||||
"roleRentalEmployee": "Rental employee",
|
||||
"roleOperationsManager": "Operationsmanager",
|
||||
"roleRentalEmployee": "Verhuurmedewerker",
|
||||
"switchRole": "Wissel van rol",
|
||||
"logout": "Uitloggen"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Uitvoeren / Live overzicht",
|
||||
"title": "Goedemorgen. Hier is je wagenpark.",
|
||||
"greeting": {
|
||||
"morning": "Goedemorgen",
|
||||
"afternoon": "Goedemiddag",
|
||||
"evening": "Goedenavond",
|
||||
"night": "Welkom terug"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Hier is de status van je wagenpark voor vandaag.",
|
||||
"afternoon": "Hier is het actuele overzicht van je wagenpark.",
|
||||
"evening": "Hier is het overzicht van je wagenpark voor vanavond.",
|
||||
"night": "Hier is het laatste overzicht van je wagenpark."
|
||||
},
|
||||
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
|
||||
"viewFleet": "Wagenpark bekijken",
|
||||
"demoStart": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"understand-state": {
|
||||
"title": "1. Begrijp de operationele status",
|
||||
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
|
||||
"whyItMatters": "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
|
||||
"whyItMatters": "Een Operationsmanager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
|
||||
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
|
||||
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
|
||||
},
|
||||
@@ -72,10 +72,10 @@
|
||||
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
|
||||
},
|
||||
"check-automation-audit": {
|
||||
"title": "7. Controleer automatisering en audit trail",
|
||||
"title": "7. Controleer automatisering en auditgeschiedenis",
|
||||
"whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
|
||||
"whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
|
||||
"startAction": "Open Integraties om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
|
||||
"startAction": "Open Integraties om de afleverstatus te zien, en Auditgeschiedenis voor het volledige spoor.",
|
||||
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
|
||||
},
|
||||
"review-real-vs-simulated": {
|
||||
@@ -99,22 +99,22 @@
|
||||
"role": "Rol",
|
||||
"demonstrates": "Toont aan:",
|
||||
"requiresRole": "Vereist rol: {{roles}}.",
|
||||
"startScenario": "Start scenario",
|
||||
"startScenario": "Scenario starten",
|
||||
"roleOr": "{{a}} of {{b}}",
|
||||
"roles": {
|
||||
"operations_manager": "Operations Manager",
|
||||
"rental_employee": "Rental Employee"
|
||||
"operations_manager": "Operationsmanager",
|
||||
"rental_employee": "Verhuurmedewerker"
|
||||
},
|
||||
"items": {
|
||||
"return-anomaly": {
|
||||
"title": "Retour met afwijkende kilometerstand",
|
||||
"problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.",
|
||||
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de audit trail die daaruit ontstaat."
|
||||
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de auditgeschiedenis die daaruit ontstaat."
|
||||
},
|
||||
"duplicate-customer": {
|
||||
"title": "Mogelijke dubbele klant samenvoegen",
|
||||
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
|
||||
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail."
|
||||
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en auditgeschiedenis."
|
||||
},
|
||||
"booking-overlap": {
|
||||
"title": "Overlappende boekingen herstellen",
|
||||
@@ -155,9 +155,9 @@
|
||||
"problemTitle": "Het fictieve probleem",
|
||||
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
||||
"scopeTitle": "Voor wie en met welke scope",
|
||||
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
|
||||
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operationsmanagers en Verhuurmedewerkers, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
|
||||
"realTitle": "Wat écht werkt",
|
||||
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
|
||||
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige auditgeschiedenis, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
|
||||
"syntheticTitle": "Wat synthetisch is",
|
||||
"syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.",
|
||||
"architectureTitle": "Architectuur in het kort",
|
||||
@@ -170,7 +170,7 @@
|
||||
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
|
||||
"resetTitle": "Demo-omgeving herstellen",
|
||||
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
|
||||
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.",
|
||||
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
|
||||
"limitationsTitle": "Beperkingen",
|
||||
"limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.",
|
||||
"unknown": "onbekend"
|
||||
|
||||
@@ -1,8 +1,180 @@
|
||||
{
|
||||
"generic": "Er is een fout opgetreden. Probeer opnieuw.",
|
||||
"workspaceLoadFailed": "We konden deze werkruimte niet laden.",
|
||||
"unauthorized": "Je sessie is verlopen. Log opnieuw in.",
|
||||
"forbidden": "Je hebt geen toegang tot dit onderdeel.",
|
||||
"notFound": "Dit record kon niet gevonden worden.",
|
||||
"networkUnavailable": "De verbinding met de server is momenteel niet beschikbaar."
|
||||
"generic": {
|
||||
"title": "Er is iets misgegaan",
|
||||
"explanation": "Deze actie kon niet voltooid worden. Probeer opnieuw."
|
||||
},
|
||||
"codes": {
|
||||
"VEHICLE_NOT_FOUND": {
|
||||
"title": "Voertuig niet gevonden",
|
||||
"explanation": "Dit voertuig kon niet gevonden worden. Het is mogelijk verwijderd, of de referentie klopt niet."
|
||||
},
|
||||
"BOOKING_NOT_FOUND": {
|
||||
"title": "Boeking niet gevonden",
|
||||
"explanation": "Deze boeking kon niet gevonden worden. Ze is mogelijk verwijderd, of de referentie klopt niet."
|
||||
},
|
||||
"CUSTOMER_NOT_FOUND": {
|
||||
"title": "Klant niet gevonden",
|
||||
"explanation": "Dit klantrecord kon niet gevonden worden."
|
||||
},
|
||||
"ENTITY_NOT_FOUND": {
|
||||
"title": "Record niet gevonden",
|
||||
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
|
||||
},
|
||||
"EVENT_NOT_FOUND": {
|
||||
"title": "Automatiseringsopdracht niet gevonden",
|
||||
"explanation": "Deze automatiseringsgebeurtenis kon niet gevonden worden."
|
||||
},
|
||||
"ISSUE_NOT_FOUND": {
|
||||
"title": "Datakwaliteitsprobleem niet gevonden",
|
||||
"explanation": "Dit datakwaliteitsprobleem kon niet gevonden worden."
|
||||
},
|
||||
"ISSUE_NOT_OPEN": {
|
||||
"title": "Probleem is niet meer openstaand",
|
||||
"explanation": "Dit probleem is al opgelost, uitgesteld of verworpen.",
|
||||
"nextStep": "Vernieuw de pagina om de huidige status te zien."
|
||||
},
|
||||
"BOOKING_NOT_ACTIVE": {
|
||||
"title": "Boeking is niet actief",
|
||||
"explanation": "Enkel een gereserveerde of actieve boeking kan voor deze actie gebruikt worden."
|
||||
},
|
||||
"INVALID_BOOKING_STATE": {
|
||||
"title": "Boeking heeft de verkeerde status",
|
||||
"explanation": "De huidige status van deze boeking laat deze actie niet toe."
|
||||
},
|
||||
"NOT_RETRYABLE": {
|
||||
"title": "Deze opdracht kan niet opnieuw geprobeerd worden",
|
||||
"explanation": "Enkel een mislukte aflevering kan opnieuw geprobeerd worden."
|
||||
},
|
||||
"CONFLICT_STILL_PRESENT": {
|
||||
"title": "Conflict is niet opgelost",
|
||||
"explanation": "Het toepassen van deze wijziging heeft het onderliggende conflict niet opgelost.",
|
||||
"nextStep": "Bekijk de aanbeveling opnieuw voordat je het nogmaals probeert."
|
||||
},
|
||||
"OVERLAP_STILL_PRESENT": {
|
||||
"title": "Overlap is niet opgelost",
|
||||
"explanation": "Het blokkeren van deze boeking heeft de overlap niet weggenomen; er blijft een andere verbintenis bestaan."
|
||||
},
|
||||
"EMPTY_VALUE": {
|
||||
"title": "Een verplicht veld is leeg",
|
||||
"explanation": "Dit veld mag niet leeg blijven.",
|
||||
"nextStep": "Vul een waarde in en probeer opnieuw."
|
||||
},
|
||||
"NO_FIELDS_PROVIDED": {
|
||||
"title": "Geen wijzigingen opgegeven",
|
||||
"explanation": "Er moet minstens één veld ingevuld worden om verder te gaan."
|
||||
},
|
||||
"INVALID_FIELD": {
|
||||
"title": "Veld hier niet toegelaten",
|
||||
"explanation": "Eén van de opgegeven velden is niet toegelaten voor deze actie."
|
||||
},
|
||||
"INVALID_FIELD_OVERRIDE": {
|
||||
"title": "Veld kan niet samengevoegd worden",
|
||||
"explanation": "Eén van de gekozen velden kan niet gebruikt worden in deze samenvoeging."
|
||||
},
|
||||
"INVALID_SURVIVOR": {
|
||||
"title": "Ongeldige keuze",
|
||||
"explanation": "Het record dat je wil behouden moet één van de twee vergeleken records zijn."
|
||||
},
|
||||
"INVALID_BOOKING_REFERENCE": {
|
||||
"title": "Boekingreferentie hier niet geldig",
|
||||
"explanation": "De gekozen boeking hoort niet bij de boekingen die aan dit probleem gekoppeld zijn."
|
||||
},
|
||||
"INVALID_EVENT_ID": {
|
||||
"title": "Ongeldige opdrachtreferentie",
|
||||
"explanation": "Deze referentie naar een automatiseringsopdracht is niet geldig."
|
||||
},
|
||||
"INVALID_IDEMPOTENCY_KEY": {
|
||||
"title": "Aanvraag kon niet veilig herhaald worden",
|
||||
"explanation": "De trackingsleutel van deze aanvraag is niet geldig.",
|
||||
"nextStep": "Herlaad de pagina en probeer opnieuw."
|
||||
},
|
||||
"IDEMPOTENCY_KEY_REUSED": {
|
||||
"title": "Deze actie werd al ingediend",
|
||||
"explanation": "Een identieke aanvraag werd al verwerkt, met een ander resultaat.",
|
||||
"nextStep": "Herlaad de pagina om de huidige status te zien voordat je het nogmaals probeert."
|
||||
},
|
||||
"CORRECTED_VALUE_REQUIRED": {
|
||||
"title": "Een gecorrigeerde waarde is vereist",
|
||||
"explanation": "Voor de keuze \"stand corrigeren\" moet je de gecorrigeerde waarde invullen."
|
||||
},
|
||||
"CORRECTION_BELOW_CANONICAL": {
|
||||
"title": "Correctie ligt onder de bevestigde stand",
|
||||
"explanation": "Een gecorrigeerde kilometerstand mag nooit lager zijn dan de laatst bevestigde stand."
|
||||
},
|
||||
"NOT_A_DUPLICATE_ISSUE": {
|
||||
"title": "Verkeerd probleemtype",
|
||||
"explanation": "Deze actie geldt enkel voor problemen van het type mogelijke dubbele klant."
|
||||
},
|
||||
"NOT_A_MISSING_FIELD_ISSUE": {
|
||||
"title": "Verkeerd probleemtype",
|
||||
"explanation": "Deze actie geldt enkel voor problemen met een ontbrekend verplicht veld."
|
||||
},
|
||||
"NOT_AN_ODOMETER_ISSUE": {
|
||||
"title": "Verkeerd probleemtype",
|
||||
"explanation": "Deze actie geldt enkel voor problemen met een afwijkende kilometerstand."
|
||||
},
|
||||
"NOT_AN_OVERLAP_ISSUE": {
|
||||
"title": "Verkeerd probleemtype",
|
||||
"explanation": "Deze actie geldt enkel voor problemen met een overlappende boeking."
|
||||
},
|
||||
"NOT_A_STATUS_CONFLICT_ISSUE": {
|
||||
"title": "Verkeerd probleemtype",
|
||||
"explanation": "Deze actie geldt enkel voor problemen met een statusconflict van het voertuig."
|
||||
},
|
||||
"UNSUPPORTED_ENTITY": {
|
||||
"title": "Niet ondersteund voor dit recordtype",
|
||||
"explanation": "Deze actie is niet beschikbaar voor dit soort record."
|
||||
},
|
||||
"MANUAL_REVIEW_REQUIRED": {
|
||||
"title": "Handmatige beoordeling vereist",
|
||||
"explanation": "De feiten voor dit voertuig spreken elkaar tegen, waardoor geen automatische wijziging veilig is.",
|
||||
"nextStep": "Stel dit probleem uit of verwerp het, of onderzoek het handmatig."
|
||||
},
|
||||
"NO_CONFLICT_DETECTED": {
|
||||
"title": "Niets om toe te passen",
|
||||
"explanation": "De huidige status komt al overeen met de feiten, dus er is niets meer om toe te passen."
|
||||
},
|
||||
"RECOMMENDATION_STALE": {
|
||||
"title": "De situatie is intussen gewijzigd",
|
||||
"explanation": "De onderliggende feiten zijn gewijzigd sinds deze aanbeveling getoond werd.",
|
||||
"nextStep": "Bekijk de aanbeveling opnieuw voordat je ze toepast."
|
||||
},
|
||||
"UNAUTHORIZED_SERVICE": {
|
||||
"title": "Dienstautorisatie mislukt",
|
||||
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"401": {
|
||||
"title": "Sessie verlopen",
|
||||
"explanation": "Je sessie is verlopen.",
|
||||
"nextStep": "Log opnieuw in."
|
||||
},
|
||||
"403": {
|
||||
"title": "Geen toegang",
|
||||
"explanation": "Je hebt geen toestemming om deze actie uit te voeren."
|
||||
},
|
||||
"404": {
|
||||
"title": "Niet gevonden",
|
||||
"explanation": "Dit record kon niet gevonden worden."
|
||||
},
|
||||
"409": {
|
||||
"title": "Deze actie is niet meer mogelijk",
|
||||
"explanation": "De onderliggende status is gewijzigd sinds deze pagina geladen werd.",
|
||||
"nextStep": "Vernieuw de pagina en probeer opnieuw."
|
||||
},
|
||||
"422": {
|
||||
"title": "Validatie mislukt",
|
||||
"explanation": "De opgegeven informatie is niet geldig."
|
||||
},
|
||||
"500": {
|
||||
"title": "Serverfout",
|
||||
"explanation": "Er is iets misgegaan aan onze kant."
|
||||
},
|
||||
"network": {
|
||||
"title": "Verbinding niet beschikbaar",
|
||||
"explanation": "De verbinding met de server is momenteel niet beschikbaar.",
|
||||
"nextStep": "Controleer je verbinding en probeer opnieuw."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,34 @@
|
||||
"demoMode": "Demomodus",
|
||||
"unavailable": "Niet beschikbaar"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Automatiseringsworkflows",
|
||||
"description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.",
|
||||
"notBuilt": "Nog niet gebouwd",
|
||||
"noEvidence": "Nog geen evidentie",
|
||||
"columns": {
|
||||
"name": "Workflow",
|
||||
"status": "Status",
|
||||
"lastSeen": "Laatste evidentie",
|
||||
"technicalId": "Details"
|
||||
},
|
||||
"names": {
|
||||
"vehicleReturn": "Voertuigretour-orkestratie",
|
||||
"scheduledScan": "Geplande datakwaliteitsscan",
|
||||
"ragcoreSync": "Synchronisatie kennisprocedures",
|
||||
"errorHandler": "Workflowfoutafhandelaar"
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.",
|
||||
"summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd."
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Automatiseringsopdrachten",
|
||||
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
|
||||
"filterLabel": "Weergave",
|
||||
"filterNeedsAttention": "Vraagt aandacht",
|
||||
"filterRecent": "Recent",
|
||||
"filterRecent": "Recentste",
|
||||
"filterSucceeded": "Geslaagd",
|
||||
"filterAll": "Alle",
|
||||
"statusFilterLabel": "Status",
|
||||
@@ -41,7 +63,7 @@
|
||||
"statusSucceeded": "Geslaagd",
|
||||
"statusFailed": "Mislukt",
|
||||
"unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.",
|
||||
"managerOnly": "Automatisering is enkel zichtbaar voor Operations Managers.",
|
||||
"managerOnly": "Automatisering is enkel zichtbaar voor Operationsmanagers.",
|
||||
"loading": "Automatiseringsopdrachten laden…",
|
||||
"empty": "Geen automatiseringsopdrachten voor dit filter.",
|
||||
"count": "{{count}} workflowgebeurtenissen",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"quality": "Datakwaliteit",
|
||||
"knowledge": "Kennis",
|
||||
"integrations": "Integraties",
|
||||
"audit": "Audit trail"
|
||||
"audit": "Auditgeschiedenis"
|
||||
},
|
||||
"primaryNavLabel": "Hoofdnavigatie",
|
||||
"mobileNavLabel": "Mobiele navigatie",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "Alle statussen",
|
||||
"statusOpen": "Open",
|
||||
"statusOpen": "Openstaand",
|
||||
"statusDeferred": "Uitgesteld",
|
||||
"statusResolved": "Opgelost",
|
||||
"statusRejected": "Verworpen",
|
||||
@@ -55,8 +55,8 @@
|
||||
"title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.",
|
||||
"notFound": "Dit probleem kon niet gevonden worden.",
|
||||
"loading": "Probleemevidentie laden…",
|
||||
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operations Managers.",
|
||||
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operations Managers.",
|
||||
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operationsmanagers.",
|
||||
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operationsmanagers.",
|
||||
"summary": {
|
||||
"rule": "Regel",
|
||||
"entity": "Entiteit",
|
||||
@@ -64,8 +64,8 @@
|
||||
},
|
||||
"resolved": {
|
||||
"title": "Probleem {{ref}} opgelost",
|
||||
"body": "De wijziging is doorgevoerd en vastgelegd in de audit trail.",
|
||||
"viewAudit": "Audit trail bekijken",
|
||||
"body": "De wijziging is doorgevoerd en vastgelegd in de auditgeschiedenis.",
|
||||
"viewAudit": "Auditgeschiedenis bekijken",
|
||||
"viewVehicle": "Voertuig bekijken",
|
||||
"continueDemo": "Ga verder met de demo"
|
||||
},
|
||||
@@ -201,7 +201,7 @@
|
||||
"consequence": {
|
||||
"statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.",
|
||||
"issueWillBeRechecked": "Dit kwaliteitsprobleem wordt opnieuw gecontroleerd.",
|
||||
"changeWillBeAudited": "De wijziging wordt vastgelegd in de audit trail.",
|
||||
"changeWillBeAudited": "De wijziging wordt vastgelegd in de auditgeschiedenis.",
|
||||
"bookingsNotDeleted": "De boekingen zelf worden niet verwijderd."
|
||||
},
|
||||
"changeStatusTo": "Status wijzigen naar {{status}}",
|
||||
@@ -212,7 +212,7 @@
|
||||
"reviewAgain": "Aanbeveling opnieuw bekijken",
|
||||
"manualReview": {
|
||||
"heading": "Handmatige beoordeling vereist",
|
||||
"body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operations Manager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.",
|
||||
"body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operationsmanager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.",
|
||||
"hint": "Gebruik 'Uitstellen' of 'Verwerpen' hieronder, of onderzoek het voertuig en de betrokken boekingen manueel."
|
||||
},
|
||||
"noConflict": {
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"odometerRegressionNotice": "De ingevoerde kilometerstand lag onder de laatst bevestigde stand van het voertuig. Ze werd zo geregistreerd; de laatst bevestigde kilometerstand is niet gewijzigd en er is een datakwaliteitsprobleem geopend ter controle.",
|
||||
"viewVehicle": "Voertuig {{ref}} bekijken",
|
||||
"viewAutomation": "Automatiseringsstatus bekijken",
|
||||
"viewAudit": "Audit trail bekijken",
|
||||
"viewAudit": "Auditgeschiedenis bekijken",
|
||||
"continueDemo": "Ga verder met de demo"
|
||||
},
|
||||
"scenario": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getGreetingPeriod, type GreetingPeriod } from "./greeting";
|
||||
|
||||
// Checked frequently enough that a period rollover (e.g. 11:59 -> 12:00) is picked up
|
||||
// within the Dashboard while the app stays open, without a page reload -- see section 9
|
||||
// of the Fleet Ops final localization brief.
|
||||
const CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
export function useGreetingPeriod(): GreetingPeriod {
|
||||
const [period, setPeriod] = useState<GreetingPeriod>(() => getGreetingPeriod());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setPeriod((current) => {
|
||||
const next = getGreetingPeriod();
|
||||
return next === current ? current : next;
|
||||
});
|
||||
}, CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return period;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
||||
import { ApiErrorNotice, ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { N8N_STATE_META, MCP_STATE_META, N8N_WORKFLOW_SLUGS } from "../data/integrationLabels";
|
||||
|
||||
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
|
||||
|
||||
@@ -25,7 +26,7 @@ export function Automation() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [view, setView] = useState<ViewFilter>("attention");
|
||||
const [expandSucceeded, setExpandSucceeded] = useState(false);
|
||||
const [retryError, setRetryError] = useState<string | null>(null);
|
||||
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
|
||||
const [retrying, setRetrying] = useState<string | null>(null);
|
||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
||||
@@ -70,7 +71,7 @@ export function Automation() {
|
||||
load();
|
||||
loadIntegrationStatus();
|
||||
} catch (err) {
|
||||
setRetryError(err instanceof ApiError ? err.message : t("ledger.retryFailed"));
|
||||
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
|
||||
} finally {
|
||||
setRetrying(null);
|
||||
}
|
||||
@@ -217,6 +218,73 @@ export function Automation() {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<SectionHeading
|
||||
title={t("workflows.title")}
|
||||
description={t("workflows.description", {
|
||||
known: integrationStatus?.n8n.known_workflow_count ?? 0,
|
||||
expected: integrationStatus?.n8n.expected_workflow_count ?? 4,
|
||||
})}
|
||||
/>
|
||||
|
||||
{integrationStatus && (
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">{t("workflows.title")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("workflows.columns.name")}</th>
|
||||
<th scope="col">{t("workflows.columns.status")}</th>
|
||||
<th scope="col">{t("workflows.columns.lastSeen")}</th>
|
||||
<th scope="col">{t("workflows.columns.technicalId")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{integrationStatus.n8n.workflows.map((w) => {
|
||||
const slug = N8N_WORKFLOW_SLUGS[w.name] ?? "";
|
||||
const displayName = slug ? t(`workflows.names.${slug}`) : w.name;
|
||||
const badge = !w.built
|
||||
? { status: "not_configured", label: t("workflows.notBuilt") }
|
||||
: w.last_seen_at
|
||||
? { status: "available", label: t("statusLabels.operational") }
|
||||
: { status: "no_events", label: t("workflows.noEvidence") };
|
||||
return (
|
||||
<tr key={w.name}>
|
||||
<td data-label={t("workflows.columns.name")}>{displayName}</td>
|
||||
<td data-label={t("workflows.columns.status")}>
|
||||
<StatusBadge status={badge.status} label={badge.label} />
|
||||
</td>
|
||||
<td data-label={t("workflows.columns.lastSeen")}>
|
||||
{w.last_seen_at ? (
|
||||
<time dateTime={w.last_seen_at}>{formatDateTime(w.last_seen_at)}</time>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td data-label={t("workflows.columns.technicalId")}>
|
||||
<details className="evidence-disclosure">
|
||||
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||
<span className="table-subtext">{w.name}</span>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="table-meta">
|
||||
{integrationStatus.n8n.error_handler.total_failures_registered > 0
|
||||
? t("workflows.errorHandler.summary", {
|
||||
count: integrationStatus.n8n.error_handler.total_failures_registered,
|
||||
workflow: integrationStatus.n8n.error_handler.latest_failure_workflow ?? "",
|
||||
when: integrationStatus.n8n.error_handler.latest_failure_at
|
||||
? formatDateTime(integrationStatus.n8n.error_handler.latest_failure_at)
|
||||
: "",
|
||||
})
|
||||
: t("workflows.errorHandler.summaryEmpty")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
|
||||
|
||||
<form className="filters" aria-label={t("ledger.title")}>
|
||||
@@ -242,7 +310,7 @@ export function Automation() {
|
||||
</form>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{retryError && <p className="error" role="alert">{retryError}</p>}
|
||||
<ApiErrorNotice error={retryError} />
|
||||
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
|
||||
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { useGreetingPeriod } from "../i18n/useGreetingPeriod";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
@@ -31,6 +32,7 @@ function attentionItemTitle(
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
||||
const { formatTime, formatShortDate } = useLocaleFormat();
|
||||
const greetingPeriod = useGreetingPeriod();
|
||||
const { user } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
@@ -78,10 +80,11 @@ export function Dashboard() {
|
||||
|
||||
const latestRun = data.recent_automation[0];
|
||||
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
|
||||
const greetingTitle = `${t(`greeting.${greetingPeriod}`)}. ${t(`greetingBody.${greetingPeriod}`)}`;
|
||||
|
||||
return (
|
||||
<div className="page dashboard-page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
|
||||
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
|
||||
|
||||
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
|
||||
<div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 { DataQualityIssue, ScanResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
|
||||
const RULE_TYPES = [
|
||||
"possible_duplicate_customer",
|
||||
@@ -23,7 +24,7 @@ export function DataQuality() {
|
||||
const [status, setStatus] = useState("open");
|
||||
const [ruleType, setRuleType] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [confirmingScan, setConfirmingScan] = useState(false);
|
||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
|
||||
@@ -54,7 +55,7 @@ export function DataQuality() {
|
||||
setConfirmingScan(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
setScanError(err instanceof ApiError ? err.message : t("list.scanFailed"));
|
||||
setScanError(describeApiError(t, err, "list.scanFailed"));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -101,7 +102,7 @@ export function DataQuality() {
|
||||
}
|
||||
/>
|
||||
|
||||
{scanError && <p className="error" role="alert">{scanError}</p>}
|
||||
<ApiErrorNotice error={scanError} />
|
||||
{scanResult && (
|
||||
<p className="quiet-empty" role="status">
|
||||
{t("list.scanComplete", {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type {
|
||||
ApplyRecommendedStatusResult,
|
||||
DataQualityIssueDetail as IssueDetail,
|
||||
@@ -15,7 +16,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
|
||||
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
|
||||
|
||||
@@ -124,7 +125,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
const { user } = useAuth();
|
||||
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
|
||||
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
@@ -155,7 +156,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
});
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
|
||||
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
|
||||
setConfirming(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -171,7 +172,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
return (
|
||||
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
|
||||
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
|
||||
<fieldset className="choice-fieldset">
|
||||
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
|
||||
@@ -210,7 +211,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
const differ = valueA !== valueB;
|
||||
return (
|
||||
<tr key={field}>
|
||||
<th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
|
||||
<th scope="row" data-label={t("detail.duplicateCustomer.fieldColumn")}>{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
|
||||
<td data-label={a.public_ref}>
|
||||
{differ ? (
|
||||
<label className="checkbox-label">
|
||||
@@ -285,7 +286,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
@@ -299,7 +300,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
|
||||
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
|
||||
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -312,7 +313,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
|
||||
title={t("detail.missingField.heading")}
|
||||
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
|
||||
/>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
<div className="form-grid">
|
||||
{fieldKeys.map((field) => (
|
||||
<label key={field}>
|
||||
@@ -345,7 +346,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
|
||||
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
|
||||
const [correctedValue, setCorrectedValue] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
@@ -362,7 +363,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
|
||||
});
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
|
||||
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -375,7 +376,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
|
||||
title={t("detail.odometerRegression.heading")}
|
||||
description={t("detail.odometerRegression.description")}
|
||||
/>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
<dl className="detail-grid">
|
||||
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
|
||||
</dl>
|
||||
@@ -453,7 +454,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
||||
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
|
||||
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
@@ -467,7 +468,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
||||
});
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
|
||||
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -480,7 +481,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
||||
title={t("detail.bookingOverlap.heading")}
|
||||
description={t("detail.bookingOverlap.description")}
|
||||
/>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
<fieldset className="choice-fieldset choice-fieldset-grid">
|
||||
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
|
||||
{bookings.map((b) => (
|
||||
@@ -570,7 +571,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
|
||||
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -596,7 +597,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
);
|
||||
setRecommendation(preview);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
|
||||
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
|
||||
} finally {
|
||||
setLoadingRecommendation(false);
|
||||
}
|
||||
@@ -614,12 +615,10 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
setResult(applied);
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
|
||||
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
|
||||
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
|
||||
setRecommendation(null);
|
||||
setStale(true);
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -645,7 +644,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
title={t("detail.vehicleStatusConflict.heading")}
|
||||
description={t("detail.vehicleStatusConflict.description")}
|
||||
/>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
<dl className="detail-grid">
|
||||
<div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
|
||||
</dl>
|
||||
@@ -739,7 +738,7 @@ export function DataQualityIssueDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [issue, setIssue] = useState<IssueDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
|
||||
const [justResolved, setJustResolved] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -776,7 +775,7 @@ export function DataQualityIssueDetail() {
|
||||
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
|
||||
setActionError(describeApiError(t, err, `detail.deferOrReject.${action}Failed`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,7 +807,7 @@ export function DataQualityIssueDetail() {
|
||||
|
||||
<RuleExplainer ruleType={issue.rule_type} />
|
||||
|
||||
{actionError && <p className="error" role="alert">{actionError}</p>}
|
||||
<ApiErrorNotice error={actionError} />
|
||||
|
||||
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
|
||||
<section className="panel success-panel" aria-live="polite">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
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 { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { PageHeader } from "../components/PageChrome";
|
||||
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
interface Exchange {
|
||||
@@ -16,7 +17,7 @@ export function Knowledge() {
|
||||
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||
|
||||
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
|
||||
@@ -39,7 +40,7 @@ export function Knowledge() {
|
||||
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
|
||||
setQuestion("");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("askFailed"));
|
||||
setError(describeApiError(t, err, "askFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -107,7 +108,7 @@ export function Knowledge() {
|
||||
{!submitting && <Icon name="chevron" />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
<div className="knowledge-suggestions">
|
||||
<span>{t("suggestedLabel")}</span>
|
||||
{suggestedQuestions.map((q) => (
|
||||
|
||||
@@ -96,9 +96,9 @@ a:hover { color: var(--teal); }
|
||||
.timezone svg { width: 15px; }
|
||||
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
|
||||
.avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; }
|
||||
.operator > span:last-child { display: grid; gap: 1px; }
|
||||
.operator strong { font-size: .74rem; }
|
||||
.operator small { color: var(--muted); font-size: .64rem; }
|
||||
.operator > span:last-child { display: grid; gap: 1px; min-width: 0; }
|
||||
.operator strong { font-size: .74rem; overflow-wrap: anywhere; }
|
||||
.operator small { color: var(--muted); font-size: .64rem; overflow-wrap: anywhere; }
|
||||
.icon-button { width: 40px; height: 40px; display: grid; place-items: center; padding: 0; color: var(--ink-soft); background: transparent; border: 1px solid transparent; border-radius: var(--radius); cursor: pointer; }
|
||||
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
|
||||
.icon-button svg { width: 18px; height: 18px; }
|
||||
@@ -347,6 +347,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
|
||||
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
|
||||
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
|
||||
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
|
||||
.api-error-notice strong { display: block; font-size: .78rem; }
|
||||
.api-error-notice p { margin: 4px 0 0; font-weight: 400; }
|
||||
.api-error-notice .api-error-next-step { color: #7a2323; font-style: italic; }
|
||||
.api-error-notice .evidence-disclosure { margin-top: 8px; }
|
||||
.api-error-notice .evidence-disclosure summary { font-weight: 700; cursor: pointer; }
|
||||
|
||||
.login-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 1.05fr) minmax(430px, .95fr); background: white; }
|
||||
.login-story { position: relative; min-height: 100vh; display: flex; flex-direction: column; padding: 34px 7vw 30px; overflow: hidden; color: white; background: var(--petrol); }
|
||||
|
||||
+15
-9
@@ -1,13 +1,19 @@
|
||||
# n8n workflows
|
||||
|
||||
`mobilityops-return-processing.json` is a starter import for the required live workflow.
|
||||
Canonical, live-validated workflow definitions live under `n8n/workflows/`. Each file is a
|
||||
cleaned export (secret values replaced by named-credential references, never literal
|
||||
tokens) of the workflow actually running on `https://n8n.itworx.tech`. See
|
||||
`n8n/workflows/MANIFEST.md` for the authoritative list: canonical name, purpose, trigger,
|
||||
required credentials, live workflow ID, active status and a checksum of each file.
|
||||
|
||||
Before final validation Claude must:
|
||||
The two pre-integration starter files that used to live directly under `n8n/`
|
||||
(`mobilityops-return-processing.json`, `mobilityops-scheduled-quality-scan.json`) have been
|
||||
retired — they predate the live n8n validation pass and embedded the service token as a
|
||||
literal header value instead of a Header Auth credential. Do not resurrect them; the
|
||||
`n8n/workflows/` versions are the superseding source of truth and are what
|
||||
`deploy/unraid/setup-existing-n8n.sh` / `setup-scheduled-scan.sh` import.
|
||||
|
||||
1. import or provision the workflow;
|
||||
2. configure a service credential for the MobilityOps callback;
|
||||
3. activate the webhook;
|
||||
4. ensure event ID is used as the idempotency key;
|
||||
5. validate online success and offline retry behaviour.
|
||||
|
||||
The callback URL in the starter file is intentionally environment-driven and may require an n8n expression or credential adjustment.
|
||||
Deploying a workflow from these files into a fresh or existing n8n instance still requires a
|
||||
one-time manual step: create the named Header Auth credentials in the n8n UI (see each
|
||||
workflow's `credentials` block and the manifest) before publishing. This is deliberate —
|
||||
credential values are never committed to the repository.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# n8n workflow manifest
|
||||
|
||||
Source of truth for the four canonical Fleet Ops n8n workflows. Definitions in this
|
||||
directory are cleaned exports of the live workflows on `https://n8n.itworx.tech` —
|
||||
credential values are never embedded; nodes reference named n8n credentials instead. Run
|
||||
`n8n/workflows/check_drift.py` to compare a live workflow against its repo definition.
|
||||
|
||||
## 1. Fleet Ops — Vehicle Return Orchestration
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| File | `fleet-ops-vehicle-return.json` |
|
||||
| Purpose | Orchestrate the post-return follow-up (cleaning vs. attention-required) once Fleet Ops emits a `vehicle.returned.v1` outbox event, and report the result back to Fleet Ops. |
|
||||
| Trigger | Production webhook, `POST /webhook/mobilityops-return`, Header Auth (`Fleet Ops Webhook Trigger Token`) |
|
||||
| Event contract | `contracts/events.schema.json`, `event_type: vehicle.returned.v1` (envelope: `event_id`, `event_type`, `occurred_at`, `correlation_id`, `aggregate`, `data`) |
|
||||
| Required credentials | `Fleet Ops Webhook Trigger Token` (Header Auth, on the trigger); `Fleet Ops Service Token` (Header Auth, on the outbound HTTP call) |
|
||||
| Live workflow ID | `mobilityops-return-processing` |
|
||||
| Active status (as of 2026-08-04) | Active / Published |
|
||||
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
|
||||
| Timeouts / bounded retries | `Record follow-up` HTTP node: 15s timeout, retry on fail (3 tries, 1000ms wait) |
|
||||
| Checksum (sha256) | `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609` |
|
||||
|
||||
## 2. Fleet Ops — Scheduled Data Quality Scan
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| File | `fleet-ops-data-quality-scan.json` |
|
||||
| Purpose | Periodically (and on-demand) run the Fleet Ops data-quality scan and summarize created-issue counts per rule. |
|
||||
| Trigger | Schedule Trigger (hourly, Europe/Brussels instance timezone) + Manual Trigger for on-demand test runs |
|
||||
| Event contract | N/A — HTTP-triggered scan call, no inbound event envelope. Request: `POST /api/v1/integrations/n8n/scheduled-scan`, Header Auth. |
|
||||
| Required credentials | `Fleet Ops Service Token` (Header Auth, on the scan HTTP call) |
|
||||
| Live workflow ID | `mobilityops-scheduled-quality-scan` |
|
||||
| Active status (as of 2026-08-04) | Active / Published |
|
||||
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
|
||||
| Timeouts / bounded retries | `Run quality scan` HTTP node: 15s timeout, retry on fail (3 tries, 1000ms wait) |
|
||||
| Checksum (sha256) | `c0d46e0519118e6336e35c4ea2a67edb2f14bd007909ccf9256c93733751244a` |
|
||||
|
||||
## 3. Fleet Ops — RAGcore Procedure Sync
|
||||
|
||||
Not yet built, but no longer blocked. Credential issuance for the `fleet-ops` application
|
||||
previously failed 100% of the time with an opaque rejection ("authoritative service-account
|
||||
state rejected issuance" via the raw API; a generic error via the admin UI). Root-caused to
|
||||
a genuine bug in RAGcore itself — a cross-transaction race in
|
||||
`src/ragcore/api/v1/control/dependencies.py` where `get_control_application` and
|
||||
`get_credential_service` each opened their own independent database transaction, so a
|
||||
freshly-created service account was invisible to the immediately-following credential-issue
|
||||
read. Fixed in RAGcore (with explicit owner approval) by sharing one request-scoped
|
||||
transaction between both dependencies; verified against RAGcore's own test suite (64
|
||||
passing) and deployed to the live instance. A working credential now exists: n8n credential
|
||||
**"RAGcore Sync Token"** (Header Auth, `Authorization: Bearer <token>`), scope
|
||||
`sources:sync` for `fleet-ops`. Will build
|
||||
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` against the real RAGcore contract
|
||||
(`POST /v1/uploads`, `GET /v1/knowledge-spaces`, etc. — see
|
||||
`contracts/ragcore-contract-assumptions.md` and the live inspection notes in
|
||||
`docs/live-ai-integration/n8n-current-state.md`) next.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| File | `fleet-ops-ragcore-procedure-sync.json` (not yet created) |
|
||||
| Live workflow ID | — |
|
||||
| Active status | Not built |
|
||||
|
||||
## 4. Fleet Ops — Workflow Error Handler
|
||||
|
||||
Central technical workflow attached to workflows 1-2 via n8n's per-workflow "Error
|
||||
Workflow" setting (workflow 3 will be wired the same way once it exists). Receives n8n's
|
||||
standard Error Trigger payload, derives a bounded/secret-free failure report (safe error
|
||||
category, truncated summary, no stack trace, no headers/tokens), and POSTs it to Fleet
|
||||
Ops, which registers an audit event idempotently keyed on `execution_id`.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| File | `fleet-ops-error-handler.json` |
|
||||
| Purpose | Central error notification target for all other Fleet Ops n8n workflows |
|
||||
| Trigger | Error Trigger (fired by n8n when an attached workflow's execution fails) |
|
||||
| Event contract | None inbound (n8n's built-in error-trigger payload); outbound `POST /api/v1/integrations/n8n/workflow-error`, Header Auth, body: `workflow_id, workflow_name, execution_id, failed_at, error_category (timeout\|authError\|connectionError\|httpError\|validationError\|unknown), error_summary, trigger_context, correlation_id, attempt, retry_action` |
|
||||
| Required credentials | `Fleet Ops Service Token` (Header Auth, on the outbound HTTP call — same credential workflows 1-2 use) |
|
||||
| Live workflow ID | `Xppn2rAEqUuyiCJF` |
|
||||
| Active status (as of 2026-08-04) | Active / Published |
|
||||
| Error Workflow (on itself) | `- No Workflow -` (deliberately unset — prevents a recursive error loop) |
|
||||
| Checksum (sha256) | `d9e2795b917a89a9b4a733e435661585f8011bc97f134d3643dee93ea05b0be6` |
|
||||
|
||||
Validated this round: mock-data run (Error Trigger pinned to a realistic payload)
|
||||
produced a real `200 {"status":"registered", ...}` from the live Fleet Ops server;
|
||||
re-running the identical payload produced `"status":"already_registered"`, confirming
|
||||
execution_id 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; n8n's Error
|
||||
Workflow trigger did not fire for that *manual* "Execute workflow" editor run — n8n only
|
||||
invokes Error Workflow for unattended/production trigger executions, not manual test
|
||||
runs from the editor. This is a known limitation of the live-validation evidence for this
|
||||
round: the mock-data path exercises the same nodes/logic and the real Fleet Ops
|
||||
endpoint, but a fully automatic (schedule- or webhook-triggered) failure cascading into
|
||||
this handler was not observed live.
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report drift between the repo's cleaned workflow definitions and the live n8n instance.
|
||||
|
||||
Read-only: this script only issues GET requests against n8n's Public API. It never writes,
|
||||
imports, activates, or otherwise modifies anything in n8n -- fixing drift is a deliberate,
|
||||
reviewed action a human takes in the n8n UI (or via a separate, explicit import step), not
|
||||
something this script does automatically.
|
||||
|
||||
Usage:
|
||||
N8N_BASE_URL=https://n8n.itworx.tech N8N_API_KEY=... python n8n/workflows/check_drift.py
|
||||
|
||||
N8N_API_KEY must be an n8n Public API key (n8n UI -> Settings -> API), not a session cookie
|
||||
and not a workflow credential. It is read from the environment only and is never printed.
|
||||
|
||||
Exit code is 0 when every checked workflow matches the live instance, 1 when any drift (or
|
||||
a fetch error) is found, so this is safe to wire into CI as a non-blocking check.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
WORKFLOWS_DIR = Path(__file__).parent
|
||||
|
||||
# (repo file name, live workflow ID) -- kept in sync with MANIFEST.md by hand, since the
|
||||
# manifest is the human-readable source of truth and this is just its machine-checkable echo.
|
||||
KNOWN_WORKFLOWS = [
|
||||
("fleet-ops-vehicle-return.json", "mobilityops-return-processing"),
|
||||
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
|
||||
("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"),
|
||||
]
|
||||
|
||||
# Fields that legitimately differ between a committed definition and the live instance
|
||||
# (instance-assigned identifiers, timestamps, UI-only cosmetics) and must not be reported
|
||||
# as drift.
|
||||
_VOLATILE_TOP_LEVEL_KEYS = {
|
||||
"versionId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"pinData",
|
||||
"staticData",
|
||||
"shared",
|
||||
"triggerCount",
|
||||
"isArchived",
|
||||
}
|
||||
_VOLATILE_NODE_KEYS = {"position", "webhookId"}
|
||||
|
||||
|
||||
def _strip_credential_ids(value: Any) -> Any:
|
||||
"""Live workflows carry instance-specific credential IDs alongside the credential name
|
||||
(e.g. {"id": "3", "name": "Fleet Ops Service Token"}). The repo definitions intentionally
|
||||
omit the id, since it's meaningless outside the instance that issued it. Drop it from both
|
||||
sides so credential *references* are compared by name only."""
|
||||
if isinstance(value, dict):
|
||||
if set(value.keys()) <= {"id", "name"} and "name" in value:
|
||||
return {"name": value["name"]}
|
||||
return {k: _strip_credential_ids(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_strip_credential_ids(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_node(node: dict[str, Any]) -> dict[str, Any]:
|
||||
cleaned = {k: v for k, v in node.items() if k not in _VOLATILE_NODE_KEYS}
|
||||
return _strip_credential_ids(cleaned)
|
||||
|
||||
|
||||
def _normalize_workflow(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
nodes_by_name = {n["name"]: _normalize_node(n) for n in doc.get("nodes", [])}
|
||||
return {
|
||||
"name": doc.get("name"),
|
||||
"active": doc.get("active"),
|
||||
"nodes": nodes_by_name,
|
||||
"connections": doc.get("connections", {}),
|
||||
"settings": doc.get("settings", {}),
|
||||
}
|
||||
|
||||
|
||||
def _diff(path: str, local: Any, live: Any, out: list[str]) -> None:
|
||||
if isinstance(local, dict) and isinstance(live, dict):
|
||||
for key in sorted(set(local) | set(live)):
|
||||
if key in _VOLATILE_TOP_LEVEL_KEYS:
|
||||
continue
|
||||
if key not in live:
|
||||
out.append(f"{path}.{key}: present in repo, missing live")
|
||||
elif key not in local:
|
||||
out.append(f"{path}.{key}: present live, missing in repo")
|
||||
else:
|
||||
_diff(f"{path}.{key}", local[key], live[key], out)
|
||||
elif local != live:
|
||||
out.append(f"{path}: repo={local!r} live={live!r}")
|
||||
|
||||
|
||||
def fetch_live_workflow(base_url: str, api_key: str, workflow_id: str) -> dict[str, Any]:
|
||||
url = f"{base_url.rstrip('/')}/api/v1/workflows/{workflow_id}"
|
||||
request = urllib.request.Request(url, headers={"X-N8N-API-KEY": api_key, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 (fixed https base url from env)
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base_url = os.environ.get("N8N_BASE_URL")
|
||||
api_key = os.environ.get("N8N_API_KEY")
|
||||
if not base_url or not api_key:
|
||||
print(
|
||||
"Set N8N_BASE_URL and N8N_API_KEY (an n8n Public API key) in the environment.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
any_drift = False
|
||||
for filename, workflow_id in KNOWN_WORKFLOWS:
|
||||
repo_path = WORKFLOWS_DIR / filename
|
||||
local_doc = json.loads(repo_path.read_text(encoding="utf-8"))
|
||||
|
||||
try:
|
||||
live_doc = fetch_live_workflow(base_url, api_key, workflow_id)
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: HTTP {exc.code}")
|
||||
any_drift = True
|
||||
continue
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: {exc.reason}")
|
||||
any_drift = True
|
||||
continue
|
||||
|
||||
differences: list[str] = []
|
||||
_diff(filename, _normalize_workflow(local_doc), _normalize_workflow(live_doc), differences)
|
||||
|
||||
if differences:
|
||||
any_drift = True
|
||||
print(f"[{filename}] DRIFT from live workflow {workflow_id}:")
|
||||
for line in differences:
|
||||
print(f" - {line}")
|
||||
else:
|
||||
print(f"[{filename}] matches live workflow {workflow_id}")
|
||||
|
||||
return 1 if any_drift else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+21
-53
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "mobilityops-scheduled-quality-scan",
|
||||
"name": "MobilityOps - Scheduled Quality Scan",
|
||||
"name": "Fleet Ops — Scheduled Data Quality Scan",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
@@ -17,10 +17,7 @@
|
||||
"name": "Hourly schedule",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
240,
|
||||
220
|
||||
]
|
||||
"position": [240, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
@@ -28,23 +25,17 @@
|
||||
"name": "Manual test trigger",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
240,
|
||||
400
|
||||
]
|
||||
"position": [240, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{$env.MOBILITYOPS_API_URL || 'http://api:8000'}}/api/v1/integrations/n8n/scheduled-scan",
|
||||
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-Service-Token",
|
||||
"value": "={{$env.MOBILITYOPS_CALLBACK_TOKEN}}"
|
||||
}
|
||||
]
|
||||
"parameters": []
|
||||
},
|
||||
"options": {
|
||||
"timeout": 15000
|
||||
@@ -54,10 +45,15 @@
|
||||
"name": "Run quality scan",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
520,
|
||||
300
|
||||
]
|
||||
"position": [520, 300],
|
||||
"credentials": {
|
||||
"httpHeaderAuth": {
|
||||
"name": "Fleet Ops Service Token"
|
||||
}
|
||||
},
|
||||
"retryOnFail": true,
|
||||
"maxTries": 3,
|
||||
"waitBetweenTries": 1000
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -67,52 +63,24 @@
|
||||
"name": "Summarize result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
780,
|
||||
300
|
||||
]
|
||||
"position": [780, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Hourly schedule": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run quality scan",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Run quality scan", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Manual test trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run quality scan",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Run quality scan", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Run quality scan": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarize result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Summarize result", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"active": false,
|
||||
"versionId": "22222222-2222-4222-8222-222222222222",
|
||||
"active": true,
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": false
|
||||
},
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"id": "Xppn2rAEqUuyiCJF",
|
||||
"name": "Fleet Ops — Workflow Error Handler",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "error-trigger-node",
|
||||
"name": "Error Trigger",
|
||||
"type": "n8n-nodes-base.errorTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [240, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "each",
|
||||
"jsCode": "const err = $json.execution?.error || {};\nconst rawMessage = String(err.message || err.description || 'Unknown error').replace(/[\\r\\n]+/g, ' ').slice(0, 490);\nconst lower = rawMessage.toLowerCase();\nlet category = 'unknown';\nif (lower.includes('timeout') || lower.includes('timed out')) category = 'timeout';\nelse if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('forbidden')) category = 'authError';\nelse if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('network')) category = 'connectionError';\nelse if (/\\b[45]\\d\\d\\b/.test(rawMessage)) category = 'httpError';\nelse if (lower.includes('valid')) category = 'validationError';\nconst workflow = $json.workflow || {};\nconst execution = $json.execution || {};\nreturn {\n json: {\n workflow_id: String(workflow.id || 'unknown'),\n workflow_name: String(workflow.name || 'unknown'),\n execution_id: String(execution.id || ('unknown-' + Date.now())),\n failed_at: new Date().toISOString(),\n error_category: category,\n error_summary: rawMessage,\n trigger_context: String(execution.mode || 'unknown').slice(0, 200),\n correlation_id: null,\n attempt: execution.retryOf ? 2 : 1,\n retry_action: 'No automatic in-workflow retry. Check n8n execution history and the Fleet Ops Automation page for redelivery status.'\n }\n};"
|
||||
},
|
||||
"id": "build-report-node",
|
||||
"name": "Build safe error report",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [500, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/workflow-error",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth",
|
||||
"sendBody": true,
|
||||
"contentType": "json",
|
||||
"specifyBody": "keypair",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{ "name": "workflow_id", "value": "={{ $json.workflow_id }}" },
|
||||
{ "name": "workflow_name", "value": "={{ $json.workflow_name }}" },
|
||||
{ "name": "execution_id", "value": "={{ $json.execution_id }}" },
|
||||
{ "name": "failed_at", "value": "={{ $json.failed_at }}" },
|
||||
{ "name": "error_category", "value": "={{ $json.error_category }}" },
|
||||
{ "name": "error_summary", "value": "={{ $json.error_summary }}" },
|
||||
{ "name": "trigger_context", "value": "={{ $json.trigger_context }}" },
|
||||
{ "name": "correlation_id", "value": "={{ $json.correlation_id }}" },
|
||||
{ "name": "attempt", "value": "={{ $json.attempt }}" },
|
||||
{ "name": "retry_action", "value": "={{ $json.retry_action }}" }
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "report-node",
|
||||
"name": "Report failure to Fleet Ops",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [760, 300],
|
||||
"credentials": {
|
||||
"httpHeaderAuth": { "name": "Fleet Ops Service Token" }
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Error Trigger": {
|
||||
"main": [[{ "node": "Build safe error report", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Build safe error report": {
|
||||
"main": [[{ "node": "Report failure to Fleet Ops", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"active": true,
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": false
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
+30
-54
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"id": "mobilityops-return-processing",
|
||||
"name": "MobilityOps - Vehicle Return Processing",
|
||||
"name": "Fleet Ops — Vehicle Return Orchestration",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "mobilityops-return",
|
||||
"authentication": "headerAuth",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
@@ -13,11 +14,13 @@
|
||||
"name": "Return webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
240,
|
||||
300
|
||||
],
|
||||
"webhookId": "mobilityops-return"
|
||||
"position": [240, 300],
|
||||
"webhookId": "mobilityops-return",
|
||||
"credentials": {
|
||||
"httpHeaderAuth": {
|
||||
"name": "Fleet Ops Webhook Trigger Token"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -27,25 +30,20 @@
|
||||
"name": "Validate and derive follow-up",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
500,
|
||||
300
|
||||
]
|
||||
"position": [500, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{$env.MOBILITYOPS_CALLBACK_URL || 'http://api:8000/api/v1/integrations/n8n/return-callback'}}",
|
||||
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Idempotency-Key",
|
||||
"value": "={{$json.event_id}}"
|
||||
},
|
||||
{
|
||||
"name": "X-Service-Token",
|
||||
"value": "={{$env.MOBILITYOPS_CALLBACK_TOKEN}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -53,17 +51,23 @@
|
||||
"contentType": "raw",
|
||||
"rawContentType": "application/json",
|
||||
"body": "={{JSON.stringify($json)}}",
|
||||
"options": {}
|
||||
"options": {
|
||||
"timeout": 15000
|
||||
}
|
||||
},
|
||||
"id": "callback-node",
|
||||
"name": "Record follow-up",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
760,
|
||||
300
|
||||
]
|
||||
},
|
||||
"position": [760, 300],
|
||||
"credentials": {
|
||||
"httpHeaderAuth": {
|
||||
"name": "Fleet Ops Service Token"
|
||||
}
|
||||
},
|
||||
"retryOnFail": true,
|
||||
"maxTries": 3,
|
||||
"waitBetweenTries": 1000
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
@@ -74,54 +78,26 @@
|
||||
"name": "Return result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.4,
|
||||
"position": [
|
||||
1020,
|
||||
300
|
||||
]
|
||||
"position": [1020, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Return webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Validate and derive follow-up",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Validate and derive follow-up", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Validate and derive follow-up": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Record follow-up",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Record follow-up", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Record follow-up": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Return result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
"main": [[{ "node": "Return result", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"active": true,
|
||||
"versionId": "11111111-1111-4111-8111-111111111111",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": false
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user