polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with eager-bundled per-namespace resources, a persisted accessible language switcher (topbar and mobile drawer), locale-aware date/number formatting, and a coverage test that fails the build on any missing or empty translation key. Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves from fixed English/Dutch prose to stable message codes + params so the frontend can localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus (11 documents each) with per-language retrieval and localized evidence-state messages. The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a floating panel that auto-collapses to a persistent, closable progress chip on standard desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+ highlight on "go to this step", Escape handling, and reduced-motion support. The Data Quality Workbench gets accessible choice-card decisions with a clear primary/ secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and uses meaningful short refs; the Audit trail groups events by correlation id with human action labels and readable before/after diffs. Attention Queue, Today's movements, Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern) with independent secondary links, keyboard support and mobile touch targets. Fixes a topbar overflow on mobile caused by the new language switcher (moved into the mobile drawer at <=960px) and two dangling aria-labelledby references introduced this session. Updates all affected Playwright specs for the new nl-BE default and the new Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
257a4cf6c0
commit
337f8716bb
@@ -1,16 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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);
|
||||
@@ -25,13 +39,9 @@ export function Automation() {
|
||||
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
|
||||
.then(setRuns)
|
||||
.catch(() =>
|
||||
setError(
|
||||
user?.role === "operations_manager"
|
||||
? "Automation runs are unavailable right now."
|
||||
: "Automation is only visible to Operations Managers.",
|
||||
),
|
||||
setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
|
||||
);
|
||||
}, [status, user]);
|
||||
}, [status, user, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -60,35 +70,90 @@ export function Automation() {
|
||||
load();
|
||||
loadIntegrationStatus();
|
||||
} catch (err) {
|
||||
setRetryError(err instanceof ApiError ? err.message : "Could not retry this delivery.");
|
||||
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="Assurance / Integrations" title="Integrations" description="Delivery evidence is visible to Operations Managers only." />
|
||||
<p>Automation delivery status is visible to Operations Managers only.</p>
|
||||
<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 ?? "—"}</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="Assurance / Integrations" title="Integration control" description="Monitor delivery health, graceful degradation and retryable workflow events." />
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
|
||||
|
||||
<section className="integration-cards" aria-label="Integration health">
|
||||
<section className="integration-cards" aria-label={t("title")}>
|
||||
<article>
|
||||
<IntegrationMark kind="n8n" />
|
||||
<div>
|
||||
<span className="integration-kicker">Orchestration</span>
|
||||
<h2>n8n delivery</h2>
|
||||
<span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
|
||||
<h2>{t("cards.n8nTitle")}</h2>
|
||||
<p>
|
||||
{integrationStatus
|
||||
? `${integrationStatus.n8n.succeeded} succeeded · ${integrationStatus.n8n.failed} failed · ${integrationStatus.n8n.pending} pending · ${integrationStatus.n8n.delivering} delivering.`
|
||||
: "Return events are committed locally first and then delivered through the outbox."}
|
||||
? t("cards.n8nSummary", {
|
||||
succeeded: integrationStatus.n8n.succeeded,
|
||||
failed: integrationStatus.n8n.failed,
|
||||
pending: integrationStatus.n8n.pending,
|
||||
delivering: integrationStatus.n8n.delivering,
|
||||
})
|
||||
: t("cards.n8nFallback")}
|
||||
</p>
|
||||
</div>
|
||||
{(() => {
|
||||
@@ -96,7 +161,7 @@ export function Automation() {
|
||||
return (
|
||||
<StatusBadge
|
||||
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")}
|
||||
label={meta?.label}
|
||||
label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@@ -104,111 +169,107 @@ export function Automation() {
|
||||
<article>
|
||||
<IntegrationMark kind="rag" />
|
||||
<div>
|
||||
<span className="integration-kicker">Knowledge</span>
|
||||
<h2>Knowledge assistant</h2>
|
||||
<span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
|
||||
<h2>{t("cards.knowledgeTitle")}</h2>
|
||||
<p>
|
||||
{knowledge
|
||||
? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed in ${knowledge.collection}.`
|
||||
: "Health evidence is currently unavailable."}
|
||||
? 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
|
||||
? knowledge.provider === "ragcore"
|
||||
? "Operational"
|
||||
: "Demo mode"
|
||||
: "Unavailable"
|
||||
? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
|
||||
: t("statusLabels.unavailable")
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
<article>
|
||||
<IntegrationMark kind="mcp" />
|
||||
<div>
|
||||
<span className="integration-kicker">Tool gateway</span>
|
||||
<h2>MCP Hub</h2>
|
||||
<p>
|
||||
{integrationStatus?.mcp_hub.registration_enabled
|
||||
? "Registration is enabled for this deployment."
|
||||
: "Not yet connected — prepared for future controlled tool calls from the ITWorx MCP Hub."}
|
||||
</p>
|
||||
<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?.label} />;
|
||||
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<SectionHeading title="Delivery ledger" description="Persisted outbox attempts with the latest failure evidence." />
|
||||
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
|
||||
|
||||
<form className="filters" aria-label="Filter automation runs">
|
||||
<form className="filters" aria-label={t("ledger.title")}>
|
||||
<label>
|
||||
Status
|
||||
{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="">All statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="delivering">Delivering</option>
|
||||
<option value="succeeded">Succeeded</option>
|
||||
<option value="failed">Failed</option>
|
||||
<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="Loading workflow delivery ledger…" />}
|
||||
{runs && runs.length === 0 && <p>No automation runs match this filter.</p>}
|
||||
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
|
||||
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
|
||||
|
||||
{runs && runs.length > 0 && (
|
||||
<div className="table-shell"><div className="table-meta"><span>{runs.length} workflow events</span><span>Bounded retries</span></div><table className="data-table">
|
||||
<caption className="visually-hidden">Automation runs</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Event</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Booking</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Attempts</th>
|
||||
<th scope="col">Last error</th>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.event_id}>
|
||||
<td className="mono" data-label="Event">{r.event_id.slice(0, 8)}</td>
|
||||
<td data-label="Type">{r.event_type}</td>
|
||||
<td data-label="Booking">{r.aggregate_ref}</td>
|
||||
<td data-label="Status">
|
||||
<StatusBadge status={r.status} />
|
||||
</td>
|
||||
<td data-label="Attempts">{r.attempts}</td>
|
||||
<td data-label="Last error">{r.last_error ?? "—"}</td>
|
||||
<td data-label="When">
|
||||
<time dateTime={r.occurred_at}>
|
||||
{new Date(r.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
</time>
|
||||
</td>
|
||||
<td data-label="Action">
|
||||
{r.status === "failed" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(r.event_id)}
|
||||
disabled={retrying === r.event_id}
|
||||
>
|
||||
{retrying === r.event_id ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user