From 4049c0c6b12fef3d948cd31f21119044143320d8 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:09 +0200 Subject: [PATCH] n8n: surface real per-workflow evidence on the integration status page Fleet Ops integration status no longer depends only on a config boolean or the most recent outbox event: N8nIntegrationStatus now reports per-canonical-workflow evidence (last successful outbox delivery for the return workflow, latest service-triggered data_quality_scan_run for the scan workflow, latest n8n_workflow_failure_registered for the error handler, and "not built" for the still-blocked RAGcore sync), plus an error-handler summary (total failures registered, latest failure + which workflow). Automation page renders this as a localized workflow table (EN/NL/FR) with technical workflow names tucked under a "Technical details" disclosure, matching the existing progressive-disclosure pattern. Co-Authored-By: Claude Sonnet 5 --- backend/app/schemas.py | 16 ++++ backend/app/services/integration_status.py | 66 +++++++++++++++- backend/tests/test_integration_status.py | 79 +++++++++++++++++++ frontend/src/api/types.ts | 16 ++++ frontend/src/data/integrationLabels.ts | 10 +++ .../src/i18n/locales/en-GB/integrations.json | 22 ++++++ .../src/i18n/locales/fr-BE/integrations.json | 22 ++++++ .../src/i18n/locales/nl-BE/integrations.json | 22 ++++++ frontend/src/pages/Automation.tsx | 69 +++++++++++++++- 9 files changed, 320 insertions(+), 2 deletions(-) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c95f887..8ce6126 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -223,6 +223,18 @@ class SearchResponse(BaseModel): results: list[SearchResultItem] +class N8nWorkflowEvidence(BaseModel): + name: str + built: bool + last_seen_at: datetime | None + + +class N8nErrorHandlerStatus(BaseModel): + total_failures_registered: int + latest_failure_at: datetime | None + latest_failure_workflow: str | None + + class N8nIntegrationStatus(BaseModel): configured: bool dispatch_enabled: bool @@ -233,6 +245,10 @@ class N8nIntegrationStatus(BaseModel): succeeded: int latest_success_at: datetime | None latest_failure_at: datetime | None + expected_workflow_count: int + known_workflow_count: int + workflows: list[N8nWorkflowEvidence] + error_handler: N8nErrorHandlerStatus class McpHubIntegrationStatus(BaseModel): diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py index d647272..ff3e646 100644 --- a/backend/app/services/integration_status.py +++ b/backend/app/services/integration_status.py @@ -6,11 +6,21 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.config import get_settings +from app.models.audit import AuditEvent from app.models.outbox import OutboxEvent -from app.schemas import N8nIntegrationStatus +from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence settings = get_settings() +# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). Workflow 3 +# (RAGcore Procedure Sync) is not built yet, so it always reports no evidence. +_CANONICAL_WORKFLOWS = ( + "Fleet Ops — Vehicle Return Orchestration", + "Fleet Ops — Scheduled Data Quality Scan", + "Fleet Ops — RAGcore Procedure Sync", + "Fleet Ops — Workflow Error Handler", +) + def derive_n8n_status(db: Session) -> N8nIntegrationStatus: counts: dict[str, int] = dict( @@ -42,6 +52,52 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus: else: state = "no_evidence" + # Scheduled scan evidence: only service-triggered runs count as n8n evidence, not + # runs an operator triggered manually from the Data Quality page. + latest_scan_at = db.scalar( + select(func.max(AuditEvent.occurred_at)).where( + AuditEvent.action == "data_quality_scan_run", + AuditEvent.actor_type == "service", + ) + ) + + # Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error + # Handler" n8n workflow itself, which also doubles as proof that workflow is wired + # up and firing correctly. + total_failures_registered = ( + db.scalar( + select(func.count(AuditEvent.id)).where( + AuditEvent.action == "n8n_workflow_failure_registered" + ) + ) + or 0 + ) + latest_failure_row = db.execute( + select(AuditEvent.occurred_at, AuditEvent.after_json) + .where(AuditEvent.action == "n8n_workflow_failure_registered") + .order_by(AuditEvent.occurred_at.desc()) + .limit(1) + ).first() + latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None + latest_handler_failure_workflow = ( + (latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None + ) + + evidence_by_workflow = { + "Fleet Ops — Vehicle Return Orchestration": latest_success_at, + "Fleet Ops — Scheduled Data Quality Scan": latest_scan_at, + "Fleet Ops — RAGcore Procedure Sync": None, + "Fleet Ops — Workflow Error Handler": latest_handler_failure_at, + } + workflows = [ + N8nWorkflowEvidence( + name=name, + built=name != "Fleet Ops — RAGcore Procedure Sync", + last_seen_at=evidence_by_workflow[name], + ) + for name in _CANONICAL_WORKFLOWS + ] + return N8nIntegrationStatus( configured=bool(settings.n8n_webhook_url), dispatch_enabled=settings.n8n_dispatch_enabled, @@ -52,4 +108,12 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus: succeeded=succeeded, latest_success_at=latest_success_at, latest_failure_at=latest_failure_at, + expected_workflow_count=len(_CANONICAL_WORKFLOWS), + known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None), + workflows=workflows, + error_handler=N8nErrorHandlerStatus( + total_failures_registered=total_failures_registered, + latest_failure_at=latest_handler_failure_at, + latest_failure_workflow=latest_handler_failure_workflow, + ), ) diff --git a/backend/tests/test_integration_status.py b/backend/tests/test_integration_status.py index 23ac3c9..c0fff53 100644 --- a/backend/tests/test_integration_status.py +++ b/backend/tests/test_integration_status.py @@ -39,3 +39,82 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c body = response.json()["n8n"] assert body["failed"] == 0 assert body["state"] == "operational" + + +def test_integration_status_lists_all_four_canonical_workflows(ops_client): + body = ops_client.get("/api/v1/integrations/status").json()["n8n"] + assert body["expected_workflow_count"] == 4 + names = {w["name"] for w in body["workflows"]} + assert names == { + "Fleet Ops — Vehicle Return Orchestration", + "Fleet Ops — Scheduled Data Quality Scan", + "Fleet Ops — RAGcore Procedure Sync", + "Fleet Ops — Workflow Error Handler", + } + ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"]) + assert ragcore_sync["built"] is False + assert ragcore_sync["last_seen_at"] is None + + +def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client): + from app.core.config import get_settings + + settings = get_settings() + + before = ops_client.get("/api/v1/integrations/status").json()["n8n"] + scan_workflow = next( + w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan") + ) + assert scan_workflow["last_seen_at"] is None + + scan = client.post( + "/api/v1/integrations/n8n/scheduled-scan", + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert scan.status_code == 200 + + after = ops_client.get("/api/v1/integrations/status").json()["n8n"] + scan_workflow = next( + w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan") + ) + assert scan_workflow["last_seen_at"] is not None + assert after["known_workflow_count"] > before["known_workflow_count"] + + +def test_integration_status_reflects_error_handler_registrations(client, ops_client): + import uuid + + from app.core.config import get_settings + + settings = get_settings() + before = ops_client.get("/api/v1/integrations/status").json()["n8n"] + + execution_id = str(uuid.uuid4()) + report = client.post( + "/api/v1/integrations/n8n/workflow-error", + json={ + "workflow_id": "mobilityops-return-processing", + "workflow_name": "Fleet Ops — Vehicle Return Orchestration", + "execution_id": execution_id, + "failed_at": "2026-08-04T10:15:00Z", + "error_category": "httpError", + "error_summary": "Simulated failure for status test", + "trigger_context": "webhook", + "attempt": 1, + }, + headers={"X-Service-Token": settings.n8n_callback_token}, + ) + assert report.status_code == 200 + + after = ops_client.get("/api/v1/integrations/status").json()["n8n"] + assert ( + after["error_handler"]["total_failures_registered"] + == before["error_handler"]["total_failures_registered"] + 1 + ) + assert after["error_handler"]["latest_failure_workflow"] == ( + "Fleet Ops — Vehicle Return Orchestration" + ) + handler_workflow = next( + w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler") + ) + assert handler_workflow["last_seen_at"] is not None diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d6de408..aedaa0d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -254,6 +254,18 @@ export interface KnowledgeHealth { document_count: number; } +export interface N8nWorkflowEvidence { + name: string; + built: boolean; + last_seen_at: string | null; +} + +export interface N8nErrorHandlerStatus { + total_failures_registered: number; + latest_failure_at: string | null; + latest_failure_workflow: string | null; +} + export interface N8nIntegrationStatus { configured: boolean; dispatch_enabled: boolean; @@ -264,6 +276,10 @@ export interface N8nIntegrationStatus { succeeded: number; latest_success_at: string | null; latest_failure_at: string | null; + expected_workflow_count: number; + known_workflow_count: number; + workflows: N8nWorkflowEvidence[]; + error_handler: N8nErrorHandlerStatus; } export interface McpHubIntegrationStatus { diff --git a/frontend/src/data/integrationLabels.ts b/frontend/src/data/integrationLabels.ts index ab07d26..6aed855 100644 --- a/frontend/src/data/integrationLabels.ts +++ b/frontend/src/data/integrationLabels.ts @@ -17,3 +17,13 @@ export const MCP_STATE_META: Record = { + "Fleet Ops — Vehicle Return Orchestration": "vehicleReturn", + "Fleet Ops — Scheduled Data Quality Scan": "scheduledScan", + "Fleet Ops — RAGcore Procedure Sync": "ragcoreSync", + "Fleet Ops — Workflow Error Handler": "errorHandler", +}; diff --git a/frontend/src/i18n/locales/en-GB/integrations.json b/frontend/src/i18n/locales/en-GB/integrations.json index 73c5e4d..db39d9b 100644 --- a/frontend/src/i18n/locales/en-GB/integrations.json +++ b/frontend/src/i18n/locales/en-GB/integrations.json @@ -26,6 +26,28 @@ "demoMode": "Demo mode", "unavailable": "Unavailable" }, + "workflows": { + "title": "Automation workflows", + "description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.", + "notBuilt": "Not built yet", + "noEvidence": "No evidence yet", + "columns": { + "name": "Workflow", + "status": "Status", + "lastSeen": "Last evidence", + "technicalId": "Details" + }, + "names": { + "vehicleReturn": "Vehicle return orchestration", + "scheduledScan": "Scheduled data quality scan", + "ragcoreSync": "Knowledge procedure sync", + "errorHandler": "Workflow error handler" + }, + "errorHandler": { + "summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.", + "summaryEmpty": "No automation failures have been registered." + } + }, "ledger": { "title": "Automation jobs", "description": "Persisted automation attempts with the latest failure evidence.", diff --git a/frontend/src/i18n/locales/fr-BE/integrations.json b/frontend/src/i18n/locales/fr-BE/integrations.json index 2c4a29a..3fe9733 100644 --- a/frontend/src/i18n/locales/fr-BE/integrations.json +++ b/frontend/src/i18n/locales/fr-BE/integrations.json @@ -26,6 +26,28 @@ "demoMode": "Mode démo", "unavailable": "Indisponible" }, + "workflows": { + "title": "Workflows d'automatisation", + "description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.", + "notBuilt": "Pas encore créé", + "noEvidence": "Aucune preuve pour l'instant", + "columns": { + "name": "Workflow", + "status": "Statut", + "lastSeen": "Dernière preuve", + "technicalId": "Détails" + }, + "names": { + "vehicleReturn": "Orchestration du retour de véhicule", + "scheduledScan": "Analyse planifiée de la qualité des données", + "ragcoreSync": "Synchronisation des procédures de connaissances", + "errorHandler": "Gestionnaire d'erreurs de workflow" + }, + "errorHandler": { + "summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.", + "summaryEmpty": "Aucun échec d'automatisation n'a été enregistré." + } + }, "ledger": { "title": "Tâches d'automatisation", "description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.", diff --git a/frontend/src/i18n/locales/nl-BE/integrations.json b/frontend/src/i18n/locales/nl-BE/integrations.json index 15517b9..8037ea2 100644 --- a/frontend/src/i18n/locales/nl-BE/integrations.json +++ b/frontend/src/i18n/locales/nl-BE/integrations.json @@ -26,6 +26,28 @@ "demoMode": "Demomodus", "unavailable": "Niet beschikbaar" }, + "workflows": { + "title": "Automatiseringsworkflows", + "description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.", + "notBuilt": "Nog niet gebouwd", + "noEvidence": "Nog geen evidentie", + "columns": { + "name": "Workflow", + "status": "Status", + "lastSeen": "Laatste evidentie", + "technicalId": "Details" + }, + "names": { + "vehicleReturn": "Voertuigretour-orkestratie", + "scheduledScan": "Geplande datakwaliteitsscan", + "ragcoreSync": "Synchronisatie kennisprocedures", + "errorHandler": "Workflowfoutafhandelaar" + }, + "errorHandler": { + "summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.", + "summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd." + } + }, "ledger": { "title": "Automatiseringsopdrachten", "description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.", diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx index 06c5752..608f03f 100644 --- a/frontend/src/pages/Automation.tsx +++ b/frontend/src/pages/Automation.tsx @@ -7,7 +7,7 @@ import { StatusBadge } from "../components/Badge"; import { useAuth } from "../context/AuthContext"; import { useLocaleFormat } from "../i18n/format"; import { ApiErrorNotice, 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, N8N_WORKFLOW_SLUGS } from "../data/integrationLabels"; type ViewFilter = "attention" | "recent" | "succeeded" | "all"; @@ -218,6 +218,73 @@ export function Automation() { + + + {integrationStatus && ( +
+ + + + + + + + + + + + {integrationStatus.n8n.workflows.map((w) => { + const slug = N8N_WORKFLOW_SLUGS[w.name] ?? ""; + const displayName = slug ? t(`workflows.names.${slug}`) : w.name; + const badge = !w.built + ? { status: "not_configured", label: t("workflows.notBuilt") } + : w.last_seen_at + ? { status: "available", label: t("statusLabels.operational") } + : { status: "no_events", label: t("workflows.noEvidence") }; + return ( + + + + + + + ); + })} + +
{t("workflows.title")}
{t("workflows.columns.name")}{t("workflows.columns.status")}{t("workflows.columns.lastSeen")}{t("workflows.columns.technicalId")}
{displayName} + + + {w.last_seen_at ? ( + + ) : ( + "—" + )} + +
+ {t("common:actions.technicalDetails")} + {w.name} +
+
+

+ {integrationStatus.n8n.error_handler.total_failures_registered > 0 + ? t("workflows.errorHandler.summary", { + count: integrationStatus.n8n.error_handler.total_failures_registered, + workflow: integrationStatus.n8n.error_handler.latest_failure_workflow ?? "", + when: integrationStatus.n8n.error_handler.latest_failure_at + ? formatDateTime(integrationStatus.n8n.error_handler.latest_failure_at) + : "", + }) + : t("workflows.errorHandler.summaryEmpty")} +

+
+ )} +