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.
This commit is contained in:
@@ -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<string, "n8n" | "rag" | "mcp"> = {
|
||||
export function AboutDemo() {
|
||||
const { manifest, loading } = useDemoManifest();
|
||||
const { user } = useAuth();
|
||||
const { openGuide } = useDemoGuide();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -38,6 +41,18 @@ export function AboutDemo() {
|
||||
|
||||
{manifest && (
|
||||
<>
|
||||
{user?.role === "operations_manager" && (
|
||||
<section className="record-surface about-card about-cta">
|
||||
<div>
|
||||
<strong>Liever meteen aan de slag?</strong>
|
||||
<p>De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.</p>
|
||||
</div>
|
||||
<button type="button" className="button button-primary" onClick={openGuide}>
|
||||
<Icon name="spark" /> Start begeleide demo
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Het fictieve probleem</h2>
|
||||
<p>
|
||||
@@ -50,6 +65,17 @@ export function AboutDemo() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Voor wie en met welke scope</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Wat écht werkt</h2>
|
||||
<p>
|
||||
@@ -72,6 +98,37 @@ export function AboutDemo() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Architectuur in het kort</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Beveiliging en toegang</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Hoe dit getest is</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-label="Koppelingsstatus" className="record-surface">
|
||||
<SectionHeading
|
||||
title="Koppelingen — eerlijk gelabeld"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { AuditEvent } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
@@ -22,9 +22,11 @@ function describeChanges(before: Record<string, unknown> | null, after: Record<s
|
||||
|
||||
export function Audit() {
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<AuditEvent[]>(`/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() {
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{correlationId && (
|
||||
<p className="quiet-empty" role="status">
|
||||
Showing only events linked to this action ({events?.length ?? "…"} related events).{" "}
|
||||
<button type="button" className="link-button" onClick={clearCorrelationFilter}>
|
||||
Clear this filter
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !events && <LoadingState label="Loading audit trail…" />}
|
||||
{events && events.length === 0 && <EmptyState icon="audit" title="No audit events found" detail="Adjust the action filter." />}
|
||||
@@ -77,6 +97,7 @@ export function Audit() {
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col">Entity</th>
|
||||
<th scope="col">Change</th>
|
||||
<th scope="col">Follow-up</th>
|
||||
<th scope="col">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -98,6 +119,11 @@ export function Audit() {
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Change">{describeChanges(e.before, e.after)}</td>
|
||||
<td data-label="Follow-up">
|
||||
<button type="button" className="link-button" onClick={() => showRelatedEvents(e.correlation_id)}>
|
||||
View related events
|
||||
</button>
|
||||
</td>
|
||||
<td className="mono" data-label="Details">
|
||||
<details>
|
||||
<summary>{e.correlation_id.slice(0, 8)}</summary>
|
||||
|
||||
@@ -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<IntegrationStatus["n8n"]["state"], string> = {
|
||||
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."}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={integrationStatus ? N8N_STATE_LABEL[integrationStatus.n8n.state] : (runs?.[0]?.status ?? "no_events")} />
|
||||
{(() => {
|
||||
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")}
|
||||
label={meta?.label}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
<article>
|
||||
<IntegrationMark kind="rag" />
|
||||
<div>
|
||||
<span className="integration-kicker">Knowledge</span>
|
||||
<h2>Knowledge assistant</h2>
|
||||
<p>
|
||||
{knowledge
|
||||
? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed in ${knowledge.collection}.`
|
||||
: "Health evidence is currently unavailable."}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={knowledge?.available ? "available" : "unavailable"}
|
||||
label={
|
||||
knowledge?.available
|
||||
? knowledge.provider === "ragcore"
|
||||
? "Operational"
|
||||
: "Demo mode"
|
||||
: "Unavailable"
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
<article><IntegrationMark kind="rag" /><div><span className="integration-kicker">Knowledge</span><h2>RAGcore</h2><p>{knowledge ? `${knowledge.document_count} procedures indexed in ${knowledge.collection}.` : "Health evidence is currently unavailable."}</p></div><StatusBadge status={knowledge?.available ? "available" : "unavailable"} /></article>
|
||||
<article>
|
||||
<IntegrationMark kind="mcp" />
|
||||
<div>
|
||||
@@ -109,10 +131,13 @@ export function Automation() {
|
||||
<p>
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={integrationStatus?.mcp_hub.state ?? "not_configured"} />
|
||||
{(() => {
|
||||
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
|
||||
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta?.label} />;
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -84,7 +84,14 @@ export function BookingDetail() {
|
||||
)}
|
||||
|
||||
{returnResult && <ReturnResultPanel result={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 && (
|
||||
<LoadingState label="Scenario voorbereiden…" />
|
||||
)}
|
||||
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
|
||||
<ReturnForm
|
||||
bookingRef={booking.public_ref}
|
||||
onRegistered={handleRegistered}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
||||
|
||||
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [
|
||||
{ key: "available", label: "Available", tone: "ready" },
|
||||
@@ -178,24 +179,37 @@ export function Dashboard() {
|
||||
: "No workflow evidence recorded"}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={meta?.statusClass ?? n8nState.toLowerCase().replace(/ /g, "_")}
|
||||
label={meta?.label}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</li>
|
||||
<li>
|
||||
<IntegrationMark kind="rag" />
|
||||
<div>
|
||||
<strong>Knowledge assistant</strong>
|
||||
<span>{knowledge ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={
|
||||
integrationStatus
|
||||
? integrationStatus.n8n.state === "operational"
|
||||
? "available"
|
||||
: integrationStatus.n8n.state
|
||||
: n8nState.toLowerCase().replace(/ /g, "_")
|
||||
}
|
||||
status={knowledge?.available ? "available" : "unavailable"}
|
||||
label={knowledge?.available ? (knowledge.provider === "ragcore" ? "Operational" : "Demo mode") : "Unavailable"}
|
||||
/>
|
||||
</li>
|
||||
<li><IntegrationMark kind="rag" /><div><strong>RAGcore knowledge</strong><span>{knowledge ? `${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span></div><StatusBadge status={knowledge?.available ? "available" : "unavailable"} /></li>
|
||||
<li>
|
||||
<IntegrationMark kind="mcp" />
|
||||
<div>
|
||||
<strong>MCP Hub</strong>
|
||||
<span>{integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "No active adapter in this PoC"}</span>
|
||||
<span>{integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "Not yet connected"}</span>
|
||||
</div>
|
||||
<StatusBadge status={integrationStatus?.mcp_hub.state ?? "not_configured"} />
|
||||
{(() => {
|
||||
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
|
||||
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta?.label} />;
|
||||
})()}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user