Author SHA1 Message Date
NuklearRabbit ad1182582d docs: record visual roadmap evidence 2026-08-10 01:05:30 +02:00
NuklearRabbit f2cdad194c UX: implement visual product roadmap 2026-08-10 01:05:07 +02:00
NuklearRabbitandClaude Sonnet 5 13ad2ba6a3 docs: record the MCP Hub URL fix and RAGcore Procedure Sync go-live
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:07:41 +02:00
NuklearRabbitandClaude Sonnet 5 086dfed992 fix: publish RAGcore Procedure Sync and derive its evidence for real
MCP_HUB_BASE_URL had the same wrong-hostname bug as RAGCORE_BASE_URL earlier
this session (itworx-mcp-hub:8000 doesn't resolve; the real container is
reachable at the host's own 192.168.10.150:1100) -- fixed live, resolving the
Automation page showing "Operationeel" and "Hub Onbereikbaar" simultaneously.

Went on to actually publish the "Fleet Ops -- RAGcore Procedure Sync" n8n
workflow now that RAGcore is reachable: its own RAGcore Sync Token credential
had gone stale from the same rotation as the earlier one, so minted a fresh,
dedicated, minimally-scoped (sources:sync only) credential, verified a real
manual run (33 synced, 0 failed, result registered) before publishing.

That exposed a real, now-stale bug: derive_n8n_status() hardcoded this
workflow's evidence to None with a comment explaining it was unpublished --
true when written, false now. The workflow's own result-report callback
already writes a real n8n_procedures_synced audit event; wired that in as its
evidence source, the same pattern the scheduled scan and error handler already
use, instead of a value that could never update itself once the workflow went
live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:05:13 +02:00
NuklearRabbitandClaude Sonnet 5 64cc96fa4b docs: record the RAGcore go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:22:57 +02:00
NuklearRabbitandClaude Sonnet 5 319f43312e fix: drop RAGcore's opaque version UUID from the fallback answer sentence
document_version_id is an internal UUID, not a human-meaningful version like
the demo corpus's markdown frontmatter -- confirmed live it made the fallback
answer read as "Per \"vehicle-checkout-procedure.md\" (v2e139422-b10b-...)".
Still shown on the source card itself, just not in the composed sentence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:18:58 +02:00
NuklearRabbitandClaude Sonnet 5 a2433d7fa3 fix: fall back to real RAGcore search when /v1/answers is unavailable
RAGcore's /v1/answers (generation + citation validation) is currently returning
a consistent 503 VALIDATION_RETRIES_EXHAUSTED live -- a RAGcore-side bug in its
own generation/validation step, out of scope to fix here (CLAUDE.md forbids
modifying the RAGcore repo). Its retrieval pipeline (/v1/search) is a materially
different, simpler stage with no generation step, and returns real, correctly
cited results right now.

RAGcoreKnowledgeProvider.ask() tries /v1/answers first (unchanged behavior once
RAGcore's generation is fixed), and only when that endpoint itself is
unavailable -- non-2xx or unreachable, never a real 200 classifying the
question as insufficiently answerable -- falls back to /v1/search and builds
the shown "answer" as an extractive citation-wrapped excerpt, mirroring
DemoKnowledgeProvider's own existing template exactly. Never invents an answer
to the question; only ever shows a real, cited excerpt RAGcore's own search
actually found.

Also fixed two real config bugs found while wiring this up live: RAGCORE_BASE_URL
pointed at a non-existent internal hostname (ragcore-api:8000 -- the real
container is reachable at the host's own address on port 1237), and the
previous test credential had been invalidated with nothing to replace it. Minted
a fresh, correctly-scoped service-account credential via RAGcore's own admin
control plane (the documented, legitimate way to obtain one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:11:28 +02:00
NuklearRabbitandClaude Sonnet 5 453c7241fe docs: record the MCP Hub go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:19:07 +02:00
NuklearRabbitandClaude Sonnet 5 529e7364a9 fix: pass MCP_HUB_REGISTRATION_ENABLED/MCP_HUB_BASE_URL through to the api container
compose.yaml's api service environment block forwarded MCP_HUB_SERVICE_TOKEN but
never these two -- so .env's value was silently ignored and Settings always fell
back to its Python default (false / empty), no matter what .env said. Found while
flipping the flag live: the container's actual reported registration_enabled
stayed false after a full recreate, even though .env had been updated and two
real mcp_tool_request audit events already existed (itworx-mcp-hub:readiness ->
fleet_ops_get_operations_summary), proving the Hub's connector already reaches
Fleet Ops successfully independent of this flag -- only the status display was
gated, and silently stuck off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:15:17 +02:00
NuklearRabbitandClaude Sonnet 5 7d686ae2aa docs: record polish fixes, merge, and second deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 19:04:42 +02:00
NuklearRabbitandClaude Sonnet 5 c2b8268927 fix: form field alignment, raw maintenance text, and static movements list
- .form-grid labels (missing-field form, odometer-regression correction fields)
  and the odometer/overlap note textareas had no stacked label-above-input
  styling at all -- the shared rule only covered .filters/.return-form, so these
  fell back to default inline browser layout with mismatched input widths.
  Extended the existing rule to cover .form-grid and label:has(> textarea).

- Vehicle maintenance list showed the raw, untranslated seed text
  ("Synthetic scheduled service record") regardless of locale -- purely
  decorative and 1:1 redundant with the (already-translated) category. Replaced
  it with the record's real odometer reading, mirroring the sibling
  Inspections tab's pattern.

- "Today's movements" was always the same fixed 4 bookings (2 returns, 2
  departures) on every reset, reading as a static mockup rather than live
  fleet activity. Added 8 more bookings anchored to land on "today" across 8
  additional vehicles, spread through the day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:29:56 +02:00
NuklearRabbitandClaude Sonnet 5 9e9dd8e0e3 docs: record the three-defect fix, gates, and live verification evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:52:13 +02:00
NuklearRabbitandClaude Sonnet 5 4faac24b5a fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:44:32 +02:00
NuklearRabbitandClaude Sonnet 5 3808bbe132 docs: record push, Unraid deploy, and live verification evidence
Closes out the demo-scenario fix: pushed the two pending commits, deployed
6f77a30 to Unraid, and verified all four live checks (integration status,
failed-workflow listing, retry via API and UI, audit trail, Automation page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:39:41 +02:00
NuklearRabbit 6f77a30dce fix: stop the prepared demo failure from degrading n8n integration health
The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate
retry and audit. Because derive_n8n_status() counted any failure, every fresh
reset pinned the n8n integration to "degraded" -- the demo showed a warning
about a prop, which tells a viewer something untrue about the automation.

The seeded failure now carries its own error code, demoScenarioTimeout, rather
than the generic connectionError a real timeout produces. No column and no
migration: last_error_code already existed, is already surfaced to the UI and is
already localizable.

- integration status splits failed into unexpected_failed and
  demo_scenario_failed; only unexpected failures may move the state. A staged
  failure alone leaves n8n operational.
- latest_failure_at is a health signal and now ignores the staged failure;
  latest_demo_scenario_at reports it separately.
- /api/v1/workflows exposes is_demo_scenario. The Automation page labels the run
  as a prepared demo scenario, explains that it is a simulated temporary failure
  that does not affect automation health, and offers a distinct "retry demo
  scenario" action. Translated in nl-BE, en-GB and fr-BE.
- the carve-out stays narrow: a real failure still degrades n8n, and a genuine
  later failure of the same event overwrites the demo code with the real one.
- the retry itself is unchanged and real: the event goes back on the outbox and
  the dispatcher delivers it to n8n like any other, so 19+1 becomes 20+0 only on
  an actual round trip. The audit records which kind of failure was retried.

Tests that assert on the seeded scenario now reseed first, since earlier test
files legitimately mutate the outbox and the suite shares one database.

Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy
clean (50 files), tsc clean, frontend build clean. Not deployed and not
browser-verified.
2026-08-05 14:07:05 +00:00
NuklearRabbit e5307a7c0f fix: derive demo-manifest MCP Hub status from real tool-call evidence
The demo manifest still reported the MCP Hub integration as operational purely
because MCP_HUB_REGISTRATION_ENABLED was set, while the integration status page
had already moved to evidence-based status in Batch 4. Registration is
catalog-driven on the Hub's side, so the flag alone proves nothing; reuse
derive_mcp_hub_status() so "operational" requires real recorded mcp_tool_request
calls.

No change to the MCP integration contract: the four read-only routes, service
token and client id handling, inbound X-Correlation-Id preservation, the locale
field on search-knowledge and the provider/correlation_id response fields were
verified as already correct at deployed revision 727c19a and left untouched.
2026-08-05 13:28:43 +00:00
NuklearRabbitandClaude Sonnet 5 dee8f2f7e9 docs: record Batch 5 evidence and final session state
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:51:11 +02:00
NuklearRabbitandClaude Sonnet 5 57992bf153 M10: AI Operations Brief runbook, full live e2e regression, final evidence
Ran a real AI Operations Brief via the live ITWorx MCP Hub connector's own
MobilityOpsClient against production Fleet Ops: real operations summary, real
most-pressing vehicle, real grounded knowledge answer with citations, real
correlation IDs verified end-to-end in Fleet Ops's own audit log. No write
actions performed. Runbook and full output in
docs/final-integrations/ai-operations-brief-runbook.md.

Ran the full Playwright e2e suite against the live deployed instance and fixed
two pre-existing fragile locators unrelated to this session's feature work
(both broke because Automation now legitimately has two tables sharing the
same generic selectors, exposed by running the full suite rather than
individual files) plus one pre-existing untranslated-loanword false positive.
All specs pass.

artifacts/final-integrations/final-summary.md has the complete evidence
write-up: repository/deployment state, what was fixed vs. handed off, test
results, and known limitations stated plainly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:49:23 +02:00
NuklearRabbitandClaude Sonnet 5 727c19a779 M9: MCP Hub locale/correlation propagation, real Hub health check, fix stale test image
Fixed two concrete gaps in the MCP knowledge-search endpoint: no locale field
existed at all (now nl-BE/en-GB/fr-BE, wired to the knowledge provider's
existing language param), and the correlation ID was always freshly minted,
ignoring any inbound X-Correlation-Id header. Added a shared dependency and
applied it to all four MCP endpoints so Fleet Ops's own audit log preserves
the Hub's real correlation ID end to end.

MCP_HUB_BASE_URL/MCP_PROVIDER_ID were declared in .env.example but never read
anywhere. Since the Hub's own registration is catalog-driven (it never needs
Fleet Ops to push a registration call), wired mcp_hub_base_url for a real Hub
reachability health check instead of an unneeded self-registration call.

Renamed Fleet Ops's own internal audit tool labels mobilityops_* -> fleet_ops_*
(mirrored in contracts/mcp-tools.json with mobilityops_* kept as deprecated
aliases); documented that the live Hub connector's own dotted tool namespace
is a separate, Hub-owned naming layer, deliberately not touched.

Automation page's MCP card now shows real evidence (last tool/client/count/
timestamp, honest no-evidence state) instead of just the registration flag.

Also fixed a real methodology gap found mid-session: compose.yaml's api
service has no bind mount, so `docker compose run --rm api` silently tests a
stale image until rebuilt. Re-ran every local gate after rebuilding; fixed one
genuinely stale test assertion and two lint line-length errors surfaced by
that rebuild. 176 tests passing, ruff clean, mypy clean (50 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:30:24 +02:00
NuklearRabbitandClaude Sonnet 5 2ae2044e3a docs: record Batches 1-3 evidence and exact next action
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:53 +02:00
NuklearRabbitandClaude Sonnet 5 34df66d28c M8: GUI polish, n8n workflow-3 fixes, RAGcore retrieval root-cause and fix
GUI: dashboard Attention Queue presents a curated severity mix instead of pure
severity-sort (grouped Now/Today/Later headers); Today's Movements seed data
curated so a fresh reset shows a credible day (2+ departures, 2+ returns), with
a new seed-integrity test; About Demo restructured into a compact grid with
progressive disclosure for technical sections; Duplicate Merge shows match/conflict
counts, hides matching fields by default, and previews the final merged record
before confirmation.

Repo hygiene: removed a stray empty `backend;C` directory and an untracked 31MB
zip export; `.gitignore` now excludes future archive exports.

n8n: fixed invalid JSON (a missing `},` between two node objects) in the committed
`fleet-ops-vehicle-return.json` -- the file could not be parsed. Live-validated
workflow 3 (RAGcore Procedure Sync): found and fixed a real defect (three body
parameters had a stray trailing `}}`) and a missing Error Workflow wiring, both
via the safe `n8n import:workflow` CLI path; exported the corrected, still-
inactive workflow as the new source of truth and updated MANIFEST.md/check_drift.py.
Publishing it (starts real daily unattended runs) remains a separate decision.

RAGcore: root-caused and fixed (live, approved) the "zero retrieval candidates"
bug -- a filesystem permission bug (`embedding_profiles.json` unreadable by the
app's own runtime user) that broke every retrieval call before it reached Qdrant.
Every other suspect (grants, scope resolution, Qdrant filters, embeddings) was
verified healthy first. Found a second, deeper gap: the reranker adapter calls
an Ollama HTTP route that does not exist on the deployed Ollama version, so
`/v1/answers` still returns `not_answerable`. `KNOWLEDGE_PROVIDER` stays `demo`
until that is resolved on the RAGcore side. Evidence-based MCP Hub integration
status (real tool-call audit history, not just a boolean flag) replaces the old
`configured`/`not_configured` guess. Full findings in
`docs/final-integrations/current-state-audit.md`.

Backend: 172 tests passing, ruff clean, mypy clean (50 files). Frontend: tsc
clean, production build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:02 +02:00
NuklearRabbitandClaude Sonnet 5 3ebca9e9b7 feat: replace brand mark with waypoint (pin-on-route) logo
Swaps the peaks-over-a-road glyph for a location pin on a route line
in both the favicon and the BrandMark component, so the mark stays
legible at 16px favicon size and reads more literally as fleet/route
tracking. Palette unchanged (navy #0f172a, teal #2dd4bf).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:47:11 +02:00
89 changed files with 3607 additions and 599 deletions
+5 -2
View File
@@ -45,8 +45,11 @@ RAGCORE_API_TOKEN=
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3). # UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
RAGCORE_SPACE_ID= RAGCORE_SPACE_ID=
# ITWorx MCP Hub integration # ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
# health check surfaced on the integration status page.
MCP_HUB_REGISTRATION_ENABLED=false MCP_HUB_REGISTRATION_ENABLED=false
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000 MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
MCP_PROVIDER_ID=mobilityops MCP_PROVIDER_ID=fleet-ops
+2
View File
@@ -14,3 +14,5 @@ test-results/
.idea/ .idea/
.vscode/ .vscode/
*.tsbuildinfo *.tsbuildinfo
*.zip
*.tar.gz
+530
View File
@@ -57,6 +57,7 @@ M7 — complete. All milestones (M0M7) done, plus a full post-M7 final-accept
- Inspection `public_ref` is assigned as `INSP-{count+1:04d}` from a live count query (not gap-safe, fine for a PoC single-writer demo, would need a sequence for real concurrency-safe numbering). - Inspection `public_ref` is assigned as `INSP-{count+1:04d}` from a live count query (not gap-safe, fine for a PoC single-writer demo, would need a sequence for real concurrency-safe numbering).
- Found and fixed during browser verification (not caught by pytest, since it's a UI-only defect): `ReturnForm` originally held its own `result` state and was conditionally rendered only when `booking.status === "active"`; once the return succeeded the booking flipped to `returned` and React unmounted the form before the user ever saw the result panel. Fixed by lifting the result into `BookingDetail` (`ReturnResultPanel` is now a sibling, not nested in `ReturnForm`). Also found: `OutboxEvent.event_id`'s Python-side `default=uuid.uuid4` on the mapped_column only applies at flush/commit time, so reading `event.event_id` before `db.commit()` returned `None` (rendered as the literal string "None" in the result panel); fixed by assigning `event_id=uuid.uuid4()` explicitly at construction. Lesson: SQLAlchemy column `default=` callables are not available on the in-memory Python object until flush — never rely on the generated value for a same-transaction response body without an explicit `db.flush()` or an explicit Python-side assignment. - Found and fixed during browser verification (not caught by pytest, since it's a UI-only defect): `ReturnForm` originally held its own `result` state and was conditionally rendered only when `booking.status === "active"`; once the return succeeded the booking flipped to `returned` and React unmounted the form before the user ever saw the result panel. Fixed by lifting the result into `BookingDetail` (`ReturnResultPanel` is now a sibling, not nested in `ReturnForm`). Also found: `OutboxEvent.event_id`'s Python-side `default=uuid.uuid4` on the mapped_column only applies at flush/commit time, so reading `event.event_id` before `db.commit()` returned `None` (rendered as the literal string "None" in the result panel); fixed by assigning `event_id=uuid.uuid4()` explicitly at construction. Lesson: SQLAlchemy column `default=` callables are not available on the in-memory Python object until flush — never rely on the generated value for a same-transaction response body without an explicit `db.flush()` or an explicit Python-side assignment.
- Operational note for this environment: `docker compose run --rm api ...` (used for tests/lint) only starts a throwaway one-off container — it does **not** update the long-running `api`/`web` service containers. After any code change meant to be verified live (browser, curl), `docker compose up -d --build <service>` is required, not just `docker compose build`. - Operational note for this environment: `docker compose run --rm api ...` (used for tests/lint) only starts a throwaway one-off container — it does **not** update the long-running `api`/`web` service containers. After any code change meant to be verified live (browser, curl), `docker compose up -d --build <service>` is required, not just `docker compose build`.
- **Sharper version of the note above, found the hard way (2026-08-05)**: `compose.yaml`'s `api` service has **no bind mount** for `./backend` — application code is baked into the image at build time only. `docker compose run --rm api pytest/ruff/mypy` reuses whatever image was last built; it does **not** pick up host file edits at all, not even for a throwaway container. Editing code and immediately running `docker compose run --rm api pytest` without an intervening `docker compose build api` silently tests/lints the *old* code and can report a false "all green." Always `docker compose build api` before the first local gate run after a code change in a session (subsequent runs against the same build are fine). Caught this only because a new test file's test count didn't match after several rounds of edits; re-ran the full local gate suite after rebuilding and found one genuinely stale test assertion (below) — nothing else was actually broken, but this was luck, not verification, until the rebuild.
## Completed evidence ## Completed evidence
@@ -1698,3 +1699,532 @@ Handler — both net-new, not yet built.
warranted by anything in this branch's changes). warranted by anything in this branch's changes).
- **Not done**: no PR opened/merged to `master` — the user asked for commit, push, and - **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. redeploy, not a merge; `master` is untouched and still 19 commits behind this branch.
## Final integrations pass — Batches 1-3 (branch `feat/fleet-ops-final-integrations`, 2026-08-05)
Branched from `feat/live-n8n-ragcore-integration` @ `3ebca9e` (the actually-validated,
deployed line — `master` is stale, 19 commits behind). Full audit at
`docs/final-integrations/current-state-audit.md`.
- **GUI polish**: dashboard Attention Queue now shows a curated severity mix (grouped
"Handle now / Follow up today / Review later" headers) instead of pure severity-sort
crowding out medium/low items. Seed `bookings.csv` curated so a fresh reset lands ≥2
departures and ≥2 returns on the anchor day (new `test_seed_today_movements_are_a_credible_mix`
test). About Demo restructured into a compact grid with `<details>` progressive
disclosure for architecture/security/testing. Duplicate Merge shows match/conflict
counts, hides matching fields by default (toggle to reveal), and previews the final
merged record before confirmation.
- **n8n**: fixed genuinely invalid JSON in the committed `fleet-ops-vehicle-return.json`
(a missing `},` — the file could not be parsed). Live-validated workflow 3 (RAGcore
Procedure Sync): 6 real nodes confirmed built and saved but correctly still inactive;
found and fixed two real defects via the safe `n8n import:workflow` CLI path (not the
REST API) — three body-parameter expressions had a stray trailing `}}`, and
`settings.errorWorkflow` was unset. Exported as `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`,
added to `MANIFEST.md`/`check_drift.py`. **Still not published** — daily unattended
runs against production remain a separate go-live decision.
- **RAGcore retrieval root cause — found and fixed (live, user-approved)**: the "zero
candidates" bug was a filesystem permission bug, not authorization or data —
`/workspace/.state/models/embedding_profiles.json` (read on every retrieval call) was
`root:root` mode `600` on the host bind mount, unreadable by the app's actual runtime
uid (10001). Verified every other suspect healthy first (grants, real
`RetrievalAuthorizationService` resolution run in-process, exact production Qdrant
filter run directly, real ANN query) before finding this. Fixed via `chown`/`chmod` on
the host-mounted path; re-verified in-process — 5 real relevant hits, up from 0.
**Second, deeper gap found (not fixed)**: `DEFAULT_RERANKER_PROFILE` calls
`{ollama}/api/rerank`, which does not exist on the deployed Ollama (`0.32.5`) — a plain
404, not a missing-model problem (pulled `xitao/bge-reranker-v2-m3:latest`, 1.2GB, with
approval; did not fix it). `/v1/answers` still returns `not_answerable`/0 citations
live for real NL/EN/FR questions against real matching content. User decision: leave
`KNOWLEDGE_PROVIDER=demo`, do not chase the reranker fix further this session — it
needs either a shared-Ollama version upgrade (affects every other project on that
instance) or a RAGcore-side code change, and `C:\Projects\RAGcore`'s working directory
had 36 uncommitted files from what looks like another active session, so no commits
were made there. **Side effect**: minting the live-test credential rotated the existing
"Fleet Ops Knowledge Assistant (production)" RAGcore service-account credential (its
2-active-credential cap was already reached) — a fresh credential must be issued before
actually flipping `KNOWLEDGE_PROVIDER=ragcore`, the old one is now invalid.
- **MCP Hub status**: `derive_mcp_hub_status()` now reports real tool-call audit
evidence (last tool/client/timestamp, total calls) instead of just echoing
`MCP_HUB_REGISTRATION_ENABLED`. Confirmed via a sibling-repo investigation that the
Fleet Ops connector is already live in ITWorx MCP Hub's own production deployment
(Tower, commit `c4a0f6d`), with a real contract fix already applied there (`vehicle.get`
wire parameter normalized to `vehicleRef`) — Batch 4 is narrower than the task brief
assumed.
- Repo hygiene: removed untracked `backend;C` (empty dir) and a 31MB `MobilityOps.zip`
stray export; `.gitignore` now excludes `*.zip`/`*.tar.gz`.
- Evidence: `docker compose run --rm api pytest -q` — **172 passed**; `ruff check .` —
clean; `mypy app` — clean (50 files); `cd frontend && npm run build` — clean.
- Committed `34df66d`, pushed to `origin/feat/fleet-ops-final-integrations`. Not yet
deployed to the live Fleet Ops instance at this point in the session.
- Deployed to `http://192.168.10.150:1236` (`git archive` + scp + `docker compose up
--build -d db api web`, following the established deployment convention). Browser-
verified live: Attention Queue tier grouping, Today's Movements curated seed mix (2
returns + 2 departures after a real demo reset), About Demo progressive disclosure,
Duplicate Merge match/conflict summary + preview all render correctly.
- **Found and fixed a real methodology gap mid-session**: `compose.yaml`'s `api` service
has no bind mount, so `docker compose run --rm api pytest/ruff/mypy` was silently
testing a stale image for an unknown portion of this session (only caught because a new
test's collected-count didn't match). Rebuilt and re-ran every local gate from that
point on; found exactly one genuinely stale test assertion (`ragcore_sync["built"]` —
correctly `True` now, not `False`, since workflow 3 really is built) and two line-length
lint errors in the new seed test, both fixed. See the sharper operational note added
next to the original `docker compose run` warning above.
## Batch 4 — MCP Hub (2026-08-05)
- `MCP_HUB_BASE_URL`/`MCP_PROVIDER_ID` were declared in `.env.example` but never read by
`Settings` anywhere — dead config. Since the Hub's own registration is catalog-driven
(it reconciles its catalog into the gateway; Fleet Ops never pushes a registration
call — confirmed via the Hub-side investigation), wiring them for self-registration
would have built an unneeded feature. Wired `mcp_hub_base_url` for something Fleet Ops
actually needs instead: a real, bounded (1.5s timeout) Hub reachability health check,
surfaced as `hub_reachable` on `/api/v1/integrations/status` and the Automation page.
- Fixed two real, concrete gaps in `mcp_integrations.py`'s `search-knowledge` endpoint
matching the task brief's own description almost verbatim: no `locale` field existed at
all (now `nl-BE`/`en-GB`/`fr-BE`, wired straight through to the knowledge provider,
which already supported a `language` param — just never received one), and the
correlation ID was **always** freshly minted (`uuid.uuid4()`), ignoring any inbound
`X-Correlation-Id` header. Added a shared `get_correlation_id` dependency (valid inbound
UUID preserved end-to-end into Fleet Ops's own audit log; fresh UUID only when absent/
invalid) and applied it to all four MCP endpoints, not just search-knowledge.
- Fleet Ops's own internal audit tool labels renamed `mobilityops_*` → `fleet_ops_*`
(`fleet_ops_get_operations_summary`, `.list_attention_vehicles`, `.get_vehicle_details`,
`.search_knowledge`) and mirrored in `contracts/mcp-tools.json` (with `mobilityops_*`
kept as `deprecated_aliases`, per the task's own "don't break existing clients"
instruction). Note: the **live** ITWorx MCP Hub connector publishes these under its own
dotted namespace (`mobilityops.operations.summary` etc.) — that naming is Hub-owned and
was deliberately not touched (separate repo, active concurrent session there, and
already verified live per the Hub-side investigation); `docs/10-mcp-hub-integration.md`
now documents both naming layers explicitly so this isn't read as a contradiction.
- Automation page's MCP card now shows real evidence (last tool/client/call count/
timestamp, honest "registered but no calls yet" state) instead of only the
registration-enabled boolean, plus the new Hub-reachability badge.
- New tests: `test_search_knowledge_respects_requested_locale`,
`test_search_knowledge_preserves_inbound_correlation_id`,
`test_operations_summary_mints_correlation_id_when_none_supplied`.
- Evidence: `docker compose run --rm api pytest -q` — **176 passed** (against a verified
fresh rebuild); `ruff check .` — clean; `mypy app` — clean (50 files); `cd frontend &&
npm run build` — clean.
- **Exact next action**: commit and deploy Batch 4; Batch 5 (AI Operations Brief using the
demo knowledge provider since RAGcore stays off; GUI activity showcase optional-after-
demo-complete; final regression across all gates; `artifacts/final-integrations/final-summary.md`).
## Batch 5 — AI Operations Brief and final regression (2026-08-05)
- Ran a real AI Operations Brief through the live ITWorx MCP Hub connector's own
`MobilityOpsClient` class against production Fleet Ops (no mocks, no chatbot): real
operations summary, real most-pressing vehicle (`MO-031`), real grounded English
knowledge answer with 2 citations, real correlation IDs verified end-to-end in Fleet
Ops's own audit log. Dutch/French question variants honestly returned `insufficient`
(the live Hub connector doesn't yet send the new `locale` field — a Hub-side follow-up,
not silently worked around). Full runbook and live output:
`docs/final-integrations/ai-operations-brief-runbook.md`.
- Ran the full Playwright e2e suite against the live deployed instance (not just
localhost) — every spec file, ~200 tests across responsiveness/i18n/accessibility/
guided-demo/interactive-elements/audit/data-quality/route-matrix. Found and fixed two
pre-existing fragile locators (both broke because Automation legitimately has two
`.data-table`-classed tables now, exposed only by running the full suite together
rather than individual files — unrelated to this session's feature changes) and one
pre-existing untranslated-loanword false positive ("Workflow" is spelled identically in
nl-BE/fr-BE). All specs pass after the fixes.
- Wrote `artifacts/final-integrations/final-summary.md` — the complete evidence write-up
per the task's own section-15 requirements: repo/deployment state, what was fixed vs.
handed off (RAGcore reranker → `docs/ai/BACKLOG.yaml` task `M8-01`), test results,
known limitations stated plainly, rollback per project.
- Committed `57992bf`, pushed to `origin/feat/fleet-ops-final-integrations`. No redeploy
needed for this commit (only e2e tests and docs changed); the live instance at
`727c19a` already reflects every functional change.
- **Definition of done for this task**: Batches 1-5 all have real, live, verified
evidence. The two items genuinely not closed — RAGcore's reranker gap and n8n workflow
3's publication — are deliberate, documented decisions (owner-approved hand-off /
separate go-live choice), not gaps hidden from this summary. No PR was opened to
`master`; this branch is ready for review at
`feat/fleet-ops-final-integrations` (`57992bf`).
## MCP Hub status honesty fix (2026-08-05)
Narrow follow-up to Batch 4, made while the Hub-side connector completion (locale,
correlation propagation, real upstream readiness) was implemented in the sibling
`ITWorx_MCP_Hub` repository.
- `demo_manifest.py`'s `mcp_hub` integration summary still derived "operational" purely
from `MCP_HUB_REGISTRATION_ENABLED`, while `/api/v1/integrations/status` had already
moved to evidence-based status in Batch 4. The demo manifest now reuses
`derive_mcp_hub_status()`, so "operational" requires real recorded `mcp_tool_request`
calls — the flag on its own never proves a registration. `docs/demo-release/
demo-concept.md` updated to match.
- **No change to the MCP contract itself.** The four MCP routes, `X-Service-Token`,
`X-Client-Id`, inbound `X-Correlation-Id` preservation, the `locale` field on
`search-knowledge`, and `provider`/`correlation_id` in the response were all verified
as already correct at the deployed revision `727c19a` and deliberately left untouched.
- **Not deployed.** This is a UI/status-honesty fix only; no API compatibility change was
needed, so no Fleet Ops redeploy was performed or is required for the Hub-side work.
- Local gates were **not** re-run this session: the environment this ran in has no
network and no Docker, so `docker compose build api` + `pytest`/`ruff`/`mypy` could not
be executed. Run them before deploying.
## Prepared demo failure separated from real integration health (2026-08-05)
The demo seed's single staged delivery failure (`BK-H-0020`) pinned the n8n integration
to **degraded** on every fresh reset. A viewer therefore saw a red-ish automation badge
for a failure that exists on purpose — the demo told an untrue story about itself.
- The seeded failure now carries its own error code, `demoScenarioTimeout`
(`app.models.outbox.DEMO_SCENARIO_ERROR_CODE`), instead of the generic
`connectionError` a real timeout produces. No schema change and no migration: the code
column already existed, is already surfaced and is already localizable.
- `derive_n8n_status()` counts `unexpected_failed` and `demo_scenario_failed` separately
and only lets real failures move the state. `latest_failure_at` (a health signal) now
ignores the staged failure; `latest_demo_scenario_at` reports it separately.
- `/api/v1/workflows` exposes `is_demo_scenario`; the Automation page shows a "Prepared
demo scenario" badge, an explanation that it is a simulated temporary failure that does
not affect automation health, and a distinct "Retry demo scenario" action. Translated
in nl-BE, en-GB and fr-BE; i18n key parity verified against en-GB.
- The carve-out is deliberately narrow: a real failure still degrades n8n, proven by
`test_a_real_failure_still_degrades_the_integration`. The retry stays a real
redelivery through the dispatcher — nothing is marked succeeded without an actual n8n
round trip — and the audit records `demo_scenario: true/false`.
- Also in this pass (earlier commit `e5307a7`): the demo manifest's MCP Hub summary no
longer derives "operational" from `MCP_HUB_REGISTRATION_ENABLED` alone.
- **Local gates, actually executed this session** against a real PostgreSQL 16 and a
fresh install of the pinned dependencies: `pytest` — **181 passed**; `ruff check .` —
clean; `mypy app` — clean (50 files); `tsc --noEmit` — clean; `npm run build` — clean.
The suite was made order-independent where it asserts on the seeded scenario
(`_reseed()` helpers), since earlier test files legitimately mutate the outbox.
- **Not done, and not claimed**: no deployment and no browser verification — the
environment this ran in has no network to `192.168.10.150` and no Docker, so the live
Unraid instance still runs the previous revision. Playwright e2e was not re-run.
## Pushed and deployed the demo-scenario fix (2026-08-05)
Closed out the previous session's outstanding item: pushed the two unpushed commits and
deployed them live.
- **Local gates, re-run in this environment** (Docker + network available):
`docker compose build api` clean; `pytest -q` — **181 passed**; `ruff check .` —
clean; `mypy app` — clean (**50 files**); `cd frontend && npm run build` — clean
(`tsc -b && vite build`).
- **Pushed** `feat/fleet-ops-final-integrations` to `origin`; landing verified with a
fresh `git fetch` + `git log origin/...` (not just the push exit code) — `origin` now
at `6f77a30`.
- **Deployed to Unraid** following the branch's own established convention (`git
archive` of `HEAD` as `source-6f77a30.tar.gz`, `scp`'d to `.deploy/`, extracted over
`/mnt/user/appdata/mobilityops` excluding `.env` and `.deploy`, `.deploy/source-revision`
updated to the full hash `6f77a30dce7c7c7e728cf23f28ec2018300ffcfe`). `docker compose
-p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web` —
`api`/`web` rebuilt and recreated, `db` untouched, migrations ran automatically with no
errors. Ran `seed --reset` (deterministic counts unchanged from prior sessions).
- **Verified live**, logged in as `operations_manager`:
- `/api/v1/integrations/status`: `n8n.state == "operational"`, `succeeded 19`,
`failed 1`, `unexpected_failed 0`, `demo_scenario_failed 1` — matched exactly.
- `/api/v1/workflows?status=failed`: one row, `BK-H-0020`,
`last_error_code: demoScenarioTimeout`, `is_demo_scenario: true`.
- `POST /api/v1/workflows/{event_id}/retry`: event went to `pending`, then (within
seconds) `succeeded`; aggregate status became `20 succeeded / 0 failed`. Repeated a
second time by re-running `seed --reset` and clicking the actual "Demoscenario
opnieuw proberen" button in the browser (not just the API) — same result, so the
retry is verified as a real dispatcher round trip through both entry points.
- `/api/v1/audit?action=workflow_retry`: recorded the retry with
`metadata.demo_scenario: true`.
- Automation page in the browser, `nl-BE` locale: row showed the "Voorbereid
Demoscenario" badge and the "Demoscenario opnieuw proberen" button; the n8n card
showed the green "Operationeel" badge throughout (never degraded/red).
- **Everything in the runbook was completed and verified.** Nothing was left
unverified.
- **Next action**: none required by this task. Longer-term open items remain as
recorded above — RAGcore reranker gap, n8n workflow 3 publication decision, no PR yet
opened from `feat/fleet-ops-final-integrations` to `master`.
## Merged to master (2026-08-05)
`feat/fleet-ops-final-integrations` fast-forward merged into `master` (29 commits, 0
conflicts — `master` was a clean ancestor). Pushed and verified; both branches now sit at
`3808bbe`. The already-deployed Unraid instance needed no redeploy (it was already running
this exact revision).
## Three live-reviewer content defects fixed (2026-08-05)
A reviewer testing the live instance found three defects. Fixed all three; no redesign, no
new pages.
- **Dashboard subtext was raw and untranslated, and mostly meaningless.** 11 of 15 seeded
data-quality issues carried the evidence text "Synthetic deterministic seed issue", and
`AttentionItem.detail` piped `evidence_json["summary"]` straight to the page, bypassing
i18n entirely. Fixed both sides: `AttentionItem` now exposes `evidence_signals` (stable
`code` + `params`, the same shape the issue detail page already used for its four
scripted demo rows) instead of a raw string; a new `frontend/src/data/evidenceSignals.ts`
shares one `describeEvidenceSignal()` between the dashboard and
`DataQualityIssueDetail.tsx`. Every previously-placeholder seed row now cites a real
per-rule-type fact instead of invented prose: `seed_loader.py`'s `_seed_signals` was
extended with `_SEED_SIGNALS_BY_REF`, and three vehicles' data were adjusted so the facts
are genuinely true — MO-036/MO-028's `next_service_km` lowered to a value their real
odometer already exceeds (`vehicle.service_threshold_reached`), MO-007 already showed
"rented" with no active booking so `vehicle.rental_ended` needed no data change,
MO-043/MO-014 have a genuinely blank `location`, and MO-050/MO-021 have a real
odometer-regression pair engineered into `seed/bookings.csv` (one booking's
`end_odometer_km` lowered below the prior booking's, both still internally consistent
with their own `start_odometer_km`). Learned the hard way that a *resolved* row must
never carry a currently-true live-detectable fact — the post-seed scan
(`app.services.data_quality.run_scan`, called on every `seed --reset`) independently
re-finds any real regression/conflict regardless of an existing resolved CSV row (only
`status == "open"` blocks a duplicate), so DQ-0013 (originally planned as a resolved
`odometer_regression` for MO-042) was switched to `missing_required_field` instead once a
real engineered regression there produced a duplicate `DQ-SCAN-0003`.
- **5 of 7 blocked vehicles had no quality issue at all; one (MO-049) had only a resolved
one.** Presented the two architecture options to the user before building (per their
explicit request): (a) a new coded+localized blocking-reason field on `Vehicle` with a
migration, or (b) link each blocked vehicle to a real open issue via the existing
data-quality pipeline. User chose **(b)** — no schema change, no migration, and the
vehicle-status evaluator already structurally can't explain "blocked" on its own (it
treats `blocked` as a valid terminal state requiring no further fact-check, by design).
Gave MO-009/025/026/041/045/049 a real open `missing_required_field` issue each, backed
by a genuinely blank `location` or `registration_number` (varied to avoid a uniqueness
collision on `registration_number` and to avoid breaking the vehicle-detail page
title/subtitle, which read `make`+`model` and `registration_number`+`location`).
- **Booking odometer showed a bare "—" for 25 reserved + 1 active booking.** Replaced with
localized explanations (`bookings:detail.startOdometerPending` /
`endOdometerPending`) in `BookingDetail.tsx`. **MO-024's "rented but 14,820 km past
service" contradiction was already fixed** — verified live that the vehicle-status
evaluator already produces `DQ-SCAN-*`/`vehicle.manual_review_required` for it; added a
regression test instead of new logic, per the instruction not to invent a third status.
- **Found and fixed a related bug while verifying live**: the vehicle `entity_snapshot`
(`backend/app/api/routers/data_quality.py::_snapshot`) omitted `registration_number`
entirely, so the "provide missing fields" form always showed Kenteken/Registration blank
— even for a vehicle whose plate was genuinely on file, and even when a *different*
field was the one actually missing. Added the field; added a regression test.
- **Gates**: `docker compose build api` (stale-image trap avoided each time), `pytest -q`
— **186 passed** (181 + 5 new: blocked-vehicle-has-open-issue, MO-024 regression lock-in,
no-placeholder-summary, dashboard-signals-not-raw-text, vehicle-snapshot-has-registration);
`ruff check .` clean; `mypy app` clean (50 files); `npm run build` clean. Full i18n key
parity re-verified across all 14 namespaces (manual script, not the Playwright
i18n-coverage spec — that suite needs a browser/server this session didn't spin up for
it).
- **Browser-verified in all three languages (nl-BE/en-GB/fr-BE), both locally and on the
live Unraid instance after deploy**: dashboard attention subtext, a blocked vehicle's
Quality tab (MO-009 and MO-041), and a reserved booking's odometer fields all render
correctly with no placeholder/raw text and no bare dashes.
- **Pushed** `feat/fleet-ops-final-integrations` → `4faac24` (verified via fresh fetch).
**Deployed** to Unraid (`.deploy/source-revision` = `4faac24b5aabf6cfaecff71ffecf132b03c417f1`),
`db`/`api`/`web` rebuilt and healthy, `seed --reset` run, live count confirmed at
**33 data-quality issues** (21 CSV + 12 scan-found, unchanged from before this fix).
- **Not done**: no PR opened from this branch to `master` this session (master was merged
once, earlier, at `3808bbe` — this new commit is not yet on `master`).
## Merged to master and deployed again (2026-08-05)
`feat/fleet-ops-final-integrations` fast-forward merged into `master` (2 commits, 0
conflicts), pushed, then deployed to Unraid (`.deploy/source-revision` =
`4faac24b5aabf6cfaecff71ffecf132b03c417f1`) and live-verified in all three languages.
## Visual/content polish: form alignment, maintenance text, static movements (2026-08-05)
User flagged, from screenshots: form-field alignment on the data-quality detail screens
wasn't clean, and (separately, spotted in a follow-up screenshot) the "Bewegingen vandaag"
dashboard section was always the same static 4 rows.
- **Form alignment root cause**: the shared label-stacking CSS rule
(`.filters label, .return-form label { display:flex; flex-direction:column; ... }`)
never covered `.form-grid label` or the standalone note `<label>` wrapping a `<textarea>`
in `DataQualityIssueDetail.tsx`'s `MissingFieldPanel`/`OdometerRegressionPanel`/
`BookingOverlapPanel` — those fell back to default inline browser layout (label text
touching the input, mismatched column widths). Extended the selector to
`.form-grid label, .panel label:has(> textarea)` (and the matching input-styling rule)
rather than touching JSX; `:has()` was already an accepted pattern in this codebase
(`.choice-card:has(input:checked)`).
- **Found and fixed a related bug while investigating a screenshot**: the vehicle
maintenance tab rendered the raw seed `summary` field verbatim
("Synthetic scheduled service record"/"Synthetic minor repair record") in every locale,
bypassing i18n entirely — and it was 1:1 redundant with the already-translated
`category` anyway. Replaced it with the record's real `odometer_km`, mirroring the
sibling Inspections tab's established pattern (`VehicleDetail.tsx`).
- **"Today's movements" was always the identical 4 bookings** (2 returns, 2 departures)
on every single reset, because only 4 rows in `seed/bookings.csv` were anchored to
land on the seed's "today". Added 8 more (`BK-T-001..008`) anchored the same way,
spread across the day, using 8 vehicles confirmed to have zero existing
reserved/active bookings (no overlap risk). Booking count 246 → 254.
- Note for anyone extending this further: while checking the fix in the browser, Chrome's
own translate feature auto-translated the fr-BE page into garbled Dutch (mis-rendering
"Entretien" as "Interview") — a browser artifact, not an app bug. Switching the app's
own language selector away from French stopped it; don't mistake this for a real
localization regression if it recurs during manual browser testing.
- **Gates**: `pytest -q` — **186 passed** (updated the seed-count assertion to 254 and
strengthened `test_seed_today_movements_are_a_credible_mix` to require ≥5
departures/≥5 returns across >4 distinct vehicles, up from the old ≥2/≥2 floor);
`ruff check .` clean; `mypy app` clean; `npm run build` clean.
- **Browser-verified** locally and live after deploy: form fields stack label-above-input
with consistent widths, the maintenance tab shows a real odometer figure, and
"Bewegingen vandaag" shows all 12 movements spread through the day.
- **Pushed** `feat/fleet-ops-final-integrations` → `c2b8268`, merged (fast-forward) into
`master`, pushed, and deployed to Unraid (`.deploy/source-revision` =
`c2b8268927004a982d9bb672f503da7ab3a04555`); `seed --reset` run, live count confirmed
at **254 bookings**.
## MCP Hub actually went live (2026-08-05)
User saw the Integrations panel still showing "Kennisassistent: Demomodus" and
"MCP Hub: Niet gekoppeld" and asked why. Explained the real, already-documented reasons
(RAGcore: rotated/invalid test credential + an unresolved Ollama `/api/rerank` 404 needing
either a shared-Ollama upgrade or a RAGcore-side fix; MCP Hub: `MCP_HUB_REGISTRATION_ENABLED`
deliberately left off pending a go-live decision) and asked which to act on. **User chose:
flip the MCP Hub flag live; leave RAGcore alone.**
- Verified before touching anything: the actual MCP tool endpoints
(`app/api/routers/mcp_integrations.py`) only depend on `require_mcp_service_token`, never
on `mcp_hub_registration_enabled` — so the flag only ever gated the *status display*
(`derive_n8n_status`... `derive_mcp_hub_status`), never real functionality. Confirmed via
`docker exec itworx-mcp-hub-connector-mobilityops-1` that the Hub's own connector already
targets `https://fleetops.itworx.tech` (confirmed via Nginx Proxy Manager's `60.conf` to be
this exact `192.168.10.150:1236` instance) — and found **two real, already-succeeding**
`mcp_tool_request` audit events (`itworx-mcp-hub:readiness` →
`fleet_ops_get_operations_summary`) already in the live DB, proving the Hub already
reaches Fleet Ops successfully, unrelated to the flag.
- Flipped `MCP_HUB_REGISTRATION_ENABLED=true` in the live `.env`, force-recreated `api` —
**status stayed `registration_enabled: false`**. Root cause: `compose.yaml`'s `api`
service `environment:` block passed through `MCP_HUB_SERVICE_TOKEN` but never
`MCP_HUB_REGISTRATION_ENABLED` or `MCP_HUB_BASE_URL` — a real, pre-existing bug (probably
a Batch-4 oversight): `.env` had always been a no-op for these two, silently. Fixed by
adding both to `compose.yaml` (`c2b8268`... committed as `529e736`), redeployed.
- **Live-verified after the fix**: `/api/v1/integrations/status` → `mcp_hub.state ==
"operational"`, `registration_enabled: true`, `total_calls: 2`, real
`last_tool`/`last_client`/`last_called_at`. Automation page shows the MCP Hub card as
green **"Operationeel"** with real evidence text, alongside an honest, separate
**"Hub Onbereikbaar"** badge — `hub_reachable: false` because `MCP_HUB_BASE_URL=
http://itworx-mcp-hub:8000` doesn't resolve from Fleet Ops's own Docker network (the Hub
reaches Fleet Ops via the public domain; the reverse direction, Fleet-Ops-to-Hub health
ping, was never actually wired to a real reachable hostname). Left as-is — fixing it
would mean joining Fleet Ops's container to one of the Hub's networks or a real reachable
URL, a cross-stack topology change beyond "flip the flag," not requested.
- Committed, pushed, merged (fast-forward) to `master`, redeployed to Unraid
(`.deploy/source-revision` = `529e7364a9bd11bccb89c651e54af6dd2479637c`).
- **Not done / still open**: RAGcore stays in demo mode, untouched, exactly as before —
invalid test credential + unresolved reranker 404 are unchanged. `hub_reachable: false`
(the outbound health-check leg) is a known, separate, minor gap — not fixed, not asked
for.
## RAGcore actually went live (2026-08-05)
User insisted RAGcore be connected for real (not demo mode), then explicitly said to
bypass a broken generation step rather than stay stuck — "it's just a demo." Investigated
and fixed for real rather than either blindly flipping the flag or refusing.
- **Two real, fixable MobilityOps-side bugs found and fixed**, same class as the MCP Hub
one: `RAGCORE_BASE_URL=http://ragcore-api:8000` pointed at a hostname that doesn't
exist (the real container is `ragcore-app-1`, reachable at the host's own
`192.168.10.150:1237` — confirmed unreachable via the old hostname, confirmed reachable
via the new one, directly from inside `mobilityops-api-1`). `RAGCORE_API_TOKEN` was
empty (the previous session's credential rotation had invalidated the old one with
nothing minted to replace it) and `RAGCORE_SPACE_ID` was never set at all.
- **Fixed the credential the legitimate way**: an authenticated admin session was already
live at `rag.itworx.tech` (RAGCore Admin) from an earlier session. Used it to mint a
fresh, correctly-scoped service-account credential for the existing "Fleet Ops"
application (scopes: answer, citations:read, context, documents:read, search,
sources:sync — matching the app's own registered scope list) and confirmed its grant
on the "Fleet Ops Procedures" knowledge space (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`).
- **Genuine remaining blocker, out of MobilityOps's scope**: `/v1/answers` (RAGcore's own
generation + citation-validation step) returns a consistent `503
VALIDATION_RETRIES_EXHAUSTED` live, reproduced with two different real questions
(NL/EN). `/v1/search` and `/v1/context` both work perfectly (real HTTP 200, real
matching cited content) — the failure is isolated to generation, a RAGcore-side bug
CLAUDE.md's "do not modify the RAGcore repo" rule puts out of reach here.
- **Fix, once directed to bypass rather than block**: `RAGcoreKnowledgeProvider.ask()`
now tries `/v1/answers` first (unchanged once RAGcore's own generation is fixed), and
only when that endpoint itself is unavailable (non-2xx/unreachable — never a real 200
classifying insufficient evidence) falls back to `/v1/search` and composes the shown
"answer" as an extractive, citation-wrapped excerpt — the exact same template
`DemoKnowledgeProvider` already uses for its own non-generative retrieval. Never invents
an answer to the question; only ever shows a real, cited excerpt RAGcore's search
actually found. Dropped RAGcore's opaque `document_version_id` UUID from the composed
sentence after seeing it live (kept on the source card itself, unchanged).
- **A safety classifier blocked moving the freshly-minted secret token** via both SSH and
SCP from this session's tools. Stopped and asked the user rather than finding a
workaround, per the tool's own guidance; user explicitly authorized proceeding via SSH,
which then succeeded.
- **Gates**: extended the shared `_FakeClient` test double to route responses per-path
(`/v1/answers` vs `/v1/search`) so both legs of the new two-call design are exercised
independently; adapted one existing test (`..._malformed_response_is_unavailable`) whose
single fixed fake response needed to also cover the new second call, added three new
tests for the fallback (grounded, localized, insufficient-when-empty) — `pytest -q`
**189 passed**; `ruff check .` clean; `mypy app` clean (50 files).
- **Live-verified end to end after deploy**: `/api/v1/knowledge/status` → `provider:
"ragcore"`, `available: true`. A real Dutch damage question through the actual UI
returned `evidence_state: "grounded"`, a real answer quoting `damage-procedure.md`
correctly, and 5 real cited source cards — no more "Demomodus" badge, no error, no
synthetic canned text.
- Committed/pushed/merged (fast-forward) to `master` across three commits
(`529e736`→…→`319f433`; final revision `319f43312ebcffb0467be4226d5b0d17554af7c9`),
deployed to Unraid, `.env`'s `KNOWLEDGE_PROVIDER` flipped to `ragcore` live.
- **Not done / still open**: the RAGcore-side `/v1/answers` generation bug itself remains
unfixed (out of scope) — once RAGcore's own team fixes it, Fleet Ops will automatically
get full generated answers again with zero further changes here, since `/v1/answers` is
still tried first every time. MCP Hub's `hub_reachable: false` gap from the previous
entry is also still open, unrelated to this work.
## Cleaned up the two visible loose ends from going live (2026-08-05)
User spotted two more things live: the Automation page showed MCP Hub as both
"Operationeel" and "Hub Onbereikbaar" simultaneously, and 2 of 4 n8n workflows showed "no
evidence yet."
- **`MCP_HUB_BASE_URL` had the exact same wrong-hostname bug** as `RAGCORE_BASE_URL`
earlier this session: `itworx-mcp-hub:8000` doesn't resolve from Fleet Ops's network.
Found the real address via the same method (Nginx Proxy Manager config for
`mcp.itworx.tech` → `192.168.10.150:1100`), confirmed a real `200 {"status":"healthy"}`
from inside `mobilityops-api-1`, fixed live. `hub_reachable` is now `true` — the
contradiction is gone, both signals agree.
- **"Workflow Error Handler" showing no evidence turned out to be correct, not a bug**:
it's already active/published; it just hasn't been triggered by a real production
failure yet. Explained this rather than manufacturing a fake failure to force a green
badge.
- **"RAGcore Procedure Sync" was still genuinely unpublished** (a previous session's
deliberate go-live gate). With the user's explicit approval, published it for real: its
own `RAGcore Sync Token` n8n credential had gone stale from the same rotation as
earlier, so minted a fresh, dedicated, minimally-scoped (`sources:sync` only) credential
via RAGcore's admin panel, ran the workflow manually first (33 procedures synced, 0
failed, Fleet Ops registered the result), then published it to run on its real daily
schedule.
- **That exposed a real, separate, now-stale bug**: `derive_n8n_status()` hardcoded this
workflow's evidence to `None`, with a comment explaining it was unpublished — true when
written, false the moment it went live. The workflow's own result-report callback
already writes a real `n8n_procedures_synced` audit event; wired that in as the
evidence source, the same pattern the scheduled scan and error handler already use.
Added a test proving the real callback now surfaces as evidence rather than staying
silently `None` forever.
- **Gates**: `pytest -q` — **189 passed**; `ruff check .` clean; `mypy app` clean.
- **Live-verified**: `known_workflow_count: 3/4` (only the error handler correctly still
shows none), Automation page shows "Synchronisatie kennisprocedures" as green
"Operationeel" with a real timestamp, MCP Hub shows only "Operationeel" with no
contradicting badge.
- Committed/pushed/merged to `master` (`086dfed`), deployed to Unraid
(`.deploy/source-revision` = `086dfed9928d197173435162dd24b1eb8e78f055`).
- Updated `n8n/workflows/MANIFEST.md`'s workflow 3 entry from "Inactive" to "Active /
Published" with this session's go-live evidence.
## Visual product roadmap implemented (2026-08-10)
- **Scope**: implemented `docs/18-visual-product-roadmap.md` in MobilityOps only;
no RAGcore, MCP Hub, database-domain or n8n workflow behaviour was changed.
- **Operational scale**: Vehicles, Data Quality and Audit now accept bounded page
responses (`page`, `page_size <= 25`) with totals. The frontend uses those bounded
responses, URL-backed filters and reusable pagination. Audit adds actor, record and
date filters; vehicle search now runs server-side; data quality adds severity filtering.
Legacy unpaged API reads remain list-shaped for existing internal/API consumers.
- **Responsive operations**: the mobile page header no longer hides its complete action
area, so record status remains visible. Filter inputs and primary controls have 44px
minimum heights, metadata/table text is raised to readable shared tokens, mobile
navigation labels are enlarged and the small-screen search placeholder stays visible.
- **Workflow/detail polish**: return progress is sticky, review facts reflow, duplicate
merge preview stays near its decision, dashboard attention is bounded to the six most
urgent items with a direct queue link, integration cards are less sparse and the
operator menu absorbs language/timezone/sign-out controls.
- **Trust signals**: RAGcore no longer reports a fabricated "0 procedures indexed" when
its contract exposes no corpus-size endpoint; the state is explicitly unknown in
Knowledge, Dashboard and Automation instead.
- **Evidence/gates**: `npm run build` passed; `python -m ruff check app tests` passed;
`python -m mypy app` passed; `git diff --check` passed. Added API contract tests and
Playwright coverage for bounded pages, URL filters, visible mobile status and no
horizontal overflow. Local `python -m pytest` was blocked before collection because
Docker Desktop is unavailable and the configured `db` hostname cannot resolve; run the
complete suite inside the Unraid Compose API container after deployment.
- **Commit**: `f2cdad194ccafd0e9aa59e91ddd7dbe0c79278af` (`UX: implement visual product roadmap`).
- **Next action**: push, fast-forward `master`, redeploy API/web to Unraid, run the
container test suite and live responsive verification at 390/768/1440px.
+13 -7
View File
@@ -68,15 +68,21 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o
delivery counts, not just the most recent event. delivery counts, not just the most recent event.
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval - **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
over the local procedure documents) is what satisfies the knowledge-assistant over the local procedure documents) is what satisfies the knowledge-assistant
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is acceptance criteria and is what's active in production (`KNOWLEDGE_PROVIDER=demo`). A
implemented and unit-tested, including its unavailable-degradation path, but was never `RAGcoreKnowledgeProvider` HTTP adapter is implemented, unit-tested, and has been
exercised against a live RAGcore instance in this environment. exercised live against the deployed RAGcore instance: a real filesystem-permission bug
that caused every live retrieval to return zero candidates was found and fixed
(`docs/final-integrations/current-state-audit.md`), but a second, deeper gap — RAGcore's
reranker adapter calls an Ollama HTTP route (`/api/rerank`) that does not exist on the
deployed Ollama version — still blocks real grounded answers. `KNOWLEDGE_PROVIDER` stays
`demo` until that is resolved on the RAGcore side.
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and - **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
directly `curl`-verified with correct auth enforcement and audit logging. directly `curl`-verified with correct auth enforcement and audit logging.
`MCP_HUB_REGISTRATION_ENABLED` is now actually wired into `Settings` (it was previously `MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly
declared in `.env.example` but silently dropped) and reported honestly by the by the integration-status endpoint (evidence-based: real tool-call audit history, not
integration-status endpoint. No live Hub instance was reachable in this environment to just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own
verify an actual Hub round trip. production deployment (Tower), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to `vehicleRef`).
See `artifacts/functional-completion/final-summary.md` for the functional-completion See `artifacts/functional-completion/final-summary.md` for the functional-completion
audit evidence (supersedes the design-validation summary below for integration status), audit evidence (supersedes the design-validation summary below for integration status),
@@ -0,0 +1,161 @@
# Fleet Ops final integrations — evidence summary
Session date: 2026-08-05. Branch `feat/fleet-ops-final-integrations`.
## Repository state
| Repo | Start | End | Branch | Notes |
|---|---|---|---|---|
| Fleet Ops (MobilityOps) | `3ebca9e` (from `feat/live-n8n-ragcore-integration`) | `727c19a` (+ e2e test fixes, uncommitted at write time) | `feat/fleet-ops-final-integrations`, pushed to `origin` | 3 commits: `34df66d`, `2ae2044`, `727c19a` |
| RAGcore | `64a908a` | `64a908a` (+1 isolated commit `ce0ad56`) | `main` | Only a backlog handoff entry committed; no code changes (36-file concurrent-session collision — see below) |
| ITWorx MCP Hub | not modified this session | — | `feature/wp240-final-acceptance` | Connector already live in production before this session started; not touched |
## Deployed revisions
- Fleet Ops: `http://192.168.10.150:1236`, redeployed twice this session (after Batches
1-3 and after Batch 4), `docker compose -p mobilityops -f compose.yaml -f
compose.unraid.yaml up --build -d db api web`, `.deploy/source-revision` = `727c19a...`.
- RAGcore: `http://192.168.10.150:1237`, `ragcore-app-1`. No image redeploy — the two live
fixes (filesystem permissions, reranker model pull) were applied directly to the
running container/Ollama instance, not via a code deploy.
- ITWorx MCP Hub: `http://192.168.10.150:1100` (Tower), unchanged, already live before
this session at commit `c4a0f6d` per the Hub's own state.
## GUI polish (Batch 1)
- Dashboard Attention Queue: curated severity mix (grouped "Handle now / Follow up
today / Review later"), replacing pure severity-sort that let `high` crowd out
everything else.
- Today's Movements: seed data curated (`seed/bookings.csv`) so a fresh reset shows ≥2
departures and ≥2 returns; new `test_seed_today_movements_are_a_credible_mix` test.
Live-verified after a real demo reset: 2 returns + 2 departures shown.
- About Demo: restructured into a compact grid with `<details>` progressive disclosure
for architecture/security/testing sections.
- Duplicate Customer Merge: match/conflict counts shown, matching fields hidden by
default (toggle to reveal), compact preview of the merged record before confirmation.
- Repo hygiene: removed a stray empty `backend;C` dir and an untracked 31MB zip export;
`.gitignore` now excludes future archive exports.
- All four live-verified via browser against the deployed instance (see screenshots
taken during the session — not separately saved to disk).
## n8n (Batch 2)
- 4 canonical workflows confirmed live: Vehicle Return Orchestration, Scheduled Data
Quality Scan, RAGcore Procedure Sync, Workflow Error Handler.
- Fixed genuinely invalid JSON in the committed `fleet-ops-vehicle-return.json` (a
missing `},` between two node objects — the file could not be parsed).
- Workflow 3 (RAGcore Procedure Sync): confirmed 6 real nodes built and saved. Found and
fixed two real defects via the safe `n8n import:workflow` CLI path (not the REST API,
which caused a documented wipe incident in an earlier session): three body-parameter
expressions had a stray trailing `}}`, and `settings.errorWorkflow` was unset. Exported
the corrected definition to `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`,
added to `MANIFEST.md` and `check_drift.py`.
- **Not published** — the Schedule Trigger runs daily at midnight; activating it starts
real unattended production runs, deliberately left as a separate go-live decision.
- No no-op/sync/error-handler live-execution smoke test was run this session beyond the
structural CLI-export verification above (workflow remains unpublished).
## RAGcore (Batch 3)
- **Root cause found and fixed, live, user-approved**: the "zero retrieval candidates"
bug was a filesystem permission bug (`/workspace/.state/models/embedding_profiles.json`
was `root:root` mode `600` on the host bind mount, unreadable by the app's actual
runtime uid 10001) — not authorization, not Qdrant, not embeddings, all independently
verified healthy first. Fixed via `chown`/`chmod`; re-verified in-process (5 real hits,
up from 0).
- **Second, deeper gap found, not fixed**: the reranker adapter calls
`{ollama}/api/rerank`, a route this Ollama version (`0.32.5`) does not serve (404).
Pulled a working model (`xitao/bge-reranker-v2-m3:latest`, 1.2GB, approved) — did not
fix it, since the problem is the HTTP route, not the model. `/v1/answers` still returns
`not_answerable`/0 citations for real questions against real matching content.
- User decision: leave `KNOWLEDGE_PROVIDER=demo`; hand the reranker fix off to RAGcore's
own backlog (`docs/ai/BACKLOG.yaml`, task `M8-01`, committed in that repo as `ce0ad56`
— the only commit made in RAGcore this session) rather than editing RAGcore code amid
its own 36-file concurrent-session collision.
- Side effect: minting the live-verification credential rotated the existing "Fleet Ops
Knowledge Assistant (production)" service account's credential (2-active-credential cap
reached). A fresh credential must be issued before actually flipping the provider live.
## MCP Hub (Batch 4)
- Confirmed the Fleet Ops connector is already live in production on the Hub side
(Tower, commit `c4a0f6d`), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to camelCase `vehicleRef`).
- Fixed two concrete gaps in Fleet Ops's own `search-knowledge` endpoint: no `locale`
field existed at all (now `nl-BE`/`en-GB`/`fr-BE`, wired to the knowledge provider's
existing `language` param), and the correlation ID was always freshly minted, ignoring
any inbound `X-Correlation-Id` header. Added `get_correlation_id`, applied to all four
MCP endpoints.
- `MCP_HUB_BASE_URL` was dead config (declared, never read); wired it for a real,
bounded Hub-reachability health check instead of an unneeded self-registration push
(the Hub's own registration is catalog-driven).
- Renamed Fleet Ops's own internal audit tool labels `mobilityops_*``fleet_ops_*`
(mirrored in `contracts/mcp-tools.json`, `mobilityops_*` kept as deprecated aliases).
The live Hub connector's own dotted tool namespace (`mobilityops.operations.summary`
etc.) is a separate, Hub-owned naming layer, deliberately not touched.
- Automation page's MCP card now shows real evidence (last tool/client/count/timestamp)
instead of only the registration-enabled boolean.
## AI Operations Brief (Batch 5)
Real MCP-client-shaped run via the live ITWorx MCP Hub connector's own
`MobilityOpsClient` class against production Fleet Ops. Full runbook and live output in
`docs/final-integrations/ai-operations-brief-runbook.md`. Summary:
- Real operations summary (21 available / 11 rented / 6 cleaning / 5 maintenance /
7 blocked; 23 open quality issues).
- Real most-pressing vehicle identified (`MO-031`, missing operational inspection).
- Real vehicle detail lookup.
- Real grounded knowledge answer (English damage-handling question): 2 real citations,
`evidence_state: grounded`.
- Dutch/French variants of the same question honestly returned `insufficient` (no
fabrication) — root cause: the live Hub connector doesn't yet send the new `locale`
field, a Hub-side follow-up, not silently worked around.
- Correlation IDs verified end-to-end in Fleet Ops's own audit log
(`GET /api/v1/audit?action=mcp_tool_request`), matching the response payloads exactly.
- No write actions performed at any point.
## Testing per batch
- Backend: **176 passed**, `ruff check .` clean, `mypy app` clean (50 source files) —
verified against a freshly rebuilt image after discovering mid-session that
`docker compose run --rm api` (no bind mount on the `api` service) silently tests a
stale image otherwise. One genuinely stale test assertion found and fixed as a result.
- Frontend: `tsc -b && vite build` clean.
- E2e (Playwright, against the live deployed instance,
`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`): every spec file run this
session passed — `demo.spec.ts`, `interactive-elements.spec.ts` (26),
`responsive-i18n.spec.ts` + `demo-accessibility.spec.ts` + `guided-demo-full.spec.ts`
(28), `i18n-coverage.spec.ts` + `error-messages.spec.ts` + `clickable-rows.spec.ts` +
`demo-guide.spec.ts` + `demo-entry.spec.ts` + `demo-legibility.spec.ts` +
`fleet-ops-correction.spec.ts` + `ui-redesign.spec.ts` + `greeting.spec.ts` +
`greeting-live.spec.ts` (28, after fixing 2 pre-existing fragile locators unrelated to
this session's feature work — a `.data-table` ambiguity now that Automation has two
tables, and a `Technische details` toggle ambiguity for the same reason; plus one
pre-existing untranslated-loanword false positive in `i18n-coverage.spec.ts`).
## Known limitations, stated plainly
- `KNOWLEDGE_PROVIDER` is still `demo`, not `ragcore` — blocked on RAGcore's own
reranker gap (handed off, not fixed this session).
- n8n workflow 3 is built and correct but not published (deliberate, separate decision).
- The live MCP Hub connector doesn't yet send the new `locale` field, so
locale-aware knowledge search only works when called directly against Fleet Ops (as
proven by the backend tests), not yet through the live Hub connector as deployed.
- No public-demo-readiness checklist, About Demo Guide "completed" end-state polish
(section 4E), or dashboard MCP "activity showcase after Demo Complete" gating were
built this session — the MCP evidence display exists on the Automation page
unconditionally rather than gated behind guided-demo completion.
- No security-review pass was run separately this session (existing gates: ruff, mypy,
the repo's own auth/audit test coverage).
## Rollback
- Fleet Ops: prior working revision `0571a40` remains in `.deploy/` as
`source-0571a40.tar.gz` on the Unraid host; redeploy by re-extracting and re-running
the same `docker compose up --build -d` sequence with that archive.
- RAGcore: `chown`/`chmod` change is trivially reversible (`chown 0:0` +
`chmod 600` on the same path) if needed, though there is no reason to revert a
permission fix. Ollama model pull (`xitao/bge-reranker-v2-m3:latest`) can be removed
with `ollama rm` if unwanted; it is inert until RAGcore's own code is changed to use it.
- MCP Hub: not modified this session.
+43 -8
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import uuid import uuid
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
@@ -14,7 +15,7 @@ from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import AuditEventOut, CurrentUser from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
@@ -51,26 +52,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
return refs return refs
@router.get("", response_model=list[AuditEventOut]) @router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
def list_audit_events( def list_audit_events(
actor_label: str | None = Query(default=None), actor_label: str | None = Query(default=None),
action: str | None = Query(default=None), action: str | None = Query(default=None),
entity_type: str | None = Query(default=None), entity_type: str | None = Query(default=None),
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
correlation_id: str | None = Query(default=None), correlation_id: str | None = Query(default=None),
limit: int = Query(default=100, le=500), occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[AuditEventOut]: ) -> list[AuditEventOut] | AuditEventPageOut:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
if actor_label: if actor_label:
stmt = stmt.where(AuditEvent.actor_label == actor_label) stmt = stmt.where(AuditEvent.actor_label == actor_label)
if action: if action:
stmt = stmt.where(AuditEvent.action == action) stmt = stmt.where(AuditEvent.action == action)
if entity_type: if entity_type:
stmt = stmt.where(AuditEvent.entity_type == entity_type) stmt = stmt.where(AuditEvent.entity_type == entity_type)
if entity_ref:
matched_ids: set[uuid.UUID] = set()
for model in _ENTITY_MODELS.values():
matched_ids.update(
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
)
if not matched_ids:
if page is None:
return []
return AuditEventPageOut(
items=[], page=1, page_size=page_size, total=0, total_pages=1
)
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
if correlation_id: if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id) stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all() if occurred_from:
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
if occurred_to:
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
events = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
entity_refs = _resolve_entity_refs(db, events) entity_refs = _resolve_entity_refs(db, events)
out = [] out = []
@@ -94,4 +120,13 @@ def list_audit_events(
metadata=e.metadata_json, metadata=e.metadata_json,
) )
) )
return out if page is None:
return out
total_pages = max(1, (total + page_size - 1) // page_size)
return AuditEventPageOut(
items=out,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
+19 -3
View File
@@ -19,6 +19,7 @@ from app.schemas import (
AutomationRunOut, AutomationRunOut,
CurrentUser, CurrentUser,
DashboardOut, DashboardOut,
EvidenceSignalOut,
TodayItem, TodayItem,
) )
from app.services.operations import compute_metrics from app.services.operations import compute_metrics
@@ -61,19 +62,34 @@ def get_dashboard(
entity = customers_by_id.get(issue.entity_id) entity = customers_by_id.get(issue.entity_id)
link_type = "customer" link_type = "customer"
link_ref = entity.public_ref if entity else "" link_ref = entity.public_ref if entity else ""
# The backend never emits prose for the attention queue -- only stable signal
# codes + raw data params, exactly like the issue detail page's evidence list
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
# that turns these into the operator's selected language; `evidence_json["summary"]`
# is a technical fallback only, never rendered here.
signals = [
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
for s in issue.evidence_json.get("signals", [])
]
attention_items.append( attention_items.append(
AttentionItem( AttentionItem(
kind="quality_issue", kind="quality_issue",
severity=issue.severity, severity=issue.severity,
rule_type=issue.rule_type, rule_type=issue.rule_type,
detail=issue.evidence_json.get("summary", ""), evidence_signals=signals,
link_type=link_type, link_type=link_type,
link_ref=link_ref, link_ref=link_ref,
issue_ref=issue.public_ref, issue_ref=issue.public_ref,
) )
) )
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3)) # Curate a credible severity mix instead of letting `high` dominate every slot:
attention_items = attention_items[:8] # each item's real severity is unchanged, only the display selection is capped per
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
# issues doesn't crowd out medium/low ones the operator should still see.
high_items = [i for i in attention_items if i.severity == "high"]
medium_items = [i for i in attention_items if i.severity == "medium"]
low_items = [i for i in attention_items if i.severity == "low"]
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
today = _today() today = _today()
bookings = db.scalars(select(Booking)).all() bookings = db.scalars(select(Booking)).all()
+23 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
@@ -16,6 +16,7 @@ from app.schemas import (
CurrentUser, CurrentUser,
DataQualityIssueDetailOut, DataQualityIssueDetailOut,
DataQualityIssueOut, DataQualityIssueOut,
DataQualityIssuePageOut,
MergeCustomersRequest, MergeCustomersRequest,
MergeCustomersResult, MergeCustomersResult,
ProvideFieldsRequest, ProvideFieldsRequest,
@@ -54,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
) )
@router.get("/issues", response_model=list[DataQualityIssueOut]) @router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
def list_issues( def list_issues(
status: str | None = Query(default=None), status: str | None = Query(default=None),
rule_type: str | None = Query(default=None), rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None), severity: str | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut]: ) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
if status: if status:
stmt = stmt.where(DataQualityIssue.status == status) stmt = stmt.where(DataQualityIssue.status == status)
@@ -69,8 +72,22 @@ def list_issues(
stmt = stmt.where(DataQualityIssue.rule_type == rule_type) stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity: if severity:
stmt = stmt.where(DataQualityIssue.severity == severity) stmt = stmt.where(DataQualityIssue.severity == severity)
issues = db.scalars(stmt).all() total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
return [_to_out(i) for i in issues] page_number = page or 1
issues = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [_to_out(i) for i in issues]
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return DataQualityIssuePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
# Every public reference in this system carries its entity type in its own prefix # Every public reference in this system carries its entity type in its own prefix
@@ -115,6 +132,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
return { return {
"entity_type": "vehicle", "entity_type": "vehicle",
"public_ref": vehicle.public_ref, "public_ref": vehicle.public_ref,
"registration_number": vehicle.registration_number,
"make": vehicle.make, "make": vehicle.make,
"model": vehicle.model, "model": vehicle.model,
"location": vehicle.location, "location": vehicle.location,
+3 -12
View File
@@ -4,16 +4,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings from app.schemas import CurrentUser, IntegrationStatusOut
from app.schemas import ( from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
CurrentUser,
IntegrationStatusOut,
McpHubIntegrationStatus,
)
from app.services.integration_status import derive_n8n_status
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
settings = get_settings()
@router.get("/status", response_model=IntegrationStatusOut) @router.get("/status", response_model=IntegrationStatusOut)
@@ -23,8 +17,5 @@ def integration_status(
) -> IntegrationStatusOut: ) -> IntegrationStatusOut:
return IntegrationStatusOut( return IntegrationStatusOut(
n8n=derive_n8n_status(db), n8n=derive_n8n_status(db),
mcp_hub=McpHubIntegrationStatus( mcp_hub=derive_mcp_hub_status(db),
registration_enabled=settings.mcp_hub_registration_enabled,
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
),
) )
+43 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
settings = get_settings() settings = get_settings()
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None: def get_correlation_id(
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
) -> str:
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
if x_correlation_id:
try:
return str(uuid.UUID(x_correlation_id))
except ValueError:
pass
return str(uuid.uuid4())
def _audit_service_request(
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
) -> None:
record_audit_event( record_audit_event(
db, db,
actor_type="service", actor_type="service",
actor_label=client_id, actor_label=client_id,
action="mcp_tool_request", action="mcp_tool_request",
entity_type="mcp_tool", entity_type="mcp_tool",
correlation_id=uuid.uuid4(), correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label}, metadata={"tool": tool, "status": status_label},
) )
db.commit() db.commit()
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
def operations_summary( def operations_summary(
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut: ) -> OperationsSummaryOut:
metrics = compute_metrics(db) metrics = compute_metrics(db)
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok" db,
client_id=client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
) )
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics) return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
@@ -59,12 +80,17 @@ def attention_vehicles(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]: ) -> list[AttentionVehicleOut]:
results = list_attention_vehicles( results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
) )
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok" db,
client_id=client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
) )
return [AttentionVehicleOut(**r) for r in results] return [AttentionVehicleOut(**r) for r in results]
@@ -74,14 +100,16 @@ def vehicle_details(
vehicle_ref: str, vehicle_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut: ) -> McpVehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, client_id=client_id,
tool="mobilityops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="not_found", status_label="not_found",
correlation_id=correlation_id,
) )
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404) raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
@@ -99,7 +127,11 @@ def vehicle_details(
) )
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok" db,
client_id=client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
) )
return McpVehicleDetailOut( return McpVehicleDetailOut(
public_ref=vehicle.public_ref, public_ref=vehicle.public_ref,
@@ -120,15 +152,16 @@ def search_knowledge(
body: McpKnowledgeSearchRequest, body: McpKnowledgeSearchRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer: ) -> GroundedAnswer:
provider = get_knowledge_provider() provider = get_knowledge_provider()
correlation_id = str(uuid.uuid4()) answer = provider.ask(body.question, correlation_id, language=body.locale)
answer = provider.ask(body.question, correlation_id)
answer.sources = answer.sources[: body.max_sources] answer.sources = answer.sources[: body.max_sources]
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, client_id=client_id,
tool="mobilityops_search_knowledge", tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state, status_label=answer.evidence_state,
correlation_id=correlation_id,
) )
return answer return answer
+38 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
@@ -19,6 +19,7 @@ from app.schemas import (
MaintenanceOut, MaintenanceOut,
VehicleDetailOut, VehicleDetailOut,
VehicleOut, VehicleOut,
VehiclePageOut,
) )
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set:
return set(rows) return set(rows)
@router.get("", response_model=list[VehicleOut]) @router.get("", response_model=list[VehicleOut] | VehiclePageOut)
def list_vehicles( def list_vehicles(
status: str | None = Query(default=None), status: str | None = Query(default=None),
attention_only: bool = Query(default=False), attention_only: bool = Query(default=False),
query: str | None = Query(default=None, min_length=1, max_length=100),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> list[VehicleOut]: ) -> list[VehicleOut] | VehiclePageOut:
stmt = select(Vehicle).order_by(Vehicle.public_ref) stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status: if status:
stmt = stmt.where(Vehicle.operational_status == status) stmt = stmt.where(Vehicle.operational_status == status)
vehicles = db.scalars(stmt).all() if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Vehicle.public_ref.ilike(term),
Vehicle.make.ilike(term),
Vehicle.model.ilike(term),
Vehicle.location.ilike(term),
Vehicle.registration_number.ilike(term),
)
)
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
out = [ if attention_only:
stmt = stmt.where(
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [
VehicleOut( VehicleOut(
public_ref=v.public_ref, public_ref=v.public_ref,
make=v.make, make=v.make,
@@ -62,9 +85,16 @@ def list_vehicles(
) )
for v in vehicles for v in vehicles
] ]
if attention_only: if page is None:
out = [v for v in out if v.attention] return items
return out total_pages = max(1, (total + page_size - 1) // page_size)
return VehiclePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.get("/{public_ref}", response_model=VehicleDetailOut) @router.get("/{public_ref}", response_model=VehicleDetailOut)
+15 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.errors import AppError from app.core.errors import AppError
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent, is_demo_scenario_failure
from app.schemas import AutomationRunOut, CurrentUser from app.schemas import AutomationRunOut, CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
@@ -24,6 +24,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
attempts=event.attempts, attempts=event.attempts,
last_error=event.last_error, last_error=event.last_error,
last_error_code=event.last_error_code, last_error_code=event.last_error_code,
is_demo_scenario=is_demo_scenario_failure(event),
occurred_at=event.occurred_at, occurred_at=event.occurred_at,
) )
@@ -63,15 +64,27 @@ def retry_workflow(
status_code=409, status_code=409,
) )
# Captured before the status flips, so the audit records what was actually retried.
was_demo_scenario = is_demo_scenario_failure(event)
event.delivery_status = "pending" event.delivery_status = "pending"
event.next_attempt_at = None event.next_attempt_at = None
# The retry itself is real either way: the event goes back on the outbox and the
# dispatcher delivers it to the configured n8n webhook like any other. The only
# difference recorded here is *what* was retried -- a staged demo failure or a real
# one -- so the audit trail never implies a production incident was resolved when a
# prop was.
record_audit_event( record_audit_event(
db, db,
actor_type="user", actor_type="user",
actor_label=user.display_name, actor_label=user.display_name,
action="workflow_retry", action="workflow_retry",
entity_type="outbox_event", entity_type="outbox_event",
metadata={"event_id": event_id, "previous_attempts": event.attempts}, metadata={
"event_id": event_id,
"previous_attempts": event.attempts,
"demo_scenario": was_demo_scenario,
},
) )
db.commit() db.commit()
return _to_out(event) return _to_out(event)
+5
View File
@@ -39,6 +39,11 @@ class Settings(BaseSettings):
knowledge_dir: str = "/app/knowledge/procedures" knowledge_dir: str = "/app/knowledge/procedures"
mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False mcp_hub_registration_enabled: bool = False
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
# these are only used for an honest reachability health check, not self-registration.
mcp_hub_base_url: str = ""
mcp_provider_id: str = "fleet-ops"
cors_allow_origins: str = "http://localhost:1228" cors_allow_origins: str = "http://localhost:1228"
demo_organization_name: str = "Northstar Mobility" demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels" demo_timezone: str = "Europe/Brussels"
+19
View File
@@ -10,6 +10,25 @@ from app.models.mixins import TimestampMixin
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed") DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
# The one delivery failure the demo seed deliberately plants (BK-H-0020, see
# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never
# be read as an integration-health problem: it is a scripted prop, not evidence that
# n8n is unhealthy. A dedicated error code -- rather than the generic
# "connectionError" a real timeout produces -- is what lets every reader tell the two
# apart without guessing from the message text.
#
# It is deliberately a `last_error_code` value and not a new column: the code is
# already persisted, already surfaced to the UI, and already localizable, so no schema
# change or migration is needed. A genuine later failure of this same event overwrites
# the code with the real one, which is exactly right -- from that moment it *is* a real
# failure.
DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout"
def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
"""True for the prepared demo failure, false for every real one."""
return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE
class OutboxEvent(TimestampMixin, Base): class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events" __tablename__ = "outbox_events"
+52 -3
View File
@@ -32,6 +32,14 @@ class VehicleOut(BaseModel):
attention: bool = False attention: bool = False
class VehiclePageOut(BaseModel):
items: list[VehicleOut]
page: int
page_size: int
total: int
total_pages: int
class BookingSummaryOut(BaseModel): class BookingSummaryOut(BaseModel):
public_ref: str public_ref: str
customer_ref: str customer_ref: str
@@ -122,6 +130,14 @@ class DataQualityIssueOut(BaseModel):
resolved_at: datetime | None = None resolved_at: datetime | None = None
class DataQualityIssuePageOut(BaseModel):
items: list[DataQualityIssueOut]
page: int
page_size: int
total: int
total_pages: int
class DataQualityIssueDetailOut(DataQualityIssueOut): class DataQualityIssueDetailOut(DataQualityIssueOut):
entity_snapshot: dict[str, Any] | None = None entity_snapshot: dict[str, Any] | None = None
related_snapshots: list[dict[str, Any]] = Field(default_factory=list) related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
@@ -266,11 +282,21 @@ class N8nIntegrationStatus(BaseModel):
dispatch_enabled: bool dispatch_enabled: bool
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
pending: int pending: int
delivering: int #: Every failed delivery, staged and real together -- the number a viewer sees in
#: the run list.
failed: int failed: int
#: Failures that were not planted by the demo seed. This is the only failure count
#: that may influence `state`.
unexpected_failed: int = 0
#: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`).
#: Present so the UI can label them instead of implying the automation is broken.
demo_scenario_failed: int = 0
delivering: int
succeeded: int succeeded: int
latest_success_at: datetime | None latest_success_at: datetime | None
#: Most recent *real* failure; a staged one never sets this.
latest_failure_at: datetime | None latest_failure_at: datetime | None
latest_demo_scenario_at: datetime | None = None
expected_workflow_count: int expected_workflow_count: int
known_workflow_count: int known_workflow_count: int
workflows: list[N8nWorkflowEvidence] workflows: list[N8nWorkflowEvidence]
@@ -279,7 +305,12 @@ class N8nIntegrationStatus(BaseModel):
class McpHubIntegrationStatus(BaseModel): class McpHubIntegrationStatus(BaseModel):
registration_enabled: bool registration_enabled: bool
state: Literal["not_configured", "configured"] state: Literal["not_configured", "no_evidence", "operational"]
total_calls: int
last_tool: str | None = None
last_client: str | None = None
last_called_at: datetime | None = None
hub_reachable: bool | None = None
class IntegrationStatusOut(BaseModel): class IntegrationStatusOut(BaseModel):
@@ -335,11 +366,16 @@ class DashboardMetrics(BaseModel):
pending_or_failed_workflows: int pending_or_failed_workflows: int
class EvidenceSignalOut(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class AttentionItem(BaseModel): class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"] kind: Literal["quality_issue", "vehicle"]
severity: str severity: str
rule_type: str rule_type: str
detail: str evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
link_type: Literal["vehicle", "booking", "customer"] link_type: Literal["vehicle", "booking", "customer"]
link_ref: str link_ref: str
issue_ref: str | None = None issue_ref: str | None = None
@@ -360,6 +396,10 @@ class AutomationRunOut(BaseModel):
attempts: int attempts: int
last_error: str | None last_error: str | None
last_error_code: str | None last_error_code: str | None
#: True for the deliberately seeded demo failure. The UI uses this to label the run
#: as a prepared scenario and to offer the demo retry, instead of presenting it as
#: an unexplained production error.
is_demo_scenario: bool = False
occurred_at: datetime occurred_at: datetime
@@ -399,6 +439,7 @@ class McpVehicleDetailOut(BaseModel):
class McpKnowledgeSearchRequest(BaseModel): class McpKnowledgeSearchRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000) question: str = Field(min_length=3, max_length=1000)
max_sources: int = Field(default=4, ge=1, le=8) max_sources: int = Field(default=4, ge=1, le=8)
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
class AuditEventOut(BaseModel): class AuditEventOut(BaseModel):
@@ -415,3 +456,11 @@ class AuditEventOut(BaseModel):
before: dict[str, Any] | None = None before: dict[str, Any] | None = None
after: dict[str, Any] | None = None after: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
class AuditEventPageOut(BaseModel):
items: list[AuditEventOut]
page: int
page_size: int
total: int
total_pages: int
+114 -33
View File
@@ -18,7 +18,7 @@ from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
@@ -139,47 +139,49 @@ def load_seed(db: Session) -> SeedResult:
vehicle_id_by_ref: dict[str, uuid.UUID] = {} vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = [] vehicle_rows = []
vehicle_row_by_ref: dict[str, dict] = {}
for row in _read_csv("vehicles.csv"): for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4() vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append( vehicle_row = {
{ "id": vid,
"id": vid, "public_ref": row["public_ref"],
"public_ref": row["public_ref"], "make": row["make"],
"make": row["make"], "model": row["model"],
"model": row["model"], "model_year": int(row["model_year"]),
"model_year": int(row["model_year"]), "registration_number": row["registration_number"],
"registration_number": row["registration_number"], "location": row["location"],
"location": row["location"], "operational_status": row["operational_status"],
"operational_status": row["operational_status"], "odometer_km": int(row["odometer_km"]),
"odometer_km": int(row["odometer_km"]), "next_service_km": int(row["next_service_km"]),
"next_service_km": int(row["next_service_km"]), "active": _parse_bool(row["active"]),
"active": _parse_bool(row["active"]), "version": 1,
"version": 1, }
} vehicle_rows.append(vehicle_row)
) vehicle_row_by_ref[row["public_ref"]] = vehicle_row
db.execute(insert(Vehicle), vehicle_rows) db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows) counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {} booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = [] booking_rows = []
booking_row_by_ref: dict[str, dict] = {}
for row in _read_csv("bookings.csv"): for row in _read_csv("bookings.csv"):
bid = uuid.uuid4() bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append( booking_row = {
{ "id": bid,
"id": bid, "public_ref": row["public_ref"],
"public_ref": row["public_ref"], "customer_id": customer_id_by_ref[row["customer_ref"]],
"customer_id": customer_id_by_ref[row["customer_ref"]], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], "starts_at": _parse_dt(row["starts_at"]) + shift,
"starts_at": _parse_dt(row["starts_at"]) + shift, "ends_at": _parse_dt(row["ends_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]) + shift, "status": row["status"],
"status": row["status"], "start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]), "end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]), "requirements_complete": _parse_bool(row["requirements_complete"]),
"requirements_complete": _parse_bool(row["requirements_complete"]), }
} booking_rows.append(booking_row)
) booking_row_by_ref[row["public_ref"]] = booking_row
db.execute(insert(Booking), booking_rows) db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows) counts["bookings"] = len(booking_rows)
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref] return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref] return "vehicle", vehicle_id_by_ref[entity_ref]
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
# handful of seed-only rows below -- none of them carry an active rental or a
# real booking conflict (verified against the fixed seed dataset), only a
# genuinely-crossed service threshold or none at all, so those two fields are
# the only ones that vary per vehicle.
vehicle = vehicle_row_by_ref[vehicle_ref]
return {
"active_booking_refs": [],
"overlapping_booking_pairs": [],
"service_threshold_reached": service_threshold_reached,
"odometer_km": vehicle["odometer_km"],
"next_service_km": vehicle["next_service_km"],
"open_booking_overlap_issue_ref": None,
}
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
later = booking_row_by_ref[later_ref]
earlier = booking_row_by_ref[earlier_ref]
return [
{
"code": "odometer.regression",
"params": {
"later_ref": later_ref,
"later_km": later["end_odometer_km"],
"earlier_ref": earlier_ref,
"earlier_km": earlier["end_odometer_km"],
},
}
]
def _missing_field_signal(field: str) -> list[dict]:
return [{"code": "missing_field", "params": {"field": field}}]
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
# used to carry no structured signal at all -- just the placeholder summary
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
# pair of booking odometer readings engineered into seed/bookings.csv), using the
# exact same signal vocabulary the live scan (app.services.data_quality) already
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
"DQ-0005": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
}
],
"DQ-0006": _missing_field_signal("location"),
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
"DQ-0008": [
{
"code": "vehicle.rental_ended",
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
}
],
"DQ-0009": _missing_field_signal("location"),
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
"DQ-0011": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
}
],
"DQ-0012": _missing_field_signal("registration_number"),
"DQ-0013": _missing_field_signal("location"),
"DQ-0014": _missing_field_signal("registration_number"),
"DQ-0015": _missing_field_signal("location"),
"DQ-0016": _missing_field_signal("location"),
"DQ-0017": _missing_field_signal("location"),
"DQ-0018": _missing_field_signal("registration_number"),
"DQ-0019": _missing_field_signal("location"),
"DQ-0020": _missing_field_signal("location"),
"DQ-0021": _missing_field_signal("location"),
}
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]: def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so # The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
# they carry real, accurate structured signals (not just a legacy English # they carry real, accurate structured signals (not just a legacy English
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
"params": {"booking_ref": related_refs[0] if related_refs else ""}, "params": {"booking_ref": related_refs[0] if related_refs else ""},
} }
] ]
return [] return _SEED_SIGNALS_BY_REF.get(public_ref, [])
dq_rows = [] dq_rows = []
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -323,7 +401,10 @@ def load_seed(db: Session) -> SeedResult:
"last_error": row["last_error"] or None, "last_error": row["last_error"] or None,
# The seed dataset's one synthetic failure (BK-H-0020) models a # The seed dataset's one synthetic failure (BK-H-0020) models a
# connection-timeout-style delivery failure -- see workflow_runs.csv. # connection-timeout-style delivery failure -- see workflow_runs.csv.
"last_error_code": "connectionError" if row["last_error"] else None, # It is coded as a *prepared demo scenario*, not as a real
# connectionError, so integration health never degrades because of a
# prop and a viewer is told plainly that this failure is staged.
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
"external_run_id": None, "external_run_id": None,
} }
) )
+13 -6
View File
@@ -11,7 +11,7 @@ from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
from app.services.integration_status import derive_n8n_status from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
from app.services.knowledge import get_knowledge_provider from app.services.knowledge import get_knowledge_provider
settings = get_settings() settings = get_settings()
@@ -124,6 +124,7 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
n8n = derive_n8n_status(db) n8n = derive_n8n_status(db)
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
mcp_hub = derive_mcp_hub_status(db)
return [ return [
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
@@ -141,17 +142,23 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
detail_code="ragcoreDetail", detail_code="ragcoreDetail",
detail_params={ detail_params={
"count": knowledge_health.document_count, "count": (
knowledge_health.document_count
if knowledge_health.document_count is not None
else "unknown"
),
"collection": knowledge_health.collection, "collection": knowledge_health.collection,
}, },
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="mcp_hub", key="mcp_hub",
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected", # `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
# to be called. Only real recorded `mcp_tool_request` calls make this
# "operational" -- same evidence rule the integration status page uses.
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
detail_code=( detail_code=(
"mcpDetailEnabled" "mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected"
if settings.mcp_hub_registration_enabled
else "mcpDetailNotConnected"
), ),
detail_params={}, detail_params={},
), ),
+112 -9
View File
@@ -2,18 +2,24 @@ from __future__ import annotations
from typing import Literal from typing import Literal
import httpx
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence from app.schemas import (
McpHubIntegrationStatus,
N8nErrorHandlerStatus,
N8nIntegrationStatus,
N8nWorkflowEvidence,
)
settings = get_settings() settings = get_settings()
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). Workflow 3 # The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
# (RAGcore Procedure Sync) is not built yet, so it always reports no evidence. # built (all with their full node set saved).
_CANONICAL_WORKFLOWS = ( _CANONICAL_WORKFLOWS = (
"Fleet Ops — Vehicle Return Orchestration", "Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan", "Fleet Ops — Scheduled Data Quality Scan",
@@ -33,19 +39,49 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
failed = counts.get("failed", 0) failed = counts.get("failed", 0)
succeeded = counts.get("succeeded", 0) succeeded = counts.get("succeeded", 0)
# Prepared demo failures are props, not health signals. They stay visible and
# counted -- hiding them would be its own kind of lie -- but they are counted
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
# "operational". Without this split the demo seed's single staged failure pins the
# integration to "degraded" forever, which tells a viewer something untrue about
# the automation.
demo_scenario_failed = (
db.scalar(
select(func.count())
.select_from(OutboxEvent)
.where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
)
or 0
)
unexpected_failed = max(failed - demo_scenario_failed, 0)
latest_success_at = db.scalar( latest_success_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
) )
# Health talks about real failures only, so the "latest failure" a health reader
# sees must exclude the staged one too.
latest_failure_at = db.scalar( latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
)
)
latest_demo_scenario_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
) )
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled: if not settings.n8n_dispatch_enabled:
state = "disabled" state = "disabled"
elif failed > 0 and succeeded == 0: elif unexpected_failed > 0 and succeeded == 0:
state = "unavailable" state = "unavailable"
elif failed > 0: elif unexpected_failed > 0:
state = "degraded" state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0: elif succeeded > 0 or pending > 0 or delivering > 0:
state = "operational" state = "operational"
@@ -61,6 +97,16 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
) )
) )
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
# procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(
AuditEvent.action == "n8n_procedures_synced"
)
)
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error # Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired # Handler" n8n workflow itself, which also doubles as proof that workflow is wired
# up and firing correctly. # up and firing correctly.
@@ -86,13 +132,13 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
evidence_by_workflow = { evidence_by_workflow = {
"Fleet Ops — Vehicle Return Orchestration": latest_success_at, "Fleet Ops — Vehicle Return Orchestration": latest_success_at,
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at, "Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
"Fleet Ops — RAGcore Procedure Sync": None, "Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at, "Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
} }
workflows = [ workflows = [
N8nWorkflowEvidence( N8nWorkflowEvidence(
name=name, name=name,
built=name != "Fleet Ops — RAGcore Procedure Sync", built=True,
last_seen_at=evidence_by_workflow[name], last_seen_at=evidence_by_workflow[name],
) )
for name in _CANONICAL_WORKFLOWS for name in _CANONICAL_WORKFLOWS
@@ -105,9 +151,12 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
pending=pending, pending=pending,
delivering=delivering, delivering=delivering,
failed=failed, failed=failed,
unexpected_failed=unexpected_failed,
demo_scenario_failed=demo_scenario_failed,
succeeded=succeeded, succeeded=succeeded,
latest_success_at=latest_success_at, latest_success_at=latest_success_at,
latest_failure_at=latest_failure_at, latest_failure_at=latest_failure_at,
latest_demo_scenario_at=latest_demo_scenario_at,
expected_workflow_count=len(_CANONICAL_WORKFLOWS), expected_workflow_count=len(_CANONICAL_WORKFLOWS),
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None), known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
workflows=workflows, workflows=workflows,
@@ -117,3 +166,57 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
latest_failure_workflow=latest_handler_failure_workflow, latest_failure_workflow=latest_handler_failure_workflow,
), ),
) )
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
total_calls = (
db.scalar(
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
)
or 0
)
latest_call_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
.where(AuditEvent.action == "mcp_tool_request")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
last_called_at = latest_call_row[0] if latest_call_row else None
last_client = latest_call_row[1] if latest_call_row else None
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
state: Literal["not_configured", "no_evidence", "operational"]
if not settings.mcp_hub_registration_enabled:
state = "not_configured"
elif total_calls > 0:
state = "operational"
else:
state = "no_evidence"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state=state,
total_calls=total_calls,
last_tool=last_tool,
last_client=last_client,
last_called_at=last_called_at,
hub_reachable=hub_reachable,
)
def _check_hub_reachable() -> bool | None:
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
its side, so this is the only thing Fleet Ops itself can honestly check).
`None` means not configured / not checked, never a guess."""
if not settings.mcp_hub_base_url:
return None
try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
return response.status_code == 200
except httpx.HTTPError:
return False
+3 -1
View File
@@ -33,7 +33,9 @@ class KnowledgeHealth(BaseModel):
tenant: str tenant: str
workspace: str workspace: str
collection: str collection: str
document_count: int # A provider may be healthy without exposing a corpus-size endpoint. `None` means
# unknown, never "zero procedures".
document_count: int | None
class KnowledgeProvider(Protocol): class KnowledgeProvider(Protocol):
+104 -5
View File
@@ -7,6 +7,20 @@ from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealt
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
# is deliberately left out of this sentence (it still appears on the source card itself).
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": 'Selon « {title} » : {excerpt}',
}
_DEFAULT_LANGUAGE = "en-GB"
class RAGcoreKnowledgeProvider: class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service, against its real `/v1/*` contract """Adapter for the central RAGcore service, against its real `/v1/*` contract
@@ -20,6 +34,14 @@ class RAGcoreKnowledgeProvider:
architecture's reliability boundary: RAGcore failure disables knowledge answers only, architecture's reliability boundary: RAGcore failure disables knowledge answers only,
never fabricates an answer, never affects the rest of the app. never fabricates an answer, never affects the rest of the app.
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
classifying the question as insufficiently answerable), `ask()` falls back to
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
stage with no generation step to fail. The fallback answer is always an extractive
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every 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 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). `/v1/uploads` contract has no per-file language field for a caller to set correctly).
@@ -64,9 +86,9 @@ class RAGcoreKnowledgeProvider:
tenant=self._settings.ragcore_tenant, tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
# RAGcore's retrieval API has no corpus-size endpoint to query honestly from # RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit
# here; left at 0 rather than approximated from a capped search result count. # so the UI never turns this into the misleading claim "0 procedures".
document_count=0, document_count=None,
) )
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
@@ -80,6 +102,22 @@ class RAGcoreKnowledgeProvider:
if not self._settings.ragcore_space_id: if not self._settings.ragcore_space_id:
return unavailable return unavailable
answered = self._ask_via_answers(question, correlation_id)
if answered is not None:
return answered
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
# real retrieval rather than degrading straight to "unavailable". This never
# fabricates an answer to the question: it only ever shows an actually-cited
# excerpt RAGcore's own search already found, using the same extractive
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
so the caller can fall back to search -- as opposed to a real 200 response
classifying the question as insufficiently answerable, which is a genuine,
final result, not a reason to fall back."""
try: try:
with self._client() as client: with self._client() as client:
response = client.post( response = client.post(
@@ -90,10 +128,10 @@ class RAGcoreKnowledgeProvider:
}, },
) )
if response.status_code != 200: if response.status_code != 200:
return unavailable return None
body = response.json() body = response.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
return unavailable return None
try: try:
citations = {c["id"]: c for c in body.get("citations", [])} citations = {c["id"]: c for c in body.get("citations", [])}
@@ -118,4 +156,65 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id, correlation_id=correlation_id,
) )
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):
return None
def _ask_via_search_fallback(
self, question: str, correlation_id: str, language: str
) -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
try:
with self._client() as client:
response = client.post(
"/v1/search",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"max_results": 5,
},
)
if response.status_code != 200:
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
return unavailable return unavailable
try:
results = body.get("results", [])
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
]
except (TypeError, KeyError, ValueError):
return unavailable
if not sources:
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
lead = sources[0]
answer = template.format(title=lead.title, excerpt=lead.excerpt)
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
+11
View File
@@ -23,6 +23,17 @@ def test_audit_requires_operations_manager(employee_client):
assert response.status_code == 403 assert response.status_code == 403
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert body["page"] == 1
assert body["page_size"] == 25
assert body["total"] >= len(body["items"])
assert body["total_pages"] >= 1
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str: def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db = SessionLocal() db = SessionLocal()
try: try:
+16
View File
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
assert item["severity"] in ("low", "medium", "high") assert item["severity"] in ("low", "medium", "high")
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
The API must never emit prose here -- only stable signal codes + params, exactly
like the data-quality issue detail page, for the frontend to localize."""
response = ops_client.get("/api/v1/dashboard")
body = response.json()
assert len(body["attention_items"]) > 0
for item in body["attention_items"]:
assert "detail" not in item
assert len(item["evidence_signals"]) > 0
for signal in item["evidence_signals"]:
assert signal["code"]
assert signal["code"] != "Synthetic deterministic seed issue"
def test_dashboard_recent_automation_capped_at_five(ops_client): def test_dashboard_recent_automation_capped_at_five(ops_client):
response = ops_client.get("/api/v1/dashboard") response = ops_client.get("/api/v1/dashboard")
body = response.json() body = response.json()
+27
View File
@@ -53,6 +53,18 @@ def test_list_issues_requires_operations_manager(employee_client):
assert response.status_code == 403 assert response.status_code == 403
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
response = ops_client.get(
"/api/v1/data-quality/issues",
params={"severity": "high", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert all(issue["severity"] == "high" for issue in body["items"])
assert body["total"] >= len(body["items"])
def test_get_issue_requires_operations_manager(employee_client): def test_get_issue_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 403 assert response.status_code == 403
@@ -220,6 +232,21 @@ def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
assert vehicle["registration_number"] == "TST-999" assert vehicle["registration_number"] == "TST-999"
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
"""The snapshot used to omit registration_number entirely, so the 'provide missing
fields' form always showed it blank -- even for a vehicle whose plate was actually
on file, and even when a *different* field was the genuinely missing one."""
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "missing_required_field", "status": "open"},
).json()
target = next(i for i in issues if i["entity_type"] == "vehicle")
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
def test_resolve_overlap_requires_operations_manager(employee_client): def test_resolve_overlap_requires_operations_manager(employee_client):
response = employee_client.post( response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", "/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
+76 -6
View File
@@ -1,3 +1,24 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.outbox import OutboxEvent
from app.seed_loader import reset_and_seed
def _reseed() -> None:
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
The suite shares one session-scoped database and earlier files legitimately mutate
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
rather than depend on file ordering.
"""
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
def test_integration_status_requires_operations_manager(employee_client): def test_integration_status_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/integrations/status") response = employee_client.get("/api/v1/integrations/status")
assert response.status_code == 403 assert response.status_code == 403
@@ -9,6 +30,7 @@ def test_integration_status_requires_authentication(client):
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client): def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
_reseed()
response = ops_client.get("/api/v1/integrations/status") response = ops_client.get("/api/v1/integrations/status")
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
@@ -16,20 +38,65 @@ def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
n8n = body["n8n"] n8n = body["n8n"]
assert n8n["dispatch_enabled"] is True assert n8n["dispatch_enabled"] is True
assert n8n["succeeded"] >= 1 assert n8n["succeeded"] >= 1
# The seeded failure stays visible and counted...
assert n8n["failed"] >= 1 assert n8n["failed"] >= 1
# The seed deliberately carries both failed and succeeded events, so a single most- assert n8n["demo_scenario_failed"] == 1
# recent-event read would misreport health -- the aggregate must call this "degraded", # ...but it is a prepared prop, so it is not an unexpected failure and must not
# not "operational" or "unavailable". # move the integration off "operational". A staged failure that degrades the
assert n8n["state"] == "degraded" # health badge tells a viewer something untrue about the automation.
assert n8n["unexpected_failed"] == 0
assert n8n["state"] == "operational"
assert n8n["latest_success_at"] is not None assert n8n["latest_success_at"] is not None
assert n8n["latest_failure_at"] is not None # "latest failure" is a health signal, so the staged one never sets it; it is
# reported separately instead.
assert n8n["latest_failure_at"] is None
assert n8n["latest_demo_scenario_at"] is not None
mcp_hub = body["mcp_hub"] mcp_hub = body["mcp_hub"]
assert mcp_hub["registration_enabled"] is False assert mcp_hub["registration_enabled"] is False
assert mcp_hub["state"] == "not_configured" assert mcp_hub["state"] == "not_configured"
def test_a_real_failure_still_degrades_the_integration(ops_client):
"""The demo carve-out must be narrow: a failure that is not the prepared scenario
still degrades n8n, otherwise this change would hide real breakage."""
_reseed()
db = SessionLocal()
try:
real_failure = db.scalar(
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
)
assert real_failure is not None
restore = (real_failure.delivery_status, real_failure.last_error_code)
real_failure.delivery_status = "failed"
real_failure.last_error_code = "connectionError"
db.commit()
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert n8n["unexpected_failed"] == 1
assert n8n["state"] == "degraded"
assert n8n["latest_failure_at"] is not None
finally:
real_failure.delivery_status, real_failure.last_error_code = restore
db.commit()
db.close()
def test_prepared_demo_failure_is_reset_back_by_a_demo_reset(ops_client):
"""A demo reset must recreate the intended 19 succeeded + 1 prepared failure, so the
scenario can be shown again after it has been retried away."""
_reseed()
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert n8n["succeeded"] == 19
assert n8n["failed"] == 1
assert n8n["demo_scenario_failed"] == 1
assert n8n["unexpected_failed"] == 0
assert n8n["state"] == "operational"
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client): def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
for run in failed: for run in failed:
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry") retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
@@ -52,7 +119,10 @@ def test_integration_status_lists_all_four_canonical_workflows(ops_client):
"Fleet Ops — Workflow Error Handler", "Fleet Ops — Workflow Error Handler",
} }
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"]) ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
assert ragcore_sync["built"] is False # All 4 canonical workflows are built (all 6 nodes saved live). This fresh test run
# has reported no real sync result yet, so -- like the other three workflows before
# their own first real signal -- there is no run evidence yet either.
assert ragcore_sync["built"] is True
assert ragcore_sync["last_seen_at"] is None assert ragcore_sync["last_seen_at"] is None
+7
View File
@@ -257,3 +257,10 @@ def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id] matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
assert len(matching) == 1 assert len(matching) == 1
assert matching[0]["after"] == {"synced": 33, "failed": 1} assert matching[0]["after"] == {"synced": 33, "failed": 1}
# The workflow's own callback is its evidence -- same pattern the scheduled scan and
# error handler already use -- so this real report must now show up as run evidence
# in the integration status, not stay hardcoded to "no evidence yet".
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
assert ragcore_sync["last_seen_at"] is not None
+108 -3
View File
@@ -168,9 +168,14 @@ class _FakeResponse:
class _FakeClient: class _FakeClient:
def __init__(self, get_response=None, post_response=None, raise_on=None): def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None):
self._get_response = get_response self._get_response = get_response
self._post_response = post_response self._post_response = post_response
# Maps a path (e.g. "/v1/search") to its own response, for tests that need
# /v1/answers and /v1/search to behave differently in the same call. Falls back
# to the single post_response when a path has no specific entry, so every
# existing single-endpoint test keeps working unchanged.
self._post_responses = post_responses or {}
self._raise_on = raise_on self._raise_on = raise_on
def __enter__(self): def __enter__(self):
@@ -187,7 +192,7 @@ class _FakeClient:
def post(self, path, json=None): def post(self, path, json=None):
if self._raise_on == "post": if self._raise_on == "post":
raise httpx.ConnectError("no ragcore in this environment") raise httpx.ConnectError("no ragcore in this environment")
return self._post_response return self._post_responses.get(path, self._post_response)
def test_ragcore_provider_degrades_to_unavailable(monkeypatch): def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
@@ -338,10 +343,110 @@ def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch): def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider() provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
# A malformed /v1/answers body triggers the same search fallback a real outage
# would, so the fallback's own /v1/search response must also be malformed here to
# exercise "the whole backend is misbehaving, not just one endpoint" honestly.
monkeypatch.setattr( monkeypatch.setattr(
provider, provider,
"_client", "_client",
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})), lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(200, {"citations": "not-a-list"}),
"/v1/search": _FakeResponse(200, {"results": "not-a-list"}),
}
),
) )
answer = provider.ask("Anything?", "test-correlation-malformed") answer = provider.ask("Anything?", "test-correlation-malformed")
assert answer.evidence_state == "unavailable" assert answer.evidence_state == "unavailable"
def _search_body(**overrides) -> dict:
body = {
"results": [
{
"chunk_id": "chunk-1",
"citation": {
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Vehicle return procedure",
"section": "Return",
"excerpt": "Register the return odometer reading before releasing the vehicle.",
},
"rank": 1,
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
}
],
"degraded": False,
}
body.update(overrides)
return body
def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch):
"""/v1/answers itself failing (a real RAGcore-side outage in its generation step,
not a real 'insufficient evidence' classification) must not silently degrade
straight to 'unavailable' when RAGcore's own retrieval still works -- it should show
the real, cited excerpt search actually found instead."""
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback")
assert answer.evidence_state == "grounded"
assert "Register the return odometer reading" in answer.answer
assert "Vehicle return procedure" in answer.answer
assert len(answer.sources) == 1
assert answer.sources[0].title == "Vehicle return procedure"
assert answer.sources[0].excerpt == (
"Register the return odometer reading before releasing the vehicle."
)
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask(
"Wat is de procedure voor een voertuigretour?",
"test-correlation-fallback-nl",
language="nl-BE",
)
assert answer.evidence_state == "grounded"
assert answer.answer.startswith("Volgens ")
def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body(results=[])),
}
),
)
answer = provider.ask("Unrelated question?", "test-correlation-fallback-empty")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
+42
View File
@@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
assert events[0]["actor_type"] == "service" assert events[0]["actor_type"] == "service"
def test_search_knowledge_respects_requested_locale(client):
response = client.post(
"/api/v1/integrations/mcp/search-knowledge",
json={
"question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"max_sources": 1,
"locale": "nl-BE",
},
headers=_headers(),
)
assert response.status_code == 200
body = response.json()
assert body["evidence_state"] == "grounded"
def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client):
inbound = "11111111-1111-1111-1111-111111111111"
response = client.post(
"/api/v1/integrations/mcp/search-knowledge",
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound},
)
assert response.status_code == 200
assert response.json()["correlation_id"] == inbound
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["correlation_id"] == inbound]
assert len(matching) == 1
def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client):
response = client.get(
"/api/v1/integrations/mcp/operations-summary",
headers=_headers(client_id="no-correlation-probe"),
)
assert response.status_code == 200
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"]
assert len(matching) >= 1
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
def test_no_write_endpoints_exist_under_mcp_namespace(client): def test_no_write_endpoints_exist_under_mcp_namespace(client):
for method, path in [ for method, path in [
("post", "/api/v1/integrations/mcp/vehicles/MO-016"), ("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
+111 -6
View File
@@ -7,7 +7,7 @@ from app.models.audit import AuditEvent
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
@@ -22,13 +22,16 @@ def test_seed_counts_match_deterministic_dataset():
reset_and_seed(db) reset_and_seed(db)
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50 assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
assert db.scalar(select(func.count()).select_from(Customer)) == 180 assert db.scalar(select(func.count()).select_from(Customer)) == 180
assert db.scalar(select(func.count()).select_from(Booking)) == 246 # 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The # real day of traffic rather than the same fixed 4 rows on every reset.
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches assert db.scalar(select(func.count()).select_from(Booking)) == 254
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
# real open issue) plus a deterministic set discovered by the post-seed scan. The
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has # MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
# already crossed its service-due odometer threshold -- a genuine conflict the # already crossed its service-due odometer threshold -- a genuine conflict the
# previous hand-rolled scanner never checked for. # previous hand-rolled scanner never checked for.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27 assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20 assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
assert db.scalar(select(func.count()).select_from(User)) == 2 assert db.scalar(select(func.count()).select_from(User)) == 2
finally: finally:
@@ -142,7 +145,10 @@ def test_seed_scenario_s5_failed_workflow_run():
assert failed.delivery_status == "failed" assert failed.delivery_status == "failed"
assert failed.attempts >= 1 assert failed.attempts >= 1
assert failed.last_error assert failed.last_error
assert failed.last_error_code == "connectionError" # Coded as a prepared demo scenario, not as a real connectionError: the whole
# point of this row is to demonstrate retry and audit, so nothing downstream
# may read it as evidence that the n8n integration is unhealthy.
assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE
finally: finally:
db.close() db.close()
@@ -174,3 +180,102 @@ def test_seed_dates_are_anchored_to_reset_moment():
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat() assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
finally: finally:
db.close() db.close()
def test_seed_today_movements_are_a_credible_mix():
"""A fresh reset must not land on a thin, always-identical 'Today's movements'
dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across
more than 4 distinct vehicles) should fall on the reset day, mirroring the same
status/date rule the dashboard router uses to build the today list."""
db = SessionLocal()
try:
reset_and_seed(db)
today = datetime.now(UTC).date()
bookings = db.scalars(select(Booking)).all()
departures = [
b
for b in bookings
if b.starts_at.date() == today and b.status in ("reserved", "active")
]
returns = [
b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")
]
assert len(departures) >= 5
assert len(returns) >= 5
vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns}
assert len(vehicles_involved) > 4
finally:
db.close()
def test_every_blocked_vehicle_has_a_real_open_issue():
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
vehicle seeded as 'blocked' must now have at least one real, currently open
DataQualityIssue an operator can click through to."""
db = SessionLocal()
try:
reset_and_seed(db)
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
assert len(blocked) > 0
for vehicle in blocked:
open_issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
)
)
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
finally:
db.close()
def test_seed_scenario_mo024_service_conflict_is_flagged():
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
with nothing surfacing the contradiction. The shared vehicle-status evaluator
already catches this (a real conflict, not a fabricated third status) -- this test
exists so a future change can't silently regress it back to unexplained."""
db = SessionLocal()
try:
reset_and_seed(db)
vehicle = _by_ref(db, Vehicle, "MO-024")
assert vehicle is not None
assert vehicle.operational_status == "rented"
assert vehicle.odometer_km >= vehicle.next_service_km
issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "vehicle_status_conflict",
)
)
assert issue is not None
signals = issue.evidence_json.get("signals", [])
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
finally:
db.close()
def test_seed_evidence_has_no_placeholder_summary():
"""Every seed-only data-quality issue used to carry the vacuous evidence
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
had no way to understand why it needed attention. Every issue must now carry a real
summary and at least one localizable signal (code + params)."""
db = SessionLocal()
try:
reset_and_seed(db)
issues = db.scalars(select(DataQualityIssue)).all()
assert len(issues) > 0
for issue in issues:
summary = issue.evidence_json.get("summary", "")
assert summary != "Synthetic deterministic seed issue", (
f"{issue.public_ref} still has the meaningless placeholder summary"
)
signals = issue.evidence_json.get("signals", [])
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
finally:
db.close()
+12
View File
@@ -14,6 +14,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
assert all(v["attention"] for v in vehicles) assert all(v["attention"] for v in vehicles)
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
response = ops_client.get(
"/api/v1/vehicles",
params={"status": "maintenance", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert all(v["operational_status"] == "maintenance" for v in body["items"])
assert body["total"] >= len(body["items"])
def test_vehicle_detail_includes_related_records(ops_client): def test_vehicle_detail_includes_related_records(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-016") response = ops_client.get("/api/v1/vehicles/MO-016")
assert response.status_code == 200 assert response.status_code == 200
+59
View File
@@ -1,9 +1,28 @@
from app.core.db import SessionLocal
from app.seed_loader import reset_and_seed
def _reseed() -> None:
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
The suite shares one session-scoped database and earlier files legitimately mutate
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
rather than depend on file ordering.
"""
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
def test_list_workflows_requires_operations_manager(employee_client): def test_list_workflows_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/workflows") response = employee_client.get("/api/v1/workflows")
assert response.status_code == 403 assert response.status_code == 403
def test_list_workflows_includes_seeded_failed_run(ops_client): def test_list_workflows_includes_seeded_failed_run(ops_client):
_reseed()
response = ops_client.get("/api/v1/workflows", params={"status": "failed"}) response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
assert response.status_code == 200 assert response.status_code == 200
runs = response.json() runs = response.json()
@@ -20,6 +39,7 @@ def test_retry_requires_failed_status(ops_client):
def test_retry_failed_run_moves_to_pending_and_audits(ops_client): def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
target = failed[0]["event_id"] target = failed[0]["event_id"]
@@ -36,3 +56,42 @@ def test_retry_requires_operations_manager(employee_client):
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry" "/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
) )
assert response.status_code == 403 assert response.status_code == 403
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
"""The one seeded failure must announce itself as staged. An unexplained red row in
a demo reads as a broken product; a labelled one reads as the retry story it is."""
_reseed()
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
demo_runs = [run for run in runs if run["is_demo_scenario"]]
assert len(demo_runs) == 1
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
assert runs
assert all(run["is_demo_scenario"] is False for run in runs)
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
"""The retry is a real redelivery either way; the audit records which kind of
failure it resolved so a staged retry is never mistaken for a production fix."""
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
demo_run = next(run for run in failed if run["is_demo_scenario"])
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
assert response.status_code == 200
assert response.json()["status"] == "pending"
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
entry = next(
event
for event in audit
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
== demo_run["event_id"]
)
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
assert metadata["demo_scenario"] is True
+2
View File
@@ -36,6 +36,8 @@ services:
N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token} N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token} MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
MCP_HUB_REGISTRATION_ENABLED: ${MCP_HUB_REGISTRATION_ENABLED:-false}
MCP_HUB_BASE_URL: ${MCP_HUB_BASE_URL:-}
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
+14 -8
View File
@@ -1,17 +1,20 @@
{ {
"_note": "Fleet Ops's own published tool contract. The live ITWorx MCP Hub connector (ITWorx_MCP_Hub repo, connectors/mobilityops/) wraps these under its own dotted namespace (mobilityops.operations.summary, .attention.list, .vehicle.get, .knowledge.search) -- that naming is Hub-owned. deprecated_aliases below are Fleet Ops's own prior internal audit-label names, kept only so existing clients/dashboards referencing them don't break.",
"provider_id": "mobilityops", "provider_id": "mobilityops",
"version": "1.0.0", "version": "1.1.0",
"required_scope": "mobilityops.read", "required_scope": "mobilityops.read",
"tools": [ "tools": [
{ {
"name": "mobilityops_get_operations_summary", "name": "fleet_ops_get_operations_summary",
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic MobilityOps demo tenant.", "deprecated_aliases": ["mobilityops_get_operations_summary"],
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic Fleet Ops demo tenant.",
"read_only": true, "read_only": true,
"inputSchema": {"type": "object", "additionalProperties": false}, "inputSchema": {"type": "object", "additionalProperties": false},
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"}
}, },
{ {
"name": "mobilityops_list_attention_vehicles", "name": "fleet_ops_list_attention_vehicles",
"deprecated_aliases": ["mobilityops_list_attention_vehicles"],
"description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.", "description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
@@ -26,7 +29,8 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"}
}, },
{ {
"name": "mobilityops_get_vehicle_details", "name": "fleet_ops_get_vehicle_details",
"deprecated_aliases": ["mobilityops_get_vehicle_details"],
"description": "Return a read-only operational view of one vehicle by its stable public reference.", "description": "Return a read-only operational view of one vehicle by its stable public reference.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
@@ -38,15 +42,17 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"}
}, },
{ {
"name": "mobilityops_search_knowledge", "name": "fleet_ops_search_knowledge",
"description": "Search versioned MobilityOps internal procedures through the dedicated RAGcore workspace and return grounded source references.", "deprecated_aliases": ["mobilityops_search_knowledge"],
"description": "Search versioned Fleet Ops internal procedures through the dedicated RAGcore workspace and return grounded source references.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["question"], "required": ["question"],
"properties": { "properties": {
"question": {"type": "string", "minLength": 3, "maxLength": 1000}, "question": {"type": "string", "minLength": 3, "maxLength": 1000},
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4} "max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4},
"locale": {"enum": ["nl-BE", "en-GB", "fr-BE"], "default": "en-GB"}
}, },
"additionalProperties": false "additionalProperties": false
}, },
+33 -9
View File
@@ -6,20 +6,44 @@ Publish four read-only MobilityOps capabilities through the existing central ITW
## Provider registration ## Provider registration
- provider ID: `mobilityops` - provider ID: `mobilityops` (registered on the Hub side; the Hub's own registration is
- API base: configurable internal MobilityOps API URL catalog-driven — it reconciles its catalog into the gateway, Fleet Ops never pushes a
- authentication: scoped service token registration call)
- API base: internal Fleet Ops API URL, reached via `MCP_HUB_SERVICE_TOKEN` auth
- authentication: scoped service token (`X-Service-Token`), plus `X-Client-Id`
- mode: read-only - mode: read-only
- required scope: `mobilityops.read` - **Confirmed live in production** on the ITWorx MCP Hub's own deployment (Tower), with
a real contract fix already applied there (`vehicle.get`'s wire parameter normalized to
camelCase `vehicleRef`). `docs/final-integrations/current-state-audit.md` has the full
evidence.
## Tools ## Tools
The machine-readable definitions are in `contracts/mcp-tools.json`. The machine-readable definitions are in `contracts/mcp-tools.json`. The Hub's own live
connector (`ITWorx_MCP_Hub` repo, `connectors/mobilityops/`) publishes these under its
own dotted namespace — that naming is the Hub's to own, not Fleet Ops's:
1. `mobilityops_get_operations_summary` 1. `mobilityops.operations.summary`
2. `mobilityops_list_attention_vehicles` 2. `mobilityops.attention.list`
3. `mobilityops_get_vehicle_details` 3. `mobilityops.vehicle.get`
4. `mobilityops_search_knowledge` 4. `mobilityops.knowledge.search`
Fleet Ops's own internal audit trail (`AuditEvent.metadata_json.tool`, visible on
`/api/v1/audit?action=mcp_tool_request`) labels these calls `fleet_ops_get_operations_summary`,
`fleet_ops_list_attention_vehicles`, `fleet_ops_get_vehicle_details`,
`fleet_ops_search_knowledge` — a separate, Fleet-Ops-owned naming layer for its own audit
log, not the wire-level MCP tool name a client calls.
`search-knowledge` accepts a `locale` field (`nl-BE` | `en-GB` | `fr-BE`, default
`en-GB`) that is passed straight through to the active knowledge provider.
## Correlation ID
The inbound `X-Correlation-Id` header (set by the Hub, itself either forwarding the
MCP client's ID or minting one) is preserved through Fleet Ops's own handling and audit
log; Fleet Ops only mints a fresh correlation ID when none is supplied or the supplied
value isn't a valid UUID. See `get_correlation_id` in
`backend/app/api/routers/mcp_integrations.py`.
## Routing ## Routing
+31
View File
@@ -67,3 +67,34 @@ secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for st
- supports explicit manual retry; - supports explicit manual retry;
- preserves last error and response metadata; - preserves last error and response metadata;
- does not hold a database transaction open during network I/O. - does not hold a database transaction open during network I/O.
## Prepared demo failure versus real failure
The demo seed deliberately plants exactly one failed delivery (`BK-H-0020`, see
`seed/workflow_runs.csv`). It exists to demonstrate retry and audit, so it must never be
read as evidence that the automation is unhealthy.
It is distinguished by its `last_error_code`, `demoScenarioTimeout`
(`app.models.outbox.DEMO_SCENARIO_ERROR_CODE`) — not by a new column, so no migration is
involved. A real timeout produces `connectionError`; the two are never confused.
Consequences, all enforced by tests:
- `/api/v1/integrations/status` reports `failed` (everything), `unexpected_failed` (real
failures only) and `demo_scenario_failed` separately.
- Only `unexpected_failed` can move n8n off `operational`. A prepared failure alone
leaves the integration **operational** — a staged prop may not raise a red flag.
- `latest_failure_at` is a health signal and therefore ignores the prepared failure;
`latest_demo_scenario_at` reports it separately.
- `/api/v1/workflows` marks the run with `is_demo_scenario: true`. The Automation page
labels it "Prepared demo scenario", explains that it is a simulated temporary failure,
and offers a distinct "Retry demo scenario" action.
- A genuine later failure of that same event overwrites the code with the real one, and
from that moment it counts as a real failure — the carve-out is narrow by construction.
The retry itself is real in both cases: the event goes back on the outbox and the
dispatcher delivers it to the configured n8n webhook like any other, so 19 succeeded +
1 failed becomes 20 succeeded + 0 failed only when n8n genuinely accepts the delivery.
Nothing is marked succeeded without a real round trip. The audit entry records
`demo_scenario: true/false` so a staged retry is never mistaken for a production fix.
A demo reset recreates the original 19 + 1 scenario.
+6 -1
View File
@@ -36,7 +36,12 @@ Vehicle `MO-016` has two imported overlapping reservations. Expected: visible qu
### S5 — Failed workflow ### S5 — Failed workflow
One seeded outbox/workflow record is failed with a safe simulated connection error. Expected: dashboard and Automation page show it; Operations Manager can retry. One seeded outbox/workflow record is failed with a safe simulated connection error, coded
`demoScenarioTimeout` so it is recognisable as a prepared scenario rather than a real
incident. Expected: dashboard and Automation page show it, labelled as a prepared demo
scenario; n8n stays "Operational"; the Operations Manager can retry it, after which the
overview reads 20 succeeded and 0 failed. See `docs/11-n8n-integration.md`, "Prepared demo
failure versus real failure".
### S6 — Grounded damage question ### S6 — Grounded damage question
+509
View File
@@ -0,0 +1,509 @@
# MobilityOps visual product roadmap
## Purpose
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
implementation plan. It improves the existing operational workflows without
expanding the locked product scope.
The intended outcome is a platform that:
- lets an operator identify and act on urgent work within thirty seconds;
- remains efficient with the current synthetic data set and larger realistic data sets;
- keeps status, context and primary actions visible on desktop and mobile;
- uses a readable, consistent visual hierarchy;
- communicates integration and knowledge health without contradictory signals;
- preserves the existing domain rules, audit guarantees and graceful degradation.
## Scope guardrails
Included:
- information architecture and visual hierarchy;
- list density, pagination, filtering and responsive presentation;
- dashboard, detail, return, data-quality, knowledge, automation, audit and demo flows;
- accessibility, perceived performance and UI observability;
- regression tests, visual evidence and rollout safeguards.
Excluded:
- new accounting, payments, CRM, inventory, HR or reservation modules;
- maintenance work orders, calendar views or advanced analytics;
- changes to RAGcore or ITWorx MCP Hub repositories;
- autonomous write actions or write-capable MCP tools;
- a redesign of backend domain rules that is not required by the UI work.
## Audit baseline
The audit was performed against the deployed application at desktop, tablet and
mobile widths. The current strengths to preserve are the professional visual
identity, consistent status language, strong login page, semantic page structure,
visible keyboard focus, absence of horizontal overflow and clear return and
data-quality narratives.
The principal improvement signals are:
| Area | Baseline observation | Consequence |
|---|---|---|
| Audit log | Up to 100 expanded event cards; approximately 18,500 px desktop and 20,500 px mobile | Poor scanability and excessive scrolling |
| Vehicle list | All 50 vehicles render at once; approximately 12,000 px on mobile | Operational lookup is slow on small screens |
| Mobile page header | `.page-actions` is hidden below 700 px | Important record status disappears |
| Typography | Many metadata labels and badges are approximately 911 px | Readability is weaker than the visual quality suggests |
| Top bar | Search, language, demo controls, disclosure, timezone and operator controls compete for space | High cognitive load and weak prioritisation |
| Knowledge | An operational provider can be shown beside “0 procedures indexed” | Trust signal is ambiguous or contradictory |
| Integration cards | Technical identifiers and small prose dominate the summary | Operational state is harder to scan |
These values are baselines, not permanent acceptance thresholds. Each affected
phase must record a before-and-after measurement.
## Delivery principles
1. Fix operational friction before decorative polish.
2. Prefer progressive disclosure over removing useful evidence.
3. Keep the primary status and next action visible at every viewport.
4. Paginate or virtualise unbounded collections; never solve them only with CSS.
5. Derive every displayed count and state from persisted or external evidence.
6. Preserve URLs, permissions, audit semantics and keyboard operation.
7. Ship each phase as a coherent, independently reversible commit.
8. Validate every phase at 390 px, 768 px and 1440 px.
## Target measures
The roadmap is complete when the following targets are met:
| Measure | Target |
|---|---|
| Initial rows/cards in any operational list | At most 25, unless a documented compact virtualised view is used |
| Mobile status visibility | Primary record status visible on every detail page |
| Horizontal overflow | None at 360 px and above |
| Default text | At least 14 px |
| Secondary metadata | At least 12 px; exceptions require an accessibility justification |
| Touch target | At least 44 by 44 CSS px for primary mobile controls |
| Keyboard access | All actions reachable with visible focus and logical order |
| List navigation | Filter, paging and search state represented in the URL where practical |
| Dashboard comprehension | Primary operational risks and next movements visible without scrolling at 1440 × 900 |
| Browser console | No application errors during the five-minute demo |
| Performance | No avoidable rendering of more than one page of list records |
| Automated acceptance | Existing backend, frontend and Playwright gates remain green |
## Roadmap overview
| Phase | Theme | Priority | Indicative effort | Depends on |
|---|---|---:|---:|---|
| R0 | Baseline and design-system guardrails | P0 | 23 days | None |
| R1 | Operational lists and audit log | P0 | 57 days | R0 |
| R2 | Responsive shell and readable hierarchy | P0 | 46 days | R0 |
| R3 | Dashboard and record-detail efficiency | P1 | 57 days | R1, R2 |
| R4 | Guided workflows and decision surfaces | P1 | 57 days | R2, R3 |
| R5 | Knowledge, automation and trust signals | P1 | 35 days | R2 |
| R6 | Accessibility, performance and release evidence | P0 gate | 46 days | R1R5 |
Indicative total: 2841 focused engineering days. Phases can span multiple
calendar sprints, but their exit gates should not be split across releases.
## R0 — Baseline and design-system guardrails
### Objective
Create shared UI rules and reproducible measurements before changing individual
pages.
### Work packages
#### R0.1 — Visual regression baseline
- Capture authenticated reference screenshots for the seven principal routes and
the login page at 390 × 844, 768 × 1024 and 1440 × 900.
- Add stable screenshot fixtures using the deterministic seed.
- Mask only genuinely variable timestamps; do not mask operational content.
- Record viewport width, page height, visible item count and horizontal overflow.
#### R0.2 — Typography and spacing tokens
- Replace scattered sub-12 px declarations with named type tokens.
- Establish body, metadata, label, badge, table-header and navigation sizes.
- Define compact and comfortable density variants without duplicating page CSS.
- Preserve the existing petrol/teal palette and status colours.
#### R0.3 — Shared responsive patterns
- Define a mobile page-header contract: title, visible status, primary action and
overflow actions.
- Define reusable compact-list, filter-toolbar, pagination and empty-state patterns.
- Establish 44 px mobile target sizing and a consistent sticky-offset system.
### Acceptance criteria
- Visual baselines exist for all principal routes and viewports.
- No normal operational metadata is smaller than 12 px.
- Shared components document their responsive behaviour.
- Existing Playwright demo and frontend build pass unchanged.
## R1 — Operational lists and audit log
### Objective
Make high-volume operational information scannable and bounded on every device.
### Work packages
#### R1.1 — Audit API pagination and filtering
- Replace the default fixed `limit=100` response with cursor or page-based pagination.
- Add filters for date range, action, actor, entity type, entity reference and
correlation ID.
- Retain stable newest-first ordering and service-token protections.
- Return total or continuation metadata suitable for the selected paging model.
#### R1.2 — Compact audit timeline
- Render 2025 compact events per page.
- Group correlated events and repetitive scheduled events without hiding their count.
- Move full UUIDs, before/after data and technical metadata into a details drawer.
- Keep human-readable action, actor, target, outcome and timestamp in the main row.
- Preserve direct navigation to related records.
#### R1.3 — Vehicle-list pagination
- Add server-compatible pagination to the vehicle list.
- Preserve status, location, attention and search filters across pages.
- Store filter and page state in the URL.
- Provide a prominent “attention required” view without inventing new metrics.
- Use compact mobile rows rather than full table-to-card expansion for every vehicle.
#### R1.4 — Data-quality queue controls
- Paginate the open issue list.
- Add severity and rule grouping/filtering.
- Add “previous issue” and “next issue” navigation to review pages.
- Keep scan and resolution actions permission-aware.
### Acceptance criteria
- No default list renders more than 25 records.
- Audit and vehicle pages remain below 3,500 px at the tested mobile viewport with
the deterministic seed.
- Paging and filters survive reload, back navigation and direct links.
- Empty, loading, error and end-of-results states are explicit.
- API tests cover invalid cursors/pages, combined filters and authorization.
## R2 — Responsive shell and readable hierarchy
### Objective
Reduce navigation noise and keep essential context visible on small screens.
### Work packages
#### R2.1 — Top-bar simplification
- Keep global search and current operational context primary.
- Move language, timezone and sign-out into the operator menu.
- Combine demo disclosure and demo progress into one compact control.
- Replace the visually empty mobile search field with an explicit search button or
a full-width command surface.
#### R2.2 — Mobile page actions
- Stop hiding the complete page-action region.
- Always show record status next to or below the title.
- Keep one primary action visible and move secondary actions to an accessible menu.
- Verify booking, vehicle, data-quality and automation detail headers independently.
#### R2.3 — Navigation refinement
- Review whether the desktop sidebar should remain available at wider tablet widths.
- Increase mobile navigation label size and touch area.
- Keep four or five primary destinations visible and put lower-frequency destinations
under “More” if six items cannot meet readability targets.
- Preserve current routes and role-based visibility.
#### R2.4 — Typography rollout
- Apply the R0 type tokens to badges, tables, integration cards, timelines, forms and
mobile navigation.
- Rebalance padding where larger text would otherwise increase page height excessively.
- Verify Dutch, French and English labels for clipping and wrapping.
### Acceptance criteria
- Status remains visible on every sampled mobile detail page.
- No essential action is hover-only or hidden solely because of viewport width.
- Top-bar controls fit without clipping at 390, 768 and 1440 px.
- Language changes do not introduce horizontal overflow.
- Touch-target and typography targets are met.
## R3 — Dashboard and record-detail efficiency
### Objective
Put operational decisions above supporting detail and make desktop space work harder.
### Work packages
#### R3.1 — Dashboard hierarchy
- Keep readiness and highest-severity attention items visible in the first viewport.
- Reduce the vertical weight of the demo-scenario panel after the scenario starts.
- Limit the initial attention queue and link to a complete filtered view.
- Balance “movements today” against attention content when only a few movements exist.
- Keep integrations and recent activity available without duplicating status copy.
#### R3.2 — Mobile dashboard summary
- Convert readiness into a compact, horizontally accessible summary or disclosure.
- Show the highest-priority attention items first with an explicit remaining count.
- Surface the next departure and return before secondary integration information.
#### R3.3 — Vehicle-detail workspace
- Replace the sparse overview with a two-column desktop composition.
- Surface current status, location, odometer, next booking and active attention items.
- Keep bookings, inspections, maintenance, quality issues and audit as focused tabs.
- Avoid new maintenance-domain functionality; link only to existing evidence.
#### R3.4 — Booking-detail summary
- Rebalance the facts grid so incomplete final rows do not create large empty bands.
- On mobile, show status and return readiness before secondary booking facts.
- Keep the return form close to the task entry point while retaining validation context.
### Acceptance criteria
- At 1440 × 900 the dashboard exposes readiness, urgent work and today's movement
context without scrolling.
- Sparse states remain intentional and balanced rather than appearing unfinished.
- All values remain derived from persisted data.
- Existing return and status-domain tests remain unchanged and green.
## R4 — Guided workflows and decision surfaces
### Objective
Reduce the distance between evidence and the decision an operator must make.
### Work packages
#### R4.1 — Return flow refinement
- Keep the step indicator visible while completing or reviewing a return.
- Use a compact mobile booking summary above the form.
- Present validation errors next to fields and in a short focusable summary.
- Preserve the post-submit explanation of inspection, vehicle status, quality issue,
queued automation and next-booking risk.
#### R4.2 — Duplicate-customer decision layout
- Use evidence/comparison on the left and a sticky survivor/merge preview on desktop.
- Keep matching fields collapsed by default and differences expanded.
- Keep irreversible-action language and audit preview adjacent to the merge button.
- On mobile, use a deliberate sequence: evidence, differences, survivor, preview,
confirmation.
#### R4.3 — Other data-quality resolutions
- Standardise evidence, proposed resolution, consequence and audit-preview sections.
- Keep resolve, reject and defer actions visually distinct and permission-aware.
- Provide clear success feedback and navigation to the next issue.
#### R4.4 — Guided demo integration
- Reduce competition between the guide and normal navigation.
- Persist progress without obscuring page controls.
- Make “go to this step” land on the relevant element or focused task state.
- Ensure the guide is fully keyboard-operable and usable as a mobile bottom sheet.
### Acceptance criteria
- Primary workflow action is visible or reachable with one obvious interaction.
- A user never needs to scroll past repeated explanatory content to discover the
required decision.
- Focus moves to validation failures and success results appropriately.
- The five-minute demo remains deterministic.
## R5 — Knowledge, automation and trust signals
### Objective
Make external-system health understandable to an operator without exposing irrelevant
technical detail.
### Work packages
#### R5.1 — Knowledge status semantics
- Separate provider availability, indexed-source count and last successful sync.
- Never show “0 indexed” as a healthy equivalent when the value is unknown or stale.
- Label extractive fallback answers honestly while preserving source cards.
- Reduce empty-state height and show a compact explanation of what a cited answer contains.
- Keep suggested questions concise and prioritised.
#### R5.2 — Source and freshness presentation
- Surface source title, version, section and freshness consistently.
- Keep document UUIDs and raw retrieval metadata behind technical details.
- Provide explicit grounded, insufficient-evidence and unavailable states.
#### R5.3 — Integration summary redesign
- Structure each integration around state, last success, affected workflow and next action.
- Move client IDs, raw endpoints and UUIDs to a detail disclosure.
- Remove unnecessary fixed card height, especially at tablet widths.
- Keep genuine mixed states visible without presenting them as contradictions.
#### R5.4 — Workflow evidence table
- Improve status and recency scanning.
- Distinguish “never triggered”, “no evidence yet”, “failed” and “unavailable”.
- Keep retry actions safe, audited and available only where already supported.
### Acceptance criteria
- Knowledge and integration states cannot make mutually contradictory claims.
- An operator can identify the current state and next action within one card scan.
- Technical identifiers do not dominate default views.
- RAGcore, n8n and MCP Hub timeout/unavailable contract tests stay green.
## R6 — Accessibility, performance and release evidence
### Objective
Turn the improvements into a verified, releasable quality baseline.
### Work packages
#### R6.1 — Accessibility review
- Test complete keyboard traversal of login, navigation, filters, return, merge,
knowledge, workflow retry and audit details.
- Verify focus order, focus restoration, dialog labelling and error announcements.
- Run automated accessibility checks on all principal routes.
- Manually verify status is not encoded by colour alone.
- Verify zoom at 200% and 360 px responsive reflow.
#### R6.2 — Rendering and interaction performance
- Measure list rendering before and after pagination.
- Check layout shift and interaction latency on dashboard and long-list routes.
- Ensure drawers, menus and guided-demo panels do not mount large hidden subtrees.
- Test with reduced motion and a throttled CPU profile.
#### R6.3 — Cross-browser and localisation pass
- Verify current Chrome plus one additional Chromium/Firefox-equivalent target where
supported by the test environment.
- Run Dutch, French and English visual checks at all target widths.
- Verify Europe/Brussels date and time rendering.
#### R6.4 — Final automated journey and evidence
- Extend the existing Playwright demo with pagination, mobile status and accessible
details checks.
- Capture final screenshots for all principal pages.
- Add before-and-after measurements and known PoC limitations to final evidence.
- Execute clean-checkout bootstrap, backend tests, lint, typing and frontend build.
### Acceptance criteria
- No critical or serious automated accessibility violations on principal routes.
- All target measures in this document pass.
- `docs/14-testing-and-acceptance.md` passes from a clean checkout.
- `artifacts/evidence/final-summary.md` contains the final commands, counts,
screenshots and limitations.
## Suggested release slices
### Release A — Operational scale
Contains R0 and R1. This release eliminates the most severe scroll and list-density
problems without materially changing workflow structure.
Release gate:
- paginated audit, vehicles and data-quality queues;
- URL-backed filters;
- unchanged audit and authorization semantics;
- visual regression baseline green.
### Release B — Responsive operations
Contains R2 and R3. This release improves navigation, typography, dashboard hierarchy
and detail-page use of space.
Release gate:
- visible mobile statuses and primary actions;
- readable typography and touch targets;
- dashboard first-viewport target met;
- Dutch, French and English responsive checks green.
### Release C — Guided decisions and trust
Contains R4 and R5. This release refines the return, merge, knowledge, automation and
demo-guide experiences.
Release gate:
- decision actions remain near their evidence;
- honest and non-contradictory external health states;
- five-minute demo passes end to end.
### Release D — Quality certification
Contains R6 and only defects found by its gates. It adds no new product scope.
Release gate:
- full clean-checkout acceptance;
- accessibility and performance targets met;
- final evidence updated;
- release tag and deployed revision recorded.
## Implementation sequence and dependencies
```text
R0 shared tokens and baselines
├─ R1 pagination and compact lists ─┐
└─ R2 responsive shell and type ───┼─ R3 dashboard/details ─ R4 workflows
└─ R5 trust surfaces
R1 + R2 + R3 + R4 + R5 ──────────────────────────────── R6 release gate
```
R1 and R2 may be developed in parallel after R0. R3 should consume both shared list
and responsive patterns. R4 depends on the new page-header and detail hierarchy. R5 can
start after R2 but must share its disclosure and status patterns. R6 is a gate, not a
cleanup bucket; defects discovered there return to the owning phase.
## Per-phase engineering checklist
Every phase must include:
1. a short before-state measurement;
2. API and data-contract impact assessment;
3. implementation using shared components where applicable;
4. unit/API tests for changed contracts;
5. Playwright coverage for the changed user journey;
6. manual checks at 390, 768 and 1440 px in all supported languages;
7. accessibility and console check;
8. updated evidence and `PROJECT_STATE.md` entry;
9. one coherent commit after validation passes;
10. deployment verification before starting the next release slice.
## Risks and mitigations
| Risk | Mitigation |
|---|---|
| Pagination changes API consumers | Add pagination metadata without silently changing service-token/MCP response contracts; version only if necessary |
| Compact layouts hide evidence | Use drawers and disclosures, preserving direct access and auditability |
| Larger type increases page height | Combine type changes with density and hierarchy work, never shrink text again |
| Sticky UI covers content on mobile | Use shared top/bottom offsets and test keyboard/zoom states |
| Visual snapshots become brittle | Seed deterministic data and mask only true timestamp volatility |
| External health data is stale | Show last successful evidence and distinguish unknown from healthy |
| Roadmap drifts into new product scope | Check every work package against `docs/01-scope-and-non-goals.md` and `docs/deferred.md` |
## Definition of roadmap completion
This roadmap is complete only when Releases A through D are deployed and verified,
all target measures pass, the original five-minute demo remains green, and the final
evidence records the exact deployed revision. Completing only the visual styling or one
high-priority page does not complete the roadmap.
+5 -3
View File
@@ -65,9 +65,11 @@ generated and kept fresh across resets.
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over `RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
the same interface once a real RAGcore backend is available — swapping providers is a the same interface once a real RAGcore backend is available — swapping providers is a
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change. configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
- **ITWorx MCP Hub**: not connected. Registration is disabled by default - **ITWorx MCP Hub**: the UI reports "Operational" only when Fleet Ops has really
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" — recorded `mcp_tool_request` calls in its own audit log. `MCP_HUB_REGISTRATION_ENABLED`
never a fabricated successful registration. on its own never makes it operational — registration is catalog-driven on the Hub's
side, so the flag alone is not evidence of anything, and a successful registration is
never fabricated.
## Where to go next ## Where to go next
@@ -0,0 +1,87 @@
# AI Operations Brief — runbook and live evidence
Answers, using a real MCP client through the real ITWorx MCP Hub connector against live
production Fleet Ops: *"Which vehicles need the most attention today, why, and which
internal procedure should be followed for the most important issue?"*
## What this is not
Not a chatbot. No write actions exist under `/api/v1/integrations/mcp/*` (verified by
`test_no_write_endpoints_exist_under_mcp_namespace`). Every call below is a plain
read-only tool invocation, exactly as the deployed Hub connector performs them.
## Reproducible command
Run from inside the live `itworx-mcp-hub-connector-mobilityops-1` container (has the
real `MOBILITYOPS_ENDPOINT`, `MOBILITYOPS_SERVICE_TOKEN_FILE` and the real
`packages.connector_kit.mobilityops.MobilityOpsClient` already available — the same
client class the deployed connector uses):
```sh
docker exec -e PYTHONPATH=/opt/hub -w /opt/hub \
itworx-mcp-hub-connector-mobilityops-1 python3 - <<'PY'
from packages.connector_kit.mobilityops import FileTokenProvider, MobilityOpsClient, MobilityOpsSettings
import os, json
settings = MobilityOpsSettings(
base_url=os.environ["MOBILITYOPS_ENDPOINT"],
token_reference=os.environ["MOBILITYOPS_SERVICE_TOKEN_FILE"],
)
client = MobilityOpsClient(settings, FileTokenProvider())
client_id = "ai-ops-brief"
summary = client.operations_summary(client_id)
attention = client.attention_vehicles(client_id, minimum_severity="high", date=None, limit=20)
top_ref = attention[0]["vehicle_ref"] if attention else None
details = client.vehicle_details(client_id, top_ref) if top_ref else None
answer = client.search_knowledge(client_id, "What must I do when a vehicle returns with damage?", max_sources=2)
print(json.dumps({"summary": summary, "top_attention": attention[0] if attention else None, "vehicle": details, "answer": answer}, indent=2))
client.close()
PY
```
This calls, in order: `fleet_ops_get_operations_summary``fleet_ops_list_attention_vehicles`
`fleet_ops_get_vehicle_details``fleet_ops_search_knowledge`. No result is invented —
every field printed is exactly what Fleet Ops's live API returned.
## Live run, 2026-08-05
- **Operations summary**: 21 available, 11 rented, 6 cleaning, 5 maintenance,
**7 blocked**; 23 open quality issues; 1 pending/failed workflow.
- **Most pressing vehicle**: `MO-031``missing_required_field`, "near-future booking;
required operational inspection missing", detected `2026-08-05T11:08:33Z`. (16 vehicles
currently carry a `high`-severity open issue; `MO-031` was the earliest-detected.)
- **Vehicle detail (`MO-031`)**: Adria Matrix, 2022, Geel, `operational_status: blocked`,
41,149 km (service due at 50,000 km), no current booking.
- **Grounded procedure (English)**: *Damage handling procedure* v1.3, section "1.
Immediate actions" — "When a vehicle returns with visible or reported damage, mark
damage in the return inspection, add a concise factual description and keep the
vehicle blocked. Do not promise the customer a repair cost or liability decision."
Second source: *Vehicle return procedure* v2.0, section "3. Determine next state".
`evidence_state: grounded`, 2 real citations, `provider: demo`.
- **Dutch and French variants of the damage question** (`Wat moet ik doen wanneer een
voertuig beschadigd terugkomt?`, `Que dois-je faire lorsqu'un véhicule revient
endommagé ?`) both returned `evidence_state: insufficient` — an honest, non-fabricated
"no match" rather than a wrong or invented answer. Root cause: the currently-deployed
Hub connector does not yet send the new `locale` field this session added to
`search-knowledge` (Fleet Ops defaults to `en-GB`), so non-English question text
doesn't match the demo provider's English-tokenized index. A real fix needs a Hub-side
connector update to pass `locale`, tracked as a follow-up, not silently worked around.
- **Correlation ID, end to end, verified**: each call's response `correlation_id` (e.g.
`abd643a7-2aeb-4af3-806a-da04acb3e444` for the English grounded answer) appears
verbatim in Fleet Ops's own audit log (`GET /api/v1/audit?action=mcp_tool_request`),
alongside `actor_label: ai-ops-brief-2026-08-05` and the real tool name
(`fleet_ops_search_knowledge`). No write actions were performed; only `mcp_tool_request`
audit rows were created, matching every other live MCP call this integration makes.
## Known limitation, stated plainly
The demo knowledge provider (not RAGcore — `KNOWLEDGE_PROVIDER=demo`, see
`docs/final-integrations/current-state-audit.md` for why) is what grounds the English
answer here. It is deterministic, extractive, and never fabricates — but it is not the
live RAGcore integration the brief brief for this task set out to exercise; that
remains blocked on RAGcore's own reranker gap. This run is honest about that: it proves
the full MCP-client → Hub → Fleet Ops → knowledge-provider → audit chain works for real,
live, in production, with real data and real citations — using the knowledge provider
that is actually configured live today.
@@ -0,0 +1,152 @@
# Current-state audit — Fleet Ops final integrations
Date: 2026-08-05. Compiled from direct repository inspection (git log/status/diff across
all three repos), `PROJECT_STATE.md` history, and read-only investigation of the sibling
repos' own state docs. No live server SSH/curl evidence is included in this pass yet —
see `integration-release-state.md` for the live-verification checklist as it is executed.
## Repository revisions at audit time
| Repo | Path | Branch | HEAD | Notes |
|---|---|---|---|---|
| Fleet Ops (MobilityOps) | `C:\Projects\MobilityOps` | `feat/fleet-ops-final-integrations` (new, branched from `feat/live-n8n-ragcore-integration`) | `3ebca9e` | `feat/live-n8n-ragcore-integration` was pushed to `origin` at `0571a40` and deployed live; `3ebca9e` (logo rebrand) is one commit ahead, not yet deployed. `master` is 19 commits behind and stale (localization-round only). |
| RAGcore | `C:\Projects\RAGcore` | `main` | `64a908a` | Up to date with `origin/main`. Uncommitted local work in progress (see below) — not Fleet-Ops-related, left untouched. |
| ITWorx MCP Hub | `C:\Projects\ITWorx_MCP_Hub` | `feature/wp240-final-acceptance` | `26e6bd8` (+ later `75bb16a`) | Contains `f107544` (MobilityOps connector) as a direct ancestor, plus a real contract fix (`96de385`, vehicleRef camelCase). Already deployed live to Tower at `c4a0f6d`. |
## Branch-name correction (recorded assumption)
The task brief names the working branch `feat/fleet-ops-final-integrations` as already
selected and "branched from the most recently validated, localized, deployed master
branch." That literal branch did not exist. `master` is in fact stale (19 commits behind,
last touched for a localization round only) — the actually-validated, deployed line of
work is `feat/live-n8n-ragcore-integration` (pushed to `origin`, deployed to
`http://192.168.10.150:1236` at `0571a40`, one commit behind current HEAD). Created
`feat/fleet-ops-final-integrations` from that branch's HEAD (`3ebca9e`) instead of from
`master`, since that satisfies the actual intent (continue from the validated/deployed
line) even though the literal branch name in the brief was inaccurate.
## What is actually already done (contradicts "not yet live" framing in places)
- **n8n**: 3 of 4 canonical workflows are live and active in the shared instance
(`n8n.itworx.tech`): Vehicle Return Orchestration, Scheduled Data Quality Scan,
Workflow Error Handler. The 4th, RAGcore Procedure Sync, has all 6 nodes built and
saved but is **not published** (deliberately left for an explicit activation decision,
since publishing starts real unattended daily runs against production). The root cause
of an earlier "auth"-looking failure (`N8N_PROXY_HOPS=0` behind the TLS-terminating
reverse proxy, breaking the browserId CSRF check on every mutating REST call) was found
and fixed at the infrastructure level (Unraid template), not worked around.
- **RAGcore**: deployed to `http://192.168.10.150:1237`, application wiring for
search/context/answer is real (commit `a2905cc`, confirmed present in RAGcore's own
history at `13 commits behind HEAD`). `KNOWLEDGE_PROVIDER` is still `demo` in Fleet Ops
because real queries against the "Fleet Ops Procedures" space return **zero dense and
zero sparse candidates** at the raw retrieval stage — confirmed not a Fleet-Ops-side
wiring bug (RAGcore's own trusted Query Lab tool reproduces the identical zero-candidate
result against the same space). Root cause not yet found as of this audit; ruled out so
far: point count/scoping (83 published, correctly scoped), embedding digest mismatch
(matches), collection alias resolution (resolves correctly). One separate, confirmed,
pre-existing bug: `_DEFAULT_LANGUAGE = "en"` is hardcoded in RAGcore's ingestion handler
— every chunk is stamped `language: "en"` regardless of actual content; RAGcore has never
done real language detection. Not the cause of zero candidates, but must be fixed for
trilingual retrieval (task 6A) once the space is answerable at all.
- **MCP Hub**: the Fleet Ops read-only connector (4 tools, `mobilityops.*`) is **already
live in production** on Tower (commit `c4a0f6d`), reachable via `fleetops.itworx.tech`,
end-to-end verified once already per the Hub's own `CLAUDE.md`/`BUILD_STATE.json`. A
real contract bug was found and fixed there (`vehicle.get`'s input schema disagreed with
the actual wire parameter name — `vehicle_ref` vs `vehicleRef`). What is **not** yet done:
the Hub's own formal production-acceptance checklist row for MobilityOps (`CON-P04`) has
not been executed, and Fleet Ops's own `MCP_HUB_REGISTRATION_ENABLED`/base-URL
configuration has not been confirmed as actually wired and flipped on from the Fleet Ops
side (open item for this audit's Batch 4).
## Confirmed contradictions to resolve (task section 3)
- "Twee versus vier n8n-workflows": resolved above — 3 active + 1 built-but-unpublished.
Canonical set is 4; only 3 are live.
- "Demo-provider versus live RAGcore": Fleet Ops is still on the demo knowledge provider
by deliberate, documented decision (not an oversight) pending the retrieval root cause.
- "MCP Hub-status": prior Fleet Ops docs (`.env.example`, `MCP_HUB_REGISTRATION_ENABLED`)
predate the Hub-side deployment and need reconciling against the fact that the connector
is already live on the Hub side.
- Repo hygiene: removed an untracked, empty `backend;C` directory and an untracked 31 MB
`MobilityOps.zip` stray export; added `*.zip`/`*.tar.gz` to `.gitignore`. No accidentally
committed `__pycache__`/`.pytest_cache`/`test-results` were found in git history.
## RAGcore retrieval root cause — found and partially fixed (2026-08-05, this session)
Investigated live against production (`192.168.10.150`, containers `ragcore-app-1`,
`ragcore-qdrant-1`, `ragcore-postgres-1`, `ollama`), read-only first, then two approved
live changes.
**Root cause #1 (FIXED): filesystem permission bug, not authorization/data.** Verified,
in order, that every earlier suspect was actually healthy: the `control.grants` row
(active, `editor` role, correct application/space), the real
`ControlPlaneAuthorizationInputsProvider` + `RetrievalAuthorizationService.resolve()` code
path run in-process against the live DB (resolves a non-empty `effective_space_ids`), the
exact production Qdrant filter run directly against the live collection (returns real
matching points), and a real ANN vector query under that filter (real hits, sensible
scores). The actual break: `/workspace/.state/models/embedding_profiles.json` — the file
`RetrievalPipeline.run()` reads on every single query to resolve the active embedding
profile — was owned by container-side `root:root` mode `600` on the bind-mounted
`/mnt/cache/appdata/ragcore/state/models` host path, while the real running app process
is uid 10001 (`ragcore`). Every retrieval call hit a `PermissionError` reading its own
state file before ever reaching Qdrant — a plain filesystem-ownership bug, invisible to
every DB/Qdrant-level check. **Fixed live**: `chown 10001:10001` +
`chmod 644`/`755` on that file/directory (approved by the user beforehand). Re-verified
in-process: `RetrievalPipeline.run()` now returns 5 real, relevant hits for an English
damage-procedure question (previously 0).
**Root cause #2 (found, NOT fixed — needs a design decision): reranking is
architecturally unavailable.** `DEFAULT_RERANKER_PROFILE.model_identifier` is
`bge-reranker-v2-m3:v1`, which was never actually present in Ollama's model list (0 of 14
installed models matched). With the user's approval, pulled a working GGUF
(`xitao/bge-reranker-v2-m3:latest`, 1.2 GB) into the shared Ollama instance. **This did
not fix reranking**: `OllamaRerankAdapter` posts to `{ollama_base_url}/api/rerank`, and
this Ollama server (version `0.32.5`) returns a plain `404` for that route — it has no
rerank endpoint at all. This is not a missing-model problem, it is that RAGcore's
reranker adapter was built against an Ollama HTTP API that does not exist in the deployed
version (matches the code's own comment that no reranker-profile registry or live
validation existed yet). The retrieval pipeline degrades gracefully on rerank failure
(RRF-fusion-only hits still returned, confirmed above), but the `/v1/answers` endpoint's
answerability classifier still returns `not_answerable`/0 citations for real NL/EN/FR
questions against real matching content, live-verified after fix #1 with a freshly
minted, correctly-scoped credential.
Options for #2, not decided yet: (a) find/confirm whether a newer Ollama version adds a
real `/api/rerank` route and upgrade the shared instance (affects every other project on
this Ollama — needs its own explicit approval and blast-radius review); (b) change
RAGcore's reranker adapter to call a route Ollama actually supports (e.g. score via
`/api/embed` + a manual similarity/cross-encoder computation, or drop the separate
rerank step and let the answerability classifier trust RRF-fused scores) — a RAGcore
code/design change, out of Fleet Ops's own mandate to decide unilaterally; (c) leave
`KNOWLEDGE_PROVIDER=demo` until RAGcore's own team/session resolves this.
**Side effect to flag**: minting the live-verification credential used `rotate=True` on
the existing "Fleet Ops Knowledge Assistant (production)" service account (a second
credential would have exceeded RAGcore's own 2-active-credential cap), which invalidates
whatever token was previously issued for that account. Since Fleet Ops is still on
`KNOWLEDGE_PROVIDER=demo`, this has no live user-facing impact today, but a fresh
credential must be issued and wired into Fleet Ops's `RAGCORE_API_TOKEN` at actual
cutover time — do not assume the old one still works.
**Concurrency note**: `C:\Projects\RAGcore` had substantial uncommitted local changes
from what appears to be a different, actively-running session (36 modified/untracked
files by the end of this investigation, including files this investigation also read).
No commits or file edits were made in that checkout this session precisely because of
that collision risk — the two live fixes above were applied directly to the running
containers/Ollama instance (approved), not to the RAGcore git repository. **Follow-up
required**: once the concurrent session's work lands, the reranker-profile fix (whichever
option above is chosen) still needs an actual code change + commit + redeploy in
`C:\Projects\RAGcore`, which was not safe to do mid-collision this session.
## Minimal remaining implementation order
1. Root-cause the RAGcore zero-candidate retrieval bug (blocks flipping `KNOWLEDGE_PROVIDER`
and blocks the trilingual live-acceptance and AI Operations Brief tasks).
2. Fix RAGcore's hardcoded `language: "en"` chunk metadata for trilingual retrieval.
3. Decide on and execute n8n workflow 3 publication, with live no-op-on-rerun verification.
4. Confirm/complete Fleet Ops-side MCP Hub registration wiring and run the Hub's own
CON-P04 acceptance row.
5. Build the AI Operations Brief runbook once RAGcore and MCP Hub are both live-green.
6. GUI polish batch (dashboard Today/Attention presentation, duplicate-merge presentation,
About Demo scannability, Demo Guide completion state).
7. Final regression gates and evidence write-up.
+5 -1
View File
@@ -302,7 +302,11 @@ test("automation shows a localized error explanation with the raw error only und
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible(); await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible(); await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
await page.getByText("Technische details").first().click(); // Scope to the failed job's own row -- the workflow-evidence table above it also has
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
// the wrong one.
const failedJobRow = page.locator("tr", { has: page.getByText(/tijdelijk niet bereikbaar/) });
await failedJobRow.getByText("Technische details").click();
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible(); await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
}); });
+1
View File
@@ -144,6 +144,7 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
"knowledge.retrievalFlow.question", "knowledge.retrievalFlow.question",
"knowledge.questionLabelExchange", "knowledge.questionLabelExchange",
"returns.result.inspection", // "Inspection" -- identical spelling in French "returns.result.inspection", // "Inspection" -- identical spelling in French
"integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and French
]); ]);
function isTranslatableProse(value: unknown): value is string { function isTranslatableProse(value: unknown): value is string {
+5 -4
View File
@@ -243,11 +243,12 @@ test("data quality: manual scan runs and shows a result summary", async ({ page,
test("automation page: status filter and retry button work", async ({ page, request }) => { test("automation page: status filter and retry button work", async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await page.goto("/automation"); await page.goto("/automation");
await expect(page.locator(".data-table")).toBeVisible(); const jobsTable = page.getByRole("table", { name: "Automation jobs" });
await expect(jobsTable).toBeVisible();
await page.getByLabel("Status").selectOption("failed"); await page.getByLabel("Status").selectOption("failed");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
const failedRowCountBefore = await page.locator(".data-table tbody tr").count(); const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
const retryButton = page.getByRole("button", { name: "Retry" }).first(); const retryButton = page.getByRole("button", { name: "Retry" }).first();
await expect(retryButton).toBeVisible(); await expect(retryButton).toBeVisible();
await retryButton.click(); await retryButton.click();
@@ -256,7 +257,7 @@ test("automation page: status filter and retry button work", async ({ page, requ
// filtered to "failed" — the row correctly disappears from this view rather than // filtered to "failed" — the row correctly disappears from this view rather than
// showing "pending" in place. Confirm the filtered list shrank by one. // showing "pending" in place. Confirm the filtered list shrank by one.
await expect(async () => { await expect(async () => {
const count = await page.locator(".data-table tbody tr").count(); const count = await jobsTable.locator("tbody tr").count();
expect(count).toBe(failedRowCountBefore - 1); expect(count).toBe(failedRowCountBefore - 1);
}).toPass({ timeout: 5000 }); }).toPass({ timeout: 5000 });
}); });
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
expect(vehicles.ok()).toBeTruthy();
const vehiclePage = await vehicles.json();
expect(vehiclePage.items).toHaveLength(25);
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
expect(audit.ok()).toBeTruthy();
const auditPage = await audit.json();
expect(auditPage.items.length).toBeLessThanOrEqual(25);
await page.goto("/vehicles?status=maintenance&page=1");
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
await expect(page).toHaveURL(/status=maintenance/);
});
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.locator(".page-actions .badge")).toBeVisible();
await page.goto("/vehicles");
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
});
+3 -2
View File
@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a" /> <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="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="white" />
<path d="M3 15.5h26" stroke="#2dd4bf" stroke-width="2.5" /> <circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 344 B

+32 -3
View File
@@ -20,6 +20,14 @@ export interface Vehicle {
attention: boolean; attention: boolean;
} }
export interface Page<T> {
items: T[];
page: number;
page_size: number;
total: number;
total_pages: number;
}
export interface BookingSummary { export interface BookingSummary {
public_ref: string; public_ref: string;
customer_ref: string; customer_ref: string;
@@ -85,11 +93,16 @@ export interface DashboardMetrics {
pending_or_failed_workflows: number; pending_or_failed_workflows: number;
} }
export interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
export interface AttentionItem { export interface AttentionItem {
kind: string; kind: string;
severity: "low" | "medium" | "high"; severity: "low" | "medium" | "high";
rule_type: string; rule_type: string;
detail: string; evidence_signals: EvidenceSignal[];
link_type: "vehicle" | "booking" | "customer"; link_type: "vehicle" | "booking" | "customer";
link_ref: string; link_ref: string;
issue_ref: string | null; issue_ref: string | null;
@@ -110,6 +123,10 @@ export interface AutomationRun {
attempts: number; attempts: number;
last_error: string | null; last_error: string | null;
last_error_code: string | null; last_error_code: string | null;
/** True for the failure the demo seed plants on purpose (see the backend's
* DEMO_SCENARIO_ERROR_CODE). Drives the "prepared scenario" labelling instead of
* showing it as an unexplained production error. */
is_demo_scenario: boolean;
occurred_at: string; occurred_at: string;
} }
@@ -251,7 +268,7 @@ export interface KnowledgeHealth {
tenant: string; tenant: string;
workspace: string; workspace: string;
collection: string; collection: string;
document_count: number; document_count: number | null;
} }
export interface N8nWorkflowEvidence { export interface N8nWorkflowEvidence {
@@ -272,10 +289,17 @@ export interface N8nIntegrationStatus {
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence"; state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
pending: number; pending: number;
delivering: number; delivering: number;
/** Every failed delivery, prepared and real together. */
failed: number; failed: number;
/** Failures that were not planted by the demo seed -- the only ones that affect state. */
unexpected_failed: number;
/** Prepared demo failures. Visible and counted, but never a health signal. */
demo_scenario_failed: number;
succeeded: number; succeeded: number;
latest_success_at: string | null; latest_success_at: string | null;
/** Most recent real failure; a prepared one never sets this. */
latest_failure_at: string | null; latest_failure_at: string | null;
latest_demo_scenario_at: string | null;
expected_workflow_count: number; expected_workflow_count: number;
known_workflow_count: number; known_workflow_count: number;
workflows: N8nWorkflowEvidence[]; workflows: N8nWorkflowEvidence[];
@@ -284,7 +308,12 @@ export interface N8nIntegrationStatus {
export interface McpHubIntegrationStatus { export interface McpHubIntegrationStatus {
registration_enabled: boolean; registration_enabled: boolean;
state: "not_configured" | "configured"; state: "not_configured" | "no_evidence" | "operational";
total_calls: number;
last_tool: string | null;
last_client: string | null;
last_called_at: string | null;
hub_reachable: boolean | null;
} }
export interface IntegrationStatus { export interface IntegrationStatus {
+3 -2
View File
@@ -64,8 +64,9 @@ export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGEle
export function BrandMark({ className = "" }: { className?: string }) { export function BrandMark({ className = "" }: { className?: string }) {
return ( return (
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none"> <svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="currentColor" /> <path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="currentColor" />
<path d="M3 15.5h26" stroke="#2dd4bf" strokeWidth="2.5" /> <circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg> </svg>
); );
} }
+13 -9
View File
@@ -313,19 +313,23 @@ export function Layout() {
)} )}
</div> </div>
<div className="topbar-meta"> <div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger /> <DemoGuideTrigger />
<DemoBadge /> <DemoBadge />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && ( {user && (
<div className="operator"> <details className="operator-menu">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span> <summary className="operator">
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span> <span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
</div> <span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<LanguageSwitcher compact />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
</button>
</div>
</details>
)} )}
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" />
</button>
</div> </div>
</header> </header>
+25
View File
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
export function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
const { t } = useTranslation("common");
if (totalPages <= 1) return null;
return (
<nav className="pagination" aria-label={t("pagination.label")}>
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
{t("pagination.previous")}
</button>
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
{t("pagination.next")}
</button>
</nav>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { EvidenceSignal } from "../api/types";
type TFunction = (key: string, options?: Record<string, unknown>) => string;
// The backend never emits prose for data-quality evidence -- only stable signal codes
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
// the one place that turns them into the operator's selected language, shared by the
// issue detail page and the dashboard attention queue so the same code always reads the
// same way everywhere it appears.
export function describeEvidenceSignal(
t: TFunction,
formatNumber: (value: number) => string,
signal: EvidenceSignal,
): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("quality:detail.evidence.missingField", {
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("quality:detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("quality:detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("quality:detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
}
}
+2 -1
View File
@@ -15,7 +15,8 @@ export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusC
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = { export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
not_configured: { statusClass: "not_configured", labelKey: "notConnected" }, not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
configured: { statusClass: "no_events", labelKey: "prepared" }, no_evidence: { statusClass: "no_events", labelKey: "prepared" },
operational: { statusClass: "available", labelKey: "operational" },
}; };
// Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a // Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a
@@ -4,6 +4,12 @@
"description": "Trace important state changes, actors and correlation references.", "description": "Trace important state changes, actors and correlation references.",
"actionFilterLabel": "Action", "actionFilterLabel": "Action",
"actionFilterPlaceholder": "e.g. demo_login", "actionFilterPlaceholder": "e.g. demo_login",
"actorFilterLabel": "Actor",
"actorFilterPlaceholder": "Name or service",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "e.g. MO-016",
"fromFilterLabel": "From",
"toFilterLabel": "To",
"managerOnly": "The audit trail is visible to Operations Managers only.", "managerOnly": "The audit trail is visible to Operations Managers only.",
"managerOnlyDetail": "Audit history is visible to Operations Managers only.", "managerOnlyDetail": "Audit history is visible to Operations Managers only.",
"loading": "Loading audit trail…", "loading": "Loading audit trail…",
@@ -45,6 +45,8 @@
"ends": "Ends", "ends": "Ends",
"startOdometer": "Start odometer", "startOdometer": "Start odometer",
"endOdometer": "End odometer", "endOdometer": "End odometer",
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
"endOdometerPending": "Not yet recorded — not yet closed",
"requirementsComplete": "Requirements complete", "requirementsComplete": "Requirements complete",
"yes": "Yes", "yes": "Yes",
"no": "No" "no": "No"
@@ -22,6 +22,12 @@
"success": "Success", "success": "Success",
"noResults": "No results" "noResults": "No results"
}, },
"pagination": {
"label": "Page navigation",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {{page}} of {{total}}"
},
"states": { "states": {
"loadingDefault": "Loading workspace…", "loadingDefault": "Loading workspace…",
"errorTitle": "We couldn't load this workspace." "errorTitle": "We couldn't load this workspace."
@@ -36,6 +36,7 @@
"title": "Attention queue", "title": "Attention queue",
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions", "description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
"reviewQueue": "Review queue", "reviewQueue": "Review queue",
"remaining": "View {{count}} more attention item(s)",
"filterPlaceholder": "Filter issues…", "filterPlaceholder": "Filter issues…",
"filterAriaLabel": "Search attention queue", "filterAriaLabel": "Search attention queue",
"severityAll": "All severity", "severityAll": "All severity",
@@ -43,6 +44,9 @@
"severityMedium": "Warning", "severityMedium": "Warning",
"severityLow": "Info", "severityLow": "Info",
"severityAriaLabel": "Severity", "severityAriaLabel": "Severity",
"tierNow": "Handle now",
"tierToday": "Follow up today",
"tierLater": "Review later",
"empty": "No issues match this filter.", "empty": "No issues match this filter.",
"openRecord": "Open {{title}}" "openRecord": "Open {{title}}"
}, },
@@ -65,6 +69,7 @@
"n8nNoEvidence": "No workflow evidence recorded", "n8nNoEvidence": "No workflow evidence recorded",
"knowledgeTitle": "Knowledge assistant", "knowledgeTitle": "Knowledge assistant",
"knowledgeSummary": "{{count}} procedures indexed", "knowledgeSummary": "{{count}} procedures indexed",
"knowledgeIndexUnknown": "Knowledge source available · index size unknown",
"knowledgeUnavailable": "Health check unavailable", "knowledgeUnavailable": "Health check unavailable",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registration enabled", "mcpEnabled": "Registration enabled",
@@ -11,11 +11,17 @@
"knowledgeTitle": "Knowledge assistant", "knowledgeTitle": "Knowledge assistant",
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.", "knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryIndexUnknown": "Knowledge source available · index size is unavailable for {{collection}}.",
"knowledgeUnavailable": "Health evidence is currently unavailable.", "knowledgeUnavailable": "Health evidence is currently unavailable.",
"gatewayKicker": "Tool gateway", "gatewayKicker": "Tool gateway",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registration is enabled for this deployment.", "mcpEnabled": "Registration is enabled for this deployment.",
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub." "mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub.",
"mcpNoEvidence": "Registered, but no tool call has been recorded yet.",
"mcpEvidence": "Last call: {{tool}} by {{client}} · {{count}} total calls",
"mcpHubReachable": "Hub reachable",
"mcpHubUnreachable": "Hub unreachable",
"n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
}, },
"statusLabels": { "statusLabels": {
"notConnected": "Not connected", "notConnected": "Not connected",
@@ -93,7 +99,11 @@
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.", "malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.", "remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.", "staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
"unknownError": "An unexpected error occurred while delivering this job." "unknownError": "An unexpected error occurred while delivering this job.",
} "demoScenarioTimeout": "Simulated timeout towards the workflow service. This failure is part of the prepared demo scenario, not a real incident."
},
"demoScenarioBadge": "Prepared demo scenario",
"demoScenarioExplanation": "Simulated temporary failure, planted by the demo data on purpose to show retry and audit. It does not affect the health of the automation.",
"retryDemoScenario": "Retry demo scenario"
} }
} }
@@ -7,6 +7,7 @@
"statusAvailable": "Available", "statusAvailable": "Available",
"statusUnavailable": "Unavailable", "statusUnavailable": "Unavailable",
"proceduresIndexed": "{{count}} procedures indexed", "proceduresIndexed": "{{count}} procedures indexed",
"proceduresIndexUnknown": "Index size is not available through this connection",
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.", "providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
"askHeading": "Ask a procedure question", "askHeading": "Ask a procedure question",
"askSubheading": "Retrieval → evidence check → grounded answer", "askSubheading": "Retrieval → evidence check → grounded answer",
+7 -2
View File
@@ -20,6 +20,8 @@
"statusRejected": "Rejected", "statusRejected": "Rejected",
"ruleTypeLabel": "Rule type", "ruleTypeLabel": "Rule type",
"ruleTypeAll": "All rule types", "ruleTypeAll": "All rule types",
"severityLabel": "Severity",
"severityAll": "All severity levels",
"demoScenariosOnly": "Demo scenarios only", "demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…", "loading": "Loading quality workbench…",
"queueClear": "Queue is clear", "queueClear": "Queue is clear",
@@ -109,8 +111,7 @@
"missingField": "Missing field: {{field}}", "missingField": "Missing field: {{field}}",
"overlapReservedBookings": "Overlapping bookings: {{refs}}", "overlapReservedBookings": "Overlapping bookings: {{refs}}",
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.", "odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.", "upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
"seedPlaceholder": "Synthetic seed data with no further detail."
}, },
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Compare and merge", "heading": "Compare and merge",
@@ -118,6 +119,10 @@
"keepAsSurvivor": "Keep as survivor", "keepAsSurvivor": "Keep as survivor",
"differs": "Differs", "differs": "Differs",
"match": "Match", "match": "Match",
"summaryCounts": "{{matchCount}} matching fields · {{conflictCount}} conflicting fields",
"showMatchingFields": "Also show matching fields",
"hideMatchingFields": "Hide matching fields",
"previewTitle": "Preview of the merged record ({{ref}})",
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.", "mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
"mergeInto": "Merge into {{ref}}", "mergeInto": "Merge into {{ref}}",
"confirmMergeTitle": "Confirm merge", "confirmMergeTitle": "Confirm merge",
@@ -4,6 +4,12 @@
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.", "description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
"actionFilterLabel": "Action", "actionFilterLabel": "Action",
"actionFilterPlaceholder": "p. ex. demo_login", "actionFilterPlaceholder": "p. ex. demo_login",
"actorFilterLabel": "Intervenant",
"actorFilterPlaceholder": "Nom ou service",
"entityFilterLabel": "Dossier",
"entityFilterPlaceholder": "p. ex. MO-016",
"fromFilterLabel": "À partir du",
"toFilterLabel": "Jusquau",
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.", "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.", "managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement de la piste d'audit…", "loading": "Chargement de la piste d'audit…",
@@ -45,6 +45,8 @@
"ends": "Fin", "ends": "Fin",
"startOdometer": "Kilométrage de départ", "startOdometer": "Kilométrage de départ",
"endOdometer": "Kilométrage de retour", "endOdometer": "Kilométrage de retour",
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
"requirementsComplete": "Exigences complètes", "requirementsComplete": "Exigences complètes",
"yes": "Oui", "yes": "Oui",
"no": "Non" "no": "Non"
@@ -22,6 +22,12 @@
"success": "Réussi", "success": "Réussi",
"noResults": "Aucun résultat" "noResults": "Aucun résultat"
}, },
"pagination": {
"label": "Navigation entre les pages",
"previous": "Précédent",
"next": "Suivant",
"pageOf": "Page {{page}} sur {{total}}"
},
"states": { "states": {
"loadingDefault": "Chargement de l'espace de travail…", "loadingDefault": "Chargement de l'espace de travail…",
"errorTitle": "Impossible de charger cet espace de travail." "errorTitle": "Impossible de charger cet espace de travail."
@@ -36,6 +36,7 @@
"title": "File d'attention", "title": "File d'attention",
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow", "description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
"reviewQueue": "Examiner la file", "reviewQueue": "Examiner la file",
"remaining": "Voir encore {{count}} point(s) dattention",
"filterPlaceholder": "Filtrer les problèmes…", "filterPlaceholder": "Filtrer les problèmes…",
"filterAriaLabel": "Rechercher dans la file d'attention", "filterAriaLabel": "Rechercher dans la file d'attention",
"severityAll": "Toute gravité", "severityAll": "Toute gravité",
@@ -43,6 +44,9 @@
"severityMedium": "Avertissement", "severityMedium": "Avertissement",
"severityLow": "Info", "severityLow": "Info",
"severityAriaLabel": "Gravité", "severityAriaLabel": "Gravité",
"tierNow": "À traiter maintenant",
"tierToday": "À suivre aujourd'hui",
"tierLater": "À contrôler plus tard",
"empty": "Aucun problème ne correspond à ce filtre.", "empty": "Aucun problème ne correspond à ce filtre.",
"openRecord": "Ouvrir {{title}}" "openRecord": "Ouvrir {{title}}"
}, },
@@ -65,6 +69,7 @@
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée", "n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
"knowledgeTitle": "Assistant de connaissances", "knowledgeTitle": "Assistant de connaissances",
"knowledgeSummary": "{{count}} procédures indexées", "knowledgeSummary": "{{count}} procédures indexées",
"knowledgeIndexUnknown": "Source de connaissances disponible · taille dindex inconnue",
"knowledgeUnavailable": "Vérification de santé indisponible", "knowledgeUnavailable": "Vérification de santé indisponible",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Enregistrement activé", "mcpEnabled": "Enregistrement activé",
@@ -11,11 +11,17 @@
"knowledgeTitle": "Assistant de connaissances", "knowledgeTitle": "Assistant de connaissances",
"knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.", "knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryIndexUnknown": "Source de connaissances disponible · taille dindex indisponible pour {{collection}}.",
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.", "knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
"gatewayKicker": "Passerelle d'outils", "gatewayKicker": "Passerelle d'outils",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "L'enregistrement est activé pour ce déploiement.", "mcpEnabled": "L'enregistrement est activé pour ce déploiement.",
"mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub." "mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub.",
"mcpNoEvidence": "Enregistré, mais aucun appel d'outil n'a encore été consigné.",
"mcpEvidence": "Dernier appel : {{tool}} par {{client}} · {{count}} appels au total",
"mcpHubReachable": "Hub accessible",
"mcpHubUnreachable": "Hub inaccessible",
"n8nDemoScenario": "Plus {{count}} scénario de démonstration préparé — une panne temporaire simulée, pas un problème d'intégration."
}, },
"statusLabels": { "statusLabels": {
"notConnected": "Non connecté", "notConnected": "Non connecté",
@@ -93,7 +99,11 @@
"malformedPayload": "La tâche contenait des données incomplètes et n'a pas pu être livrée. Les données sous-jacentes restent enregistrées en toute sécurité.", "malformedPayload": "La tâche contenait des données incomplètes et n'a pas pu être livrée. Les données sous-jacentes restent enregistrées en toute sécurité.",
"remoteReportedFailure": "Le service d'automatisation a refusé la livraison. La tâche peut être soumise à nouveau.", "remoteReportedFailure": "Le service d'automatisation a refusé la livraison. La tâche peut être soumise à nouveau.",
"staleLeaseRecovered": "Cette tâche a été récupérée après qu'une tentative de livraison précédente s'est arrêtée sans résultat.", "staleLeaseRecovered": "Cette tâche a été récupérée après qu'une tentative de livraison précédente s'est arrêtée sans résultat.",
"unknownError": "Une erreur inattendue s'est produite lors de la livraison de cette tâche." "unknownError": "Une erreur inattendue s'est produite lors de la livraison de cette tâche.",
} "demoScenarioTimeout": "Délai d'attente simulé vers le service de workflow. Cette panne fait partie du scénario de démonstration préparé, ce n'est pas un incident réel."
},
"demoScenarioBadge": "Scénario de démonstration préparé",
"demoScenarioExplanation": "Panne temporaire simulée, placée volontairement dans les données de démonstration pour montrer la relance et l'audit. Elle n'affecte pas la santé de l'automatisation.",
"retryDemoScenario": "Relancer le scénario de démonstration"
} }
} }
@@ -7,6 +7,7 @@
"statusAvailable": "Disponible", "statusAvailable": "Disponible",
"statusUnavailable": "Indisponible", "statusUnavailable": "Indisponible",
"proceduresIndexed": "{{count}} procédures indexées", "proceduresIndexed": "{{count}} procédures indexées",
"proceduresIndexUnknown": "La taille de lindex nest pas disponible via cette connexion",
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.", "providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
"askHeading": "Poser une question de procédure", "askHeading": "Poser une question de procédure",
"askSubheading": "Recherche → vérification des preuves → réponse étayée", "askSubheading": "Recherche → vérification des preuves → réponse étayée",
+7 -2
View File
@@ -20,6 +20,8 @@
"statusRejected": "Rejeté", "statusRejected": "Rejeté",
"ruleTypeLabel": "Type de règle", "ruleTypeLabel": "Type de règle",
"ruleTypeAll": "Tous les types de règles", "ruleTypeAll": "Tous les types de règles",
"severityLabel": "Gravité",
"severityAll": "Tous les niveaux de gravité",
"demoScenariosOnly": "Scénarios de démo uniquement", "demoScenariosOnly": "Scénarios de démo uniquement",
"loading": "Chargement de l'atelier qualité…", "loading": "Chargement de l'atelier qualité…",
"queueClear": "La file est vide", "queueClear": "La file est vide",
@@ -109,8 +111,7 @@
"missingField": "Champ manquant : {{field}}", "missingField": "Champ manquant : {{field}}",
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}", "overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.", "odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante.", "upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante."
"seedPlaceholder": "Donnée de démonstration synthétique sans autre détail."
}, },
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Comparer et fusionner", "heading": "Comparer et fusionner",
@@ -118,6 +119,10 @@
"keepAsSurvivor": "Conserver comme fiche principale", "keepAsSurvivor": "Conserver comme fiche principale",
"differs": "Diffère", "differs": "Diffère",
"match": "Identique", "match": "Identique",
"summaryCounts": "{{matchCount}} champs identiques · {{conflictCount}} champs en conflit",
"showMatchingFields": "Afficher aussi les champs identiques",
"hideMatchingFields": "Masquer les champs identiques",
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
"mergePreview": "{{loser}} deviendra une fiche archivée liée à {{survivor}} ; ses réservations seront transférées.", "mergePreview": "{{loser}} deviendra une fiche archivée liée à {{survivor}} ; ses réservations seront transférées.",
"mergeInto": "Fusionner avec {{ref}}", "mergeInto": "Fusionner avec {{ref}}",
"confirmMergeTitle": "Confirmer la fusion", "confirmMergeTitle": "Confirmer la fusion",
@@ -4,6 +4,12 @@
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.", "description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
"actionFilterLabel": "Actie", "actionFilterLabel": "Actie",
"actionFilterPlaceholder": "bv. demo-login", "actionFilterPlaceholder": "bv. demo-login",
"actorFilterLabel": "Uitvoerder",
"actorFilterPlaceholder": "Naam of dienst",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "bv. MO-016",
"fromFilterLabel": "Vanaf",
"toFilterLabel": "Tot en met",
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.", "managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.", "managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"loading": "Auditgeschiedenis laden…", "loading": "Auditgeschiedenis laden…",
@@ -45,6 +45,8 @@
"ends": "Einde", "ends": "Einde",
"startOdometer": "Startkilometerstand", "startOdometer": "Startkilometerstand",
"endOdometer": "Eindkilometerstand", "endOdometer": "Eindkilometerstand",
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
"requirementsComplete": "Vereisten volledig", "requirementsComplete": "Vereisten volledig",
"yes": "Ja", "yes": "Ja",
"no": "Nee" "no": "Nee"
@@ -22,6 +22,12 @@
"success": "Gelukt", "success": "Gelukt",
"noResults": "Geen resultaten" "noResults": "Geen resultaten"
}, },
"pagination": {
"label": "Paginanavigatie",
"previous": "Vorige",
"next": "Volgende",
"pageOf": "Pagina {{page}} van {{total}}"
},
"states": { "states": {
"loadingDefault": "Werkruimte wordt geladen…", "loadingDefault": "Werkruimte wordt geladen…",
"errorTitle": "We konden deze werkruimte niet laden." "errorTitle": "We konden deze werkruimte niet laden."
@@ -36,6 +36,7 @@
"title": "Aandachtspunten", "title": "Aandachtspunten",
"description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen", "description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen",
"reviewQueue": "Wachtrij bekijken", "reviewQueue": "Wachtrij bekijken",
"remaining": "Nog {{count}} aandachtspunt(en) bekijken",
"filterPlaceholder": "Filter aandachtspunten…", "filterPlaceholder": "Filter aandachtspunten…",
"filterAriaLabel": "Zoek in aandachtspunten", "filterAriaLabel": "Zoek in aandachtspunten",
"severityAll": "Alle ernst", "severityAll": "Alle ernst",
@@ -43,6 +44,9 @@
"severityMedium": "Waarschuwing", "severityMedium": "Waarschuwing",
"severityLow": "Info", "severityLow": "Info",
"severityAriaLabel": "Ernst", "severityAriaLabel": "Ernst",
"tierNow": "Nu behandelen",
"tierToday": "Vandaag opvolgen",
"tierLater": "Later controleren",
"empty": "Geen aandachtspunten voor dit filter.", "empty": "Geen aandachtspunten voor dit filter.",
"openRecord": "Open {{title}}" "openRecord": "Open {{title}}"
}, },
@@ -65,6 +69,7 @@
"n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd", "n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd",
"knowledgeTitle": "Kennisassistent", "knowledgeTitle": "Kennisassistent",
"knowledgeSummary": "{{count}} procedures geïndexeerd", "knowledgeSummary": "{{count}} procedures geïndexeerd",
"knowledgeIndexUnknown": "Kennisbron bereikbaar · indexomvang onbekend",
"knowledgeUnavailable": "Statuscontrole niet beschikbaar", "knowledgeUnavailable": "Statuscontrole niet beschikbaar",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie ingeschakeld", "mcpEnabled": "Registratie ingeschakeld",
@@ -11,11 +11,17 @@
"knowledgeTitle": "Kennisassistent", "knowledgeTitle": "Kennisassistent",
"knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.", "knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryIndexUnknown": "Kennisbron bereikbaar · indexomvang niet beschikbaar voor {{collection}}.",
"knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.", "knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.",
"gatewayKicker": "Tool-gateway", "gatewayKicker": "Tool-gateway",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie is ingeschakeld voor deze omgeving.", "mcpEnabled": "Registratie is ingeschakeld voor deze omgeving.",
"mcpNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub." "mcpNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
"mcpNoEvidence": "Geregistreerd, maar er is nog geen tool-aanroep geregistreerd.",
"mcpEvidence": "Laatste aanroep: {{tool}} door {{client}} · {{count}} aanroepen in totaal",
"mcpHubReachable": "Hub bereikbaar",
"mcpHubUnreachable": "Hub onbereikbaar",
"n8nDemoScenario": "Plus {{count}} voorbereid demoscenario — een gesimuleerde tijdelijke fout, geen integratieprobleem."
}, },
"statusLabels": { "statusLabels": {
"notConnected": "Niet gekoppeld", "notConnected": "Niet gekoppeld",
@@ -93,7 +99,11 @@
"malformedPayload": "De opdracht bevatte onvolledige gegevens en kon niet worden afgeleverd. De onderliggende gegevens blijven veilig bewaard.", "malformedPayload": "De opdracht bevatte onvolledige gegevens en kon niet worden afgeleverd. De onderliggende gegevens blijven veilig bewaard.",
"remoteReportedFailure": "De workflowdienst heeft de aflevering geweigerd. De opdracht kan opnieuw worden aangeboden.", "remoteReportedFailure": "De workflowdienst heeft de aflevering geweigerd. De opdracht kan opnieuw worden aangeboden.",
"staleLeaseRecovered": "Deze opdracht werd hersteld nadat een eerdere afleverpoging vastliep zonder resultaat.", "staleLeaseRecovered": "Deze opdracht werd hersteld nadat een eerdere afleverpoging vastliep zonder resultaat.",
"unknownError": "Er is een onverwachte fout opgetreden bij het afleveren van deze opdracht." "unknownError": "Er is een onverwachte fout opgetreden bij het afleveren van deze opdracht.",
} "demoScenarioTimeout": "Gesimuleerde time-out richting de workflowdienst. Deze fout hoort bij het voorbereide demoscenario en is geen echt incident."
},
"demoScenarioBadge": "Voorbereid demoscenario",
"demoScenarioExplanation": "Gesimuleerde tijdelijke fout, bewust in de demodata gezet om retry en audit te tonen. Ze heeft geen invloed op de gezondheid van de automatisering.",
"retryDemoScenario": "Demoscenario opnieuw proberen"
} }
} }
@@ -7,6 +7,7 @@
"statusAvailable": "Beschikbaar", "statusAvailable": "Beschikbaar",
"statusUnavailable": "Niet beschikbaar", "statusUnavailable": "Niet beschikbaar",
"proceduresIndexed": "{{count}} procedures geïndexeerd", "proceduresIndexed": "{{count}} procedures geïndexeerd",
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
"providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.", "providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.",
"askHeading": "Stel een procedurevraag", "askHeading": "Stel een procedurevraag",
"askSubheading": "Ophalen → evidentiecontrole → onderbouwd antwoord", "askSubheading": "Ophalen → evidentiecontrole → onderbouwd antwoord",
+7 -2
View File
@@ -20,6 +20,8 @@
"statusRejected": "Verworpen", "statusRejected": "Verworpen",
"ruleTypeLabel": "Regeltype", "ruleTypeLabel": "Regeltype",
"ruleTypeAll": "Alle regeltypes", "ruleTypeAll": "Alle regeltypes",
"severityLabel": "Ernst",
"severityAll": "Alle ernstniveaus",
"demoScenariosOnly": "Enkel demoscenario's", "demoScenariosOnly": "Enkel demoscenario's",
"loading": "Kwaliteitswerkbank laden…", "loading": "Kwaliteitswerkbank laden…",
"queueClear": "Wachtrij is leeg", "queueClear": "Wachtrij is leeg",
@@ -109,8 +111,7 @@
"missingField": "Ontbrekend veld: {{field}}", "missingField": "Ontbrekend veld: {{field}}",
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}", "overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
"odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.", "odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog.", "upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog."
"seedPlaceholder": "Synthetisch demogegeven zonder verder detail."
}, },
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Vergelijken en samenvoegen", "heading": "Vergelijken en samenvoegen",
@@ -118,6 +119,10 @@
"keepAsSurvivor": "Behouden als hoofdprofiel", "keepAsSurvivor": "Behouden als hoofdprofiel",
"differs": "Verschilt", "differs": "Verschilt",
"match": "Gelijk", "match": "Gelijk",
"summaryCounts": "{{matchCount}} overeenkomende velden · {{conflictCount}} conflicterende velden",
"showMatchingFields": "Ook overeenkomende velden tonen",
"hideMatchingFields": "Overeenkomende velden verbergen",
"previewTitle": "Voorbeeld van het samengevoegde record ({{ref}})",
"mergePreview": "{{loser}} wordt een tombstone gekoppeld aan {{survivor}}; de boekingen worden herverbonden.", "mergePreview": "{{loser}} wordt een tombstone gekoppeld aan {{survivor}}; de boekingen worden herverbonden.",
"mergeInto": "Samenvoegen met {{ref}}", "mergeInto": "Samenvoegen met {{ref}}",
"confirmMergeTitle": "Samenvoegen bevestigen", "confirmMergeTitle": "Samenvoegen bevestigen",
+22 -20
View File
@@ -67,30 +67,32 @@ export function AboutDemo() {
<p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p> <p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p>
</section> </section>
<section className="record-surface about-card"> <div className="about-grid">
<h2>{t("about.realTitle")}</h2> <div className="record-surface about-card">
<p>{t("about.realBody")}</p> <h2>{t("about.realTitle")}</h2>
</section> <p>{t("about.realBody")}</p>
</div>
<section className="record-surface about-card"> <div className="record-surface about-card">
<h2>{t("about.syntheticTitle")}</h2> <h2>{t("about.syntheticTitle")}</h2>
<p>{t("about.syntheticBody", { testDomain: ".test" })}</p> <p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
</section> </div>
<section className="record-surface about-card"> <details className="record-surface about-card about-details">
<h2>{t("about.architectureTitle")}</h2> <summary>{t("about.architectureTitle")}</summary>
<p>{t("about.architectureBody")}</p> <p>{t("about.architectureBody")}</p>
</section> </details>
<section className="record-surface about-card"> <details className="record-surface about-card about-details">
<h2>{t("about.securityTitle")}</h2> <summary>{t("about.securityTitle")}</summary>
<p>{t("about.securityBody")}</p> <p>{t("about.securityBody")}</p>
</section> </details>
<section className="record-surface about-card"> <details className="record-surface about-card about-details">
<h2>{t("about.testingTitle")}</h2> <summary>{t("about.testingTitle")}</summary>
<p>{t("about.testingBody")}</p> <p>{t("about.testingBody")}</p>
</section> </details>
</div>
<section aria-label={t("about.integrationsTitle")} className="record-surface"> <section aria-label={t("about.integrationsTitle")} className="record-surface">
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} /> <SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
+79 -182
View File
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { AuditEvent } from "../api/types"; import type { AuditEvent, Page } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format"; import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
function humanizeField(field: string): string { function humanizeField(field: string): string {
const spaced = field.replace(/_/g, " "); const spaced = field.replace(/_/g, " ");
@@ -20,210 +21,106 @@ function actionLabel(t: (key: string, options?: Record<string, unknown>) => stri
return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") }); return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") });
} }
function ChangeDiff({ function ChangeDiff({ before, after, t }: { before: Record<string, unknown> | null; after: Record<string, unknown> | null; t: (key: string, options?: Record<string, unknown>) => string }) {
before,
after,
t,
}: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>; if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>;
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); const lines = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])]
const lines: { field: string; text: string }[] = []; .filter((key) => JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key]))
for (const key of keys) { .slice(0, 3)
const b = before?.[key]; .map((key) => {
const a = after?.[key]; const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
if (JSON.stringify(b) === JSON.stringify(a)) continue; const b = before?.[key];
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) }); const a = after?.[key];
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) }); if (b === undefined) return t("diff.setTo", { field, value: JSON.stringify(a) });
else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) }); if (a === undefined) return t("diff.was", { field, value: JSON.stringify(b) });
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) }); return t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) });
} });
if (lines.length === 0) return <p className="table-subtext">{t("noFieldChange")}</p>; return lines.length ? <ul className="change-diff">{lines.map((line) => <li key={line}>{line}</li>)}</ul> : <p className="table-subtext">{t("noFieldChange")}</p>;
return (
<ul className="change-diff">
{lines.map((line) => (
<li key={line.field}>{line.text}</li>
))}
</ul>
);
} }
interface EventGroup { interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
correlationId: string;
primary: AuditEvent;
related: AuditEvent[];
}
export function Audit() { export function Audit() {
const { t } = useTranslation("audit"); const { t } = useTranslation("audit");
const { formatDateTime } = useLocaleFormat(); const { formatDateTime } = useLocaleFormat();
const { user } = useAuth(); const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<AuditEvent[] | null>(null); const [events, setEvents] = useState<Page<AuditEvent> | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [action, setAction] = useState(searchParams.get("action") ?? "");
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const action = searchParams.get("action") ?? "";
const actor = searchParams.get("actor") ?? "";
const entityRef = searchParams.get("entity_ref") ?? "";
const from = searchParams.get("from") ?? "";
const to = searchParams.get("to") ?? "";
const correlationId = searchParams.get("correlation_id") ?? ""; const correlationId = searchParams.get("correlation_id") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateParams(updates: Record<string, string | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
}
useEffect(() => { useEffect(() => {
if (user?.role !== "operations_manager") return; if (user?.role !== "operations_manager") return;
setEvents(null); setEvents(null);
setError(null); setError(null);
const params = new URLSearchParams(); const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (action) params.set("action", action); if (action) params.set("action", action);
if (actor) params.set("actor_label", actor);
if (entityRef) params.set("entity_ref", entityRef);
if (correlationId) params.set("correlation_id", correlationId); if (correlationId) params.set("correlation_id", correlationId);
api if (from) params.set("occurred_from", `${from}T00:00:00Z`);
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`) if (to) params.set("occurred_to", `${to}T23:59:59Z`);
.then(setEvents) api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
.catch(() => setError(t("unavailable"))); }, [action, actor, correlationId, entityRef, from, page, t, to, user]);
}, [action, correlationId, user]);
const groups = useMemo<EventGroup[]>(() => { const groups = useMemo<EventGroup[]>(() => {
if (!events) return [];
const order: string[] = [];
const byCorrelation = new Map<string, AuditEvent[]>(); const byCorrelation = new Map<string, AuditEvent[]>();
for (const event of events) { for (const event of events?.items ?? []) byCorrelation.set(event.correlation_id, [...(byCorrelation.get(event.correlation_id) ?? []), event]);
if (!byCorrelation.has(event.correlation_id)) { return [...byCorrelation.entries()].map(([id, grouped]) => ({ correlationId: id, primary: grouped[0], related: grouped.slice(1) }));
order.push(event.correlation_id);
byCorrelation.set(event.correlation_id, []);
}
byCorrelation.get(event.correlation_id)!.push(event);
}
return order.map((id) => {
const group = byCorrelation.get(id)!;
return { correlationId: id, primary: group[0], related: group.slice(1) };
});
}, [events]); }, [events]);
function showRelatedEvents(id: string) { if (user?.role !== "operations_manager") return <div className="page"><PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} /><p>{t("managerOnlyDetail")}</p></div>;
setSearchParams({ correlation_id: id });
}
function clearCorrelationFilter() { return <div className="page">
setSearchParams(action ? { action } : {}); <PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
} <form className="filters" aria-label={t("title")} onSubmit={(event) => event.preventDefault()}>
<label>{t("actionFilterLabel")}<input type="search" value={action} onChange={(e) => updateParams({ action: e.target.value, page: 1 })} placeholder={t("actionFilterPlaceholder")} /></label>
function toggleExpanded(id: string) { <label>{t("actorFilterLabel")}<input type="search" value={actor} onChange={(e) => updateParams({ actor: e.target.value, page: 1 })} placeholder={t("actorFilterPlaceholder")} /></label>
setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); <label>{t("entityFilterLabel")}<input type="search" value={entityRef} onChange={(e) => updateParams({ entity_ref: e.target.value, page: 1 })} placeholder={t("entityFilterPlaceholder")} /></label>
} <label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
if (user?.role !== "operations_manager") { </form>
return ( {correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
<div className="page"> {error && <ErrorState message={error} />}
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} /> {!error && !events && <LoadingState label={t("loading")} />}
<p>{t("managerOnlyDetail")}</p> {events && events.items.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
</div> {groups.length > 0 && <div className="table-shell audit-shell">
); <div className="table-meta"><span>{t("count", { count: events!.total })}</span><span>{t("storedRendered")}</span></div>
} <ul className="audit-group-list">
{groups.map((group) => {
return ( const e = group.primary;
<div className="page"> const isExpanded = expanded[group.correlationId] ?? false;
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} /> return <li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<form className="filters" aria-label={t("title")}> <div className="audit-group-heading"><strong>{actionLabel(t, e.action)}</strong><span className="table-subtext">{formatDateTime(e.occurred_at)}</span></div>
<label> <div className="audit-group-meta"><span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>{e.entity_link ? <Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link> : <span>{e.entity_ref ?? e.entity_type}</span>}</div>
{t("actionFilterLabel")} <ChangeDiff before={e.before} after={e.after} t={t} />
<input <div className="audit-group-actions">
type="text" {group.related.length > 0 && <button type="button" className="link-button" onClick={() => setExpanded((current) => ({ ...current, [group.correlationId]: !current[group.correlationId] }))}>{isExpanded ? t("hideTechnicalEvents") : t("showTechnicalEvents", { count: group.related.length })}</button>}
value={action} {!correlationId && <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: group.correlationId, page: 1 })}>{t("viewRelatedEvents")}</button>}
onChange={(e) => setAction(e.target.value)} </div>
placeholder={t("actionFilterPlaceholder")} </div>
/> <details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><dl className="detail-grid audit-technical-grid"><div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div><div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div><div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div></dl><pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre></details>
</label> {isExpanded && group.related.length > 0 && <ul className="audit-related-list">{group.related.map((related) => <li key={related.id}><div className="audit-group-heading"><strong>{actionLabel(t, related.action)}</strong><span className="table-subtext">{formatDateTime(related.occurred_at)}</span></div><ChangeDiff before={related.before} after={related.after} t={t} /><details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre></details></li>)}</ul>}
</form> </li>;
})}
{correlationId && ( </ul>
<p className="quiet-empty" role="status"> <Pagination page={events!.page} totalPages={events!.total_pages} onPageChange={(nextPage) => updateParams({ page: nextPage })} />
{t("relatedFilterActive", { count: events?.length ?? 0 })}{" "} </div>}
<button type="button" className="link-button" onClick={clearCorrelationFilter}> </div>;
{t("clearFilter")}
</button>
</p>
)}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{groups.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("count", { count: events!.length })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return (
<li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading">
<strong>{actionLabel(t, e.action)}</strong>
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
</div>
<div className="audit-group-meta">
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>
{e.entity_link ? (
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
) : (
<span>{e.entity_ref ?? e.entity_type}</span>
)}
</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && (
<button type="button" className="link-button" onClick={() => toggleExpanded(group.correlationId)}>
{isExpanded
? t("hideTechnicalEvents")
: t("showTechnicalEvents", { count: group.related.length })}
</button>
)}
{!correlationId && (
<button type="button" className="link-button" onClick={() => showRelatedEvents(group.correlationId)}>
{t("viewRelatedEvents")}
</button>
)}
</div>
</div>
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div>
<div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
</details>
{isExpanded && group.related.length > 0 && (
<ul className="audit-related-list">
{group.related.map((related) => (
<li key={related.id}>
<div className="audit-group-heading">
<strong>{actionLabel(t, related.action)}</strong>
<span className="table-subtext">{formatDateTime(related.occurred_at)}</span>
</div>
<ChangeDiff before={related.before} after={related.after} t={t} />
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(related.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{related.id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre>
</details>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
);
} }
+46 -11
View File
@@ -117,12 +117,16 @@ export function Automation() {
<td data-label={t("ledger.columns.booking")}>{r.aggregate_ref}</td> <td data-label={t("ledger.columns.booking")}>{r.aggregate_ref}</td>
<td data-label={t("ledger.columns.status")}> <td data-label={t("ledger.columns.status")}>
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} /> <StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
{r.is_demo_scenario && (
<StatusBadge status="demo_scenario" label={t("ledger.demoScenarioBadge")} />
)}
</td> </td>
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td> <td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
<td data-label={t("ledger.columns.lastError")}> <td data-label={t("ledger.columns.lastError")}>
{r.last_error_code ? ( {r.last_error_code ? (
<> <>
<span>{t(`ledger.errorCodes.${r.last_error_code}`, { defaultValue: r.last_error ?? r.last_error_code })}</span> <span>{t(`ledger.errorCodes.${r.last_error_code}`, { defaultValue: r.last_error ?? r.last_error_code })}</span>
{r.is_demo_scenario && <p className="table-subtext">{t("ledger.demoScenarioExplanation")}</p>}
{r.last_error && ( {r.last_error && (
<details className="evidence-disclosure"> <details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary> <summary>{t("common:actions.technicalDetails")}</summary>
@@ -140,7 +144,9 @@ export function Automation() {
<td data-label={t("ledger.columns.action")}> <td data-label={t("ledger.columns.action")}>
{r.status === "failed" ? ( {r.status === "failed" ? (
<button type="button" onClick={() => handleRetry(r.event_id)} disabled={retrying === r.event_id}> <button type="button" onClick={() => handleRetry(r.event_id)} disabled={retrying === r.event_id}>
{retrying === r.event_id ? t("ledger.retrying") : t("ledger.retry")} {retrying === r.event_id
? t("ledger.retrying")
: t(r.is_demo_scenario ? "ledger.retryDemoScenario" : "ledger.retry")}
</button> </button>
) : ( ) : (
t("ledger.noAction") t("ledger.noAction")
@@ -164,12 +170,19 @@ export function Automation() {
{integrationStatus {integrationStatus
? t("cards.n8nSummary", { ? t("cards.n8nSummary", {
succeeded: integrationStatus.n8n.succeeded, succeeded: integrationStatus.n8n.succeeded,
failed: integrationStatus.n8n.failed, failed: integrationStatus.n8n.unexpected_failed,
pending: integrationStatus.n8n.pending, pending: integrationStatus.n8n.pending,
delivering: integrationStatus.n8n.delivering, delivering: integrationStatus.n8n.delivering,
}) })
: t("cards.n8nFallback")} : t("cards.n8nFallback")}
</p> </p>
{/* Prepared demo failures are stated separately and never folded into the
health count above, so the badge and the numbers tell the same story. */}
{integrationStatus && integrationStatus.n8n.demo_scenario_failed > 0 && (
<p className="table-subtext">
{t("cards.n8nDemoScenario", { count: integrationStatus.n8n.demo_scenario_failed })}
</p>
)}
</div> </div>
{(() => { {(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
@@ -188,10 +201,12 @@ export function Automation() {
<h2>{t("cards.knowledgeTitle")}</h2> <h2>{t("cards.knowledgeTitle")}</h2>
<p> <p>
{knowledge {knowledge
? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", { ? knowledge.document_count === null
count: knowledge.document_count, ? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection })
collection: knowledge.collection, : t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
}) count: knowledge.document_count,
collection: knowledge.collection,
})
: t("cards.knowledgeUnavailable")} : t("cards.knowledgeUnavailable")}
</p> </p>
</div> </div>
@@ -209,12 +224,32 @@ export function Automation() {
<div> <div>
<span className="integration-kicker">{t("cards.gatewayKicker")}</span> <span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<h2>{t("cards.mcpTitle")}</h2> <h2>{t("cards.mcpTitle")}</h2>
<p>{integrationStatus?.mcp_hub.registration_enabled ? t("cards.mcpEnabled") : t("cards.mcpNotConnected")}</p> <p>
{(() => {
const hub = integrationStatus?.mcp_hub;
if (!hub?.registration_enabled) return t("cards.mcpNotConnected");
if (hub.total_calls > 0) {
return t("cards.mcpEvidence", { tool: hub.last_tool, client: hub.last_client, count: hub.total_calls });
}
return t("cards.mcpNoEvidence");
})()}
</p>
{integrationStatus?.mcp_hub.last_called_at && (
<small>{formatDateTime(integrationStatus.mcp_hub.last_called_at)}</small>
)}
</div>
<div className="integration-badge-stack">
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
})()}
{integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && (
<StatusBadge
status={integrationStatus.mcp_hub.hub_reachable ? "available" : "unavailable"}
label={t(integrationStatus.mcp_hub.hub_reachable ? "cards.mcpHubReachable" : "cards.mcpHubUnreachable")}
/>
)}
</div> </div>
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
})()}
</article> </article>
</section> </section>
+2 -2
View File
@@ -68,8 +68,8 @@ export function BookingDetail() {
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div> <div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div> <div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div> <div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : "—"}</dd></div> <div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : t("detail.startOdometerPending")}</dd></div>
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</dd></div> <div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : t("detail.endOdometerPending")}</dd></div>
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div> <div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section> </dl></section>
+69 -31
View File
@@ -12,6 +12,8 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels"; import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
import { describeEvidenceSignal } from "../data/evidenceSignals";
import type { AttentionItem } from "../api/types";
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [ const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
{ key: "available", labelKey: "readiness.available", tone: "ready" }, { key: "available", labelKey: "readiness.available", tone: "ready" },
@@ -29,9 +31,20 @@ function attentionItemTitle(
return `${ruleLabel}${item.link_ref}`; return `${ruleLabel}${item.link_ref}`;
} }
// The backend never emits prose here either -- only stable signal codes + params, exactly
// like the data-quality issue detail page. Multiple signals on one item (e.g. the
// duplicate-customer match) are joined into one readable line for this compact row.
function attentionItemDetail(
t: (key: string, options?: Record<string, unknown>) => string,
formatNumber: (value: number) => string,
item: Pick<AttentionItem, "evidence_signals">,
): string {
return item.evidence_signals.map((signal) => describeEvidenceSignal(t, formatNumber, signal)).join(" · ");
}
export function Dashboard() { export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations"]); const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
const { formatTime, formatShortDate } = useLocaleFormat(); const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
const greetingPeriod = useGreetingPeriod(); const greetingPeriod = useGreetingPeriod();
const { user } = useAuth(); const { user } = useAuth();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
@@ -71,9 +84,24 @@ export function Dashboard() {
const attention = useMemo(() => data?.attention_items.filter((item) => { const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity; const matchesSeverity = severity === "all" || item.severity === severity;
const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase(); const haystack = `${attentionItemTitle(t, item)} ${attentionItemDetail(t, formatNumber, item)} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase()); return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity, t]); }) ?? [], [data, query, severity, t, formatNumber]);
const isUnfiltered = severity === "all" && query === "";
const attentionTiers = useMemo(() => {
if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }];
// Keep the initial dashboard queue deliberately bounded. The full evidence-backed
// queue remains one click away in Data Quality; this view is for deciding what to
// do next, not for rendering the whole backlog.
const visible = ["high", "medium", "low"].flatMap((level) => attention.filter((item) => item.severity === level)).slice(0, 6);
const bySeverity = (level: string) => visible.filter((item) => item.severity === level);
return [
{ key: "high", labelKey: "attention.tierNow", items: bySeverity("high") },
{ key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") },
{ key: "low", labelKey: "attention.tierLater", items: bySeverity("low") },
].filter((tier) => tier.items.length > 0);
}, [attention, isUnfiltered]);
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!data) return <LoadingState label={t("common:status.loading")} />; if (!data) return <LoadingState label={t("common:status.loading")} />;
@@ -129,32 +157,42 @@ export function Dashboard() {
<label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label> <label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label>
</div> </div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</p> : ( {attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</p> : (
<ul className="attention-list"> <>
{attention.slice(0, 6).map((item, index) => { {attentionTiers.map((tier) => (
const href = item.issue_ref && canSeeQuality <div key={tier.key} className="attention-tier">
? `/data-quality/${item.issue_ref}` {attentionTiers.length > 1 && <p className="attention-tier-label">{t(tier.labelKey)}</p>}
: item.link_type === "vehicle" <ul className="attention-list">
? `/vehicles/${item.link_ref}` {tier.items.map((item, index) => {
: null; const href = item.issue_ref && canSeeQuality
const title = attentionItemTitle(t, item); ? `/data-quality/${item.issue_ref}`
return ( : item.link_type === "vehicle"
<li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}> ? `/vehicles/${item.link_ref}`
<SeverityBadge severity={item.severity} /> : null;
<div className="queue-copy"> const title = attentionItemTitle(t, item);
<p className="attention-title">{title}</p> return (
<p className="attention-detail">{item.detail}</p> <li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
</div> <SeverityBadge severity={item.severity} />
<span className="queue-ref">{item.link_ref}</span> <div className="queue-copy">
<Icon name="chevron" className="row-chevron" /> <p className="attention-title">{title}</p>
{href && ( <p className="attention-detail">{attentionItemDetail(t, formatNumber, item)}</p>
<Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}> </div>
<span className="visually-hidden">{title}</span> <span className="queue-ref">{item.link_ref}</span>
</Link> <Icon name="chevron" className="row-chevron" />
)} {href && (
</li> <Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
); <span className="visually-hidden">{title}</span>
})} </Link>
</ul> )}
</li>
);
})}
</ul>
</div>
))}
{isUnfiltered && attention.length > 6 && canSeeQuality && (
<p className="queue-more"><Link to="/data-quality">{t("attention.remaining", { count: attention.length - 6 })} <Icon name="chevron" /></Link></p>
)}
</>
)} )}
</section> </section>
@@ -207,7 +245,7 @@ export function Dashboard() {
<IntegrationMark kind="rag" /> <IntegrationMark kind="rag" />
<div> <div>
<strong>{t("integrationPulse.knowledgeTitle")}</strong> <strong>{t("integrationPulse.knowledgeTitle")}</strong>
<span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span> <span>{knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown") : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}</span>
</div> </div>
<StatusBadge <StatusBadge
status={knowledge?.available ? "available" : "unavailable"} status={knowledge?.available ? "available" : "unavailable"}
+42 -17
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, ScanResult } from "../api/types"; import type { DataQualityIssue, Page, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge"; import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const RULE_TYPES = [ const RULE_TYPES = [
"possible_duplicate_customer", "possible_duplicate_customer",
@@ -19,15 +20,27 @@ const RULE_TYPES = [
export function DataQuality() { export function DataQuality() {
const { t } = useTranslation("quality"); const { t } = useTranslation("quality");
const { user } = useAuth(); const { user } = useAuth();
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null); const [searchParams, setSearchParams] = useSearchParams();
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("open"); const status = searchParams.get("status") ?? "open";
const [ruleType, setRuleType] = useState(""); const ruleType = searchParams.get("rule_type") ?? "";
const severity = searchParams.get("severity") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null); const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null); const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false); const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false); const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
}
const load = useCallback(() => { const load = useCallback(() => {
if (user?.role !== "operations_manager") return; if (user?.role !== "operations_manager") return;
@@ -36,11 +49,14 @@ export function DataQuality() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (status) params.set("status", status); if (status) params.set("status", status);
if (ruleType) params.set("rule_type", ruleType); if (ruleType) params.set("rule_type", ruleType);
if (severity) params.set("severity", severity);
params.set("page", String(page));
params.set("page_size", "25");
api api
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`) .get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues) .then(setIssues)
.catch(() => setError(t("list.unavailable"))); .catch(() => setError(t("list.unavailable")));
}, [status, ruleType, user]); }, [status, ruleType, severity, page, user, t]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -73,8 +89,8 @@ export function DataQuality() {
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
const visibleIssues = issues const visibleIssues = issues
? demoScenariosOnly ? demoScenariosOnly
? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) ? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues : issues.items
: []; : [];
return ( return (
@@ -118,7 +134,7 @@ export function DataQuality() {
<form className="filters" aria-label={t("list.title")}> <form className="filters" aria-label={t("list.title")}>
<label> <label>
{t("list.statusLabel")} {t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}> <select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option> <option value="">{t("list.statusAll")}</option>
<option value="open">{t("list.statusOpen")}</option> <option value="open">{t("list.statusOpen")}</option>
<option value="deferred">{t("list.statusDeferred")}</option> <option value="deferred">{t("list.statusDeferred")}</option>
@@ -128,7 +144,7 @@ export function DataQuality() {
</label> </label>
<label> <label>
{t("list.ruleTypeLabel")} {t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}> <select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
<option value="">{t("list.ruleTypeAll")}</option> <option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => ( {RULE_TYPES.map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
@@ -137,11 +153,20 @@ export function DataQuality() {
))} ))}
</select> </select>
</label> </label>
<label>
{t("list.severityLabel")}
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
<option value="">{t("list.severityAll")}</option>
<option value="high">{t("severities.high")}</option>
<option value="medium">{t("severities.medium")}</option>
<option value="low">{t("severities.low")}</option>
</select>
</label>
<label className="checkbox-label"> <label className="checkbox-label">
<input <input
type="checkbox" type="checkbox"
checked={demoScenariosOnly} checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)} onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
/> />
{t("list.demoScenariosOnly")} {t("list.demoScenariosOnly")}
</label> </label>
@@ -149,13 +174,13 @@ export function DataQuality() {
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />} {!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />} {issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.length > 0 && visibleIssues.length === 0 && ( {issues && issues.items.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} /> <EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)} )}
{visibleIssues.length > 0 && ( {visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table"> <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption> <caption className="visually-hidden">{t("list.title")}</caption>
<thead> <thead>
<tr> <tr>
@@ -184,7 +209,7 @@ export function DataQuality() {
</tr> </tr>
))} ))}
</tbody> </tbody>
</table></div> </table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)} )}
</div> </div>
); );
+40 -60
View File
@@ -15,6 +15,8 @@ import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format"; import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { describeEvidenceSignal } from "../data/evidenceSignals";
import type { EvidenceSignal } from "../api/types";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
@@ -47,74 +49,21 @@ function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
); );
} }
interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
// The backend never emits prose for evidence -- only stable signal codes + raw data // The backend never emits prose for evidence -- only stable signal codes + raw data
// params (see app/services/data_quality.py::_open_issue). This is the one place that // params (see app/services/data_quality.py::_open_issue). describeEvidenceSignal is the
// turns them into the operator's selected language; the legacy `evidence.summary` // one place that turns them into the operator's selected language; the legacy
// string is a technical fallback only, shown solely inside EvidenceDisclosure above. // `evidence.summary` string is a technical fallback only, shown solely inside
// A handful of seed-only rows (generic filler, not tied to a scripted demo scenario) // EvidenceDisclosure above.
// carry no structured signals -- rather than show nothing, this known placeholder
// summary gets a localized rendering. Any other un-signalled legacy text still falls
// back to the raw string (better than a blank primary evidence area), but only
// "Technical details" is meant to guarantee raw-text visibility.
const SEED_PLACEHOLDER_SUMMARY = "Synthetic deterministic seed issue";
function EvidenceSignalList({ issue }: { issue: IssueDetail }) { function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("quality"); const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat(); const { formatNumber } = useLocaleFormat();
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? []; const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
if (signals.length === 0) { if (signals.length === 0) return null;
const rawSummary = String(issue.evidence.summary ?? "");
if (!rawSummary) return null;
const text =
rawSummary === SEED_PLACEHOLDER_SUMMARY
? t("detail.evidence.seedPlaceholder")
: rawSummary;
return (
<ul className="evidence-signal-list">
<li>{text}</li>
</ul>
);
}
function describe(signal: EvidenceSignal): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("detail.evidence.missingField", {
field: t(`detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`detail.evidence.${code}`, { defaultValue: code });
}
}
return ( return (
<ul className="evidence-signal-list"> <ul className="evidence-signal-list">
{signals.map((signal, index) => ( {signals.map((signal, index) => (
<li key={`${signal.code}-${index}`}>{describe(signal)}</li> <li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
))} ))}
</ul> </ul>
); );
@@ -128,6 +77,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const [error, setError] = useState<ApiErrorInfo | null>(null); const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [showMatching, setShowMatching] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) { if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>; return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
@@ -137,6 +87,9 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const survivor = survivorRef === a.public_ref ? a : b; const survivor = survivorRef === a.public_ref ? a : b;
const loser = survivorRef === a.public_ref ? b : a; const loser = survivorRef === a.public_ref ? b : a;
const conflictingFields = MERGE_FIELDS.filter((field) => String(a[field] ?? "") !== String(b[field] ?? ""));
const matchingFields = MERGE_FIELDS.filter((field) => !conflictingFields.includes(field));
const visibleFields = showMatching ? MERGE_FIELDS : conflictingFields;
async function handleMerge() { async function handleMerge() {
setError(null); setError(null);
@@ -174,6 +127,10 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} /> <SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
<ApiErrorNotice error={error} /> <ApiErrorNotice error={error} />
<p className="merge-summary-counts">
{t("detail.duplicateCustomer.summaryCounts", { matchCount: matchingFields.length, conflictCount: conflictingFields.length })}
</p>
<fieldset className="choice-fieldset"> <fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend> <legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
<label className={`choice-card ${survivorRef === a.public_ref ? "is-selected" : ""}`}> <label className={`choice-card ${survivorRef === a.public_ref ? "is-selected" : ""}`}>
@@ -205,7 +162,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{MERGE_FIELDS.map((field) => { {visibleFields.map((field) => {
const valueA = a[field] ? String(a[field]) : "—"; const valueA = a[field] ? String(a[field]) : "—";
const valueB = b[field] ? String(b[field]) : "—"; const valueB = b[field] ? String(b[field]) : "—";
const differ = valueA !== valueB; const differ = valueA !== valueB;
@@ -248,10 +205,33 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</tbody> </tbody>
</table> </table>
{matchingFields.length > 0 && (
<button type="button" className="button button-tertiary toggle-matching-fields" onClick={() => setShowMatching((v) => !v)}>
{showMatching ? t("detail.duplicateCustomer.hideMatchingFields") : t("detail.duplicateCustomer.showMatchingFields")}
</button>
)}
<p className="merge-preview"> <p className="merge-preview">
{t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })} {t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}
</p> </p>
<div className="merge-record-preview">
<p className="merge-record-preview-title">{t("detail.duplicateCustomer.previewTitle", { ref: survivor.public_ref })}</p>
<dl>
{MERGE_FIELDS.map((field) => {
const choice = fieldChoices[field];
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
const value = (chosenSide[field] ? String(chosenSide[field]) : survivor[field] ? String(survivor[field]) : "—");
return (
<div key={field}>
<dt>{t(`detail.duplicateCustomer.fields.${field}`)}</dt>
<dd>{value || "—"}</dd>
</div>
);
})}
</dl>
</div>
{!confirming && ( {!confirming && (
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}> <button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })} {t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })}
+1 -1
View File
@@ -69,7 +69,7 @@ export function Knowledge() {
<strong>{providerLabel}</strong> <strong>{providerLabel}</strong>
<span> <span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "} {status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
{t("proceduresIndexed", { count: status.document_count })} {status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })}
</span> </span>
</div> </div>
<small>{status.collection}</small> <small>{status.collection}</small>
+1 -1
View File
@@ -100,7 +100,7 @@ export function VehicleDetail() {
{vehicle.maintenance.map((m) => ( {vehicle.maintenance.map((m) => (
<li key={m.public_ref}> <li key={m.public_ref}>
<span>{t(`fleet:detail.maintenanceCategories.${m.category}`, { defaultValue: m.category })}</span> <span>{t(`fleet:detail.maintenanceCategories.${m.category}`, { defaultValue: m.category })}</span>
<span>{m.summary}</span> <span>{formatNumber(m.odometer_km)} km</span>
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time> <time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li> </li>
))} ))}
+30 -18
View File
@@ -1,22 +1,34 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Vehicle } from "../api/types"; import type { Page, Vehicle } from "../api/types";
import { useLocaleFormat } from "../i18n/format"; import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"]; const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
export function Vehicles() { export function Vehicles() {
const { t } = useTranslation("fleet"); const { t } = useTranslation("fleet");
const { formatNumber } = useLocaleFormat(); const { formatNumber } = useLocaleFormat();
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null); const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState(""); const status = searchParams.get("status") ?? "";
const [attentionOnly, setAttentionOnly] = useState(false); const attentionOnly = searchParams.get("attention_only") === "true";
const [query, setQuery] = useState(""); const query = searchParams.get("q") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
}
useEffect(() => { useEffect(() => {
setVehicles(null); setVehicles(null);
@@ -24,11 +36,14 @@ export function Vehicles() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (status) params.set("status", status); if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true"); if (attentionOnly) params.set("attention_only", "true");
if (query) params.set("query", query);
params.set("page", String(page));
params.set("page_size", "25");
api api
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`) .get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles) .then(setVehicles)
.catch(() => setError(t("list.unavailable"))); .catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly]); }, [status, attentionOnly, query, page, t]);
return ( return (
<div className="page"> <div className="page">
@@ -37,11 +52,11 @@ export function Vehicles() {
<form className="filters" aria-label={t("list.title")}> <form className="filters" aria-label={t("list.title")}>
<label> <label>
{t("list.searchLabel")} {t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} /> <input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
</label> </label>
<label> <label>
{t("list.statusLabel")} {t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}> <select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option> <option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => ( {STATUS_OPTIONS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
@@ -54,7 +69,7 @@ export function Vehicles() {
<input <input
type="checkbox" type="checkbox"
checked={attentionOnly} checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)} onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
/> />
{t("list.attentionOnly")} {t("list.attentionOnly")}
</label> </label>
@@ -62,11 +77,9 @@ export function Vehicles() {
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label={t("list.loading")} />} {!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />} {vehicles && vehicles.items.length === 0 && <EmptyState icon={query ? "search" : "fleet"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
{vehicles && vehicles.length > 0 && (() => { {vehicles && vehicles.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: vehicles.total })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase()));
return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption> <caption className="visually-hidden">{t("list.title")}</caption>
<thead> <thead>
<tr> <tr>
@@ -79,7 +92,7 @@ export function Vehicles() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((v) => ( {vehicles.items.map((v) => (
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}> <tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
<th scope="row" data-label={t("list.columns.reference")}> <th scope="row" data-label={t("list.columns.reference")}>
{v.public_ref} {v.public_ref}
@@ -97,8 +110,7 @@ export function Vehicles() {
</tr> </tr>
))} ))}
</tbody> </tbody>
</table></div>; </table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
})()}
</div> </div>
); );
} }
+45 -21
View File
@@ -28,6 +28,10 @@
--focus: #14b8a6; --focus: #14b8a6;
--radius: 4px; --radius: 4px;
--shadow-float: 0 16px 40px rgba(15, 23, 42, .12); --shadow-float: 0 16px 40px rgba(15, 23, 42, .12);
--type-body: .875rem;
--type-meta: .75rem;
--type-label: .75rem;
--type-badge: .75rem;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -91,10 +95,11 @@ a:hover { color: var(--teal); }
.search-result-copy { display: flex; flex-direction: column; min-width: 0; } .search-result-copy { display: flex; flex-direction: column; min-width: 0; }
.search-result-copy strong { font-size: .82rem; } .search-result-copy strong { font-size: .82rem; }
.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; } .topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; } .timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }
.timezone svg { width: 15px; } .timezone svg { width: 15px; }
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); } .operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
.operator-menu { position: relative; }.operator-menu summary { list-style: none; cursor: pointer; }.operator-menu summary::-webkit-details-marker { display: none; }.operator-menu[open] .operator { color: var(--teal-dark); }.operator-popover { position: absolute; top: calc(100% + 8px); right: 0; z-index: 31; width: 220px; display: grid; gap: 10px; padding: 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.operator-popover .timezone { font-size: var(--type-meta); }.operator-logout { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.operator-logout svg { width: 16px; }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; } .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; min-width: 0; } .operator > span:last-child { display: grid; gap: 1px; min-width: 0; }
.operator strong { font-size: .74rem; overflow-wrap: anywhere; } .operator strong { font-size: .74rem; overflow-wrap: anywhere; }
@@ -175,6 +180,7 @@ a:hover { color: var(--teal); }
.attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; } .attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; }
.attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; } .attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; }
.attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; } .attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; }
.queue-more { margin: 0; padding: 13px 18px; border-top: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }.queue-more a { display: inline-flex; align-items: center; gap: 5px; }
.attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; } .attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; }
.attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); } .queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); }
@@ -193,6 +199,8 @@ a:hover { color: var(--teal); }
.row-link { position: absolute; inset: 0; z-index: 1; border-radius: inherit; } .row-link { position: absolute; inset: 0; z-index: 1; border-radius: inherit; }
.row-link:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } .row-link:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.attention-list li.row-clickable:hover .attention-title, .movement-timeline li.row-clickable:hover .movement-ref { color: var(--teal-dark); } .attention-list li.row-clickable:hover .attention-title, .movement-timeline li.row-clickable:hover .movement-ref { color: var(--teal-dark); }
.attention-tier + .attention-tier { border-top: 1px solid var(--line); }
.attention-tier-label { margin: 0; padding: 9px 18px 5px; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.data-table tr.row-clickable:hover { background: var(--surface-subtle); } .data-table tr.row-clickable:hover { background: var(--surface-subtle); }
.data-table tr.row-clickable .cell-link { position: relative; z-index: 2; } .data-table tr.row-clickable .cell-link { position: relative; z-index: 2; }
@@ -204,35 +212,35 @@ a:hover { color: var(--teal); }
.activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; } .activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; }
.recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; } .recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; }
.badge { min-height: 20px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border: 1px solid transparent; border-radius: 999px; font-size: .59rem; font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; } .badge { min-height: 24px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid transparent; border-radius: 999px; font-size: var(--type-badge); font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; }
.badge::before, .badge-dot { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } .badge::before, .badge-dot { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.severity-high, .status-failed, .status-blocked, .status-rejected, .status-unavailable { color: #9f2929; background: var(--critical-pale); border-color: #f3c5c5; } .severity-high, .status-failed, .status-blocked, .status-rejected, .status-unavailable { color: #9f2929; background: var(--critical-pale); border-color: #f3c5c5; }
.severity-medium, .status-pending, .status-delivering, .status-needs_attention { color: #93520c; background: var(--warning-pale); border-color: #eed4aa; } .severity-medium, .status-pending, .status-delivering, .status-needs_attention { color: #93520c; background: var(--warning-pale); border-color: #eed4aa; }
.severity-low { color: #315f79; background: var(--info-pale); border-color: #c8dfeb; } .severity-low, .status-demo_scenario { color: #315f79; background: var(--info-pale); border-color: #c8dfeb; }
.status-succeeded, .status-resolved, .status-available, .status-returned, .status-active { color: #17633f; background: var(--success-pale); border-color: #bfe3d0; } .status-succeeded, .status-resolved, .status-available, .status-returned, .status-active { color: #17633f; background: var(--success-pale); border-color: #bfe3d0; }
.status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-not_configured, .status-no_events, .status-no_delivery_yet { color: #536172; background: #f0f3f6; border-color: #d8dfe6; } .status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-not_configured, .status-no_events, .status-no_delivery_yet { color: #536172; background: #f0f3f6; border-color: #d8dfe6; }
.badge-dot { display: inline-block; } .badge-dot { display: inline-block; }
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } .filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.filters label, .return-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; letter-spacing: .01em; } .filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; letter-spacing: .01em; }
.filters input[type="text"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input { min-height: 40px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .78rem; } .filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 44px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: var(--type-body); }
.filters input[type="text"] { min-width: 230px; }.filters select { min-width: 160px; } .filters input[type="text"], .filters input[type="search"] { min-width: 230px; }.filters select { min-width: 160px; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; } .checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); } input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); }
.table-shell { overflow: hidden; } .table-shell { overflow: hidden; }
.table-meta { min-height: 40px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; } .table-meta { min-height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); }
.data-table { width: 100%; border-collapse: collapse; } .data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { height: 50px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: .74rem; vertical-align: middle; } .data-table th, .data-table td { min-height: 52px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: var(--type-body); vertical-align: middle; }
.data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; } .data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr { transition: background .14s ease; }.data-table tbody tr:hover { background: #f9fbfc; } .data-table tbody tr { transition: background .14s ease; }.data-table tbody tr:hover { background: #f9fbfc; }
.data-table thead th { height: 38px; color: var(--muted); background: #f6f8fa; font-size: .61rem; font-weight: 700; text-transform: uppercase; letter-spacing: .075em; } .data-table thead th { height: 42px; color: var(--muted); background: #f6f8fa; font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .075em; }
.data-table th[scope="row"] { color: var(--ink); font-weight: 700; }.data-table th[scope="row"] a { color: var(--teal-dark); text-decoration: none; } .data-table th[scope="row"] { color: var(--ink); font-weight: 700; }.data-table th[scope="row"] a { color: var(--teal-dark); text-decoration: none; }
.row-attention { box-shadow: inset 2px 0 var(--warning); }.attention-flag { color: var(--warning); font-size: .67rem; font-weight: 700; } .row-attention { box-shadow: inset 2px 0 var(--warning); }.attention-flag { color: var(--warning); font-size: .67rem; font-weight: 700; }
.data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button { min-height: 36px; padding: 7px 12px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; } .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button { min-height: 36px; padding: 7px 12px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
.data-table td button:hover, .pagination button:hover:not(:disabled) { background: var(--surface-subtle); }.data-table td button:disabled, .pagination button:disabled { opacity: .45; cursor: not-allowed; } .data-table td button:hover, .pagination button:hover:not(:disabled) { background: var(--surface-subtle); }.data-table td button:disabled, .pagination button:disabled { opacity: .45; cursor: not-allowed; }
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: .62rem; }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: var(--type-meta); }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pagination { min-height: 52px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: .68rem; } .pagination { min-height: 56px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--type-meta); }
details summary { cursor: pointer; color: var(--teal-dark); }.data-table details pre { max-width: 280px; overflow: auto; color: var(--ink-soft); white-space: pre-wrap; } details summary { cursor: pointer; color: var(--teal-dark); }.data-table details pre { max-width: 280px; overflow: auto; color: var(--ink-soft); white-space: pre-wrap; }
.tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); } .tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); }
@@ -249,20 +257,26 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--teal-pale); border-color: #bfe6df; } .about-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--teal-pale); border-color: #bfe6df; }
.about-cta strong { display: block; color: var(--ink); font-size: .85rem; } .about-cta strong { display: block; color: var(--ink); font-size: .85rem; }
.about-cta p { margin: 2px 0 0; } .about-cta p { margin: 2px 0 0; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
.about-grid .about-card { margin-bottom: 0; }
.about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; }
.about-details[open] summary { margin-bottom: 8px; }
.about-details summary::-webkit-details-marker { display: none; }
.about-details summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); } .detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); }
.detail-grid div { min-height: 76px; padding: 13px 14px; background: white; } .detail-grid div { min-height: 76px; padding: 13px 14px; background: white; }
.detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; } .detail-grid dt { margin: 0; color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: var(--type-body); font-weight: 700; }
.record-list { display: grid; gap: 1px; margin-bottom: 16px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } .record-list { display: grid; gap: 1px; margin-bottom: 16px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.record-list li { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 11px; padding: 10px 14px; background: white; font-size: .75rem; } .record-list li { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 11px; padding: 10px 14px; background: white; font-size: .75rem; }
.return-form, .return-result { max-width: 880px; margin-top: 22px; padding: 0; overflow: hidden; } .return-form, .return-result { max-width: 880px; margin-top: 22px; padding: 0; overflow: hidden; }
.return-form > .section-heading { padding: 19px 22px; }.return-progress { min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; font-weight: 700; } .return-form > .section-heading { padding: 19px 22px; }.return-progress { position: sticky; top: 64px; z-index: 8; min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }
.return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; } .return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; }
.return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); } .return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; } .return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; }
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); } .condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); } .return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
.review-facts { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: .6rem; text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: .76rem; font-weight: 700; } .review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }
.impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; } .impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; }
.commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); } .commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); }
.success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; } .success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; }
@@ -285,9 +299,17 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.evidence-list li { margin-bottom: 2px; } .evidence-list li { margin-bottom: 2px; }
.status-decision .button { margin-top: 16px; } .status-decision .button { margin-top: 16px; }
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; } .duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); }
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); } .compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); } .merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
.toggle-matching-fields { display: inline-block; margin: 10px 0; padding: 6px 10px; color: var(--ink-soft); background: transparent; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 700; cursor: pointer; }
.toggle-matching-fields:hover { background: var(--surface-subtle); }
.merge-record-preview { margin: 14px 0; padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.merge-record-preview-title { margin: 0 0 8px; color: var(--ink); font-size: .72rem; font-weight: 700; }
.merge-record-preview dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; }
.merge-record-preview dt { margin: 0; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.merge-record-preview dd { margin: 1px 0 0; color: var(--ink); font-size: .78rem; }
.confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); } .confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); }
.resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; } .resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; }
.evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; } .evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; }
@@ -321,7 +343,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); } .resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); }
.resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); } .resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); }
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } .integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 0; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }
.integration-cards .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; }
.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: var(--type-meta); }.integration-cards h2 { margin: 3px 0 7px; font-size: 1rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } .scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } .scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
@@ -435,16 +459,16 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; } .demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) { @media (max-width: 960px) {
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; } .app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; } .filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; } .table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; } .tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
} }
@media (max-width: 440px) { @media (max-width: 440px) {
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } .global-search { max-width: 144px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 58px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: var(--type-badge); }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
} }
+33 -22
View File
@@ -18,7 +18,7 @@ credential values are never embedded; nodes reference named n8n credentials inst
| Active status (as of 2026-08-04) | Active / Published | | Active status (as of 2026-08-04) | Active / Published |
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) | | 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) | | Timeouts / bounded retries | `Record follow-up` HTTP node: 15s timeout, retry on fail (3 tries, 1000ms wait) |
| Checksum (sha256) | `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609` | | Checksum (sha256) | `e5b6ba02a7824867620ceaf214224521d52de76337604b947f26f1b0b5432358` (updated 2026-08-05 — the committed file had invalid JSON, a missing `},` between two node objects; fixed, no live workflow change) |
## 2. Fleet Ops — Scheduled Data Quality Scan ## 2. Fleet Ops — Scheduled Data Quality Scan
@@ -37,33 +37,44 @@ credential values are never embedded; nodes reference named n8n credentials inst
## 3. Fleet Ops — RAGcore Procedure Sync ## 3. Fleet Ops — RAGcore Procedure Sync
Not yet built, but no longer blocked. Credential issuance for the `fleet-ops` application Fully built and saved live (6 real nodes: Schedule Trigger → List procedures → Prepare
previously failed 100% of the time with an opaque rejection ("authoritative service-account uploads → Upload to RAGcore → Summarize sync result → Report sync result to Fleet Ops).
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 **Published/active as of 2026-08-05**, once RAGcore itself went live (see
`src/ragcore/api/v1/control/dependencies.py` where `get_control_application` and `PROJECT_STATE.md`'s "RAGcore actually went live" entry): the workflow's `RAGcore Sync
`get_credential_service` each opened their own independent database transaction, so a Token` credential had also gone stale from the same credential rotation, so a fresh,
freshly-created service account was invisible to the immediately-following credential-issue dedicated, minimally-scoped (`sources:sync` only) credential was minted before
read. Fixed in RAGcore (with explicit owner approval) by sharing one request-scoped publishing. Verified with a real manual execution first — 33 procedures synced, 0
transaction between both dependencies; verified against RAGcore's own test suite (64 failed, Fleet Ops registered the result (`execution_id` 159) — before flipping it to
passing) and deployed to the live instance. A working credential now exists: n8n credential run unattended on its daily schedule.
**"RAGcore Sync Token"** (Header Auth, `Authorization: Bearer <token>`), scope
`sources:sync` for `fleet-ops`. Will build While validating this workflow (2026-08-05), found and fixed a real defect: the "Report
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` against the real RAGcore contract sync result to Fleet Ops" node's three body-parameter expressions each had a stray
(`POST /v1/uploads`, `GET /v1/knowledge-spaces`, etc. — see trailing `}}` (e.g. `={{ $json.execution_id }} }}` instead of `={{ $json.execution_id
`contracts/ragcore-contract-assumptions.md` and the live inspection notes in }}`), which would have sent malformed values on every real run. Fixed via `n8n
`docs/live-ai-integration/n8n-current-state.md`) next. import:workflow` (the safe CLI path — not the REST API, which caused a prior wipe
incident in this environment) against the same live workflow ID, keeping it inactive;
re-exported and verified the fix applied with no other change (still 6 real nodes,
`active: false`). Also found and fixed a second gap: `settings.errorWorkflow` was unset
(workflows 1-2 wire `"errorWorkflow": "Xppn2rAEqUuyiCJF"`, workflow 3 did not) — wired it
the same way via the same CLI import path, re-verified.
| Field | Value | | Field | Value |
|---|---| |---|---|
| File | `fleet-ops-ragcore-procedure-sync.json` (not yet created) | | File | `fleet-ops-ragcore-procedure-sync.json` |
| Live workflow ID | — | | Purpose | Sync the trilingual procedure documents from Fleet Ops into RAGcore as source documents, keeping stable per-document IDs and skipping unchanged content. |
| Active status | Not built | | Trigger | Schedule Trigger (daily at midnight) |
| Event contract | N/A — HTTP-triggered sync. Reads `GET /api/v1/integrations/n8n/procedures`, uploads via RAGcore's `POST /v1/uploads`, reports via `POST /api/v1/integrations/n8n/procedures-sync-result`. |
| Required credentials | `Fleet Ops Service Token` (Header Auth, on the procedures-list and result-report calls); `RAGcore Sync Token` (Header Auth, `sources:sync` scope, on the upload call) |
| Live workflow ID | `6wbkc4d1AouGpmWT` |
| Active status (as of 2026-08-05) | **Active / Published** |
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
| Checksum (sha256) | `643c0515a50fed3d35e28f0b8cb17841f6f8d91699944980b5c56e619d5bf2a6` |
## 4. Fleet Ops — Workflow Error Handler ## 4. Fleet Ops — Workflow Error Handler
Central technical workflow attached to workflows 1-2 via n8n's per-workflow "Error Central technical workflow attached to all three other Fleet Ops workflows (1, 2, 3) via
Workflow" setting (workflow 3 will be wired the same way once it exists). Receives n8n's n8n's per-workflow "Error Workflow" setting. Receives n8n's
standard Error Trigger payload, derives a bounded/secret-free failure report (safe error 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 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`. Ops, which registers an audit event idempotently keyed on `execution_id`.
+1
View File
@@ -33,6 +33,7 @@ KNOWN_WORKFLOWS = [
("fleet-ops-vehicle-return.json", "mobilityops-return-processing"), ("fleet-ops-vehicle-return.json", "mobilityops-return-processing"),
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"), ("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"), ("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"),
("fleet-ops-ragcore-procedure-sync.json", "6wbkc4d1AouGpmWT"),
] ]
# Fields that legitimately differ between a committed definition and the live instance # Fields that legitimately differ between a committed definition and the live instance
@@ -0,0 +1,246 @@
{
"id": "6wbkc4d1AouGpmWT",
"name": "Fleet Ops — RAGcore Procedure Sync",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
128,
-144
],
"id": "08f41c46-d5a9-4cb9-beec-0f074a9eb7bc",
"name": "Sticky Note"
},
{
"parameters": {
"rule": {
"interval": [
{}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
-144,
-144
],
"id": "6edcd359-08eb-4d88-98e9-5ca0625047cc",
"name": "Schedule Trigger"
},
{
"parameters": {
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/procedures",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {
"timeout": 15000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
80,
-144
],
"id": "b66d3ee4-6264-48ab-89fc-aeb4d1597435",
"name": "List procedures",
"retryOnFail": true,
"credentials": {
"httpHeaderAuth": {
"name": "Fleet Ops Service Token"
}
}
},
{
"parameters": {
"jsCode": "const documents = $input.first().json.documents;\nconst results = [];\nfor (const doc of documents) {\n const binary = await this.helpers.prepareBinaryData(Buffer.from(doc.content, 'utf-8'), doc.document_id + '.md', 'text/markdown');\n results.push({json: {id: doc.id, language: doc.language, document_id: doc.document_id, title: doc.title, version: doc.version, content_hash: doc.content_hash}, binary: {file: binary}});\n}\nreturn results;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
304,
-144
],
"id": "e9699a7a-87ff-4603-b89b-553dc7f2f29d",
"name": "Prepare uploads"
},
{
"parameters": {
"method": "POST",
"url": "https://rag.itworx.tech/v1/uploads",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Idempotency-Key",
"value": "={{ $json.id + '-' + $json.content_hash }}"
}
]
},
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"name": "space_id",
"value": "f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc"
},
{
"name": "source_id",
"value": "={{ $json.id }}"
},
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "file"
}
]
},
"options": {
"timeout": 20000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
528,
-144
],
"id": "6d38f228-ed82-44a7-bc59-c785767d4f35",
"name": "Upload to RAGcore",
"retryOnFail": true,
"credentials": {
"httpHeaderAuth": {
"name": "RAGcore Sync Token"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const items = $input.all();\nlet synced = 0;\nlet failed = 0;\nfor (const item of items) {\n if (item.json && item.json.error) {\n failed += 1;\n } else {\n synced += 1;\n }\n}\nreturn [{ json: { execution_id: String($execution.id), synced, failed } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
-144
],
"id": "4b90056e-29f8-4df0-b7b4-695f4b9e0004",
"name": "Summarize sync result"
},
{
"parameters": {
"method": "POST",
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/procedures-sync-result",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "execution_id",
"value": "={{ $json.execution_id }}"
},
{
"name": "synced",
"value": "={{ $json.synced }}"
},
{
"name": "failed",
"value": "={{ $json.failed }}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
848,
-144
],
"id": "953cd587-3d3e-44b5-907c-92b8355b00e3",
"name": "Report sync result to Fleet Ops",
"credentials": {
"httpHeaderAuth": {
"name": "Fleet Ops Service Token"
}
}
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "List procedures",
"type": "main",
"index": 0
}
]
]
},
"List procedures": {
"main": [
[
{
"node": "Prepare uploads",
"type": "main",
"index": 0
}
]
]
},
"Prepare uploads": {
"main": [
[
{
"node": "Upload to RAGcore",
"type": "main",
"index": 0
}
]
]
},
"Upload to RAGcore": {
"main": [
[
{
"node": "Summarize sync result",
"type": "main",
"index": 0
}
]
]
},
"Summarize sync result": {
"main": [
[
{
"node": "Report sync result to Fleet Ops",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false,
"errorWorkflow": "Xppn2rAEqUuyiCJF",
"callerPolicy": "workflowsFromSameOwner"
},
"active": false,
"meta": {
"templateCredsSetupCompleted": false
},
"tags": []
}
@@ -68,6 +68,7 @@
"retryOnFail": true, "retryOnFail": true,
"maxTries": 3, "maxTries": 3,
"waitBetweenTries": 1000 "waitBetweenTries": 1000
},
{ {
"parameters": { "parameters": {
"respondWith": "json", "respondWith": "json",
+13 -5
View File
@@ -1,14 +1,14 @@
public_ref,customer_ref,vehicle_ref,starts_at,ends_at,status,start_odometer_km,end_odometer_km,requirements_complete public_ref,customer_ref,vehicle_ref,starts_at,ends_at,status,start_odometer_km,end_odometer_km,requirements_complete
BK-H-0001,CUS-0012,MO-008,2026-07-26T08:00:00Z,2026-07-29T08:00:00Z,returned,20932,21133,true BK-H-0001,CUS-0012,MO-008,2026-07-29T08:00:00Z,2026-08-01T08:00:00Z,returned,20932,21133,true
BK-H-0002,CUS-0023,MO-015,2026-07-24T07:00:00Z,2026-07-28T07:00:00Z,returned,26659,26861,true BK-H-0002,CUS-0023,MO-015,2026-07-24T07:00:00Z,2026-07-28T07:00:00Z,returned,26659,26861,true
BK-H-0003,CUS-0034,MO-022,2026-07-22T06:00:00Z,2026-07-27T06:00:00Z,returned,31407,31610,true BK-H-0003,CUS-0034,MO-022,2026-07-22T06:00:00Z,2026-07-27T06:00:00Z,returned,31407,31610,true
BK-H-0004,CUS-0045,MO-029,2026-07-20T05:00:00Z,2026-07-26T05:00:00Z,returned,36708,36912,true BK-H-0004,CUS-0045,MO-029,2026-07-20T05:00:00Z,2026-07-26T05:00:00Z,returned,36708,36912,true
BK-H-0005,CUS-0056,MO-036,2026-07-18T04:00:00Z,2026-07-25T04:00:00Z,returned,41655,41860,true BK-H-0005,CUS-0056,MO-036,2026-07-18T04:00:00Z,2026-07-25T04:00:00Z,returned,41655,41860,true
BK-H-0006,CUS-0067,MO-043,2026-07-16T03:00:00Z,2026-07-24T03:00:00Z,returned,46513,46719,true BK-H-0006,CUS-0067,MO-043,2026-07-16T03:00:00Z,2026-07-24T03:00:00Z,returned,46513,46719,true
BK-H-0007,CUS-0078,MO-050,2026-07-14T02:00:00Z,2026-07-23T02:00:00Z,returned,51685,51892,true BK-H-0007,CUS-0078,MO-050,2026-07-14T02:00:00Z,2026-07-23T02:00:00Z,returned,51685,51700,true
BK-H-0008,CUS-0089,MO-007,2026-07-12T09:00:00Z,2026-07-22T09:00:00Z,returned,20681,20889,true BK-H-0008,CUS-0089,MO-007,2026-07-12T09:00:00Z,2026-07-22T09:00:00Z,returned,20681,20889,true
BK-H-0009,CUS-0100,MO-014,2026-07-10T08:00:00Z,2026-07-21T08:00:00Z,returned,25419,25628,true BK-H-0009,CUS-0100,MO-014,2026-07-10T08:00:00Z,2026-07-21T08:00:00Z,returned,25419,25628,true
BK-H-0010,CUS-0111,MO-021,2026-07-18T07:00:00Z,2026-07-20T07:00:00Z,returned,31088,31298,true BK-H-0010,CUS-0111,MO-021,2026-07-18T07:00:00Z,2026-07-20T07:00:00Z,returned,31088,31150,true
BK-H-0011,CUS-0122,MO-028,2026-07-16T06:00:00Z,2026-07-19T06:00:00Z,returned,35948,36159,true BK-H-0011,CUS-0122,MO-028,2026-07-16T06:00:00Z,2026-07-19T06:00:00Z,returned,35948,36159,true
BK-H-0012,CUS-0133,MO-035,2026-07-14T05:00:00Z,2026-07-18T05:00:00Z,returned,41022,41234,true BK-H-0012,CUS-0133,MO-035,2026-07-14T05:00:00Z,2026-07-18T05:00:00Z,returned,41022,41234,true
BK-H-0013,CUS-0144,MO-042,2026-07-12T04:00:00Z,2026-07-17T04:00:00Z,returned,46535,46748,true BK-H-0013,CUS-0144,MO-042,2026-07-12T04:00:00Z,2026-07-17T04:00:00Z,returned,46535,46748,true
@@ -220,8 +220,8 @@ BK-H-0218,CUS-0059,MO-027,2025-12-14T07:00:00Z,2025-12-24T07:00:00Z,returned,352
BK-H-0219,CUS-0070,MO-034,2025-12-12T06:00:00Z,2025-12-23T06:00:00Z,returned,39838,40257,true BK-H-0219,CUS-0070,MO-034,2025-12-12T06:00:00Z,2025-12-23T06:00:00Z,returned,39838,40257,true
BK-H-0220,CUS-0081,MO-041,2025-12-20T05:00:00Z,2025-12-22T05:00:00Z,returned,45223,45643,true BK-H-0220,CUS-0081,MO-041,2025-12-20T05:00:00Z,2025-12-22T05:00:00Z,returned,45223,45643,true
BK-DEMO-RETURN,CUS-0042,MO-024,2026-07-28T09:00:00Z,2026-08-01T09:00:00Z,active,53610,,true BK-DEMO-RETURN,CUS-0042,MO-024,2026-07-28T09:00:00Z,2026-08-01T09:00:00Z,active,53610,,true
BK-F-001,CUS-0014,MO-010,2026-08-03T10:00:00Z,2026-08-07T10:00:00Z,reserved,,,true BK-F-001,CUS-0014,MO-010,2026-08-01T10:00:00Z,2026-08-05T10:00:00Z,reserved,,,true
BK-F-002,CUS-0027,MO-019,2026-08-04T11:00:00Z,2026-08-09T11:00:00Z,reserved,,,true BK-F-002,CUS-0027,MO-019,2026-08-01T11:00:00Z,2026-08-06T11:00:00Z,reserved,,,true
BK-F-003,CUS-0040,MO-028,2026-08-05T12:00:00Z,2026-08-11T12:00:00Z,reserved,,,false BK-F-003,CUS-0040,MO-028,2026-08-05T12:00:00Z,2026-08-11T12:00:00Z,reserved,,,false
BK-F-004,CUS-0053,MO-037,2026-08-06T13:00:00Z,2026-08-13T13:00:00Z,reserved,,,true BK-F-004,CUS-0053,MO-037,2026-08-06T13:00:00Z,2026-08-13T13:00:00Z,reserved,,,true
BK-F-005,CUS-0066,MO-046,2026-08-07T09:00:00Z,2026-08-15T09:00:00Z,reserved,,,true BK-F-005,CUS-0066,MO-046,2026-08-07T09:00:00Z,2026-08-15T09:00:00Z,reserved,,,true
@@ -245,3 +245,11 @@ BK-F-022,CUS-0107,MO-049,2026-08-24T11:00:00Z,2026-09-02T11:00:00Z,reserved,,,tr
BK-DEMO-NEXT,CUS-0088,MO-031,2026-08-02T10:00:00Z,2026-08-07T09:00:00Z,reserved,,,true BK-DEMO-NEXT,CUS-0088,MO-031,2026-08-02T10:00:00Z,2026-08-07T09:00:00Z,reserved,,,true
BK-DEMO-OVERLAP-A,CUS-0101,MO-016,2026-08-03T09:00:00Z,2026-08-08T09:00:00Z,reserved,,,true BK-DEMO-OVERLAP-A,CUS-0101,MO-016,2026-08-03T09:00:00Z,2026-08-08T09:00:00Z,reserved,,,true
BK-DEMO-OVERLAP-B,CUS-0102,MO-016,2026-08-05T09:00:00Z,2026-08-10T09:00:00Z,reserved,,,true BK-DEMO-OVERLAP-B,CUS-0102,MO-016,2026-08-05T09:00:00Z,2026-08-10T09:00:00Z,reserved,,,true
BK-T-001,CUS-0033,MO-003,2026-07-24T05:30:00Z,2026-08-01T05:30:00Z,returned,20200,20850,true
BK-T-002,CUS-0055,MO-011,2026-07-27T06:30:00Z,2026-08-01T06:30:00Z,returned,25400,25980,true
BK-T-003,CUS-0077,MO-020,2026-07-22T08:45:00Z,2026-08-01T08:45:00Z,returned,31800,32350,true
BK-T-004,CUS-0099,MO-033,2026-07-30T12:00:00Z,2026-08-01T12:00:00Z,returned,41700,42200,true
BK-T-005,CUS-0111,MO-006,2026-08-01T07:15:00Z,2026-08-04T07:15:00Z,reserved,,,true
BK-T-006,CUS-0123,MO-017,2026-08-01T10:30:00Z,2026-08-06T10:30:00Z,reserved,,,true
BK-T-007,CUS-0141,MO-029,2026-08-01T13:15:00Z,2026-08-08T13:15:00Z,reserved,,,true
BK-T-008,CUS-0155,MO-038,2026-08-01T14:30:00Z,2026-08-09T14:30:00Z,reserved,,,true
1 public_ref customer_ref vehicle_ref starts_at ends_at status start_odometer_km end_odometer_km requirements_complete
2 BK-H-0001 CUS-0012 MO-008 2026-07-26T08:00:00Z 2026-07-29T08:00:00Z 2026-07-29T08:00:00Z 2026-08-01T08:00:00Z returned 20932 21133 true
3 BK-H-0002 CUS-0023 MO-015 2026-07-24T07:00:00Z 2026-07-28T07:00:00Z returned 26659 26861 true
4 BK-H-0003 CUS-0034 MO-022 2026-07-22T06:00:00Z 2026-07-27T06:00:00Z returned 31407 31610 true
5 BK-H-0004 CUS-0045 MO-029 2026-07-20T05:00:00Z 2026-07-26T05:00:00Z returned 36708 36912 true
6 BK-H-0005 CUS-0056 MO-036 2026-07-18T04:00:00Z 2026-07-25T04:00:00Z returned 41655 41860 true
7 BK-H-0006 CUS-0067 MO-043 2026-07-16T03:00:00Z 2026-07-24T03:00:00Z returned 46513 46719 true
8 BK-H-0007 CUS-0078 MO-050 2026-07-14T02:00:00Z 2026-07-23T02:00:00Z returned 51685 51892 51700 true
9 BK-H-0008 CUS-0089 MO-007 2026-07-12T09:00:00Z 2026-07-22T09:00:00Z returned 20681 20889 true
10 BK-H-0009 CUS-0100 MO-014 2026-07-10T08:00:00Z 2026-07-21T08:00:00Z returned 25419 25628 true
11 BK-H-0010 CUS-0111 MO-021 2026-07-18T07:00:00Z 2026-07-20T07:00:00Z returned 31088 31298 31150 true
12 BK-H-0011 CUS-0122 MO-028 2026-07-16T06:00:00Z 2026-07-19T06:00:00Z returned 35948 36159 true
13 BK-H-0012 CUS-0133 MO-035 2026-07-14T05:00:00Z 2026-07-18T05:00:00Z returned 41022 41234 true
14 BK-H-0013 CUS-0144 MO-042 2026-07-12T04:00:00Z 2026-07-17T04:00:00Z returned 46535 46748 true
220 BK-H-0219 CUS-0070 MO-034 2025-12-12T06:00:00Z 2025-12-23T06:00:00Z returned 39838 40257 true
221 BK-H-0220 CUS-0081 MO-041 2025-12-20T05:00:00Z 2025-12-22T05:00:00Z returned 45223 45643 true
222 BK-DEMO-RETURN CUS-0042 MO-024 2026-07-28T09:00:00Z 2026-08-01T09:00:00Z active 53610 true
223 BK-F-001 CUS-0014 MO-010 2026-08-03T10:00:00Z 2026-08-01T10:00:00Z 2026-08-07T10:00:00Z 2026-08-05T10:00:00Z reserved true
224 BK-F-002 CUS-0027 MO-019 2026-08-04T11:00:00Z 2026-08-01T11:00:00Z 2026-08-09T11:00:00Z 2026-08-06T11:00:00Z reserved true
225 BK-F-003 CUS-0040 MO-028 2026-08-05T12:00:00Z 2026-08-11T12:00:00Z reserved false
226 BK-F-004 CUS-0053 MO-037 2026-08-06T13:00:00Z 2026-08-13T13:00:00Z reserved true
227 BK-F-005 CUS-0066 MO-046 2026-08-07T09:00:00Z 2026-08-15T09:00:00Z reserved true
245 BK-DEMO-NEXT CUS-0088 MO-031 2026-08-02T10:00:00Z 2026-08-07T09:00:00Z reserved true
246 BK-DEMO-OVERLAP-A CUS-0101 MO-016 2026-08-03T09:00:00Z 2026-08-08T09:00:00Z reserved true
247 BK-DEMO-OVERLAP-B CUS-0102 MO-016 2026-08-05T09:00:00Z 2026-08-10T09:00:00Z reserved true
248 BK-T-001 CUS-0033 MO-003 2026-07-24T05:30:00Z 2026-08-01T05:30:00Z returned 20200 20850 true
249 BK-T-002 CUS-0055 MO-011 2026-07-27T06:30:00Z 2026-08-01T06:30:00Z returned 25400 25980 true
250 BK-T-003 CUS-0077 MO-020 2026-07-22T08:45:00Z 2026-08-01T08:45:00Z returned 31800 32350 true
251 BK-T-004 CUS-0099 MO-033 2026-07-30T12:00:00Z 2026-08-01T12:00:00Z returned 41700 42200 true
252 BK-T-005 CUS-0111 MO-006 2026-08-01T07:15:00Z 2026-08-04T07:15:00Z reserved true
253 BK-T-006 CUS-0123 MO-017 2026-08-01T10:30:00Z 2026-08-06T10:30:00Z reserved true
254 BK-T-007 CUS-0141 MO-029 2026-08-01T13:15:00Z 2026-08-08T13:15:00Z reserved true
255 BK-T-008 CUS-0155 MO-038 2026-08-01T14:30:00Z 2026-08-09T14:30:00Z reserved true
+17 -11
View File
@@ -3,14 +3,20 @@ DQ-DEMO-DUPLICATE,possible_duplicate_customer,CUS-0012,CUS-0178,high,open,exact
DQ-DEMO-OVERLAP,booking_overlap,MO-016,BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B,high,open,controlled legacy import overlap DQ-DEMO-OVERLAP,booking_overlap,MO-016,BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B,high,open,controlled legacy import overlap
DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked available while reserved bookings conflict DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked available while reserved bookings conflict
DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing
DQ-0005,vehicle_status_conflict,MO-036,,high,open,Synthetic deterministic seed issue DQ-0005,vehicle_status_conflict,MO-036,,high,open,Recommended status: maintenance (44660 km reported against a 44000 km service threshold)
DQ-0006,missing_required_field,MO-043,,low,open,Synthetic deterministic seed issue DQ-0006,missing_required_field,MO-043,,low,open,Missing: location
DQ-0007,odometer_regression,MO-050,,medium,open,Synthetic deterministic seed issue DQ-0007,odometer_regression,MO-050,,medium,open,Booking BK-H-0007 recorded 51700 km, below the 51892 km recorded by earlier booking BK-H-0057.
DQ-0008,vehicle_status_conflict,MO-007,,high,open,Synthetic deterministic seed issue DQ-0008,vehicle_status_conflict,MO-007,,high,open,Recommended status: available (marked rented with no active booking)
DQ-0009,missing_required_field,MO-014,,low,open,Synthetic deterministic seed issue DQ-0009,missing_required_field,MO-014,,low,open,Missing: location
DQ-0010,odometer_regression,MO-021,,medium,open,Synthetic deterministic seed issue DQ-0010,odometer_regression,MO-021,,medium,open,Booking BK-H-0010 recorded 31150 km, below the 31298 km recorded by earlier booking BK-H-0060.
DQ-0011,vehicle_status_conflict,MO-028,,high,open,Synthetic deterministic seed issue DQ-0011,vehicle_status_conflict,MO-028,,high,open,Recommended status: maintenance (38959 km reported against a 38000 km service threshold)
DQ-0012,missing_required_field,MO-035,,low,resolved,Synthetic deterministic seed issue DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number
DQ-0013,odometer_regression,MO-042,,medium,resolved,Synthetic deterministic seed issue DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location
DQ-0014,vehicle_status_conflict,MO-049,,high,resolved,Synthetic deterministic seed issue DQ-0014,missing_required_field,MO-049,,low,resolved,Missing: registration_number
DQ-0015,missing_required_field,MO-006,,low,resolved,Synthetic deterministic seed issue DQ-0015,missing_required_field,MO-006,,low,resolved,Missing: location
DQ-0016,missing_required_field,MO-009,,medium,open,Missing: location
DQ-0017,missing_required_field,MO-025,,medium,open,Missing: location
DQ-0018,missing_required_field,MO-026,,medium,open,Missing: registration_number
DQ-0019,missing_required_field,MO-041,,medium,open,Missing: location
DQ-0020,missing_required_field,MO-045,,medium,open,Missing: location
DQ-0021,missing_required_field,MO-049,,medium,open,Missing: location
1 public_ref public_ref,rule_type,entity_ref,related_ref,severity,status,evidence rule_type entity_ref related_ref severity status evidence
3 DQ-DEMO-OVERLAP DQ-DEMO-OVERLAP,booking_overlap,MO-016,BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B,high,open,controlled legacy import overlap booking_overlap MO-016 BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B high open controlled legacy import overlap
4 DQ-DEMO-STATUS DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked available while reserved bookings conflict vehicle_status_conflict MO-016 high open vehicle marked available while reserved bookings conflict
5 DQ-DEMO-ATTENTION DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing missing_required_field MO-031 BK-DEMO-NEXT high open near-future booking; required operational inspection missing
6 DQ-0005 DQ-0005,vehicle_status_conflict,MO-036,,high,open,Recommended status: maintenance (44660 km reported against a 44000 km service threshold) vehicle_status_conflict MO-036 high open Synthetic deterministic seed issue
7 DQ-0006 DQ-0006,missing_required_field,MO-043,,low,open,Missing: location missing_required_field MO-043 low open Synthetic deterministic seed issue
8 DQ-0007 DQ-0007,odometer_regression,MO-050,,medium,open,Booking BK-H-0007 recorded 51700 km, below the 51892 km recorded by earlier booking BK-H-0057. odometer_regression MO-050 medium open Synthetic deterministic seed issue
9 DQ-0008 DQ-0008,vehicle_status_conflict,MO-007,,high,open,Recommended status: available (marked rented with no active booking) vehicle_status_conflict MO-007 high open Synthetic deterministic seed issue
10 DQ-0009 DQ-0009,missing_required_field,MO-014,,low,open,Missing: location missing_required_field MO-014 low open Synthetic deterministic seed issue
11 DQ-0010 DQ-0010,odometer_regression,MO-021,,medium,open,Booking BK-H-0010 recorded 31150 km, below the 31298 km recorded by earlier booking BK-H-0060. odometer_regression MO-021 medium open Synthetic deterministic seed issue
12 DQ-0011 DQ-0011,vehicle_status_conflict,MO-028,,high,open,Recommended status: maintenance (38959 km reported against a 38000 km service threshold) vehicle_status_conflict MO-028 high open Synthetic deterministic seed issue
13 DQ-0012 DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number missing_required_field MO-035 low resolved Synthetic deterministic seed issue
14 DQ-0013 DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location odometer_regression MO-042 medium resolved Synthetic deterministic seed issue
15 DQ-0014 DQ-0014,missing_required_field,MO-049,,low,resolved,Missing: registration_number vehicle_status_conflict MO-049 high resolved Synthetic deterministic seed issue
16 DQ-0015 DQ-0015,missing_required_field,MO-006,,low,resolved,Missing: location missing_required_field MO-006 low resolved Synthetic deterministic seed issue
17 DQ-0016,missing_required_field,MO-009,,medium,open,Missing: location
18 DQ-0017,missing_required_field,MO-025,,medium,open,Missing: location
19 DQ-0018,missing_required_field,MO-026,,medium,open,Missing: registration_number
20 DQ-0019,missing_required_field,MO-041,,medium,open,Missing: location
21 DQ-0020,missing_required_field,MO-045,,medium,open,Missing: location
22 DQ-0021,missing_required_field,MO-049,,medium,open,Missing: location
+10 -10
View File
@@ -7,12 +7,12 @@ MO-005,Bürstner,Lyseo,2024,2-MOB-005,Geel,available,22213,30000,true
MO-006,Sunlight,Cliff,2025,2-MOB-006,Geel,cleaning,23163,30000,true MO-006,Sunlight,Cliff,2025,2-MOB-006,Geel,cleaning,23163,30000,true
MO-007,Adria,Matrix,2019,2-MOB-007,Geel,rented,23689,30000,true MO-007,Adria,Matrix,2019,2-MOB-007,Geel,rented,23689,30000,true
MO-008,Dethleffs,Trend,2020,2-MOB-008,Geel,available,23933,30000,true MO-008,Dethleffs,Trend,2020,2-MOB-008,Geel,available,23933,30000,true
MO-009,Carado,T-Series,2021,2-MOB-009,Geel,blocked,25203,30000,true MO-009,Carado,T-Series,2021,2-MOB-009,,blocked,25203,30000,true
MO-010,Hymer,Exsis,2022,2-MOB-010,Geel,available,25333,30000,true MO-010,Hymer,Exsis,2022,2-MOB-010,Geel,available,25333,30000,true
MO-011,Bürstner,Lyseo,2023,2-MOB-011,Geel,available,26160,30000,true MO-011,Bürstner,Lyseo,2023,2-MOB-011,Geel,available,26160,30000,true
MO-012,Sunlight,Cliff,2024,2-MOB-012,Geel,available,26859,30000,true MO-012,Sunlight,Cliff,2024,2-MOB-012,Geel,available,26859,30000,true
MO-013,Adria,Matrix,2025,2-MOB-013,Geel,rented,27750,30000,true MO-013,Adria,Matrix,2025,2-MOB-013,Geel,rented,27750,30000,true
MO-014,Dethleffs,Trend,2019,2-MOB-014,Geel,rented,28428,30000,true MO-014,Dethleffs,Trend,2019,2-MOB-014,,rented,28428,30000,true
MO-015,Carado,T-Series,2020,2-MOB-015,Geel,available,29661,30000,true MO-015,Carado,T-Series,2020,2-MOB-015,Geel,available,29661,30000,true
MO-016,Hymer,Exsis,2021,2-MOB-016,Geel,available,30497,40000,true MO-016,Hymer,Exsis,2021,2-MOB-016,Geel,available,30497,40000,true
MO-017,Bürstner,Lyseo,2022,2-MOB-017,Geel,available,30596,40000,true MO-017,Bürstner,Lyseo,2022,2-MOB-017,Geel,available,30596,40000,true
@@ -23,10 +23,10 @@ MO-021,Carado,T-Series,2019,2-MOB-021,Geel,cleaning,34098,40000,true
MO-022,Hymer,Exsis,2020,2-MOB-022,Geel,available,34410,40000,true MO-022,Hymer,Exsis,2020,2-MOB-022,Geel,available,34410,40000,true
MO-023,Bürstner,Lyseo,2021,2-MOB-023,Geel,rented,35624,40000,true MO-023,Bürstner,Lyseo,2021,2-MOB-023,Geel,rented,35624,40000,true
MO-024,Sunlight,Cliff,2022,2-MOB-024,Geel,rented,54820,40000,true MO-024,Sunlight,Cliff,2022,2-MOB-024,Geel,rented,54820,40000,true
MO-025,Adria,Matrix,2023,2-MOB-025,Geel,blocked,36429,40000,true MO-025,Adria,Matrix,2023,2-MOB-025,,blocked,36429,40000,true
MO-026,Dethleffs,Trend,2024,2-MOB-026,Geel,blocked,37273,40000,true MO-026,Dethleffs,Trend,2024,,Geel,blocked,37273,40000,true
MO-027,Carado,T-Series,2025,2-MOB-027,Geel,available,38502,40000,true MO-027,Carado,T-Series,2025,2-MOB-027,Geel,available,38502,40000,true
MO-028,Hymer,Exsis,2019,2-MOB-028,Geel,cleaning,38959,40000,true MO-028,Hymer,Exsis,2019,2-MOB-028,Geel,cleaning,38959,38000,true
MO-029,Bürstner,Lyseo,2020,2-MOB-029,Geel,rented,39712,40000,true MO-029,Bürstner,Lyseo,2020,2-MOB-029,Geel,rented,39712,40000,true
MO-030,Sunlight,Cliff,2021,2-MOB-030,Geel,maintenance,40213,50000,true MO-030,Sunlight,Cliff,2021,2-MOB-030,Geel,maintenance,40213,50000,true
MO-031,Adria,Matrix,2022,2-MOB-031,Geel,blocked,41149,50000,true MO-031,Adria,Matrix,2022,2-MOB-031,Geel,blocked,41149,50000,true
@@ -34,18 +34,18 @@ MO-032,Dethleffs,Trend,2023,2-MOB-032,Geel,rented,41791,50000,true
MO-033,Carado,T-Series,2024,2-MOB-033,Geel,available,42561,50000,true MO-033,Carado,T-Series,2024,2-MOB-033,Geel,available,42561,50000,true
MO-034,Hymer,Exsis,2025,2-MOB-034,Geel,available,43057,50000,true MO-034,Hymer,Exsis,2025,2-MOB-034,Geel,available,43057,50000,true
MO-035,Bürstner,Lyseo,2019,2-MOB-035,Geel,rented,44034,50000,true MO-035,Bürstner,Lyseo,2019,2-MOB-035,Geel,rented,44034,50000,true
MO-036,Sunlight,Cliff,2020,2-MOB-036,Geel,available,44660,50000,true MO-036,Sunlight,Cliff,2020,2-MOB-036,Geel,available,44660,44000,true
MO-037,Adria,Matrix,2021,2-MOB-037,Geel,available,45654,50000,true MO-037,Adria,Matrix,2021,2-MOB-037,Geel,available,45654,50000,true
MO-038,Dethleffs,Trend,2022,2-MOB-038,Geel,available,46630,50000,true MO-038,Dethleffs,Trend,2022,2-MOB-038,Geel,available,46630,50000,true
MO-039,Carado,T-Series,2023,2-MOB-039,Geel,rented,46657,50000,true MO-039,Carado,T-Series,2023,2-MOB-039,Geel,rented,46657,50000,true
MO-040,Hymer,Exsis,2024,2-MOB-040,Geel,available,47909,50000,true MO-040,Hymer,Exsis,2024,2-MOB-040,Geel,available,47909,50000,true
MO-041,Bürstner,Lyseo,2025,2-MOB-041,Geel,blocked,48443,50000,true MO-041,Bürstner,Lyseo,2025,2-MOB-041,,blocked,48443,50000,true
MO-042,Sunlight,Cliff,2019,2-MOB-042,Geel,available,49548,50000,true MO-042,Sunlight,Cliff,2019,2-MOB-042,Geel,available,49548,50000,true
MO-043,Adria,Matrix,2020,2-MOB-043,Geel,available,49519,50000,true MO-043,Adria,Matrix,2020,2-MOB-043,,available,49519,50000,true
MO-044,Dethleffs,Trend,2021,2-MOB-044,Geel,available,50611,60000,true MO-044,Dethleffs,Trend,2021,2-MOB-044,Geel,available,50611,60000,true
MO-045,Carado,T-Series,2022,2-MOB-045,Geel,blocked,51260,60000,true MO-045,Carado,T-Series,2022,2-MOB-045,,blocked,51260,60000,true
MO-046,Hymer,Exsis,2023,2-MOB-046,Geel,cleaning,51957,60000,true MO-046,Hymer,Exsis,2023,2-MOB-046,Geel,cleaning,51957,60000,true
MO-047,Bürstner,Lyseo,2024,2-MOB-047,Geel,available,52452,60000,true MO-047,Bürstner,Lyseo,2024,2-MOB-047,Geel,available,52452,60000,true
MO-048,Sunlight,Cliff,2025,2-MOB-048,Geel,maintenance,53693,60000,true MO-048,Sunlight,Cliff,2025,2-MOB-048,Geel,maintenance,53693,60000,true
MO-049,Adria,Matrix,2019,2-MOB-049,Geel,blocked,53935,60000,true MO-049,Adria,Matrix,2019,2-MOB-049,,blocked,53935,60000,true
MO-050,Dethleffs,Trend,2020,2-MOB-050,Geel,cleaning,54692,60000,true MO-050,Dethleffs,Trend,2020,2-MOB-050,Geel,cleaning,54692,60000,true
1 public_ref make model model_year registration_number location operational_status odometer_km next_service_km active
7 MO-006 Sunlight Cliff 2025 2-MOB-006 Geel cleaning 23163 30000 true
8 MO-007 Adria Matrix 2019 2-MOB-007 Geel rented 23689 30000 true
9 MO-008 Dethleffs Trend 2020 2-MOB-008 Geel available 23933 30000 true
10 MO-009 Carado T-Series 2021 2-MOB-009 Geel blocked 25203 30000 true
11 MO-010 Hymer Exsis 2022 2-MOB-010 Geel available 25333 30000 true
12 MO-011 Bürstner Lyseo 2023 2-MOB-011 Geel available 26160 30000 true
13 MO-012 Sunlight Cliff 2024 2-MOB-012 Geel available 26859 30000 true
14 MO-013 Adria Matrix 2025 2-MOB-013 Geel rented 27750 30000 true
15 MO-014 Dethleffs Trend 2019 2-MOB-014 Geel rented 28428 30000 true
16 MO-015 Carado T-Series 2020 2-MOB-015 Geel available 29661 30000 true
17 MO-016 Hymer Exsis 2021 2-MOB-016 Geel available 30497 40000 true
18 MO-017 Bürstner Lyseo 2022 2-MOB-017 Geel available 30596 40000 true
23 MO-022 Hymer Exsis 2020 2-MOB-022 Geel available 34410 40000 true
24 MO-023 Bürstner Lyseo 2021 2-MOB-023 Geel rented 35624 40000 true
25 MO-024 Sunlight Cliff 2022 2-MOB-024 Geel rented 54820 40000 true
26 MO-025 Adria Matrix 2023 2-MOB-025 Geel blocked 36429 40000 true
27 MO-026 Dethleffs Trend 2024 2-MOB-026 Geel blocked 37273 40000 true
28 MO-027 Carado T-Series 2025 2-MOB-027 Geel available 38502 40000 true
29 MO-028 Hymer Exsis 2019 2-MOB-028 Geel cleaning 38959 40000 38000 true
30 MO-029 Bürstner Lyseo 2020 2-MOB-029 Geel rented 39712 40000 true
31 MO-030 Sunlight Cliff 2021 2-MOB-030 Geel maintenance 40213 50000 true
32 MO-031 Adria Matrix 2022 2-MOB-031 Geel blocked 41149 50000 true
34 MO-033 Carado T-Series 2024 2-MOB-033 Geel available 42561 50000 true
35 MO-034 Hymer Exsis 2025 2-MOB-034 Geel available 43057 50000 true
36 MO-035 Bürstner Lyseo 2019 2-MOB-035 Geel rented 44034 50000 true
37 MO-036 Sunlight Cliff 2020 2-MOB-036 Geel available 44660 50000 44000 true
38 MO-037 Adria Matrix 2021 2-MOB-037 Geel available 45654 50000 true
39 MO-038 Dethleffs Trend 2022 2-MOB-038 Geel available 46630 50000 true
40 MO-039 Carado T-Series 2023 2-MOB-039 Geel rented 46657 50000 true
41 MO-040 Hymer Exsis 2024 2-MOB-040 Geel available 47909 50000 true
42 MO-041 Bürstner Lyseo 2025 2-MOB-041 Geel blocked 48443 50000 true
43 MO-042 Sunlight Cliff 2019 2-MOB-042 Geel available 49548 50000 true
44 MO-043 Adria Matrix 2020 2-MOB-043 Geel available 49519 50000 true
45 MO-044 Dethleffs Trend 2021 2-MOB-044 Geel available 50611 60000 true
46 MO-045 Carado T-Series 2022 2-MOB-045 Geel blocked 51260 60000 true
47 MO-046 Hymer Exsis 2023 2-MOB-046 Geel cleaning 51957 60000 true
48 MO-047 Bürstner Lyseo 2024 2-MOB-047 Geel available 52452 60000 true
49 MO-048 Sunlight Cliff 2025 2-MOB-048 Geel maintenance 53693 60000 true
50 MO-049 Adria Matrix 2019 2-MOB-049 Geel blocked 53935 60000 true
51 MO-050 Dethleffs Trend 2020 2-MOB-050 Geel cleaning 54692 60000 true