Compare commits
+5
-2
@@ -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
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ test-results/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ M7 — complete. All milestones (M0–M7) 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
@@ -14,7 +15,7 @@ from app.models.booking import Booking
|
|||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import AuditEventOut, CurrentUser
|
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||||
|
|
||||||
@@ -51,26 +52,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
|
|||||||
return refs
|
return refs
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AuditEventOut])
|
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
|
||||||
def list_audit_events(
|
def list_audit_events(
|
||||||
actor_label: str | None = Query(default=None),
|
actor_label: str | None = Query(default=None),
|
||||||
action: str | None = Query(default=None),
|
action: str | None = Query(default=None),
|
||||||
entity_type: str | None = Query(default=None),
|
entity_type: str | None = Query(default=None),
|
||||||
|
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
correlation_id: str | None = Query(default=None),
|
correlation_id: str | None = Query(default=None),
|
||||||
limit: int = Query(default=100, le=500),
|
occurred_from: datetime | None = Query(default=None),
|
||||||
|
occurred_to: datetime | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[AuditEventOut]:
|
) -> list[AuditEventOut] | AuditEventPageOut:
|
||||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
|
||||||
if actor_label:
|
if actor_label:
|
||||||
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
||||||
if action:
|
if action:
|
||||||
stmt = stmt.where(AuditEvent.action == action)
|
stmt = stmt.where(AuditEvent.action == action)
|
||||||
if entity_type:
|
if entity_type:
|
||||||
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
||||||
|
if entity_ref:
|
||||||
|
matched_ids: set[uuid.UUID] = set()
|
||||||
|
for model in _ENTITY_MODELS.values():
|
||||||
|
matched_ids.update(
|
||||||
|
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
|
||||||
|
)
|
||||||
|
if not matched_ids:
|
||||||
|
if page is None:
|
||||||
|
return []
|
||||||
|
return AuditEventPageOut(
|
||||||
|
items=[], page=1, page_size=page_size, total=0, total_pages=1
|
||||||
|
)
|
||||||
|
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||||
if correlation_id:
|
if correlation_id:
|
||||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||||
events = db.scalars(stmt).all()
|
if occurred_from:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
|
||||||
|
if occurred_to:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
events = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
entity_refs = _resolve_entity_refs(db, events)
|
entity_refs = _resolve_entity_refs(db, events)
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
@@ -94,4 +120,13 @@ def list_audit_events(
|
|||||||
metadata=e.metadata_json,
|
metadata=e.metadata_json,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
if page is None:
|
||||||
|
return out
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return AuditEventPageOut(
|
||||||
|
items=out,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
|||||||
AutomationRunOut,
|
AutomationRunOut,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DashboardOut,
|
DashboardOut,
|
||||||
|
EvidenceSignalOut,
|
||||||
TodayItem,
|
TodayItem,
|
||||||
)
|
)
|
||||||
from app.services.operations import compute_metrics
|
from app.services.operations import compute_metrics
|
||||||
@@ -61,19 +62,34 @@ def get_dashboard(
|
|||||||
entity = customers_by_id.get(issue.entity_id)
|
entity = customers_by_id.get(issue.entity_id)
|
||||||
link_type = "customer"
|
link_type = "customer"
|
||||||
link_ref = entity.public_ref if entity else ""
|
link_ref = entity.public_ref if entity else ""
|
||||||
|
# The backend never emits prose for the attention queue -- only stable signal
|
||||||
|
# codes + raw data params, exactly like the issue detail page's evidence list
|
||||||
|
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
|
||||||
|
# that turns these into the operator's selected language; `evidence_json["summary"]`
|
||||||
|
# is a technical fallback only, never rendered here.
|
||||||
|
signals = [
|
||||||
|
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
|
||||||
|
for s in issue.evidence_json.get("signals", [])
|
||||||
|
]
|
||||||
attention_items.append(
|
attention_items.append(
|
||||||
AttentionItem(
|
AttentionItem(
|
||||||
kind="quality_issue",
|
kind="quality_issue",
|
||||||
severity=issue.severity,
|
severity=issue.severity,
|
||||||
rule_type=issue.rule_type,
|
rule_type=issue.rule_type,
|
||||||
detail=issue.evidence_json.get("summary", ""),
|
evidence_signals=signals,
|
||||||
link_type=link_type,
|
link_type=link_type,
|
||||||
link_ref=link_ref,
|
link_ref=link_ref,
|
||||||
issue_ref=issue.public_ref,
|
issue_ref=issue.public_ref,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3))
|
# Curate a credible severity mix instead of letting `high` dominate every slot:
|
||||||
attention_items = attention_items[:8]
|
# each item's real severity is unchanged, only the display selection is capped per
|
||||||
|
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
|
||||||
|
# issues doesn't crowd out medium/low ones the operator should still see.
|
||||||
|
high_items = [i for i in attention_items if i.severity == "high"]
|
||||||
|
medium_items = [i for i in attention_items if i.severity == "medium"]
|
||||||
|
low_items = [i for i in attention_items if i.severity == "low"]
|
||||||
|
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
|
||||||
|
|
||||||
today = _today()
|
today = _today()
|
||||||
bookings = db.scalars(select(Booking)).all()
|
bookings = db.scalars(select(Booking)).all()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
@@ -16,6 +16,7 @@ from app.schemas import (
|
|||||||
CurrentUser,
|
CurrentUser,
|
||||||
DataQualityIssueDetailOut,
|
DataQualityIssueDetailOut,
|
||||||
DataQualityIssueOut,
|
DataQualityIssueOut,
|
||||||
|
DataQualityIssuePageOut,
|
||||||
MergeCustomersRequest,
|
MergeCustomersRequest,
|
||||||
MergeCustomersResult,
|
MergeCustomersResult,
|
||||||
ProvideFieldsRequest,
|
ProvideFieldsRequest,
|
||||||
@@ -54,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues", response_model=list[DataQualityIssueOut])
|
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
|
||||||
def list_issues(
|
def list_issues(
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
rule_type: str | None = Query(default=None),
|
rule_type: str | None = Query(default=None),
|
||||||
severity: str | None = Query(default=None),
|
severity: str | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[DataQualityIssueOut]:
|
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
|
||||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(DataQualityIssue.status == status)
|
stmt = stmt.where(DataQualityIssue.status == status)
|
||||||
@@ -69,8 +72,22 @@ def list_issues(
|
|||||||
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(DataQualityIssue.severity == severity)
|
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||||
issues = db.scalars(stmt).all()
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
return [_to_out(i) for i in issues]
|
page_number = page or 1
|
||||||
|
issues = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
items = [_to_out(i) for i in issues]
|
||||||
|
if page is None:
|
||||||
|
return items
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return DataQualityIssuePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Every public reference in this system carries its entity type in its own prefix
|
# Every public reference in this system carries its entity type in its own prefix
|
||||||
@@ -115,6 +132,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
return {
|
return {
|
||||||
"entity_type": "vehicle",
|
"entity_type": "vehicle",
|
||||||
"public_ref": vehicle.public_ref,
|
"public_ref": vehicle.public_ref,
|
||||||
|
"registration_number": vehicle.registration_number,
|
||||||
"make": vehicle.make,
|
"make": vehicle.make,
|
||||||
"model": vehicle.model,
|
"model": vehicle.model,
|
||||||
"location": vehicle.location,
|
"location": vehicle.location,
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
from app.core.config import get_settings
|
from app.schemas import CurrentUser, IntegrationStatusOut
|
||||||
from app.schemas import (
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
CurrentUser,
|
|
||||||
IntegrationStatusOut,
|
|
||||||
McpHubIntegrationStatus,
|
|
||||||
)
|
|
||||||
from app.services.integration_status import derive_n8n_status
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=IntegrationStatusOut)
|
@router.get("/status", response_model=IntegrationStatusOut)
|
||||||
@@ -23,8 +17,5 @@ def integration_status(
|
|||||||
) -> IntegrationStatusOut:
|
) -> IntegrationStatusOut:
|
||||||
return IntegrationStatusOut(
|
return IntegrationStatusOut(
|
||||||
n8n=derive_n8n_status(db),
|
n8n=derive_n8n_status(db),
|
||||||
mcp_hub=McpHubIntegrationStatus(
|
mcp_hub=derive_mcp_hub_status(db),
|
||||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
|
||||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Header, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None:
|
def get_correlation_id(
|
||||||
|
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
|
||||||
|
) -> str:
|
||||||
|
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
|
||||||
|
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
|
||||||
|
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
|
||||||
|
if x_correlation_id:
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(x_correlation_id))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_service_request(
|
||||||
|
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
|
||||||
|
) -> None:
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="service",
|
actor_type="service",
|
||||||
actor_label=client_id,
|
actor_label=client_id,
|
||||||
action="mcp_tool_request",
|
action="mcp_tool_request",
|
||||||
entity_type="mcp_tool",
|
entity_type="mcp_tool",
|
||||||
correlation_id=uuid.uuid4(),
|
correlation_id=uuid.UUID(correlation_id),
|
||||||
metadata={"tool": tool, "status": status_label},
|
metadata={"tool": tool, "status": status_label},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
|
|||||||
def operations_summary(
|
def operations_summary(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> OperationsSummaryOut:
|
) -> OperationsSummaryOut:
|
||||||
metrics = compute_metrics(db)
|
metrics = compute_metrics(db)
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok"
|
db,
|
||||||
|
client_id=client_id,
|
||||||
|
tool="fleet_ops_get_operations_summary",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
|
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
|
||||||
|
|
||||||
@@ -59,12 +80,17 @@ def attention_vehicles(
|
|||||||
limit: int = Query(default=20, ge=1, le=50),
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> list[AttentionVehicleOut]:
|
) -> list[AttentionVehicleOut]:
|
||||||
results = list_attention_vehicles(
|
results = list_attention_vehicles(
|
||||||
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
|
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
|
||||||
)
|
)
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok"
|
db,
|
||||||
|
client_id=client_id,
|
||||||
|
tool="fleet_ops_list_attention_vehicles",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return [AttentionVehicleOut(**r) for r in results]
|
return [AttentionVehicleOut(**r) for r in results]
|
||||||
|
|
||||||
@@ -74,14 +100,16 @@ def vehicle_details(
|
|||||||
vehicle_ref: str,
|
vehicle_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> McpVehicleDetailOut:
|
) -> McpVehicleDetailOut:
|
||||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db,
|
db,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
tool="mobilityops_get_vehicle_details",
|
tool="fleet_ops_get_vehicle_details",
|
||||||
status_label="not_found",
|
status_label="not_found",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
||||||
|
|
||||||
@@ -99,7 +127,11 @@ def vehicle_details(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok"
|
db,
|
||||||
|
client_id=client_id,
|
||||||
|
tool="fleet_ops_get_vehicle_details",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return McpVehicleDetailOut(
|
return McpVehicleDetailOut(
|
||||||
public_ref=vehicle.public_ref,
|
public_ref=vehicle.public_ref,
|
||||||
@@ -120,15 +152,16 @@ def search_knowledge(
|
|||||||
body: McpKnowledgeSearchRequest,
|
body: McpKnowledgeSearchRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
client_id: str = Depends(require_mcp_service_token),
|
client_id: str = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
) -> GroundedAnswer:
|
) -> GroundedAnswer:
|
||||||
provider = get_knowledge_provider()
|
provider = get_knowledge_provider()
|
||||||
correlation_id = str(uuid.uuid4())
|
answer = provider.ask(body.question, correlation_id, language=body.locale)
|
||||||
answer = provider.ask(body.question, correlation_id)
|
|
||||||
answer.sources = answer.sources[: body.max_sources]
|
answer.sources = answer.sources[: body.max_sources]
|
||||||
_audit_service_request(
|
_audit_service_request(
|
||||||
db,
|
db,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
tool="mobilityops_search_knowledge",
|
tool="fleet_ops_search_knowledge",
|
||||||
status_label=answer.evidence_state,
|
status_label=answer.evidence_state,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
return answer
|
return answer
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
|||||||
MaintenanceOut,
|
MaintenanceOut,
|
||||||
VehicleDetailOut,
|
VehicleDetailOut,
|
||||||
VehicleOut,
|
VehicleOut,
|
||||||
|
VehiclePageOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||||
@@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set:
|
|||||||
return set(rows)
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[VehicleOut])
|
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
|
||||||
def list_vehicles(
|
def list_vehicles(
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
attention_only: bool = Query(default=False),
|
attention_only: bool = Query(default=False),
|
||||||
|
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[VehicleOut]:
|
) -> list[VehicleOut] | VehiclePageOut:
|
||||||
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(Vehicle.operational_status == status)
|
stmt = stmt.where(Vehicle.operational_status == status)
|
||||||
vehicles = db.scalars(stmt).all()
|
if query:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
Vehicle.make.ilike(term),
|
||||||
|
Vehicle.model.ilike(term),
|
||||||
|
Vehicle.location.ilike(term),
|
||||||
|
Vehicle.registration_number.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
attention_ids = _attention_vehicle_ids(db)
|
attention_ids = _attention_vehicle_ids(db)
|
||||||
out = [
|
if attention_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
|
||||||
|
)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
vehicles = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
items = [
|
||||||
VehicleOut(
|
VehicleOut(
|
||||||
public_ref=v.public_ref,
|
public_ref=v.public_ref,
|
||||||
make=v.make,
|
make=v.make,
|
||||||
@@ -62,9 +85,16 @@ def list_vehicles(
|
|||||||
)
|
)
|
||||||
for v in vehicles
|
for v in vehicles
|
||||||
]
|
]
|
||||||
if attention_only:
|
if page is None:
|
||||||
out = [v for v in out if v.attention]
|
return items
|
||||||
return out
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return VehiclePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||||
from app.schemas import AutomationRunOut, CurrentUser
|
from app.schemas import AutomationRunOut, CurrentUser
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
|||||||
attempts=event.attempts,
|
attempts=event.attempts,
|
||||||
last_error=event.last_error,
|
last_error=event.last_error,
|
||||||
last_error_code=event.last_error_code,
|
last_error_code=event.last_error_code,
|
||||||
|
is_demo_scenario=is_demo_scenario_failure(event),
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,15 +64,27 @@ def retry_workflow(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Captured before the status flips, so the audit records what was actually retried.
|
||||||
|
was_demo_scenario = is_demo_scenario_failure(event)
|
||||||
|
|
||||||
event.delivery_status = "pending"
|
event.delivery_status = "pending"
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
|
# The retry itself is real either way: the event goes back on the outbox and the
|
||||||
|
# dispatcher delivers it to the configured n8n webhook like any other. The only
|
||||||
|
# difference recorded here is *what* was retried -- a staged demo failure or a real
|
||||||
|
# one -- so the audit trail never implies a production incident was resolved when a
|
||||||
|
# prop was.
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_label=user.display_name,
|
actor_label=user.display_name,
|
||||||
action="workflow_retry",
|
action="workflow_retry",
|
||||||
entity_type="outbox_event",
|
entity_type="outbox_event",
|
||||||
metadata={"event_id": event_id, "previous_attempts": event.attempts},
|
metadata={
|
||||||
|
"event_id": event_id,
|
||||||
|
"previous_attempts": event.attempts,
|
||||||
|
"demo_scenario": was_demo_scenario,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _to_out(event)
|
return _to_out(event)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -18,7 +18,7 @@ from app.models.data_quality import DataQualityIssue
|
|||||||
from app.models.idempotency import IdempotencyRecord
|
from app.models.idempotency import IdempotencyRecord
|
||||||
from app.models.inspection import Inspection
|
from app.models.inspection import Inspection
|
||||||
from app.models.maintenance import MaintenanceRecord
|
from app.models.maintenance import MaintenanceRecord
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
@@ -139,47 +139,49 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
|
|
||||||
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
vehicle_rows = []
|
vehicle_rows = []
|
||||||
|
vehicle_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("vehicles.csv"):
|
for row in _read_csv("vehicles.csv"):
|
||||||
vid = uuid.uuid4()
|
vid = uuid.uuid4()
|
||||||
vehicle_id_by_ref[row["public_ref"]] = vid
|
vehicle_id_by_ref[row["public_ref"]] = vid
|
||||||
vehicle_rows.append(
|
vehicle_row = {
|
||||||
{
|
"id": vid,
|
||||||
"id": vid,
|
"public_ref": row["public_ref"],
|
||||||
"public_ref": row["public_ref"],
|
"make": row["make"],
|
||||||
"make": row["make"],
|
"model": row["model"],
|
||||||
"model": row["model"],
|
"model_year": int(row["model_year"]),
|
||||||
"model_year": int(row["model_year"]),
|
"registration_number": row["registration_number"],
|
||||||
"registration_number": row["registration_number"],
|
"location": row["location"],
|
||||||
"location": row["location"],
|
"operational_status": row["operational_status"],
|
||||||
"operational_status": row["operational_status"],
|
"odometer_km": int(row["odometer_km"]),
|
||||||
"odometer_km": int(row["odometer_km"]),
|
"next_service_km": int(row["next_service_km"]),
|
||||||
"next_service_km": int(row["next_service_km"]),
|
"active": _parse_bool(row["active"]),
|
||||||
"active": _parse_bool(row["active"]),
|
"version": 1,
|
||||||
"version": 1,
|
}
|
||||||
}
|
vehicle_rows.append(vehicle_row)
|
||||||
)
|
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
|
||||||
db.execute(insert(Vehicle), vehicle_rows)
|
db.execute(insert(Vehicle), vehicle_rows)
|
||||||
counts["vehicles"] = len(vehicle_rows)
|
counts["vehicles"] = len(vehicle_rows)
|
||||||
|
|
||||||
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
booking_rows = []
|
booking_rows = []
|
||||||
|
booking_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("bookings.csv"):
|
for row in _read_csv("bookings.csv"):
|
||||||
bid = uuid.uuid4()
|
bid = uuid.uuid4()
|
||||||
booking_id_by_ref[row["public_ref"]] = bid
|
booking_id_by_ref[row["public_ref"]] = bid
|
||||||
booking_rows.append(
|
booking_row = {
|
||||||
{
|
"id": bid,
|
||||||
"id": bid,
|
"public_ref": row["public_ref"],
|
||||||
"public_ref": row["public_ref"],
|
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||||
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||||
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
"status": row["status"],
|
||||||
"status": row["status"],
|
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
||||||
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
}
|
||||||
}
|
booking_rows.append(booking_row)
|
||||||
)
|
booking_row_by_ref[row["public_ref"]] = booking_row
|
||||||
db.execute(insert(Booking), booking_rows)
|
db.execute(insert(Booking), booking_rows)
|
||||||
counts["bookings"] = len(booking_rows)
|
counts["bookings"] = len(booking_rows)
|
||||||
|
|
||||||
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
return "customer", customer_id_by_ref[entity_ref]
|
return "customer", customer_id_by_ref[entity_ref]
|
||||||
return "vehicle", vehicle_id_by_ref[entity_ref]
|
return "vehicle", vehicle_id_by_ref[entity_ref]
|
||||||
|
|
||||||
|
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
|
||||||
|
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
|
||||||
|
# handful of seed-only rows below -- none of them carry an active rental or a
|
||||||
|
# real booking conflict (verified against the fixed seed dataset), only a
|
||||||
|
# genuinely-crossed service threshold or none at all, so those two fields are
|
||||||
|
# the only ones that vary per vehicle.
|
||||||
|
vehicle = vehicle_row_by_ref[vehicle_ref]
|
||||||
|
return {
|
||||||
|
"active_booking_refs": [],
|
||||||
|
"overlapping_booking_pairs": [],
|
||||||
|
"service_threshold_reached": service_threshold_reached,
|
||||||
|
"odometer_km": vehicle["odometer_km"],
|
||||||
|
"next_service_km": vehicle["next_service_km"],
|
||||||
|
"open_booking_overlap_issue_ref": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
|
||||||
|
later = booking_row_by_ref[later_ref]
|
||||||
|
earlier = booking_row_by_ref[earlier_ref]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"params": {
|
||||||
|
"later_ref": later_ref,
|
||||||
|
"later_km": later["end_odometer_km"],
|
||||||
|
"earlier_ref": earlier_ref,
|
||||||
|
"earlier_km": earlier["end_odometer_km"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def _missing_field_signal(field: str) -> list[dict]:
|
||||||
|
return [{"code": "missing_field", "params": {"field": field}}]
|
||||||
|
|
||||||
|
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
|
||||||
|
# used to carry no structured signal at all -- just the placeholder summary
|
||||||
|
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
|
||||||
|
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
|
||||||
|
# pair of booking odometer readings engineered into seed/bookings.csv), using the
|
||||||
|
# exact same signal vocabulary the live scan (app.services.data_quality) already
|
||||||
|
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||||
|
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
|
||||||
|
"DQ-0005": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.service_threshold_reached",
|
||||||
|
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0006": _missing_field_signal("location"),
|
||||||
|
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
|
||||||
|
"DQ-0008": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.rental_ended",
|
||||||
|
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0009": _missing_field_signal("location"),
|
||||||
|
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
|
||||||
|
"DQ-0011": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.service_threshold_reached",
|
||||||
|
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0012": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0013": _missing_field_signal("location"),
|
||||||
|
"DQ-0014": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0015": _missing_field_signal("location"),
|
||||||
|
"DQ-0016": _missing_field_signal("location"),
|
||||||
|
"DQ-0017": _missing_field_signal("location"),
|
||||||
|
"DQ-0018": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0019": _missing_field_signal("location"),
|
||||||
|
"DQ-0020": _missing_field_signal("location"),
|
||||||
|
"DQ-0021": _missing_field_signal("location"),
|
||||||
|
}
|
||||||
|
|
||||||
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
||||||
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
||||||
# they carry real, accurate structured signals (not just a legacy English
|
# they carry real, accurate structured signals (not just a legacy English
|
||||||
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return []
|
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
|
||||||
|
|
||||||
dq_rows = []
|
dq_rows = []
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -323,7 +401,10 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"last_error": row["last_error"] or None,
|
"last_error": row["last_error"] or None,
|
||||||
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
||||||
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
||||||
"last_error_code": "connectionError" if row["last_error"] else None,
|
# It is coded as a *prepared demo scenario*, not as a real
|
||||||
|
# connectionError, so integration health never degrades because of a
|
||||||
|
# prop and a viewer is told plainly that this failure is staged.
|
||||||
|
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
|
||||||
"external_run_id": None,
|
"external_run_id": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.models.booking import Booking
|
|||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||||
from app.services.integration_status import derive_n8n_status
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
from app.services.knowledge import get_knowledge_provider
|
from app.services.knowledge import get_knowledge_provider
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -124,6 +124,7 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
|||||||
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||||
n8n = derive_n8n_status(db)
|
n8n = derive_n8n_status(db)
|
||||||
knowledge_health = get_knowledge_provider().health()
|
knowledge_health = get_knowledge_provider().health()
|
||||||
|
mcp_hub = derive_mcp_hub_status(db)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DemoIntegrationSummaryOut(
|
DemoIntegrationSummaryOut(
|
||||||
@@ -141,17 +142,23 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
|||||||
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
||||||
detail_code="ragcoreDetail",
|
detail_code="ragcoreDetail",
|
||||||
detail_params={
|
detail_params={
|
||||||
"count": knowledge_health.document_count,
|
"count": (
|
||||||
|
knowledge_health.document_count
|
||||||
|
if knowledge_health.document_count is not None
|
||||||
|
else "unknown"
|
||||||
|
),
|
||||||
"collection": knowledge_health.collection,
|
"collection": knowledge_health.collection,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DemoIntegrationSummaryOut(
|
DemoIntegrationSummaryOut(
|
||||||
key="mcp_hub",
|
key="mcp_hub",
|
||||||
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected",
|
# `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
|
||||||
|
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
|
||||||
|
# to be called. Only real recorded `mcp_tool_request` calls make this
|
||||||
|
# "operational" -- same evidence rule the integration status page uses.
|
||||||
|
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||||
detail_code=(
|
detail_code=(
|
||||||
"mcpDetailEnabled"
|
"mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected"
|
||||||
if settings.mcp_hub_registration_enabled
|
|
||||||
else "mcpDetailNotConnected"
|
|
||||||
),
|
),
|
||||||
detail_params={},
|
detail_params={},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ class KnowledgeHealth(BaseModel):
|
|||||||
tenant: str
|
tenant: str
|
||||||
workspace: str
|
workspace: str
|
||||||
collection: str
|
collection: str
|
||||||
document_count: int
|
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
|
||||||
|
# unknown, never "zero procedures".
|
||||||
|
document_count: int | None
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeProvider(Protocol):
|
class KnowledgeProvider(Protocol):
|
||||||
|
|||||||
@@ -7,6 +7,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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ def test_audit_requires_operations_manager(employee_client):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert body["page"] == 1
|
||||||
|
assert body["page_size"] == 25
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
assert body["total_pages"] >= 1
|
||||||
|
|
||||||
|
|
||||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
|
|||||||
assert item["severity"] in ("low", "medium", "high")
|
assert item["severity"] in ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
|
||||||
|
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
|
||||||
|
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
|
||||||
|
The API must never emit prose here -- only stable signal codes + params, exactly
|
||||||
|
like the data-quality issue detail page, for the frontend to localize."""
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["attention_items"]) > 0
|
||||||
|
for item in body["attention_items"]:
|
||||||
|
assert "detail" not in item
|
||||||
|
assert len(item["evidence_signals"]) > 0
|
||||||
|
for signal in item["evidence_signals"]:
|
||||||
|
assert signal["code"]
|
||||||
|
assert signal["code"] != "Synthetic deterministic seed issue"
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
||||||
response = ops_client.get("/api/v1/dashboard")
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ def test_list_issues_requires_operations_manager(employee_client):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"severity": "high", "page": 1, "page_size": 25},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert all(issue["severity"] == "high" for issue in body["items"])
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_get_issue_requires_operations_manager(employee_client):
|
def test_get_issue_requires_operations_manager(employee_client):
|
||||||
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -220,6 +232,21 @@ def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
|
|||||||
assert vehicle["registration_number"] == "TST-999"
|
assert vehicle["registration_number"] == "TST-999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
|
||||||
|
"""The snapshot used to omit registration_number entirely, so the 'provide missing
|
||||||
|
fields' form always showed it blank -- even for a vehicle whose plate was actually
|
||||||
|
on file, and even when a *different* field was the genuinely missing one."""
|
||||||
|
issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"rule_type": "missing_required_field", "status": "open"},
|
||||||
|
).json()
|
||||||
|
target = next(i for i in issues if i["entity_type"] == "vehicle")
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
|
||||||
|
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||||
|
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_overlap_requires_operations_manager(employee_client):
|
def test_resolve_overlap_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|||||||
@@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
|
|||||||
assert events[0]["actor_type"] == "service"
|
assert events[0]["actor_type"] == "service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_knowledge_respects_requested_locale(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/mcp/search-knowledge",
|
||||||
|
json={
|
||||||
|
"question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||||
|
"max_sources": 1,
|
||||||
|
"locale": "nl-BE",
|
||||||
|
},
|
||||||
|
headers=_headers(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["evidence_state"] == "grounded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client):
|
||||||
|
inbound = "11111111-1111-1111-1111-111111111111"
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/mcp/search-knowledge",
|
||||||
|
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
|
||||||
|
headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["correlation_id"] == inbound
|
||||||
|
|
||||||
|
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||||
|
matching = [e for e in events if e["correlation_id"] == inbound]
|
||||||
|
assert len(matching) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/mcp/operations-summary",
|
||||||
|
headers=_headers(client_id="no-correlation-probe"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||||
|
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"]
|
||||||
|
assert len(matching) >= 1
|
||||||
|
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
|
||||||
|
|
||||||
|
|
||||||
def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
||||||
for method, path in [
|
for method, path in [
|
||||||
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||||
|
|||||||
+111
-6
@@ -7,7 +7,7 @@ from app.models.audit import AuditEvent
|
|||||||
from app.models.booking import Booking
|
from app.models.booking import Booking
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
||||||
@@ -22,13 +22,16 @@ def test_seed_counts_match_deterministic_dataset():
|
|||||||
reset_and_seed(db)
|
reset_and_seed(db)
|
||||||
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
||||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
# 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a
|
||||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
# real day of traffic rather than the same fixed 4 rows on every reset.
|
||||||
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
|
assert db.scalar(select(func.count()).select_from(Booking)) == 254
|
||||||
|
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
|
||||||
|
# real open issue) plus a deterministic set discovered by the post-seed scan. The
|
||||||
|
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
|
||||||
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
||||||
# already crossed its service-due odometer threshold -- a genuine conflict the
|
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||||
# previous hand-rolled scanner never checked for.
|
# previous hand-rolled scanner never checked for.
|
||||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
|
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
|
||||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||||
finally:
|
finally:
|
||||||
@@ -142,7 +145,10 @@ def test_seed_scenario_s5_failed_workflow_run():
|
|||||||
assert failed.delivery_status == "failed"
|
assert failed.delivery_status == "failed"
|
||||||
assert failed.attempts >= 1
|
assert failed.attempts >= 1
|
||||||
assert failed.last_error
|
assert failed.last_error
|
||||||
assert failed.last_error_code == "connectionError"
|
# Coded as a prepared demo scenario, not as a real connectionError: the whole
|
||||||
|
# point of this row is to demonstrate retry and audit, so nothing downstream
|
||||||
|
# may read it as evidence that the n8n integration is unhealthy.
|
||||||
|
assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -174,3 +180,102 @@ def test_seed_dates_are_anchored_to_reset_moment():
|
|||||||
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_today_movements_are_a_credible_mix():
|
||||||
|
"""A fresh reset must not land on a thin, always-identical 'Today's movements'
|
||||||
|
dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across
|
||||||
|
more than 4 distinct vehicles) should fall on the reset day, mirroring the same
|
||||||
|
status/date rule the dashboard router uses to build the today list."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
bookings = db.scalars(select(Booking)).all()
|
||||||
|
departures = [
|
||||||
|
b
|
||||||
|
for b in bookings
|
||||||
|
if b.starts_at.date() == today and b.status in ("reserved", "active")
|
||||||
|
]
|
||||||
|
returns = [
|
||||||
|
b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")
|
||||||
|
]
|
||||||
|
assert len(departures) >= 5
|
||||||
|
assert len(returns) >= 5
|
||||||
|
vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns}
|
||||||
|
assert len(vehicles_involved) > 4
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_blocked_vehicle_has_a_real_open_issue():
|
||||||
|
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
|
||||||
|
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
|
||||||
|
vehicle seeded as 'blocked' must now have at least one real, currently open
|
||||||
|
DataQualityIssue an operator can click through to."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
|
||||||
|
assert len(blocked) > 0
|
||||||
|
for vehicle in blocked:
|
||||||
|
open_issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_scenario_mo024_service_conflict_is_flagged():
|
||||||
|
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
|
||||||
|
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
|
||||||
|
with nothing surfacing the contradiction. The shared vehicle-status evaluator
|
||||||
|
already catches this (a real conflict, not a fabricated third status) -- this test
|
||||||
|
exists so a future change can't silently regress it back to unexplained."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||||
|
assert vehicle is not None
|
||||||
|
assert vehicle.operational_status == "rented"
|
||||||
|
assert vehicle.odometer_km >= vehicle.next_service_km
|
||||||
|
|
||||||
|
issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.rule_type == "vehicle_status_conflict",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert issue is not None
|
||||||
|
signals = issue.evidence_json.get("signals", [])
|
||||||
|
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_evidence_has_no_placeholder_summary():
|
||||||
|
"""Every seed-only data-quality issue used to carry the vacuous evidence
|
||||||
|
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
|
||||||
|
had no way to understand why it needed attention. Every issue must now carry a real
|
||||||
|
summary and at least one localizable signal (code + params)."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
issues = db.scalars(select(DataQualityIssue)).all()
|
||||||
|
assert len(issues) > 0
|
||||||
|
for issue in issues:
|
||||||
|
summary = issue.evidence_json.get("summary", "")
|
||||||
|
assert summary != "Synthetic deterministic seed issue", (
|
||||||
|
f"{issue.public_ref} still has the meaningless placeholder summary"
|
||||||
|
)
|
||||||
|
signals = issue.evidence_json.get("signals", [])
|
||||||
|
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
|
|||||||
assert all(v["attention"] for v in vehicles)
|
assert all(v["attention"] for v in vehicles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/vehicles",
|
||||||
|
params={"status": "maintenance", "page": 1, "page_size": 25},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert all(v["operational_status"] == "maintenance" for v in body["items"])
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_vehicle_detail_includes_related_records(ops_client):
|
def test_vehicle_detail_includes_related_records(ops_client):
|
||||||
response = ops_client.get("/api/v1/vehicles/MO-016")
|
response = ops_client.get("/api/v1/vehicles/MO-016")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
|
def _reseed() -> None:
|
||||||
|
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
|
||||||
|
|
||||||
|
The suite shares one session-scoped database and earlier files legitimately mutate
|
||||||
|
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
|
||||||
|
rather than depend on file ordering.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_list_workflows_requires_operations_manager(employee_client):
|
def test_list_workflows_requires_operations_manager(employee_client):
|
||||||
response = employee_client.get("/api/v1/workflows")
|
response = employee_client.get("/api/v1/workflows")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_list_workflows_includes_seeded_failed_run(ops_client):
|
def test_list_workflows_includes_seeded_failed_run(ops_client):
|
||||||
|
_reseed()
|
||||||
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
|
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
runs = response.json()
|
runs = response.json()
|
||||||
@@ -20,6 +39,7 @@ def test_retry_requires_failed_status(ops_client):
|
|||||||
|
|
||||||
|
|
||||||
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
||||||
|
_reseed()
|
||||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
target = failed[0]["event_id"]
|
target = failed[0]["event_id"]
|
||||||
|
|
||||||
@@ -36,3 +56,42 @@ def test_retry_requires_operations_manager(employee_client):
|
|||||||
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
|
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
|
||||||
|
"""The one seeded failure must announce itself as staged. An unexplained red row in
|
||||||
|
a demo reads as a broken product; a labelled one reads as the retry story it is."""
|
||||||
|
_reseed()
|
||||||
|
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
|
demo_runs = [run for run in runs if run["is_demo_scenario"]]
|
||||||
|
assert len(demo_runs) == 1
|
||||||
|
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
|
||||||
|
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
|
||||||
|
|
||||||
|
|
||||||
|
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
|
||||||
|
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
|
||||||
|
assert runs
|
||||||
|
assert all(run["is_demo_scenario"] is False for run in runs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
|
||||||
|
"""The retry is a real redelivery either way; the audit records which kind of
|
||||||
|
failure it resolved so a staged retry is never mistaken for a production fix."""
|
||||||
|
_reseed()
|
||||||
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
|
demo_run = next(run for run in failed if run["is_demo_scenario"])
|
||||||
|
|
||||||
|
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "pending"
|
||||||
|
|
||||||
|
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
|
||||||
|
entry = next(
|
||||||
|
event
|
||||||
|
for event in audit
|
||||||
|
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
|
||||||
|
== demo_run["event_id"]
|
||||||
|
)
|
||||||
|
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
|
||||||
|
assert metadata["demo_scenario"] is True
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# MobilityOps visual product roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
|
||||||
|
implementation plan. It improves the existing operational workflows without
|
||||||
|
expanding the locked product scope.
|
||||||
|
|
||||||
|
The intended outcome is a platform that:
|
||||||
|
|
||||||
|
- lets an operator identify and act on urgent work within thirty seconds;
|
||||||
|
- remains efficient with the current synthetic data set and larger realistic data sets;
|
||||||
|
- keeps status, context and primary actions visible on desktop and mobile;
|
||||||
|
- uses a readable, consistent visual hierarchy;
|
||||||
|
- communicates integration and knowledge health without contradictory signals;
|
||||||
|
- preserves the existing domain rules, audit guarantees and graceful degradation.
|
||||||
|
|
||||||
|
## Scope guardrails
|
||||||
|
|
||||||
|
Included:
|
||||||
|
|
||||||
|
- information architecture and visual hierarchy;
|
||||||
|
- list density, pagination, filtering and responsive presentation;
|
||||||
|
- dashboard, detail, return, data-quality, knowledge, automation, audit and demo flows;
|
||||||
|
- accessibility, perceived performance and UI observability;
|
||||||
|
- regression tests, visual evidence and rollout safeguards.
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
|
||||||
|
- new accounting, payments, CRM, inventory, HR or reservation modules;
|
||||||
|
- maintenance work orders, calendar views or advanced analytics;
|
||||||
|
- changes to RAGcore or ITWorx MCP Hub repositories;
|
||||||
|
- autonomous write actions or write-capable MCP tools;
|
||||||
|
- a redesign of backend domain rules that is not required by the UI work.
|
||||||
|
|
||||||
|
## Audit baseline
|
||||||
|
|
||||||
|
The audit was performed against the deployed application at desktop, tablet and
|
||||||
|
mobile widths. The current strengths to preserve are the professional visual
|
||||||
|
identity, consistent status language, strong login page, semantic page structure,
|
||||||
|
visible keyboard focus, absence of horizontal overflow and clear return and
|
||||||
|
data-quality narratives.
|
||||||
|
|
||||||
|
The principal improvement signals are:
|
||||||
|
|
||||||
|
| Area | Baseline observation | Consequence |
|
||||||
|
|---|---|---|
|
||||||
|
| Audit log | Up to 100 expanded event cards; approximately 18,500 px desktop and 20,500 px mobile | Poor scanability and excessive scrolling |
|
||||||
|
| Vehicle list | All 50 vehicles render at once; approximately 12,000 px on mobile | Operational lookup is slow on small screens |
|
||||||
|
| Mobile page header | `.page-actions` is hidden below 700 px | Important record status disappears |
|
||||||
|
| Typography | Many metadata labels and badges are approximately 9–11 px | Readability is weaker than the visual quality suggests |
|
||||||
|
| Top bar | Search, language, demo controls, disclosure, timezone and operator controls compete for space | High cognitive load and weak prioritisation |
|
||||||
|
| Knowledge | An operational provider can be shown beside “0 procedures indexed” | Trust signal is ambiguous or contradictory |
|
||||||
|
| Integration cards | Technical identifiers and small prose dominate the summary | Operational state is harder to scan |
|
||||||
|
|
||||||
|
These values are baselines, not permanent acceptance thresholds. Each affected
|
||||||
|
phase must record a before-and-after measurement.
|
||||||
|
|
||||||
|
## Delivery principles
|
||||||
|
|
||||||
|
1. Fix operational friction before decorative polish.
|
||||||
|
2. Prefer progressive disclosure over removing useful evidence.
|
||||||
|
3. Keep the primary status and next action visible at every viewport.
|
||||||
|
4. Paginate or virtualise unbounded collections; never solve them only with CSS.
|
||||||
|
5. Derive every displayed count and state from persisted or external evidence.
|
||||||
|
6. Preserve URLs, permissions, audit semantics and keyboard operation.
|
||||||
|
7. Ship each phase as a coherent, independently reversible commit.
|
||||||
|
8. Validate every phase at 390 px, 768 px and 1440 px.
|
||||||
|
|
||||||
|
## Target measures
|
||||||
|
|
||||||
|
The roadmap is complete when the following targets are met:
|
||||||
|
|
||||||
|
| Measure | Target |
|
||||||
|
|---|---|
|
||||||
|
| Initial rows/cards in any operational list | At most 25, unless a documented compact virtualised view is used |
|
||||||
|
| Mobile status visibility | Primary record status visible on every detail page |
|
||||||
|
| Horizontal overflow | None at 360 px and above |
|
||||||
|
| Default text | At least 14 px |
|
||||||
|
| Secondary metadata | At least 12 px; exceptions require an accessibility justification |
|
||||||
|
| Touch target | At least 44 by 44 CSS px for primary mobile controls |
|
||||||
|
| Keyboard access | All actions reachable with visible focus and logical order |
|
||||||
|
| List navigation | Filter, paging and search state represented in the URL where practical |
|
||||||
|
| Dashboard comprehension | Primary operational risks and next movements visible without scrolling at 1440 × 900 |
|
||||||
|
| Browser console | No application errors during the five-minute demo |
|
||||||
|
| Performance | No avoidable rendering of more than one page of list records |
|
||||||
|
| Automated acceptance | Existing backend, frontend and Playwright gates remain green |
|
||||||
|
|
||||||
|
## Roadmap overview
|
||||||
|
|
||||||
|
| Phase | Theme | Priority | Indicative effort | Depends on |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| R0 | Baseline and design-system guardrails | P0 | 2–3 days | None |
|
||||||
|
| R1 | Operational lists and audit log | P0 | 5–7 days | R0 |
|
||||||
|
| R2 | Responsive shell and readable hierarchy | P0 | 4–6 days | R0 |
|
||||||
|
| R3 | Dashboard and record-detail efficiency | P1 | 5–7 days | R1, R2 |
|
||||||
|
| R4 | Guided workflows and decision surfaces | P1 | 5–7 days | R2, R3 |
|
||||||
|
| R5 | Knowledge, automation and trust signals | P1 | 3–5 days | R2 |
|
||||||
|
| R6 | Accessibility, performance and release evidence | P0 gate | 4–6 days | R1–R5 |
|
||||||
|
|
||||||
|
Indicative total: 28–41 focused engineering days. Phases can span multiple
|
||||||
|
calendar sprints, but their exit gates should not be split across releases.
|
||||||
|
|
||||||
|
## R0 — Baseline and design-system guardrails
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Create shared UI rules and reproducible measurements before changing individual
|
||||||
|
pages.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R0.1 — Visual regression baseline
|
||||||
|
|
||||||
|
- Capture authenticated reference screenshots for the seven principal routes and
|
||||||
|
the login page at 390 × 844, 768 × 1024 and 1440 × 900.
|
||||||
|
- Add stable screenshot fixtures using the deterministic seed.
|
||||||
|
- Mask only genuinely variable timestamps; do not mask operational content.
|
||||||
|
- Record viewport width, page height, visible item count and horizontal overflow.
|
||||||
|
|
||||||
|
#### R0.2 — Typography and spacing tokens
|
||||||
|
|
||||||
|
- Replace scattered sub-12 px declarations with named type tokens.
|
||||||
|
- Establish body, metadata, label, badge, table-header and navigation sizes.
|
||||||
|
- Define compact and comfortable density variants without duplicating page CSS.
|
||||||
|
- Preserve the existing petrol/teal palette and status colours.
|
||||||
|
|
||||||
|
#### R0.3 — Shared responsive patterns
|
||||||
|
|
||||||
|
- Define a mobile page-header contract: title, visible status, primary action and
|
||||||
|
overflow actions.
|
||||||
|
- Define reusable compact-list, filter-toolbar, pagination and empty-state patterns.
|
||||||
|
- Establish 44 px mobile target sizing and a consistent sticky-offset system.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Visual baselines exist for all principal routes and viewports.
|
||||||
|
- No normal operational metadata is smaller than 12 px.
|
||||||
|
- Shared components document their responsive behaviour.
|
||||||
|
- Existing Playwright demo and frontend build pass unchanged.
|
||||||
|
|
||||||
|
## R1 — Operational lists and audit log
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Make high-volume operational information scannable and bounded on every device.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R1.1 — Audit API pagination and filtering
|
||||||
|
|
||||||
|
- Replace the default fixed `limit=100` response with cursor or page-based pagination.
|
||||||
|
- Add filters for date range, action, actor, entity type, entity reference and
|
||||||
|
correlation ID.
|
||||||
|
- Retain stable newest-first ordering and service-token protections.
|
||||||
|
- Return total or continuation metadata suitable for the selected paging model.
|
||||||
|
|
||||||
|
#### R1.2 — Compact audit timeline
|
||||||
|
|
||||||
|
- Render 20–25 compact events per page.
|
||||||
|
- Group correlated events and repetitive scheduled events without hiding their count.
|
||||||
|
- Move full UUIDs, before/after data and technical metadata into a details drawer.
|
||||||
|
- Keep human-readable action, actor, target, outcome and timestamp in the main row.
|
||||||
|
- Preserve direct navigation to related records.
|
||||||
|
|
||||||
|
#### R1.3 — Vehicle-list pagination
|
||||||
|
|
||||||
|
- Add server-compatible pagination to the vehicle list.
|
||||||
|
- Preserve status, location, attention and search filters across pages.
|
||||||
|
- Store filter and page state in the URL.
|
||||||
|
- Provide a prominent “attention required” view without inventing new metrics.
|
||||||
|
- Use compact mobile rows rather than full table-to-card expansion for every vehicle.
|
||||||
|
|
||||||
|
#### R1.4 — Data-quality queue controls
|
||||||
|
|
||||||
|
- Paginate the open issue list.
|
||||||
|
- Add severity and rule grouping/filtering.
|
||||||
|
- Add “previous issue” and “next issue” navigation to review pages.
|
||||||
|
- Keep scan and resolution actions permission-aware.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- No default list renders more than 25 records.
|
||||||
|
- Audit and vehicle pages remain below 3,500 px at the tested mobile viewport with
|
||||||
|
the deterministic seed.
|
||||||
|
- Paging and filters survive reload, back navigation and direct links.
|
||||||
|
- Empty, loading, error and end-of-results states are explicit.
|
||||||
|
- API tests cover invalid cursors/pages, combined filters and authorization.
|
||||||
|
|
||||||
|
## R2 — Responsive shell and readable hierarchy
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Reduce navigation noise and keep essential context visible on small screens.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R2.1 — Top-bar simplification
|
||||||
|
|
||||||
|
- Keep global search and current operational context primary.
|
||||||
|
- Move language, timezone and sign-out into the operator menu.
|
||||||
|
- Combine demo disclosure and demo progress into one compact control.
|
||||||
|
- Replace the visually empty mobile search field with an explicit search button or
|
||||||
|
a full-width command surface.
|
||||||
|
|
||||||
|
#### R2.2 — Mobile page actions
|
||||||
|
|
||||||
|
- Stop hiding the complete page-action region.
|
||||||
|
- Always show record status next to or below the title.
|
||||||
|
- Keep one primary action visible and move secondary actions to an accessible menu.
|
||||||
|
- Verify booking, vehicle, data-quality and automation detail headers independently.
|
||||||
|
|
||||||
|
#### R2.3 — Navigation refinement
|
||||||
|
|
||||||
|
- Review whether the desktop sidebar should remain available at wider tablet widths.
|
||||||
|
- Increase mobile navigation label size and touch area.
|
||||||
|
- Keep four or five primary destinations visible and put lower-frequency destinations
|
||||||
|
under “More” if six items cannot meet readability targets.
|
||||||
|
- Preserve current routes and role-based visibility.
|
||||||
|
|
||||||
|
#### R2.4 — Typography rollout
|
||||||
|
|
||||||
|
- Apply the R0 type tokens to badges, tables, integration cards, timelines, forms and
|
||||||
|
mobile navigation.
|
||||||
|
- Rebalance padding where larger text would otherwise increase page height excessively.
|
||||||
|
- Verify Dutch, French and English labels for clipping and wrapping.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Status remains visible on every sampled mobile detail page.
|
||||||
|
- No essential action is hover-only or hidden solely because of viewport width.
|
||||||
|
- Top-bar controls fit without clipping at 390, 768 and 1440 px.
|
||||||
|
- Language changes do not introduce horizontal overflow.
|
||||||
|
- Touch-target and typography targets are met.
|
||||||
|
|
||||||
|
## R3 — Dashboard and record-detail efficiency
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Put operational decisions above supporting detail and make desktop space work harder.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R3.1 — Dashboard hierarchy
|
||||||
|
|
||||||
|
- Keep readiness and highest-severity attention items visible in the first viewport.
|
||||||
|
- Reduce the vertical weight of the demo-scenario panel after the scenario starts.
|
||||||
|
- Limit the initial attention queue and link to a complete filtered view.
|
||||||
|
- Balance “movements today” against attention content when only a few movements exist.
|
||||||
|
- Keep integrations and recent activity available without duplicating status copy.
|
||||||
|
|
||||||
|
#### R3.2 — Mobile dashboard summary
|
||||||
|
|
||||||
|
- Convert readiness into a compact, horizontally accessible summary or disclosure.
|
||||||
|
- Show the highest-priority attention items first with an explicit remaining count.
|
||||||
|
- Surface the next departure and return before secondary integration information.
|
||||||
|
|
||||||
|
#### R3.3 — Vehicle-detail workspace
|
||||||
|
|
||||||
|
- Replace the sparse overview with a two-column desktop composition.
|
||||||
|
- Surface current status, location, odometer, next booking and active attention items.
|
||||||
|
- Keep bookings, inspections, maintenance, quality issues and audit as focused tabs.
|
||||||
|
- Avoid new maintenance-domain functionality; link only to existing evidence.
|
||||||
|
|
||||||
|
#### R3.4 — Booking-detail summary
|
||||||
|
|
||||||
|
- Rebalance the facts grid so incomplete final rows do not create large empty bands.
|
||||||
|
- On mobile, show status and return readiness before secondary booking facts.
|
||||||
|
- Keep the return form close to the task entry point while retaining validation context.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- At 1440 × 900 the dashboard exposes readiness, urgent work and today's movement
|
||||||
|
context without scrolling.
|
||||||
|
- Sparse states remain intentional and balanced rather than appearing unfinished.
|
||||||
|
- All values remain derived from persisted data.
|
||||||
|
- Existing return and status-domain tests remain unchanged and green.
|
||||||
|
|
||||||
|
## R4 — Guided workflows and decision surfaces
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Reduce the distance between evidence and the decision an operator must make.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R4.1 — Return flow refinement
|
||||||
|
|
||||||
|
- Keep the step indicator visible while completing or reviewing a return.
|
||||||
|
- Use a compact mobile booking summary above the form.
|
||||||
|
- Present validation errors next to fields and in a short focusable summary.
|
||||||
|
- Preserve the post-submit explanation of inspection, vehicle status, quality issue,
|
||||||
|
queued automation and next-booking risk.
|
||||||
|
|
||||||
|
#### R4.2 — Duplicate-customer decision layout
|
||||||
|
|
||||||
|
- Use evidence/comparison on the left and a sticky survivor/merge preview on desktop.
|
||||||
|
- Keep matching fields collapsed by default and differences expanded.
|
||||||
|
- Keep irreversible-action language and audit preview adjacent to the merge button.
|
||||||
|
- On mobile, use a deliberate sequence: evidence, differences, survivor, preview,
|
||||||
|
confirmation.
|
||||||
|
|
||||||
|
#### R4.3 — Other data-quality resolutions
|
||||||
|
|
||||||
|
- Standardise evidence, proposed resolution, consequence and audit-preview sections.
|
||||||
|
- Keep resolve, reject and defer actions visually distinct and permission-aware.
|
||||||
|
- Provide clear success feedback and navigation to the next issue.
|
||||||
|
|
||||||
|
#### R4.4 — Guided demo integration
|
||||||
|
|
||||||
|
- Reduce competition between the guide and normal navigation.
|
||||||
|
- Persist progress without obscuring page controls.
|
||||||
|
- Make “go to this step” land on the relevant element or focused task state.
|
||||||
|
- Ensure the guide is fully keyboard-operable and usable as a mobile bottom sheet.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Primary workflow action is visible or reachable with one obvious interaction.
|
||||||
|
- A user never needs to scroll past repeated explanatory content to discover the
|
||||||
|
required decision.
|
||||||
|
- Focus moves to validation failures and success results appropriately.
|
||||||
|
- The five-minute demo remains deterministic.
|
||||||
|
|
||||||
|
## R5 — Knowledge, automation and trust signals
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Make external-system health understandable to an operator without exposing irrelevant
|
||||||
|
technical detail.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R5.1 — Knowledge status semantics
|
||||||
|
|
||||||
|
- Separate provider availability, indexed-source count and last successful sync.
|
||||||
|
- Never show “0 indexed” as a healthy equivalent when the value is unknown or stale.
|
||||||
|
- Label extractive fallback answers honestly while preserving source cards.
|
||||||
|
- Reduce empty-state height and show a compact explanation of what a cited answer contains.
|
||||||
|
- Keep suggested questions concise and prioritised.
|
||||||
|
|
||||||
|
#### R5.2 — Source and freshness presentation
|
||||||
|
|
||||||
|
- Surface source title, version, section and freshness consistently.
|
||||||
|
- Keep document UUIDs and raw retrieval metadata behind technical details.
|
||||||
|
- Provide explicit grounded, insufficient-evidence and unavailable states.
|
||||||
|
|
||||||
|
#### R5.3 — Integration summary redesign
|
||||||
|
|
||||||
|
- Structure each integration around state, last success, affected workflow and next action.
|
||||||
|
- Move client IDs, raw endpoints and UUIDs to a detail disclosure.
|
||||||
|
- Remove unnecessary fixed card height, especially at tablet widths.
|
||||||
|
- Keep genuine mixed states visible without presenting them as contradictions.
|
||||||
|
|
||||||
|
#### R5.4 — Workflow evidence table
|
||||||
|
|
||||||
|
- Improve status and recency scanning.
|
||||||
|
- Distinguish “never triggered”, “no evidence yet”, “failed” and “unavailable”.
|
||||||
|
- Keep retry actions safe, audited and available only where already supported.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- Knowledge and integration states cannot make mutually contradictory claims.
|
||||||
|
- An operator can identify the current state and next action within one card scan.
|
||||||
|
- Technical identifiers do not dominate default views.
|
||||||
|
- RAGcore, n8n and MCP Hub timeout/unavailable contract tests stay green.
|
||||||
|
|
||||||
|
## R6 — Accessibility, performance and release evidence
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Turn the improvements into a verified, releasable quality baseline.
|
||||||
|
|
||||||
|
### Work packages
|
||||||
|
|
||||||
|
#### R6.1 — Accessibility review
|
||||||
|
|
||||||
|
- Test complete keyboard traversal of login, navigation, filters, return, merge,
|
||||||
|
knowledge, workflow retry and audit details.
|
||||||
|
- Verify focus order, focus restoration, dialog labelling and error announcements.
|
||||||
|
- Run automated accessibility checks on all principal routes.
|
||||||
|
- Manually verify status is not encoded by colour alone.
|
||||||
|
- Verify zoom at 200% and 360 px responsive reflow.
|
||||||
|
|
||||||
|
#### R6.2 — Rendering and interaction performance
|
||||||
|
|
||||||
|
- Measure list rendering before and after pagination.
|
||||||
|
- Check layout shift and interaction latency on dashboard and long-list routes.
|
||||||
|
- Ensure drawers, menus and guided-demo panels do not mount large hidden subtrees.
|
||||||
|
- Test with reduced motion and a throttled CPU profile.
|
||||||
|
|
||||||
|
#### R6.3 — Cross-browser and localisation pass
|
||||||
|
|
||||||
|
- Verify current Chrome plus one additional Chromium/Firefox-equivalent target where
|
||||||
|
supported by the test environment.
|
||||||
|
- Run Dutch, French and English visual checks at all target widths.
|
||||||
|
- Verify Europe/Brussels date and time rendering.
|
||||||
|
|
||||||
|
#### R6.4 — Final automated journey and evidence
|
||||||
|
|
||||||
|
- Extend the existing Playwright demo with pagination, mobile status and accessible
|
||||||
|
details checks.
|
||||||
|
- Capture final screenshots for all principal pages.
|
||||||
|
- Add before-and-after measurements and known PoC limitations to final evidence.
|
||||||
|
- Execute clean-checkout bootstrap, backend tests, lint, typing and frontend build.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- No critical or serious automated accessibility violations on principal routes.
|
||||||
|
- All target measures in this document pass.
|
||||||
|
- `docs/14-testing-and-acceptance.md` passes from a clean checkout.
|
||||||
|
- `artifacts/evidence/final-summary.md` contains the final commands, counts,
|
||||||
|
screenshots and limitations.
|
||||||
|
|
||||||
|
## Suggested release slices
|
||||||
|
|
||||||
|
### Release A — Operational scale
|
||||||
|
|
||||||
|
Contains R0 and R1. This release eliminates the most severe scroll and list-density
|
||||||
|
problems without materially changing workflow structure.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- paginated audit, vehicles and data-quality queues;
|
||||||
|
- URL-backed filters;
|
||||||
|
- unchanged audit and authorization semantics;
|
||||||
|
- visual regression baseline green.
|
||||||
|
|
||||||
|
### Release B — Responsive operations
|
||||||
|
|
||||||
|
Contains R2 and R3. This release improves navigation, typography, dashboard hierarchy
|
||||||
|
and detail-page use of space.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- visible mobile statuses and primary actions;
|
||||||
|
- readable typography and touch targets;
|
||||||
|
- dashboard first-viewport target met;
|
||||||
|
- Dutch, French and English responsive checks green.
|
||||||
|
|
||||||
|
### Release C — Guided decisions and trust
|
||||||
|
|
||||||
|
Contains R4 and R5. This release refines the return, merge, knowledge, automation and
|
||||||
|
demo-guide experiences.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- decision actions remain near their evidence;
|
||||||
|
- honest and non-contradictory external health states;
|
||||||
|
- five-minute demo passes end to end.
|
||||||
|
|
||||||
|
### Release D — Quality certification
|
||||||
|
|
||||||
|
Contains R6 and only defects found by its gates. It adds no new product scope.
|
||||||
|
|
||||||
|
Release gate:
|
||||||
|
|
||||||
|
- full clean-checkout acceptance;
|
||||||
|
- accessibility and performance targets met;
|
||||||
|
- final evidence updated;
|
||||||
|
- release tag and deployed revision recorded.
|
||||||
|
|
||||||
|
## Implementation sequence and dependencies
|
||||||
|
|
||||||
|
```text
|
||||||
|
R0 shared tokens and baselines
|
||||||
|
├─ R1 pagination and compact lists ─┐
|
||||||
|
└─ R2 responsive shell and type ───┼─ R3 dashboard/details ─ R4 workflows
|
||||||
|
└─ R5 trust surfaces
|
||||||
|
R1 + R2 + R3 + R4 + R5 ──────────────────────────────── R6 release gate
|
||||||
|
```
|
||||||
|
|
||||||
|
R1 and R2 may be developed in parallel after R0. R3 should consume both shared list
|
||||||
|
and responsive patterns. R4 depends on the new page-header and detail hierarchy. R5 can
|
||||||
|
start after R2 but must share its disclosure and status patterns. R6 is a gate, not a
|
||||||
|
cleanup bucket; defects discovered there return to the owning phase.
|
||||||
|
|
||||||
|
## Per-phase engineering checklist
|
||||||
|
|
||||||
|
Every phase must include:
|
||||||
|
|
||||||
|
1. a short before-state measurement;
|
||||||
|
2. API and data-contract impact assessment;
|
||||||
|
3. implementation using shared components where applicable;
|
||||||
|
4. unit/API tests for changed contracts;
|
||||||
|
5. Playwright coverage for the changed user journey;
|
||||||
|
6. manual checks at 390, 768 and 1440 px in all supported languages;
|
||||||
|
7. accessibility and console check;
|
||||||
|
8. updated evidence and `PROJECT_STATE.md` entry;
|
||||||
|
9. one coherent commit after validation passes;
|
||||||
|
10. deployment verification before starting the next release slice.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Pagination changes API consumers | Add pagination metadata without silently changing service-token/MCP response contracts; version only if necessary |
|
||||||
|
| Compact layouts hide evidence | Use drawers and disclosures, preserving direct access and auditability |
|
||||||
|
| Larger type increases page height | Combine type changes with density and hierarchy work, never shrink text again |
|
||||||
|
| Sticky UI covers content on mobile | Use shared top/bottom offsets and test keyboard/zoom states |
|
||||||
|
| Visual snapshots become brittle | Seed deterministic data and mask only true timestamp volatility |
|
||||||
|
| External health data is stale | Show last successful evidence and distinguish unknown from healthy |
|
||||||
|
| Roadmap drifts into new product scope | Check every work package against `docs/01-scope-and-non-goals.md` and `docs/deferred.md` |
|
||||||
|
|
||||||
|
## Definition of roadmap completion
|
||||||
|
|
||||||
|
This roadmap is complete only when Releases A through D are deployed and verified,
|
||||||
|
all target measures pass, the original five-minute demo remains green, and the final
|
||||||
|
evidence records the exact deployed revision. Completing only the visual styling or one
|
||||||
|
high-priority page does not complete the roadmap.
|
||||||
@@ -65,9 +65,11 @@ generated and kept fresh across resets.
|
|||||||
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
||||||
the same interface once a real RAGcore backend is available — swapping providers is a
|
the same interface once a real RAGcore backend is available — swapping providers is a
|
||||||
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
||||||
- **ITWorx MCP Hub**: not connected. Registration is disabled by default
|
- **ITWorx MCP Hub**: the UI reports "Operational" only when Fleet Ops has really
|
||||||
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" —
|
recorded `mcp_tool_request` calls in its own audit log. `MCP_HUB_REGISTRATION_ENABLED`
|
||||||
never a fabricated successful registration.
|
on its own never makes it operational — registration is catalog-driven on the Hub's
|
||||||
|
side, so the flag alone is not evidence of anything, and a successful registration is
|
||||||
|
never fabricated.
|
||||||
|
|
||||||
## Where to go next
|
## Where to go next
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# AI Operations Brief — runbook and live evidence
|
||||||
|
|
||||||
|
Answers, using a real MCP client through the real ITWorx MCP Hub connector against live
|
||||||
|
production Fleet Ops: *"Which vehicles need the most attention today, why, and which
|
||||||
|
internal procedure should be followed for the most important issue?"*
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
Not a chatbot. No write actions exist under `/api/v1/integrations/mcp/*` (verified by
|
||||||
|
`test_no_write_endpoints_exist_under_mcp_namespace`). Every call below is a plain
|
||||||
|
read-only tool invocation, exactly as the deployed Hub connector performs them.
|
||||||
|
|
||||||
|
## Reproducible command
|
||||||
|
|
||||||
|
Run from inside the live `itworx-mcp-hub-connector-mobilityops-1` container (has the
|
||||||
|
real `MOBILITYOPS_ENDPOINT`, `MOBILITYOPS_SERVICE_TOKEN_FILE` and the real
|
||||||
|
`packages.connector_kit.mobilityops.MobilityOpsClient` already available — the same
|
||||||
|
client class the deployed connector uses):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -e PYTHONPATH=/opt/hub -w /opt/hub \
|
||||||
|
itworx-mcp-hub-connector-mobilityops-1 python3 - <<'PY'
|
||||||
|
from packages.connector_kit.mobilityops import FileTokenProvider, MobilityOpsClient, MobilityOpsSettings
|
||||||
|
import os, json
|
||||||
|
|
||||||
|
settings = MobilityOpsSettings(
|
||||||
|
base_url=os.environ["MOBILITYOPS_ENDPOINT"],
|
||||||
|
token_reference=os.environ["MOBILITYOPS_SERVICE_TOKEN_FILE"],
|
||||||
|
)
|
||||||
|
client = MobilityOpsClient(settings, FileTokenProvider())
|
||||||
|
client_id = "ai-ops-brief"
|
||||||
|
|
||||||
|
summary = client.operations_summary(client_id)
|
||||||
|
attention = client.attention_vehicles(client_id, minimum_severity="high", date=None, limit=20)
|
||||||
|
top_ref = attention[0]["vehicle_ref"] if attention else None
|
||||||
|
details = client.vehicle_details(client_id, top_ref) if top_ref else None
|
||||||
|
answer = client.search_knowledge(client_id, "What must I do when a vehicle returns with damage?", max_sources=2)
|
||||||
|
|
||||||
|
print(json.dumps({"summary": summary, "top_attention": attention[0] if attention else None, "vehicle": details, "answer": answer}, indent=2))
|
||||||
|
client.close()
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
This calls, in order: `fleet_ops_get_operations_summary` → `fleet_ops_list_attention_vehicles`
|
||||||
|
→ `fleet_ops_get_vehicle_details` → `fleet_ops_search_knowledge`. No result is invented —
|
||||||
|
every field printed is exactly what Fleet Ops's live API returned.
|
||||||
|
|
||||||
|
## Live run, 2026-08-05
|
||||||
|
|
||||||
|
- **Operations summary**: 21 available, 11 rented, 6 cleaning, 5 maintenance,
|
||||||
|
**7 blocked**; 23 open quality issues; 1 pending/failed workflow.
|
||||||
|
- **Most pressing vehicle**: `MO-031` — `missing_required_field`, "near-future booking;
|
||||||
|
required operational inspection missing", detected `2026-08-05T11:08:33Z`. (16 vehicles
|
||||||
|
currently carry a `high`-severity open issue; `MO-031` was the earliest-detected.)
|
||||||
|
- **Vehicle detail (`MO-031`)**: Adria Matrix, 2022, Geel, `operational_status: blocked`,
|
||||||
|
41,149 km (service due at 50,000 km), no current booking.
|
||||||
|
- **Grounded procedure (English)**: *Damage handling procedure* v1.3, section "1.
|
||||||
|
Immediate actions" — "When a vehicle returns with visible or reported damage, mark
|
||||||
|
damage in the return inspection, add a concise factual description and keep the
|
||||||
|
vehicle blocked. Do not promise the customer a repair cost or liability decision."
|
||||||
|
Second source: *Vehicle return procedure* v2.0, section "3. Determine next state".
|
||||||
|
`evidence_state: grounded`, 2 real citations, `provider: demo`.
|
||||||
|
- **Dutch and French variants of the damage question** (`Wat moet ik doen wanneer een
|
||||||
|
voertuig beschadigd terugkomt?`, `Que dois-je faire lorsqu'un véhicule revient
|
||||||
|
endommagé ?`) both returned `evidence_state: insufficient` — an honest, non-fabricated
|
||||||
|
"no match" rather than a wrong or invented answer. Root cause: the currently-deployed
|
||||||
|
Hub connector does not yet send the new `locale` field this session added to
|
||||||
|
`search-knowledge` (Fleet Ops defaults to `en-GB`), so non-English question text
|
||||||
|
doesn't match the demo provider's English-tokenized index. A real fix needs a Hub-side
|
||||||
|
connector update to pass `locale`, tracked as a follow-up, not silently worked around.
|
||||||
|
- **Correlation ID, end to end, verified**: each call's response `correlation_id` (e.g.
|
||||||
|
`abd643a7-2aeb-4af3-806a-da04acb3e444` for the English grounded answer) appears
|
||||||
|
verbatim in Fleet Ops's own audit log (`GET /api/v1/audit?action=mcp_tool_request`),
|
||||||
|
alongside `actor_label: ai-ops-brief-2026-08-05` and the real tool name
|
||||||
|
(`fleet_ops_search_knowledge`). No write actions were performed; only `mcp_tool_request`
|
||||||
|
audit rows were created, matching every other live MCP call this integration makes.
|
||||||
|
|
||||||
|
## Known limitation, stated plainly
|
||||||
|
|
||||||
|
The demo knowledge provider (not RAGcore — `KNOWLEDGE_PROVIDER=demo`, see
|
||||||
|
`docs/final-integrations/current-state-audit.md` for why) is what grounds the English
|
||||||
|
answer here. It is deterministic, extractive, and never fabricates — but it is not the
|
||||||
|
live RAGcore integration the brief brief for this task set out to exercise; that
|
||||||
|
remains blocked on RAGcore's own reranker gap. This run is honest about that: it proves
|
||||||
|
the full MCP-client → Hub → Fleet Ops → knowledge-provider → audit chain works for real,
|
||||||
|
live, in production, with real data and real citations — using the knowledge provider
|
||||||
|
that is actually configured live today.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Current-state audit — Fleet Ops final integrations
|
||||||
|
|
||||||
|
Date: 2026-08-05. Compiled from direct repository inspection (git log/status/diff across
|
||||||
|
all three repos), `PROJECT_STATE.md` history, and read-only investigation of the sibling
|
||||||
|
repos' own state docs. No live server SSH/curl evidence is included in this pass yet —
|
||||||
|
see `integration-release-state.md` for the live-verification checklist as it is executed.
|
||||||
|
|
||||||
|
## Repository revisions at audit time
|
||||||
|
|
||||||
|
| Repo | Path | Branch | HEAD | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Fleet Ops (MobilityOps) | `C:\Projects\MobilityOps` | `feat/fleet-ops-final-integrations` (new, branched from `feat/live-n8n-ragcore-integration`) | `3ebca9e` | `feat/live-n8n-ragcore-integration` was pushed to `origin` at `0571a40` and deployed live; `3ebca9e` (logo rebrand) is one commit ahead, not yet deployed. `master` is 19 commits behind and stale (localization-round only). |
|
||||||
|
| RAGcore | `C:\Projects\RAGcore` | `main` | `64a908a` | Up to date with `origin/main`. Uncommitted local work in progress (see below) — not Fleet-Ops-related, left untouched. |
|
||||||
|
| ITWorx MCP Hub | `C:\Projects\ITWorx_MCP_Hub` | `feature/wp240-final-acceptance` | `26e6bd8` (+ later `75bb16a`) | Contains `f107544` (MobilityOps connector) as a direct ancestor, plus a real contract fix (`96de385`, vehicleRef camelCase). Already deployed live to Tower at `c4a0f6d`. |
|
||||||
|
|
||||||
|
## Branch-name correction (recorded assumption)
|
||||||
|
|
||||||
|
The task brief names the working branch `feat/fleet-ops-final-integrations` as already
|
||||||
|
selected and "branched from the most recently validated, localized, deployed master
|
||||||
|
branch." That literal branch did not exist. `master` is in fact stale (19 commits behind,
|
||||||
|
last touched for a localization round only) — the actually-validated, deployed line of
|
||||||
|
work is `feat/live-n8n-ragcore-integration` (pushed to `origin`, deployed to
|
||||||
|
`http://192.168.10.150:1236` at `0571a40`, one commit behind current HEAD). Created
|
||||||
|
`feat/fleet-ops-final-integrations` from that branch's HEAD (`3ebca9e`) instead of from
|
||||||
|
`master`, since that satisfies the actual intent (continue from the validated/deployed
|
||||||
|
line) even though the literal branch name in the brief was inaccurate.
|
||||||
|
|
||||||
|
## What is actually already done (contradicts "not yet live" framing in places)
|
||||||
|
|
||||||
|
- **n8n**: 3 of 4 canonical workflows are live and active in the shared instance
|
||||||
|
(`n8n.itworx.tech`): Vehicle Return Orchestration, Scheduled Data Quality Scan,
|
||||||
|
Workflow Error Handler. The 4th, RAGcore Procedure Sync, has all 6 nodes built and
|
||||||
|
saved but is **not published** (deliberately left for an explicit activation decision,
|
||||||
|
since publishing starts real unattended daily runs against production). The root cause
|
||||||
|
of an earlier "auth"-looking failure (`N8N_PROXY_HOPS=0` behind the TLS-terminating
|
||||||
|
reverse proxy, breaking the browserId CSRF check on every mutating REST call) was found
|
||||||
|
and fixed at the infrastructure level (Unraid template), not worked around.
|
||||||
|
- **RAGcore**: deployed to `http://192.168.10.150:1237`, application wiring for
|
||||||
|
search/context/answer is real (commit `a2905cc`, confirmed present in RAGcore's own
|
||||||
|
history at `13 commits behind HEAD`). `KNOWLEDGE_PROVIDER` is still `demo` in Fleet Ops
|
||||||
|
because real queries against the "Fleet Ops Procedures" space return **zero dense and
|
||||||
|
zero sparse candidates** at the raw retrieval stage — confirmed not a Fleet-Ops-side
|
||||||
|
wiring bug (RAGcore's own trusted Query Lab tool reproduces the identical zero-candidate
|
||||||
|
result against the same space). Root cause not yet found as of this audit; ruled out so
|
||||||
|
far: point count/scoping (83 published, correctly scoped), embedding digest mismatch
|
||||||
|
(matches), collection alias resolution (resolves correctly). One separate, confirmed,
|
||||||
|
pre-existing bug: `_DEFAULT_LANGUAGE = "en"` is hardcoded in RAGcore's ingestion handler
|
||||||
|
— every chunk is stamped `language: "en"` regardless of actual content; RAGcore has never
|
||||||
|
done real language detection. Not the cause of zero candidates, but must be fixed for
|
||||||
|
trilingual retrieval (task 6A) once the space is answerable at all.
|
||||||
|
- **MCP Hub**: the Fleet Ops read-only connector (4 tools, `mobilityops.*`) is **already
|
||||||
|
live in production** on Tower (commit `c4a0f6d`), reachable via `fleetops.itworx.tech`,
|
||||||
|
end-to-end verified once already per the Hub's own `CLAUDE.md`/`BUILD_STATE.json`. A
|
||||||
|
real contract bug was found and fixed there (`vehicle.get`'s input schema disagreed with
|
||||||
|
the actual wire parameter name — `vehicle_ref` vs `vehicleRef`). What is **not** yet done:
|
||||||
|
the Hub's own formal production-acceptance checklist row for MobilityOps (`CON-P04`) has
|
||||||
|
not been executed, and Fleet Ops's own `MCP_HUB_REGISTRATION_ENABLED`/base-URL
|
||||||
|
configuration has not been confirmed as actually wired and flipped on from the Fleet Ops
|
||||||
|
side (open item for this audit's Batch 4).
|
||||||
|
|
||||||
|
## Confirmed contradictions to resolve (task section 3)
|
||||||
|
|
||||||
|
- "Twee versus vier n8n-workflows": resolved above — 3 active + 1 built-but-unpublished.
|
||||||
|
Canonical set is 4; only 3 are live.
|
||||||
|
- "Demo-provider versus live RAGcore": Fleet Ops is still on the demo knowledge provider
|
||||||
|
by deliberate, documented decision (not an oversight) pending the retrieval root cause.
|
||||||
|
- "MCP Hub-status": prior Fleet Ops docs (`.env.example`, `MCP_HUB_REGISTRATION_ENABLED`)
|
||||||
|
predate the Hub-side deployment and need reconciling against the fact that the connector
|
||||||
|
is already live on the Hub side.
|
||||||
|
- Repo hygiene: removed an untracked, empty `backend;C` directory and an untracked 31 MB
|
||||||
|
`MobilityOps.zip` stray export; added `*.zip`/`*.tar.gz` to `.gitignore`. No accidentally
|
||||||
|
committed `__pycache__`/`.pytest_cache`/`test-results` were found in git history.
|
||||||
|
|
||||||
|
## RAGcore retrieval root cause — found and partially fixed (2026-08-05, this session)
|
||||||
|
|
||||||
|
Investigated live against production (`192.168.10.150`, containers `ragcore-app-1`,
|
||||||
|
`ragcore-qdrant-1`, `ragcore-postgres-1`, `ollama`), read-only first, then two approved
|
||||||
|
live changes.
|
||||||
|
|
||||||
|
**Root cause #1 (FIXED): filesystem permission bug, not authorization/data.** Verified,
|
||||||
|
in order, that every earlier suspect was actually healthy: the `control.grants` row
|
||||||
|
(active, `editor` role, correct application/space), the real
|
||||||
|
`ControlPlaneAuthorizationInputsProvider` + `RetrievalAuthorizationService.resolve()` code
|
||||||
|
path run in-process against the live DB (resolves a non-empty `effective_space_ids`), the
|
||||||
|
exact production Qdrant filter run directly against the live collection (returns real
|
||||||
|
matching points), and a real ANN vector query under that filter (real hits, sensible
|
||||||
|
scores). The actual break: `/workspace/.state/models/embedding_profiles.json` — the file
|
||||||
|
`RetrievalPipeline.run()` reads on every single query to resolve the active embedding
|
||||||
|
profile — was owned by container-side `root:root` mode `600` on the bind-mounted
|
||||||
|
`/mnt/cache/appdata/ragcore/state/models` host path, while the real running app process
|
||||||
|
is uid 10001 (`ragcore`). Every retrieval call hit a `PermissionError` reading its own
|
||||||
|
state file before ever reaching Qdrant — a plain filesystem-ownership bug, invisible to
|
||||||
|
every DB/Qdrant-level check. **Fixed live**: `chown 10001:10001` +
|
||||||
|
`chmod 644`/`755` on that file/directory (approved by the user beforehand). Re-verified
|
||||||
|
in-process: `RetrievalPipeline.run()` now returns 5 real, relevant hits for an English
|
||||||
|
damage-procedure question (previously 0).
|
||||||
|
|
||||||
|
**Root cause #2 (found, NOT fixed — needs a design decision): reranking is
|
||||||
|
architecturally unavailable.** `DEFAULT_RERANKER_PROFILE.model_identifier` is
|
||||||
|
`bge-reranker-v2-m3:v1`, which was never actually present in Ollama's model list (0 of 14
|
||||||
|
installed models matched). With the user's approval, pulled a working GGUF
|
||||||
|
(`xitao/bge-reranker-v2-m3:latest`, 1.2 GB) into the shared Ollama instance. **This did
|
||||||
|
not fix reranking**: `OllamaRerankAdapter` posts to `{ollama_base_url}/api/rerank`, and
|
||||||
|
this Ollama server (version `0.32.5`) returns a plain `404` for that route — it has no
|
||||||
|
rerank endpoint at all. This is not a missing-model problem, it is that RAGcore's
|
||||||
|
reranker adapter was built against an Ollama HTTP API that does not exist in the deployed
|
||||||
|
version (matches the code's own comment that no reranker-profile registry or live
|
||||||
|
validation existed yet). The retrieval pipeline degrades gracefully on rerank failure
|
||||||
|
(RRF-fusion-only hits still returned, confirmed above), but the `/v1/answers` endpoint's
|
||||||
|
answerability classifier still returns `not_answerable`/0 citations for real NL/EN/FR
|
||||||
|
questions against real matching content, live-verified after fix #1 with a freshly
|
||||||
|
minted, correctly-scoped credential.
|
||||||
|
|
||||||
|
Options for #2, not decided yet: (a) find/confirm whether a newer Ollama version adds a
|
||||||
|
real `/api/rerank` route and upgrade the shared instance (affects every other project on
|
||||||
|
this Ollama — needs its own explicit approval and blast-radius review); (b) change
|
||||||
|
RAGcore's reranker adapter to call a route Ollama actually supports (e.g. score via
|
||||||
|
`/api/embed` + a manual similarity/cross-encoder computation, or drop the separate
|
||||||
|
rerank step and let the answerability classifier trust RRF-fused scores) — a RAGcore
|
||||||
|
code/design change, out of Fleet Ops's own mandate to decide unilaterally; (c) leave
|
||||||
|
`KNOWLEDGE_PROVIDER=demo` until RAGcore's own team/session resolves this.
|
||||||
|
|
||||||
|
**Side effect to flag**: minting the live-verification credential used `rotate=True` on
|
||||||
|
the existing "Fleet Ops Knowledge Assistant (production)" service account (a second
|
||||||
|
credential would have exceeded RAGcore's own 2-active-credential cap), which invalidates
|
||||||
|
whatever token was previously issued for that account. Since Fleet Ops is still on
|
||||||
|
`KNOWLEDGE_PROVIDER=demo`, this has no live user-facing impact today, but a fresh
|
||||||
|
credential must be issued and wired into Fleet Ops's `RAGCORE_API_TOKEN` at actual
|
||||||
|
cutover time — do not assume the old one still works.
|
||||||
|
|
||||||
|
**Concurrency note**: `C:\Projects\RAGcore` had substantial uncommitted local changes
|
||||||
|
from what appears to be a different, actively-running session (36 modified/untracked
|
||||||
|
files by the end of this investigation, including files this investigation also read).
|
||||||
|
No commits or file edits were made in that checkout this session precisely because of
|
||||||
|
that collision risk — the two live fixes above were applied directly to the running
|
||||||
|
containers/Ollama instance (approved), not to the RAGcore git repository. **Follow-up
|
||||||
|
required**: once the concurrent session's work lands, the reranker-profile fix (whichever
|
||||||
|
option above is chosen) still needs an actual code change + commit + redeploy in
|
||||||
|
`C:\Projects\RAGcore`, which was not safe to do mid-collision this session.
|
||||||
|
|
||||||
|
## Minimal remaining implementation order
|
||||||
|
|
||||||
|
1. Root-cause the RAGcore zero-candidate retrieval bug (blocks flipping `KNOWLEDGE_PROVIDER`
|
||||||
|
and blocks the trilingual live-acceptance and AI Operations Brief tasks).
|
||||||
|
2. Fix RAGcore's hardcoded `language: "en"` chunk metadata for trilingual retrieval.
|
||||||
|
3. Decide on and execute n8n workflow 3 publication, with live no-op-on-rerun verification.
|
||||||
|
4. Confirm/complete Fleet Ops-side MCP Hub registration wiring and run the Hub's own
|
||||||
|
CON-P04 acceptance row.
|
||||||
|
5. Build the AI Operations Brief runbook once RAGcore and MCP Hub are both live-green.
|
||||||
|
6. GUI polish batch (dashboard Today/Attention presentation, duplicate-merge presentation,
|
||||||
|
About Demo scannability, Demo Guide completion state).
|
||||||
|
7. Final regression gates and evidence write-up.
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ test("data quality: manual scan runs and shows a result summary", async ({ page,
|
|||||||
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/automation");
|
await page.goto("/automation");
|
||||||
await expect(page.locator(".data-table")).toBeVisible();
|
const jobsTable = page.getByRole("table", { name: "Automation jobs" });
|
||||||
|
await expect(jobsTable).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel("Status").selectOption("failed");
|
await page.getByLabel("Status").selectOption("failed");
|
||||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
|
||||||
const failedRowCountBefore = await page.locator(".data-table tbody tr").count();
|
const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
|
||||||
const retryButton = page.getByRole("button", { name: "Retry" }).first();
|
const retryButton = page.getByRole("button", { name: "Retry" }).first();
|
||||||
await expect(retryButton).toBeVisible();
|
await expect(retryButton).toBeVisible();
|
||||||
await retryButton.click();
|
await retryButton.click();
|
||||||
@@ -256,7 +257,7 @@ test("automation page: status filter and retry button work", async ({ page, requ
|
|||||||
// filtered to "failed" — the row correctly disappears from this view rather than
|
// filtered to "failed" — the row correctly disappears from this view rather than
|
||||||
// showing "pending" in place. Confirm the filtered list shrank by one.
|
// showing "pending" in place. Confirm the filtered list shrank by one.
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const count = await page.locator(".data-table tbody tr").count();
|
const count = await jobsTable.locator("tbody tr").count();
|
||||||
expect(count).toBe(failedRowCountBefore - 1);
|
expect(count).toBe(failedRowCountBefore - 1);
|
||||||
}).toPass({ timeout: 5000 });
|
}).toPass({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
|
||||||
|
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||||
|
|
||||||
|
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
|
||||||
|
expect(vehicles.ok()).toBeTruthy();
|
||||||
|
const vehiclePage = await vehicles.json();
|
||||||
|
expect(vehiclePage.items).toHaveLength(25);
|
||||||
|
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
|
||||||
|
|
||||||
|
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
|
||||||
|
expect(audit.ok()).toBeTruthy();
|
||||||
|
const auditPage = await audit.json();
|
||||||
|
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
||||||
|
|
||||||
|
await page.goto("/vehicles?status=maintenance&page=1");
|
||||||
|
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/status=maintenance/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
|
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||||
|
await expect(page.locator(".page-actions .badge")).toBeVisible();
|
||||||
|
await page.goto("/vehicles");
|
||||||
|
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
|
||||||
|
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
|
||||||
|
});
|
||||||
@@ -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 |
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { EvidenceSignal } from "../api/types";
|
||||||
|
|
||||||
|
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
// The backend never emits prose for data-quality evidence -- only stable signal codes
|
||||||
|
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
|
||||||
|
// the one place that turns them into the operator's selected language, shared by the
|
||||||
|
// issue detail page and the dashboard attention queue so the same code always reads the
|
||||||
|
// same way everywhere it appears.
|
||||||
|
export function describeEvidenceSignal(
|
||||||
|
t: TFunction,
|
||||||
|
formatNumber: (value: number) => string,
|
||||||
|
signal: EvidenceSignal,
|
||||||
|
): string {
|
||||||
|
const { code, params = {} } = signal;
|
||||||
|
if (code.startsWith("vehicle.")) {
|
||||||
|
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
|
||||||
|
}
|
||||||
|
switch (code) {
|
||||||
|
case "missing_field":
|
||||||
|
return t("quality:detail.evidence.missingField", {
|
||||||
|
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
|
||||||
|
});
|
||||||
|
case "overlap.reserved_bookings":
|
||||||
|
return t("quality:detail.evidence.overlapReservedBookings", {
|
||||||
|
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
|
||||||
|
});
|
||||||
|
case "odometer.regression":
|
||||||
|
return t("quality:detail.evidence.odometerRegression", {
|
||||||
|
laterRef: params.later_ref,
|
||||||
|
laterKm: formatNumber(Number(params.later_km ?? 0)),
|
||||||
|
earlierRef: params.earlier_ref,
|
||||||
|
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
|
||||||
|
});
|
||||||
|
case "duplicate.similar_name":
|
||||||
|
return t("quality:detail.evidence.duplicateSimilarName", { score: params.score });
|
||||||
|
case "attention.upcoming_booking_missing_inspection":
|
||||||
|
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
|
||||||
|
default:
|
||||||
|
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,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",
|
||||||
|
|||||||
@@ -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": "Jusqu’au",
|
||||||
"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) d’attention",
|
||||||
"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 d’index 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 d’index 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 l’index n’est 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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
|
@@ -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
|
||||||
|
|||||||
|
+10
-10
@@ -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
|
||||||
|
|||||||
|
Reference in New Issue
Block a user