From 5fa4fe08111d890ac5f98fd5169cab17db44fc08 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:00:11 +0200 Subject: [PATCH] feat(demo): plain-language integration status, richer audit, reset integrity Integration status badges across Dashboard/Automation now show honest plain-language labels instead of raw backend state strings (and fix a few states that had no matching CSS colour class at all). Audit trail gets a "view related events" action reusing the existing correlation_id filter. About page gains scope/architecture/security/testing sections and a guided-demo entry point. POST /api/v1/demo/reset now runs and records a server-side scenario-integrity check. Also fixes a second real race condition (caught by the return-review e2e test): the odometer scenario pre-fill now resolves before ReturnForm mounts instead of patching its value in after the fact. --- PROJECT_STATE.md | 48 ++++++++++++++++++++++ backend/app/api/routers/demo.py | 5 ++- backend/app/services/demo_manifest.py | 14 +++++++ backend/tests/test_auth.py | 9 ++-- frontend/src/components/Badge.tsx | 4 +- frontend/src/components/ReturnForm.tsx | 18 +------- frontend/src/data/integrationLabels.ts | 18 ++++++++ frontend/src/pages/AboutDemo.tsx | 57 ++++++++++++++++++++++++++ frontend/src/pages/Audit.tsx | 32 +++++++++++++-- frontend/src/pages/Automation.tsx | 49 ++++++++++++++++------ frontend/src/pages/BookingDetail.tsx | 9 +++- frontend/src/pages/Dashboard.tsx | 34 ++++++++++----- frontend/src/styles.css | 5 +++ 13 files changed, 254 insertions(+), 48 deletions(-) create mode 100644 frontend/src/data/integrationLabels.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d09c139..de4e22a 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -688,3 +688,51 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem - Exact next action: plain-language integration-status labels, richer audit narration, the full "Over deze demo" page content (currently a first pass from Batch 2), and wiring reset into the guide/About/OM menu narrative — task #34. + +### Batch 5 — integration-status UX, audit UX, About page, reset integrity (complete) + +- Plain-language integration status: extracted `frontend/src/data/integrationLabels.ts` + (`N8N_STATE_META`/`MCP_STATE_META`) mapping raw backend states to honest labels + ("Operational"/"Not connected"/"Prepared"/"Delivery failed"/"Retry available") while + keeping each mapped onto an existing `.status-*` CSS colour class (a few raw values like + `degraded`/`disabled`/`configured` had no matching CSS rule at all before this — a real, + pre-existing colour-coding gap). `StatusBadge` gained an optional `label` override prop + (backward compatible) so the badge's colour class and its displayed text can differ. + Wired into both `Automation.tsx` and `Dashboard.tsx`'s integration cards; also renamed + the "RAGcore" card heading to "Knowledge assistant" and made its text honestly name the + actual active provider (same bug class fixed in Knowledge.tsx in Batch 4). +- Audit trail: added a "Follow-up" column with a "View related events" action per row that + filters the same list by `correlation_id` (reuses the backend's existing, already-tested + `correlation_id` query param — no new business logic), with a "Clear this filter" + affordance. This is how a visitor sees "what else happened as a result of this action" + (e.g. a return's linked vehicle-status-changed / workflow-queued events) without a + bigger grouped-timeline rebuild. +- About page: added target-audience/scope, a short architecture summary, security + principles, and a testing-approach section (previously only covered the fictional + problem/real/synthetic/integrations/reset); added a "Start begeleide demo" CTA for + Operations Managers that opens the Demo Guide directly from this page. +- Reset integrity: added `scenario_integrity_report()` (`backend/app/services/ + demo_manifest.py`), reusing the exact same scenario-readiness derivation the manifest + and scenario overview already use (so it can't drift), and wired it into `POST + /api/v1/demo/reset` — both the response body and the `demo_reset` audit event's + metadata now carry `scenario_integrity: {all_ready, not_ready}`. This is the + server-side post-reset integrity check the brief asks for; visible today via the audit + event's raw-detail view, satisfying the requirement without adding a UI banner to a + flow that immediately logs the user out and redirects to `/login`. +- **Fixed a second real regression this batch, caught by the existing return-review + e2e test**: restructured the odometer pre-fill so `BookingDetail.tsx` withholds + rendering `ReturnForm` until the scenario's canonical odometer has resolved (with a + brief "Scenario voorbereiden…" loading state), instead of mounting the form immediately + and patching its value in asynchronously. The previous approach raced visibly with + Playwright's `fill()` (and would have raced with a real visitor typing quickly), + producing a corrupted concatenated value in one observed failure. This also let the + now-unnecessary `odometerEditedByUser` ref guard be removed — simpler and more robust + than the effect-based patch it replaced. +- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files); + frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **51 passed**, + confirmed stable across three consecutive full runs (given how many timing races this + batch and the previous one surfaced, stability was verified deliberately rather than + assumed from a single green run). +- Exact next action: full guided-demo Playwright test + remaining targeted demo tests per + section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/ + reduced-motion/console/network checks) — task #35. diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 97c5a78..fd06a68 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -14,7 +14,7 @@ from app.models.user import User from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut from app.seed_loader import reset_and_seed from app.services.audit import record_audit_event -from app.services.demo_manifest import build_demo_manifest +from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) settings = get_settings() @@ -107,6 +107,7 @@ def demo_reset( detail="Demo reset is disabled on this deployment.", ) result = reset_and_seed(db) + integrity = scenario_integrity_report(db) record_audit_event( db, actor_type="user", @@ -116,6 +117,7 @@ def demo_reset( metadata={ "counts": result.counts, "anchor_date": result.anchor_date.isoformat(), + "scenario_integrity": integrity, }, ) db.commit() @@ -125,4 +127,5 @@ def demo_reset( "counts": result.counts, "anchor_date": result.anchor_date.isoformat(), "seeded_at": result.seeded_at.isoformat(), + "scenario_integrity": integrity, } diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py index f9b38b2..d3e2106 100644 --- a/backend/app/services/demo_manifest.py +++ b/backend/app/services/demo_manifest.py @@ -223,6 +223,20 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: ] +def scenario_integrity_report(db: Session) -> dict: + """Server-side scenario-integrity check run after every reset (section 15): confirms + each of the 5 named scenarios is actually present and ready, rather than trusting the + seed loader silently. Reuses the same readiness derivation the manifest/scenario + overview already use, so this can never drift from what a visitor actually sees.""" + scenarios = _scenarios(db) + not_ready = [ + {"id": s.id, "title": s.title, "reason": s.blocked_reason} + for s in scenarios + if not s.ready + ] + return {"all_ready": len(not_ready) == 0, "not_ready": not_ready} + + def build_demo_manifest(db: Session) -> DemoManifestOut: last_reset_at, anchor_date = _last_reset(db) return DemoManifestOut( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 143757f..4cdd169 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -17,9 +17,12 @@ def test_rental_employee_cannot_reset_demo(employee_client): def test_operations_manager_can_reset_demo(ops_client): response = ops_client.post("/api/v1/demo/reset") assert response.status_code == 200 - assert response.json()["counts"]["vehicles"] == 50 - assert response.json()["anchor_date"] - assert response.json()["seeded_at"] + body = response.json() + assert body["counts"]["vehicles"] == 50 + assert body["anchor_date"] + assert body["seeded_at"] + assert body["scenario_integrity"]["all_ready"] is True + assert body["scenario_integrity"]["not_ready"] == [] def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch): diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx index 7cbfc09..b0e1eea 100644 --- a/frontend/src/components/Badge.tsx +++ b/frontend/src/components/Badge.tsx @@ -3,6 +3,6 @@ export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high return {label} severity; } -export function StatusBadge({ status }: { status: string }) { - return {status.replace(/_/g, " ")}; +export function StatusBadge({ status, label }: { status: string; label?: string }) { + return {label ?? status.replace(/_/g, " ")}; } diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index 3694691..4198061 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useState, type FormEvent } from "react"; import { Link, useNavigate } from "react-router-dom"; import { api, ApiError } from "../api/client"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; @@ -95,7 +95,6 @@ export function ReturnForm({ suggestedOdometerKm?: number; }) { const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : ""); - const odometerEditedByUser = useRef(false); const [fuel, setFuel] = useState("50"); const [cleanlinessOk, setCleanlinessOk] = useState(true); const [damageReported, setDamageReported] = useState(false); @@ -108,16 +107,6 @@ export function ReturnForm({ const [step, setStep] = useState<"capture" | "review">("capture"); const [preview, setPreview] = useState(null); - useEffect(() => { - // suggestedOdometerKm arrives asynchronously (BookingDetail fetches the vehicle's - // canonical odometer after this form has already mounted), so the initial useState - // value alone won't catch it -- sync it once it resolves. Never overwrite a value the - // visitor has already started typing. - if (suggestedOdometerKm !== undefined && !odometerEditedByUser.current) { - setOdometer(String(suggestedOdometerKm)); - } - }, [suggestedOdometerKm]); - function currentBody(): RegisterReturnRequest { return { end_odometer_km: Number(odometer), @@ -184,10 +173,7 @@ export function ReturnForm({ required min={0} value={odometer} - onChange={(e) => { - odometerEditedByUser.current = true; - setOdometer(e.target.value); - }} + onChange={(e) => setOdometer(e.target.value)} /> diff --git a/frontend/src/data/integrationLabels.ts b/frontend/src/data/integrationLabels.ts new file mode 100644 index 0000000..3125236 --- /dev/null +++ b/frontend/src/data/integrationLabels.ts @@ -0,0 +1,18 @@ +import type { IntegrationStatus } from "../api/types"; + +// Plain-language status per section 12 of the demo brief: a visitor shouldn't have to +// decode raw backend state strings to tell whether an integration is actually working. +// `statusClass` maps onto an existing `.status-*` CSS modifier so the badge keeps +// correct colour-coding; `label` is the human-readable text shown instead of the raw value. +export const N8N_STATE_META: Record = { + disabled: { statusClass: "not_configured", label: "Not connected" }, + unavailable: { statusClass: "unavailable", label: "Delivery failed" }, + degraded: { statusClass: "needs_attention", label: "Retry available" }, + operational: { statusClass: "available", label: "Operational" }, + no_evidence: { statusClass: "no_events", label: "Prepared" }, +}; + +export const MCP_STATE_META: Record = { + not_configured: { statusClass: "not_configured", label: "Not connected" }, + configured: { statusClass: "no_events", label: "Prepared" }, +}; diff --git a/frontend/src/pages/AboutDemo.tsx b/frontend/src/pages/AboutDemo.tsx index 5c2733e..e38d8f6 100644 --- a/frontend/src/pages/AboutDemo.tsx +++ b/frontend/src/pages/AboutDemo.tsx @@ -1,4 +1,6 @@ +import { Link } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoManifest } from "../context/DemoManifestContext"; import { Icon } from "../components/Icons"; import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; @@ -21,6 +23,7 @@ const INTEGRATION_ICON: Record = { export function AboutDemo() { const { manifest, loading } = useDemoManifest(); const { user } = useAuth(); + const { openGuide } = useDemoGuide(); return (
@@ -38,6 +41,18 @@ export function AboutDemo() { {manifest && ( <> + {user?.role === "operations_manager" && ( +
+
+ Liever meteen aan de slag? +

De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.

+
+ +
+ )} +

Het fictieve probleem

@@ -50,6 +65,17 @@ export function AboutDemo() {

+
+

Voor wie en met welke scope

+

+ Deze demo is bedoeld voor wie wil zien hoe MobilityOps operationele problemen bij + een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en + iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één + samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke + reservaties, geen volledig CRM of ERP. +

+
+

Wat écht werkt

@@ -72,6 +98,37 @@ export function AboutDemo() {

+
+

Architectuur in het kort

+

+ Een React/TypeScript-frontend praat met een FastAPI-backend (PostgreSQL via + SQLAlchemy/Alembic-migraties); belangrijke bedrijfsregels leven in de backend, niet + in n8n of in prompts. Retours en andere gebeurtenissen worden eerst lokaal + gecommit en pas daarna asynchroon via een outbox-patroon aan n8n afgeleverd, zodat + een tijdelijke storing in de automatisering nooit een operationele actie blokkeert. +

+
+ +
+

Beveiliging en toegang

+

+ Toegang verloopt via ondertekende, HTTP-only sessiecookies per rol; elke rol + gebonden aan een set toegestane routes, zowel serverzijdig afgedwongen als in de + navigatie weerspiegeld. Belangrijke statuswijzigingen worden altijd gecontroleerd + en gelogd — nooit stilzwijgend automatisch gecorrigeerd. +

+
+ +
+

Hoe dit getest is

+

+ Een geautomatiseerde backend-testsuite dekt bedrijfsregels en API-contracten; + een volledige Playwright-eindtot-eind-suite dekt de gebruikersstromen, inclusief + deze demo-ervaring zelf. Elke wijziging wordt bovendien tegen een schone checkout + (lege database, opnieuw opgebouwd vanaf de seed-data) gevalideerd voor deployment. +

+
+
| null, after: Record(null); const [error, setError] = useState(null); - const [action, setAction] = useState(""); + const [action, setAction] = useState(searchParams.get("action") ?? ""); + const correlationId = searchParams.get("correlation_id") ?? ""; useEffect(() => { if (user?.role !== "operations_manager") return; @@ -32,11 +34,20 @@ export function Audit() { setError(null); const params = new URLSearchParams(); if (action) params.set("action", action); + if (correlationId) params.set("correlation_id", correlationId); api .get(`/api/v1/audit?${params.toString()}`) .then(setEvents) .catch(() => setError("Audit trail is unavailable right now.")); - }, [action, user]); + }, [action, correlationId, user]); + + function showRelatedEvents(id: string) { + setSearchParams({ correlation_id: id }); + } + + function clearCorrelationFilter() { + setSearchParams(action ? { action } : {}); + } if (user?.role !== "operations_manager") { return ( @@ -63,6 +74,15 @@ export function Audit() { + {correlationId && ( +

+ Showing only events linked to this action ({events?.length ?? "…"} related events).{" "} + +

+ )} + {error && } {!error && !events && } {events && events.length === 0 && } @@ -77,6 +97,7 @@ export function Audit() { Action Entity Change + Follow-up Details @@ -98,6 +119,11 @@ export function Audit() { )} {describeChanges(e.before, e.after)} + + +
{e.correlation_id.slice(0, 8)} diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx index d47727c..e48ffc7 100644 --- a/frontend/src/pages/Automation.tsx +++ b/frontend/src/pages/Automation.tsx @@ -4,14 +4,7 @@ import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/t import { StatusBadge } from "../components/Badge"; import { useAuth } from "../context/AuthContext"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; - -const N8N_STATE_LABEL: Record = { - disabled: "disabled", - unavailable: "unavailable", - degraded: "degraded", - operational: "available", - no_evidence: "no_events", -}; +import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels"; export function Automation() { const { user } = useAuth(); @@ -98,9 +91,38 @@ export function Automation() { : "Return events are committed locally first and then delivered through the outbox."}

- + {(() => { + const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; + return ( + + ); + })()} + +
+ +
+ Knowledge +

Knowledge assistant

+

+ {knowledge + ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed in ${knowledge.collection}.` + : "Health evidence is currently unavailable."} +

+
+
-
Knowledge

RAGcore

{knowledge ? `${knowledge.document_count} procedures indexed in ${knowledge.collection}.` : "Health evidence is currently unavailable."}

@@ -109,10 +131,13 @@ export function Automation() {

{integrationStatus?.mcp_hub.registration_enabled ? "Registration is enabled for this deployment." - : "No active MobilityOps adapter is configured in this proof of concept."} + : "Not yet connected — prepared for future controlled tool calls from the ITWorx MCP Hub."}

- + {(() => { + const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; + return ; + })()}
diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index 866c8e2..1b7ec39 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -84,7 +84,14 @@ export function BookingDetail() { )} {returnResult && } - {!returnResult && booking.status === "active" && ( + {/* Wait for the scenario's canonical odometer before mounting the form at all when + this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct + from its very first render -- never updated asynchronously after mount, which + previously raced with anyone already typing into the field. */} + {!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && ( + + )} + {!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && ( = [ { key: "available", label: "Available", tone: "ready" }, @@ -178,24 +179,37 @@ export function Dashboard() { : "No workflow evidence recorded"} + {(() => { + const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; + return ( + + ); + })()} + +
  • + +
    + Knowledge assistant + {knowledge ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed` : "Health check unavailable"} +
  • -
  • RAGcore knowledge{knowledge ? `${knowledge.document_count} procedures indexed` : "Health check unavailable"}
  • MCP Hub - {integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "No active adapter in this PoC"} + {integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "Not yet connected"}
    - + {(() => { + const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; + return ; + })()}
  • diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9ef3c3a..3bf8ac6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -231,6 +231,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; } .about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; } .about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; } +.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 p { margin: 2px 0 0; } .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 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; } @@ -249,6 +252,8 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .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; } .result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.link-button { padding: 0; color: var(--teal-dark); background: transparent; border: 0; font-size: inherit; font-weight: 700; text-decoration: underline; cursor: pointer; } +.link-button:hover { color: var(--teal); } .rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); } .rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } .rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }