diff --git a/apps/admin-ui/src/app/audit/page.tsx b/apps/admin-ui/src/app/audit/page.tsx index bac2107..788cb05 100644 --- a/apps/admin-ui/src/app/audit/page.tsx +++ b/apps/admin-ui/src/app/audit/page.tsx @@ -3,8 +3,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, Filter, Search, ScrollText, User } from "lucide-react"; import { useMemo, useState } from "react"; -import { useApp } from "@/components/providers"; -import { CopyableId, DataPanel, DefItem, JsonBlock, PageHeader, useResolvedState } from "@/components/shared"; +import { CopyableId, DataPanel, DefItem, JsonBlock, PageHeader, SourceBadge, useResolvedState } from "@/components/shared"; import { Dropdown } from "@/components/ui/overlays"; import { Sheet } from "@/components/ui/overlays"; import { @@ -23,10 +22,12 @@ import { Table, TableSkeleton, } from "@/components/ui/primitives"; +import { fetchAudit } from "@/lib/api"; import { cn } from "@/lib/cn"; import { ACTIONS, AUDIT, OUTCOMES } from "@/lib/mock"; import { absTime, relTime } from "@/lib/time"; import type { AuditEvent, AuditOutcome } from "@/lib/types"; +import { useLive } from "@/lib/use-live"; const PAGE_SIZE = 12; const OUTCOME_BADGE: Record = { @@ -36,8 +37,12 @@ const OUTCOME_BADGE: Record = { }; export default function AuditPage() { - const { tenant } = useApp(); const state = useResolvedState("ready"); + const { rows: audit, source } = useLive( + (tid) => AUDIT.filter((e) => e.tenant_id === tid), + fetchAudit, + { fallbackOnEmpty: true }, + ); const [actions, setActions] = useState([]); const [outcomes, setOutcomes] = useState([]); @@ -48,7 +53,7 @@ export default function AuditPage() { const [selected, setSelected] = useState(null); const filtered = useMemo(() => { - return AUDIT.filter((e) => e.tenant_id === tenant.id) + return audit .filter((e) => (actions.length ? actions.includes(e.action) : true)) .filter((e) => (outcomes.length ? outcomes.includes(e.outcome) : true)) .filter((e) => (principal ? e.principal_id.includes(principal) : true)) @@ -57,7 +62,7 @@ export default function AuditPage() { ? JSON.stringify(e).toLowerCase().includes(text.toLowerCase()) : true, ); - }, [tenant.id, actions, outcomes, principal, text]); + }, [audit, actions, outcomes, principal, text]); const total = filtered.length; const pageRows = filtered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); @@ -70,6 +75,7 @@ export default function AuditPage() { } /> {/* filter bar */} diff --git a/apps/admin-ui/src/app/page.tsx b/apps/admin-ui/src/app/page.tsx index dfb41ed..0070041 100644 --- a/apps/admin-ui/src/app/page.tsx +++ b/apps/admin-ui/src/app/page.tsx @@ -3,26 +3,37 @@ import { Activity, ChevronRight, + Clock, Database, - FileText, Gauge, + HeartPulse, + MessageSquare, ScrollText, + ShieldCheck, Terminal, - TrendingUp, Webhook, - Zap, type LucideIcon, } from "lucide-react"; import Link from "next/link"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useApp } from "@/components/providers"; -import { ConnectorIcon, PageHeader } from "@/components/shared"; -import { Badge, Card, StatusDot } from "@/components/ui/primitives"; +import { PageHeader, SourceBadge } from "@/components/shared"; +import { Badge, Card, EmptyState, StatusDot } from "@/components/ui/primitives"; +import { fetchAudit, fetchCorpora, fetchFeedback, fetchWebhooks } from "@/lib/api"; import { cn } from "@/lib/cn"; -import { AUDIT, CONNECTORS, CORPORA, WEBHOOKS } from "@/lib/mock"; +import { GATEWAY_URL } from "@/lib/config"; +import { AUDIT, CORPORA, WEBHOOKS } from "@/lib/mock"; import { relTime } from "@/lib/time"; -import type { AuditOutcome, ConnectorStatus, HealthStatus } from "@/lib/types"; +import type { + AuditEvent, + AuditOutcome, + Corpus, + DataSource, + HealthStatus, + SystemHealth, + WebhookSubscription, +} from "@/lib/types"; import { useStatusStream } from "@/lib/use-stream"; const HEALTH_DOT: Record = { @@ -45,43 +56,81 @@ const OUTCOME_BADGE: Record = { denied: "error", error: "warning", }; -const STATUS_DOT: Record = { - healthy: "emerald", - degraded: "amber", - failed: "red", - paused: "muted", -}; + +interface DashData { + corpora: Corpus[]; + webhooks: WebhookSubscription[]; + audit: AuditEvent[]; + feedbackTotal: number; +} + +function seed(tenantId: string): DashData { + return { + corpora: CORPORA.filter((c) => c.tenant_id === tenantId), + webhooks: WEBHOOKS.filter((w) => w.tenant_id === tenantId), + audit: AUDIT.filter((e) => e.tenant_id === tenantId), + feedbackTotal: 0, + }; +} export default function DashboardPage() { const { tenant } = useApp(); + const { health } = useStatusStream(); + + const [data, setData] = useState(() => seed(tenant.id)); + const [source, setSource] = useState("mock"); - const data = useMemo(() => { - const corpora = CORPORA.filter((c) => c.tenant_id === tenant.id); - const connectors = CONNECTORS.filter((c) => c.tenant_id === tenant.id); - const webhooks = WEBHOOKS.filter((w) => w.tenant_id === tenant.id); - const audit = AUDIT.filter((e) => e.tenant_id === tenant.id); - const healthy = connectors.filter((c) => c.status === "healthy").length; - const docs = corpora.reduce((sum, c) => sum + c.document_count, 0); - return { corpora, connectors, webhooks, audit, healthy, docs }; + useEffect(() => { + if (!GATEWAY_URL) { + setData(seed(tenant.id)); + setSource("mock"); + return; + } + let cancelled = false; + Promise.all([ + fetchCorpora(tenant.id), + fetchWebhooks(tenant.id), + fetchAudit(tenant.id), + fetchFeedback(tenant.id), + ]) + .then(([corpora, webhooks, audit, feedback]) => { + if (cancelled) return; + setData({ corpora, webhooks, audit, feedbackTotal: feedback[0]?.total ?? 0 }); + setSource("live"); + }) + .catch(() => { + if (cancelled) return; + setData(seed(tenant.id)); + setSource("mock"); + }); + return () => { + cancelled = true; + }; }, [tenant.id]); - const stats: { label: string; value: string; sub: string; icon: LucideIcon }[] = [ - { label: "Corpora", value: String(data.corpora.length), sub: "across all backends", icon: Database }, - { label: "Documents indexed", value: data.docs.toLocaleString(), sub: "+312 in last 24h", icon: FileText }, - { label: "Active webhooks", value: String(data.webhooks.filter((w) => w.active).length), sub: "delivering normally", icon: Webhook }, - { label: "Audit events today", value: "1,204", sub: "12% above 7-day avg", icon: ScrollText }, - { label: "Connectors healthy", value: `${data.healthy}/${data.connectors.length}`, sub: data.healthy < data.connectors.length ? "1 needs attention" : "all healthy", icon: Zap }, - { label: "Queries 24h", value: "8,917", sub: "p95 latency 184ms", icon: TrendingUp }, - ]; + const up = health.components.filter((c) => c.status === "up").length; + + const stats: { label: string; value: string; sub: string; icon: LucideIcon }[] = useMemo( + () => [ + { label: "Corpora", value: String(data.corpora.length), sub: "registered for this tenant", icon: Database }, + { label: "Active webhooks", value: String(data.webhooks.filter((w) => w.active).length), sub: `${data.webhooks.length} total`, icon: Webhook }, + { label: "Audit events", value: String(data.audit.length), sub: "tamper-evident log", icon: ScrollText }, + { label: "Feedback received", value: String(data.feedbackTotal), sub: "explicit + implicit", icon: MessageSquare }, + { label: "Components healthy", value: `${up}/${health.components.length}`, sub: health.status === "up" ? "all systems up" : health.status, icon: HeartPulse }, + { label: "Uptime", value: `${Math.floor(health.uptime_s / 3600)}h ${Math.floor((health.uptime_s % 3600) / 60)}m`, sub: "since last restart", icon: Clock }, + ], + [data, up, health.components.length, health.status, health.uptime_s], + ); return (
} /> - +
{stats.map((s, i) => { @@ -102,7 +151,7 @@ export default function DashboardPage() {
- {/* recent activity */} + {/* recent activity — live audit */}
Recent activity
@@ -110,58 +159,68 @@ export default function DashboardPage() { View all
-
    - {data.audit.slice(0, 8).map((e) => ( -
  • -
    - -
    -
    -
    - {e.action} - {e.outcome} + {data.audit.length === 0 ? ( + } + title="No activity yet" + description="Run a query or ingest a document — audit events appear here." + /> + ) : ( +
      + {data.audit.slice(0, 8).map((e) => ( +
    • +
      +
      -
      - {e.principal_id} · {e.resource} +
      +
      + {e.action} + {e.outcome} +
      +
      + {e.principal_id} · {e.resource} +
      -
      - {relTime(e.timestamp)} -
    • - ))} -
    + {relTime(e.timestamp)} +
  • + ))} +
+ )}
- {/* connector health */} + {/* corpora — live */}
-
Connector health
-
- {data.healthy} of {data.connectors.length} healthy -
+
Corpora
+
{data.corpora.length} registered
- + Manage
-
    - {data.connectors.map((c) => ( -
  • - -
    -
    {c.name}
    -
    Last run {relTime(c.last_run)}
    -
    -
    - - {c.status} -
    -
  • - ))} -
+ {data.corpora.length === 0 ? ( + } + title="No corpora" + description="Create a corpus and ingest documents to start retrieving." + /> + ) : ( +
    + {data.corpora.slice(0, 6).map((c) => ( +
  • + +
    +
    {c.display_name}
    +
    {c.id}
    +
    + + {c.document_count.toLocaleString()} docs + +
  • + ))} +
+ )}
@@ -169,8 +228,7 @@ export default function DashboardPage() { } // Live system-status strip — one click into the Observability section (drilldown). -function SystemStatusBanner() { - const { health } = useStatusStream(); +function SystemStatusBanner({ health }: { health: SystemHealth }) { const up = health.components.filter((c) => c.status === "up").length; return ( @@ -185,6 +243,9 @@ function SystemStatusBanner() { {up}/{health.components.length} components healthy · uptime {Math.floor(health.uptime_s / 3600)}h
+ + Governance + Metrics diff --git a/apps/admin-ui/src/app/trace/page.tsx b/apps/admin-ui/src/app/trace/page.tsx index 3308295..ce04c64 100644 --- a/apps/admin-ui/src/app/trace/page.tsx +++ b/apps/admin-ui/src/app/trace/page.tsx @@ -191,7 +191,10 @@ export default function QueryTracePage() { const t = await fetchTrace(tenant.id, id); if (t === null) { setTrace(null); - setError(`No provenance record for "${id}" (unknown id, other tenant, or disabled).`); + setError( + `No provenance record for "${id}". Use the request_id (a UUID) from the query ` + + `response — not the trace_id — and the same tenant (${tenant.id}) that ran the query.`, + ); } else { setTrace(t); setSource("live"); @@ -241,7 +244,7 @@ export default function QueryTracePage() { id="trace-rid" value={requestId} onChange={(e) => setRequestId(e.target.value)} - placeholder="req_…" + placeholder="00000000-0000-0000-0000-000000000000" className="mt-1 font-mono" />
@@ -250,7 +253,9 @@ export default function QueryTracePage() {

- Paste the request_id returned by POST /v1/query. + Paste the top-level request_id (a UUID) from a{" "} + POST /v1/query response — not the{" "} + trace_id nested under trace.

diff --git a/apps/admin-ui/src/components/ui/overlays.tsx b/apps/admin-ui/src/components/ui/overlays.tsx index 62aa252..7041532 100644 --- a/apps/admin-ui/src/components/ui/overlays.tsx +++ b/apps/admin-ui/src/components/ui/overlays.tsx @@ -162,32 +162,71 @@ export function Dropdown({ children: ReactNode; align?: "start" | "end"; }) { + const mounted = useMounted(); const [open, setOpen] = useState(false); - const ref = useRef(null); + const [coords, setCoords] = useState<{ top: number; left?: number; right?: number } | null>(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + // The menu is portaled to so it is never clipped by an ancestor's + // overflow (e.g. the Table wrapper's `overflow-hidden`). Position it against + // the trigger with viewport coords and keep it pinned on scroll/resize. + useEffect(() => { + if (!open) { + setCoords(null); + return; + } + const place = () => { + const t = triggerRef.current; + if (!t) return; + const r = t.getBoundingClientRect(); + setCoords( + align === "end" + ? { top: r.bottom + 4, right: window.innerWidth - r.right } + : { top: r.bottom + 4, left: r.left }, + ); + }; + place(); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, align]); + + // Outside-click: both the trigger and the portaled menu count as "inside". useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + const target = e.target as Node; + if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return; + setOpen(false); }; document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); }, [open]); + useEscape(open, () => setOpen(false)); + return ( -
+
{trigger({ open, toggle: () => setOpen((o) => !o) })} - {open && ( -
setOpen(false)} - > - {children} -
- )} + {mounted && + open && + coords && + createPortal( +
setOpen(false)} + > + {children} +
, + document.body, + )}
); } diff --git a/apps/admin-ui/src/lib/api.ts b/apps/admin-ui/src/lib/api.ts index cafe7ac..194a0ab 100644 --- a/apps/admin-ui/src/lib/api.ts +++ b/apps/admin-ui/src/lib/api.ts @@ -8,6 +8,8 @@ import { GATEWAY_URL } from "./config"; import type { ABAnalysis, + AuditEvent, + AuditOutcome, BreakerView, ConnectorStatusLive, Corpus, @@ -137,6 +139,39 @@ export async function fetchLogs( return data.records; } +// --------------------------------------------------------------------------- +// Immutable audit log (Step 6.6) +// --------------------------------------------------------------------------- +interface RawAuditEvent { + id: string; + tenant_id: string; + principal_id: string | null; + action: string; + resource: string; + outcome: AuditOutcome; + trace_context?: { trace_id?: string }; + metadata?: Record; + timestamp: string; +} + +export async function fetchAudit(tenantId: string): Promise { + const data = await request<{ events: RawAuditEvent[]; total: number }>( + tenantId, + "/v1/audit?limit=200", + ); + return data.events.map((e) => ({ + id: e.id, + tenant_id: e.tenant_id, + principal_id: e.principal_id ?? "system", + action: e.action, + resource: e.resource, + outcome: e.outcome, + timestamp: e.timestamp, + trace_id: e.trace_context?.trace_id ?? "", + metadata: e.metadata ?? {}, + })); +} + export async function fetchConnectorStatus(tenantId: string): Promise { const data = await request<{ connectors: ConnectorStatusLive[]; total: number }>( tenantId,