feat(ui): redesign knowledge integrations and audit
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { AuditEvent } from "../api/types";
|
import type { AuditEvent } from "../api/types";
|
||||||
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
export function Audit() {
|
export function Audit() {
|
||||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||||
@@ -8,6 +9,8 @@ export function Audit() {
|
|||||||
const [action, setAction] = useState("");
|
const [action, setAction] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setEvents(null);
|
||||||
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (action) params.set("action", action);
|
if (action) params.set("action", action);
|
||||||
api
|
api
|
||||||
@@ -18,7 +21,7 @@ export function Audit() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Audit</h1>
|
<PageHeader eyebrow="Assurance / Immutable history" title="Audit trail" description="Trace important state changes, actors and correlation references." />
|
||||||
|
|
||||||
<form className="filters" aria-label="Filter audit events">
|
<form className="filters" aria-label="Filter audit events">
|
||||||
<label>
|
<label>
|
||||||
@@ -32,12 +35,12 @@ export function Audit() {
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <ErrorState message={error} />}
|
||||||
{!error && !events && <p>Loading audit trail…</p>}
|
{!error && !events && <LoadingState label="Loading audit trail…" />}
|
||||||
{events && events.length === 0 && <p>No audit events match this filter.</p>}
|
{events && events.length === 0 && <EmptyState icon="audit" title="No audit events found" detail="Adjust the action filter." />}
|
||||||
|
|
||||||
{events && events.length > 0 && (
|
{events && events.length > 0 && (
|
||||||
<table className="data-table">
|
<div className="table-shell"><div className="table-meta"><span>{events.length} events</span><span>UTC stored · Brussels rendered</span></div><table className="data-table">
|
||||||
<caption className="visually-hidden">Audit events</caption>
|
<caption className="visually-hidden">Audit events</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -51,19 +54,19 @@ export function Audit() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{events.map((e) => (
|
{events.map((e) => (
|
||||||
<tr key={e.id}>
|
<tr key={e.id}>
|
||||||
<td>
|
<td data-label="When">
|
||||||
<time dateTime={e.occurred_at}>
|
<time dateTime={e.occurred_at}>
|
||||||
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||||
</time>
|
</time>
|
||||||
</td>
|
</td>
|
||||||
<td>{e.actor_label} ({e.actor_type})</td>
|
<td data-label="Actor"><strong>{e.actor_label}</strong><small className="table-subtext">{e.actor_type}</small></td>
|
||||||
<td>{e.action}</td>
|
<td data-label="Action">{e.action.replace(/_/g, " ")}</td>
|
||||||
<td>{e.entity_type}</td>
|
<td data-label="Entity">{e.entity_type}</td>
|
||||||
<td className="mono">{e.correlation_id.slice(0, 8)}</td>
|
<td className="mono" data-label="Correlation"><details><summary>{e.correlation_id.slice(0, 8)}</summary><pre>{JSON.stringify(e.metadata ?? {}, null, 2)}</pre></details></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api, ApiError } from "../api/client";
|
||||||
import type { AutomationRun } from "../api/types";
|
import type { AutomationRun, KnowledgeHealth } from "../api/types";
|
||||||
import { StatusBadge } from "../components/Badge";
|
import { StatusBadge } from "../components/Badge";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
|
|
||||||
export function Automation() {
|
export function Automation() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -11,8 +12,11 @@ export function Automation() {
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [retryError, setRetryError] = useState<string | null>(null);
|
const [retryError, setRetryError] = useState<string | null>(null);
|
||||||
const [retrying, setRetrying] = useState<string | null>(null);
|
const [retrying, setRetrying] = useState<string | null>(null);
|
||||||
|
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
setRuns(null);
|
||||||
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
api
|
api
|
||||||
@@ -31,6 +35,10 @@ export function Automation() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleRetry(eventId: string) {
|
async function handleRetry(eventId: string) {
|
||||||
setRetryError(null);
|
setRetryError(null);
|
||||||
setRetrying(eventId);
|
setRetrying(eventId);
|
||||||
@@ -47,7 +55,7 @@ export function Automation() {
|
|||||||
if (user?.role !== "operations_manager") {
|
if (user?.role !== "operations_manager") {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Automation</h1>
|
<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>
|
<p>Automation delivery status is visible to Operations Managers only.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -55,7 +63,15 @@ export function Automation() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Automation</h1>
|
<PageHeader eyebrow="Assurance / Integrations" title="Integration control" description="Monitor delivery health, graceful degradation and retryable workflow events." />
|
||||||
|
|
||||||
|
<section className="integration-cards" aria-label="Integration health">
|
||||||
|
<article><IntegrationMark kind="n8n" /><div><span className="integration-kicker">Orchestration</span><h2>n8n delivery</h2><p>Return events are committed locally first and then delivered through the outbox.</p></div><StatusBadge status={runs?.[0]?.status ?? "no_events"} /></article>
|
||||||
|
<article><IntegrationMark kind="rag" /><div><span className="integration-kicker">Knowledge</span><h2>RAGcore</h2><p>{knowledge ? `${knowledge.document_count} procedures indexed in ${knowledge.collection}.` : "Health evidence is currently unavailable."}</p></div><StatusBadge status={knowledge?.available ? "available" : "unavailable"} /></article>
|
||||||
|
<article><IntegrationMark kind="mcp" /><div><span className="integration-kicker">Tool gateway</span><h2>MCP Hub</h2><p>No active MobilityOps adapter is configured in this proof of concept.</p></div><StatusBadge status="not_configured" /></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SectionHeading title="Delivery ledger" description="Persisted outbox attempts with the latest failure evidence." />
|
||||||
|
|
||||||
<form className="filters" aria-label="Filter automation runs">
|
<form className="filters" aria-label="Filter automation runs">
|
||||||
<label>
|
<label>
|
||||||
@@ -70,13 +86,13 @@ export function Automation() {
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <ErrorState message={error} />}
|
||||||
{retryError && <p className="error" role="alert">{retryError}</p>}
|
{retryError && <p className="error" role="alert">{retryError}</p>}
|
||||||
{!error && !runs && <p>Loading automation runs…</p>}
|
{!error && !runs && <LoadingState label="Loading workflow delivery ledger…" />}
|
||||||
{runs && runs.length === 0 && <p>No automation runs match this filter.</p>}
|
{runs && runs.length === 0 && <p>No automation runs match this filter.</p>}
|
||||||
|
|
||||||
{runs && runs.length > 0 && (
|
{runs && runs.length > 0 && (
|
||||||
<table className="data-table">
|
<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>
|
<caption className="visually-hidden">Automation runs</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -93,20 +109,20 @@ export function Automation() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{runs.map((r) => (
|
{runs.map((r) => (
|
||||||
<tr key={r.event_id}>
|
<tr key={r.event_id}>
|
||||||
<td className="mono">{r.event_id.slice(0, 8)}</td>
|
<td className="mono" data-label="Event">{r.event_id.slice(0, 8)}</td>
|
||||||
<td>{r.event_type}</td>
|
<td data-label="Type">{r.event_type}</td>
|
||||||
<td>{r.aggregate_ref}</td>
|
<td data-label="Booking">{r.aggregate_ref}</td>
|
||||||
<td>
|
<td data-label="Status">
|
||||||
<StatusBadge status={r.status} />
|
<StatusBadge status={r.status} />
|
||||||
</td>
|
</td>
|
||||||
<td>{r.attempts}</td>
|
<td data-label="Attempts">{r.attempts}</td>
|
||||||
<td>{r.last_error ?? "—"}</td>
|
<td data-label="Last error">{r.last_error ?? "—"}</td>
|
||||||
<td>
|
<td data-label="When">
|
||||||
<time dateTime={r.occurred_at}>
|
<time dateTime={r.occurred_at}>
|
||||||
{new Date(r.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
{new Date(r.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||||
</time>
|
</time>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Action">
|
||||||
{r.status === "failed" ? (
|
{r.status === "failed" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -122,7 +138,7 @@ export function Automation() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api, ApiError } from "../api/client";
|
||||||
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
||||||
|
import { Icon } from "../components/Icons";
|
||||||
|
import { PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
interface Exchange {
|
interface Exchange {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -45,17 +47,13 @@ export function Knowledge() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Knowledge</h1>
|
<PageHeader eyebrow="Assurance / Grounded knowledge" title="Procedure knowledge" description="Ask operational questions. Answers are shown only when RAGcore returns sufficient cited evidence." />
|
||||||
{status && (
|
{status && (
|
||||||
<p className="knowledge-status">
|
<div className="knowledge-status"><span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} /><div><strong>{status.provider}</strong><span>{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed</span></div><small>{status.collection}</small></div>
|
||||||
Provider: <strong>{status.provider}</strong> ·{" "}
|
|
||||||
{status.available ? "available" : "unavailable"} · {status.document_count} procedures
|
|
||||||
indexed
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
|
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
|
||||||
<h2 id="ask-heading">Ask a procedure question</h2>
|
<div className="ask-heading"><span><Icon name="spark" /></span><div><h2 id="ask-heading">Ask a procedure question</h2><p>Retrieval → evidence check → grounded answer</p></div></div>
|
||||||
<label htmlFor="knowledge-question" className="visually-hidden">
|
<label htmlFor="knowledge-question" className="visually-hidden">
|
||||||
Question
|
Question
|
||||||
</label>
|
</label>
|
||||||
@@ -70,22 +68,23 @@ export function Knowledge() {
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={submitting}>
|
<button className="button button-primary" type="submit" disabled={submitting}>
|
||||||
{submitting ? "Asking…" : "Ask"}
|
{submitting ? "Asking…" : "Ask"}
|
||||||
|
{!submitting && <Icon name="chevron" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{exchanges.length === 0 && !error && (
|
{exchanges.length === 0 && !error && (
|
||||||
<p>Ask a question about one of the ten operational procedures to see cited sources.</p>
|
<div className="knowledge-empty"><span><Icon name="knowledge" /></span><h2>Evidence before answers</h2><p>Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.</p><div className="retrieval-flow" aria-hidden="true"><span>Question</span><i /><span>RAGcore</span><i /><span>Sources</span><i /><span>Answer</span></div></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="exchange-list">
|
<ul className="exchange-list">
|
||||||
{exchanges.map((exchange, index) => (
|
{exchanges.map((exchange, index) => (
|
||||||
<li key={index} className="panel exchange">
|
<li key={index} className="panel exchange">
|
||||||
<p className="exchange-question">
|
<p className="exchange-question">
|
||||||
<strong>Q:</strong> {exchange.question}
|
<strong>Question</strong> {exchange.question}
|
||||||
</p>
|
</p>
|
||||||
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
|
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
|
||||||
{EVIDENCE_LABEL[exchange.answer.evidence_state]}
|
{EVIDENCE_LABEL[exchange.answer.evidence_state]}
|
||||||
|
|||||||
Reference in New Issue
Block a user