Files
MobilityOps/frontend/src/pages/Automation.tsx
T
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
  used identically by the data-quality scanner, a new non-mutating status-recommendation
  preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
  revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
  "maintenance + active booking -> auto rented" shortcut. Frontend
  DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
  why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
  <status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
  after the status-conflict issue now converges on the same final vehicle status,
  proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
  backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
  appeared in locale prose; add a permanent test guarding against a translation file
  ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
  reasons, audit field/actor-type labels, automation last_error, and search
  section/vehicle/booking/issue results all now carry codes the frontend localizes,
  with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
  decision table documenting the evaluator's rules and safe-status principles.

148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:37:34 +02:00

291 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } 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<string | 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(err instanceof ApiError ? err.message : t("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)}`)} />
</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.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("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.failed,
pending: integrationStatus.n8n.pending,
delivering: integrationStatus.n8n.delivering,
})
: t("cards.n8nFallback")}
</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
? 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>{integrationStatus?.mcp_hub.registration_enabled ? t("cards.mcpEnabled") : t("cards.mcpNotConnected")}</p>
</div>
{(() => {
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} />;
})()}
</article>
</section>
<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} />}
{retryError && <p className="error" role="alert">{retryError}</p>}
{!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>
);
}