- 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>
291 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|