Files
MobilityOps/frontend/src/pages/Automation.tsx
T

398 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
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, N8N_WORKFLOW_SLUGS } from "../data/integrationLabels";
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
function deriveDisplayRef(run: AutomationRun): string {
const digits = run.aggregate_ref.match(/(\d+)$/)?.[1];
if (digits) return `AUT-RET-${digits}`;
return `AUT-${run.event_id.slice(0, 4).toUpperCase()}`;
}
export function Automation() {
const { t } = useTranslation(["integrations", "common"]);
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [runs, setRuns] = useState<AutomationRun[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [view, setView] = useState<ViewFilter>("attention");
const [expandSucceeded, setExpandSucceeded] = useState(false);
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const load = useCallback(() => {
setRuns(null);
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
api
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
.then(setRuns)
.catch(() =>
setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
);
}, [status, user, t]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []);
const loadIntegrationStatus = useCallback(() => {
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null));
}, []);
useEffect(() => {
loadIntegrationStatus();
}, [loadIntegrationStatus]);
async function handleRetry(eventId: string) {
setRetryError(null);
setRetrying(eventId);
try {
await api.post(`/api/v1/workflows/${eventId}/retry`);
load();
loadIntegrationStatus();
} catch (err) {
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
setRetrying(null);
}
}
const visibleRuns = useMemo(() => {
if (!runs) return null;
switch (view) {
case "attention":
return runs.filter((r) => r.status === "failed" || r.status === "pending" || r.status === "delivering");
case "recent":
return runs.slice(0, 5);
case "succeeded":
return runs.filter((r) => r.status === "succeeded");
default:
return runs;
}
}, [runs, view]);
const succeededRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status === "succeeded"), [visibleRuns]);
const otherRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status !== "succeeded"), [visibleRuns]);
const groupSucceeded = view !== "succeeded" && succeededRuns.length > 3;
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("ledger.managerOnly")} />
<p>{t("ledger.managerOnly")}</p>
</div>
);
}
function renderRow(r: AutomationRun) {
return (
<tr key={r.event_id}>
<td className="mono" data-label={t("ledger.columns.event")}>
<details>
<summary>{deriveDisplayRef(r)}</summary>
<span className="table-subtext">{r.event_id}</span>
</details>
</td>
<td data-label={t("ledger.columns.type")}>{t(`ledger.eventTypes.${r.event_type}`, { defaultValue: r.event_type })}</td>
<td data-label={t("ledger.columns.booking")}>{r.aggregate_ref}</td>
<td data-label={t("ledger.columns.status")}>
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
{r.is_demo_scenario && (
<StatusBadge status="demo_scenario" label={t("ledger.demoScenarioBadge")} />
)}
</td>
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
<td data-label={t("ledger.columns.lastError")}>
{r.last_error_code ? (
<>
<span>{t(`ledger.errorCodes.${r.last_error_code}`, { defaultValue: r.last_error ?? r.last_error_code })}</span>
{r.is_demo_scenario && <p className="table-subtext">{t("ledger.demoScenarioExplanation")}</p>}
{r.last_error && (
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<pre className="evidence-block">{r.last_error}</pre>
</details>
)}
</>
) : (
"—"
)}
</td>
<td data-label={t("ledger.columns.when")}>
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
</td>
<td data-label={t("ledger.columns.action")}>
{r.status === "failed" ? (
<button type="button" onClick={() => handleRetry(r.event_id)} disabled={retrying === r.event_id}>
{retrying === r.event_id
? t("ledger.retrying")
: t(r.is_demo_scenario ? "ledger.retryDemoScenario" : "ledger.retry")}
</button>
) : (
t("ledger.noAction")
)}
</td>
</tr>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<section className="integration-cards" aria-label={t("title")}>
<article>
<IntegrationMark kind="n8n" />
<div>
<span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
<h2>{t("cards.n8nTitle")}</h2>
<p>
{integrationStatus
? t("cards.n8nSummary", {
succeeded: integrationStatus.n8n.succeeded,
failed: integrationStatus.n8n.unexpected_failed,
pending: integrationStatus.n8n.pending,
delivering: integrationStatus.n8n.delivering,
})
: t("cards.n8nFallback")}
</p>
{/* Prepared demo failures are stated separately and never folded into the
health count above, so the badge and the numbers tell the same story. */}
{integrationStatus && integrationStatus.n8n.demo_scenario_failed > 0 && (
<p className="table-subtext">
{t("cards.n8nDemoScenario", { count: integrationStatus.n8n.demo_scenario_failed })}
</p>
)}
</div>
{(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return (
<StatusBadge
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")}
label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined}
/>
);
})()}
</article>
<article>
<IntegrationMark kind="rag" />
<div>
<span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
<h2>{t("cards.knowledgeTitle")}</h2>
<p>
{knowledge
? knowledge.document_count === null
? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection })
: t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
count: knowledge.document_count,
collection: knowledge.collection,
})
: t("cards.knowledgeUnavailable")}
</p>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
label={
knowledge?.available
? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("statusLabels.unavailable")
}
/>
</article>
<article>
<IntegrationMark kind="mcp" />
<div>
<span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<h2>{t("cards.mcpTitle")}</h2>
<p>
{(() => {
const hub = integrationStatus?.mcp_hub;
if (!hub?.registration_enabled) return t("cards.mcpNotConnected");
if (hub.total_calls > 0) {
return t("cards.mcpEvidence", { tool: hub.last_tool, client: hub.last_client, count: hub.total_calls });
}
return t("cards.mcpNoEvidence");
})()}
</p>
{integrationStatus?.mcp_hub.last_called_at && (
<small>{formatDateTime(integrationStatus.mcp_hub.last_called_at)}</small>
)}
</div>
<div className="integration-badge-stack">
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
})()}
{integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && (
<StatusBadge
status={integrationStatus.mcp_hub.hub_reachable ? "available" : "unavailable"}
label={t(integrationStatus.mcp_hub.hub_reachable ? "cards.mcpHubReachable" : "cards.mcpHubUnreachable")}
/>
)}
</div>
</article>
</section>
<SectionHeading
title={t("workflows.title")}
description={t("workflows.description", {
known: integrationStatus?.n8n.known_workflow_count ?? 0,
expected: integrationStatus?.n8n.expected_workflow_count ?? 4,
})}
/>
{integrationStatus && (
<div className="table-shell">
<table className="data-table">
<caption className="visually-hidden">{t("workflows.title")}</caption>
<thead>
<tr>
<th scope="col">{t("workflows.columns.name")}</th>
<th scope="col">{t("workflows.columns.status")}</th>
<th scope="col">{t("workflows.columns.lastSeen")}</th>
<th scope="col">{t("workflows.columns.technicalId")}</th>
</tr>
</thead>
<tbody>
{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.state === "healthy"
? { status: "available", label: t("workflows.healthy") }
: w.state === "stale"
? { status: "degraded", label: t("workflows.stale") }
: w.state === "failed"
? { status: "failed", label: t("workflows.failed") }
: { status: "no_events", label: t("workflows.noEvidence") };
return (
<tr key={w.name}>
<td data-label={t("workflows.columns.name")}>{displayName}</td>
<td data-label={t("workflows.columns.status")}>
<StatusBadge status={badge.status} label={badge.label} />
</td>
<td data-label={t("workflows.columns.lastSeen")}>
{w.last_seen_at ? (
<time dateTime={w.last_seen_at}>{formatDateTime(w.last_seen_at)}</time>
) : (
"—"
)}
</td>
<td data-label={t("workflows.columns.technicalId")}>
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<span className="table-subtext">{w.name}{w.last_execution_id ? ` · ${t("workflows.executionId")}: ${w.last_execution_id}` : ""}</span>
</details>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="table-meta">
{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")}
</p>
</div>
)}
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
<form className="filters" aria-label={t("ledger.title")}>
<label>
{t("ledger.filterLabel")}
<select value={view} onChange={(e) => setView(e.target.value as ViewFilter)}>
<option value="attention">{t("ledger.filterNeedsAttention")}</option>
<option value="recent">{t("ledger.filterRecent")}</option>
<option value="succeeded">{t("ledger.filterSucceeded")}</option>
<option value="all">{t("ledger.filterAll")}</option>
</select>
</label>
<label>
{t("ledger.statusFilterLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">{t("ledger.statusAll")}</option>
<option value="pending">{t("ledger.statusPending")}</option>
<option value="delivering">{t("ledger.statusDelivering")}</option>
<option value="succeeded">{t("ledger.statusSucceeded")}</option>
<option value="failed">{t("ledger.statusFailed")}</option>
</select>
</label>
</form>
{error && <ErrorState message={error} />}
<ApiErrorNotice error={retryError} />
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
{visibleRuns && visibleRuns.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("ledger.count", { count: visibleRuns.length })}</span><span>{t("ledger.boundedRetries")}</span></div>
<table className="data-table">
<caption className="visually-hidden">{t("ledger.title")}</caption>
<thead>
<tr>
<th scope="col">{t("ledger.columns.event")}</th>
<th scope="col">{t("ledger.columns.type")}</th>
<th scope="col">{t("ledger.columns.booking")}</th>
<th scope="col">{t("ledger.columns.status")}</th>
<th scope="col">{t("ledger.columns.attempts")}</th>
<th scope="col">{t("ledger.columns.lastError")}</th>
<th scope="col">{t("ledger.columns.when")}</th>
<th scope="col">{t("ledger.columns.action")}</th>
</tr>
</thead>
<tbody>
{otherRuns.map(renderRow)}
{groupSucceeded ? (
<>
<tr>
<td colSpan={8}>
<button type="button" className="link-button" onClick={() => setExpandSucceeded((v) => !v)}>
{expandSucceeded
? t("ledger.hideIndividually")
: t("ledger.groupedSucceeded", { count: succeededRuns.length })}
</button>
</td>
</tr>
{expandSucceeded && succeededRuns.map(renderRow)}
</>
) : (
succeededRuns.map(renderRow)
)}
</tbody>
</table>
</div>
)}
</div>
);
}