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:
@@ -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,
|
- 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
|
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.
|
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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.models.user import User
|
|||||||
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||||
from app.seed_loader import reset_and_seed
|
from app.seed_loader import reset_and_seed
|
||||||
from app.services.audit import record_audit_event
|
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"])
|
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -107,6 +107,7 @@ def demo_reset(
|
|||||||
detail="Demo reset is disabled on this deployment.",
|
detail="Demo reset is disabled on this deployment.",
|
||||||
)
|
)
|
||||||
result = reset_and_seed(db)
|
result = reset_and_seed(db)
|
||||||
|
integrity = scenario_integrity_report(db)
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
@@ -116,6 +117,7 @@ def demo_reset(
|
|||||||
metadata={
|
metadata={
|
||||||
"counts": result.counts,
|
"counts": result.counts,
|
||||||
"anchor_date": result.anchor_date.isoformat(),
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -125,4 +127,5 @@ def demo_reset(
|
|||||||
"counts": result.counts,
|
"counts": result.counts,
|
||||||
"anchor_date": result.anchor_date.isoformat(),
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
"seeded_at": result.seeded_at.isoformat(),
|
"seeded_at": result.seeded_at.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
def build_demo_manifest(db: Session) -> DemoManifestOut:
|
||||||
last_reset_at, anchor_date = _last_reset(db)
|
last_reset_at, anchor_date = _last_reset(db)
|
||||||
return DemoManifestOut(
|
return DemoManifestOut(
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ def test_rental_employee_cannot_reset_demo(employee_client):
|
|||||||
def test_operations_manager_can_reset_demo(ops_client):
|
def test_operations_manager_can_reset_demo(ops_client):
|
||||||
response = ops_client.post("/api/v1/demo/reset")
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["counts"]["vehicles"] == 50
|
body = response.json()
|
||||||
assert response.json()["anchor_date"]
|
assert body["counts"]["vehicles"] == 50
|
||||||
assert response.json()["seeded_at"]
|
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):
|
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high
|
|||||||
return <span className={`badge severity-${severity}`}>{label} severity</span>;
|
return <span className={`badge severity-${severity}`}>{label} severity</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: string }) {
|
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||||
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>;
|
return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Link, useNavigate } from "react-router-dom";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api, ApiError } from "../api/client";
|
||||||
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||||
@@ -95,7 +95,6 @@ export function ReturnForm({
|
|||||||
suggestedOdometerKm?: number;
|
suggestedOdometerKm?: number;
|
||||||
}) {
|
}) {
|
||||||
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
|
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
|
||||||
const odometerEditedByUser = useRef(false);
|
|
||||||
const [fuel, setFuel] = useState("50");
|
const [fuel, setFuel] = useState("50");
|
||||||
const [cleanlinessOk, setCleanlinessOk] = useState(true);
|
const [cleanlinessOk, setCleanlinessOk] = useState(true);
|
||||||
const [damageReported, setDamageReported] = useState(false);
|
const [damageReported, setDamageReported] = useState(false);
|
||||||
@@ -108,16 +107,6 @@ export function ReturnForm({
|
|||||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
const [preview, setPreview] = useState<ReturnPreviewResult | null>(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 {
|
function currentBody(): RegisterReturnRequest {
|
||||||
return {
|
return {
|
||||||
end_odometer_km: Number(odometer),
|
end_odometer_km: Number(odometer),
|
||||||
@@ -184,10 +173,7 @@ export function ReturnForm({
|
|||||||
required
|
required
|
||||||
min={0}
|
min={0}
|
||||||
value={odometer}
|
value={odometer}
|
||||||
onChange={(e) => {
|
onChange={(e) => setOdometer(e.target.value)}
|
||||||
odometerEditedByUser.current = true;
|
|
||||||
setOdometer(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -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<IntegrationStatus["n8n"]["state"], { statusClass: string; label: string }> = {
|
||||||
|
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<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; label: string }> = {
|
||||||
|
not_configured: { statusClass: "not_configured", label: "Not connected" },
|
||||||
|
configured: { statusClass: "no_events", label: "Prepared" },
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
@@ -21,6 +23,7 @@ const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
|
|||||||
export function AboutDemo() {
|
export function AboutDemo() {
|
||||||
const { manifest, loading } = useDemoManifest();
|
const { manifest, loading } = useDemoManifest();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { openGuide } = useDemoGuide();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -38,6 +41,18 @@ export function AboutDemo() {
|
|||||||
|
|
||||||
{manifest && (
|
{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">
|
<section className="record-surface about-card">
|
||||||
<h2>Het fictieve probleem</h2>
|
<h2>Het fictieve probleem</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -50,6 +65,17 @@ export function AboutDemo() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</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">
|
<section className="record-surface about-card">
|
||||||
<h2>Wat écht werkt</h2>
|
<h2>Wat écht werkt</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -72,6 +98,37 @@ export function AboutDemo() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</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">
|
<section aria-label="Koppelingsstatus" className="record-surface">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Koppelingen — eerlijk gelabeld"
|
title="Koppelingen — eerlijk gelabeld"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { AuditEvent } from "../api/types";
|
import type { AuditEvent } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -22,9 +22,11 @@ function describeChanges(before: Record<string, unknown> | null, after: Record<s
|
|||||||
|
|
||||||
export function Audit() {
|
export function Audit() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== "operations_manager") return;
|
if (user?.role !== "operations_manager") return;
|
||||||
@@ -32,11 +34,20 @@ export function Audit() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (action) params.set("action", action);
|
if (action) params.set("action", action);
|
||||||
|
if (correlationId) params.set("correlation_id", correlationId);
|
||||||
api
|
api
|
||||||
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
||||||
.then(setEvents)
|
.then(setEvents)
|
||||||
.catch(() => setError("Audit trail is unavailable right now."));
|
.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") {
|
if (user?.role !== "operations_manager") {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +74,15 @@ export function Audit() {
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</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 && <ErrorState message={error} />}
|
||||||
{!error && !events && <LoadingState label="Loading audit trail…" />}
|
{!error && !events && <LoadingState label="Loading audit trail…" />}
|
||||||
{events && events.length === 0 && <EmptyState icon="audit" title="No audit events found" detail="Adjust the action filter." />}
|
{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">Action</th>
|
||||||
<th scope="col">Entity</th>
|
<th scope="col">Entity</th>
|
||||||
<th scope="col">Change</th>
|
<th scope="col">Change</th>
|
||||||
|
<th scope="col">Follow-up</th>
|
||||||
<th scope="col">Details</th>
|
<th scope="col">Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -98,6 +119,11 @@ export function Audit() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Change">{describeChanges(e.before, e.after)}</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">
|
<td className="mono" data-label="Details">
|
||||||
<details>
|
<details>
|
||||||
<summary>{e.correlation_id.slice(0, 8)}</summary>
|
<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 { StatusBadge } from "../components/Badge";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
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";
|
||||||
const N8N_STATE_LABEL: Record<IntegrationStatus["n8n"]["state"], string> = {
|
|
||||||
disabled: "disabled",
|
|
||||||
unavailable: "unavailable",
|
|
||||||
degraded: "degraded",
|
|
||||||
operational: "available",
|
|
||||||
no_evidence: "no_events",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Automation() {
|
export function Automation() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -98,9 +91,38 @@ export function Automation() {
|
|||||||
: "Return events are committed locally first and then delivered through the outbox."}
|
: "Return events are committed locally first and then delivered through the outbox."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
<article>
|
||||||
<IntegrationMark kind="mcp" />
|
<IntegrationMark kind="mcp" />
|
||||||
<div>
|
<div>
|
||||||
@@ -109,10 +131,13 @@ export function Automation() {
|
|||||||
<p>
|
<p>
|
||||||
{integrationStatus?.mcp_hub.registration_enabled
|
{integrationStatus?.mcp_hub.registration_enabled
|
||||||
? "Registration is enabled for this deployment."
|
? "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>
|
</p>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,14 @@ export function BookingDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{returnResult && <ReturnResultPanel result={returnResult} />}
|
{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
|
<ReturnForm
|
||||||
bookingRef={booking.public_ref}
|
bookingRef={booking.public_ref}
|
||||||
onRegistered={handleRegistered}
|
onRegistered={handleRegistered}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
|||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
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";
|
||||||
|
|
||||||
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [
|
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [
|
||||||
{ key: "available", label: "Available", tone: "ready" },
|
{ key: "available", label: "Available", tone: "ready" },
|
||||||
@@ -178,24 +179,37 @@ export function Dashboard() {
|
|||||||
: "No workflow evidence recorded"}
|
: "No workflow evidence recorded"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
|
||||||
|
return (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={
|
status={meta?.statusClass ?? n8nState.toLowerCase().replace(/ /g, "_")}
|
||||||
integrationStatus
|
label={meta?.label}
|
||||||
? integrationStatus.n8n.state === "operational"
|
/>
|
||||||
? "available"
|
);
|
||||||
: integrationStatus.n8n.state
|
})()}
|
||||||
: n8nState.toLowerCase().replace(/ /g, "_")
|
</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={knowledge?.available ? "available" : "unavailable"}
|
||||||
|
label={knowledge?.available ? (knowledge.provider === "ragcore" ? "Operational" : "Demo mode") : "Unavailable"}
|
||||||
/>
|
/>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<IntegrationMark kind="mcp" />
|
<IntegrationMark kind="mcp" />
|
||||||
<div>
|
<div>
|
||||||
<strong>MCP Hub</strong>
|
<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>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 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 { 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-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 { 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: .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); }
|
.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; }
|
||||||
.result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
.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 { 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 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; }
|
.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||||
|
|||||||
Reference in New Issue
Block a user