398 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|