Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apps/admin-ui/src/app/audit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<AuditOutcome, "success" | "error" | "warning"> = {
Expand All @@ -36,8 +37,12 @@ const OUTCOME_BADGE: Record<AuditOutcome, "success" | "error" | "warning"> = {
};

export default function AuditPage() {
const { tenant } = useApp();
const state = useResolvedState("ready");
const { rows: audit, source } = useLive<AuditEvent>(
(tid) => AUDIT.filter((e) => e.tenant_id === tid),
fetchAudit,
{ fallbackOnEmpty: true },
);

const [actions, setActions] = useState<string[]>([]);
const [outcomes, setOutcomes] = useState<string[]>([]);
Expand All @@ -48,7 +53,7 @@ export default function AuditPage() {
const [selected, setSelected] = useState<AuditEvent | null>(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))
Expand All @@ -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);
Expand All @@ -70,6 +75,7 @@ export default function AuditPage() {
<PageHeader
title="Audit Log"
description="Immutable record of retrieval, policy, routing, ingest, and delivery events."
badge={<SourceBadge source={source} />}
/>

{/* filter bar */}
Expand Down
211 changes: 136 additions & 75 deletions apps/admin-ui/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthStatus, "emerald" | "amber" | "red"> = {
Expand All @@ -45,43 +56,81 @@ const OUTCOME_BADGE: Record<AuditOutcome, "success" | "error" | "warning"> = {
denied: "error",
error: "warning",
};
const STATUS_DOT: Record<ConnectorStatus, "emerald" | "amber" | "red" | "muted"> = {
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<DashData>(() => seed(tenant.id));
const [source, setSource] = useState<DataSource>("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 (
<div>
<PageHeader
title="Dashboard"
description={`Operational overview for ${tenant.display_name} (${tenant.id}).`}
badge={<SourceBadge source={source} />}
/>

<SystemStatusBanner />
<SystemStatusBanner health={health} />

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{stats.map((s, i) => {
Expand All @@ -102,75 +151,84 @@ export default function DashboardPage() {
</div>

<div className="mt-6 grid gap-4 lg:grid-cols-5">
{/* recent activity */}
{/* recent activity — live audit */}
<Card className="lg:col-span-3">
<div className="flex items-center justify-between border-b px-5 py-3">
<div className="text-sm font-semibold">Recent activity</div>
<Link href="/audit" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
View all <ChevronRight className="h-3 w-3" />
</Link>
</div>
<ul className="divide-y">
{data.audit.slice(0, 8).map((e) => (
<li key={e.id} className="flex items-center gap-3 px-5 py-2.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Activity className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs">{e.action}</span>
<Badge variant={OUTCOME_BADGE[e.outcome]}>{e.outcome}</Badge>
{data.audit.length === 0 ? (
<EmptyState
icon={<Activity className="h-6 w-6" />}
title="No activity yet"
description="Run a query or ingest a document — audit events appear here."
/>
) : (
<ul className="divide-y">
{data.audit.slice(0, 8).map((e) => (
<li key={e.id} className="flex items-center gap-3 px-5 py-2.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Activity className="h-3.5 w-3.5" />
</div>
<div className="truncate text-xs text-muted-foreground">
{e.principal_id} · {e.resource}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs">{e.action}</span>
<Badge variant={OUTCOME_BADGE[e.outcome]}>{e.outcome}</Badge>
</div>
<div className="truncate text-xs text-muted-foreground">
{e.principal_id} · {e.resource}
</div>
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{relTime(e.timestamp)}</span>
</li>
))}
</ul>
<span className="shrink-0 text-xs text-muted-foreground">{relTime(e.timestamp)}</span>
</li>
))}
</ul>
)}
</Card>

{/* connector health */}
{/* corpora — live */}
<Card className="lg:col-span-2">
<div className="flex items-center justify-between border-b px-5 py-3">
<div>
<div className="text-sm font-semibold">Connector health</div>
<div className="text-xs text-muted-foreground">
{data.healthy} of {data.connectors.length} healthy
</div>
<div className="text-sm font-semibold">Corpora</div>
<div className="text-xs text-muted-foreground">{data.corpora.length} registered</div>
</div>
<Link
href="/connectors"
className="text-xs text-muted-foreground hover:text-foreground"
>
<Link href="/corpora" className="text-xs text-muted-foreground hover:text-foreground">
Manage
</Link>
</div>
<ul className="divide-y">
{data.connectors.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-5 py-3">
<ConnectorIcon type={c.type} className="h-4 w-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{c.name}</div>
<div className="text-xs text-muted-foreground">Last run {relTime(c.last_run)}</div>
</div>
<div className="flex items-center gap-1.5">
<StatusDot color={STATUS_DOT[c.status]} ping={c.status === "healthy"} />
<span className="text-xs capitalize text-muted-foreground">{c.status}</span>
</div>
</li>
))}
</ul>
{data.corpora.length === 0 ? (
<EmptyState
icon={<Database className="h-6 w-6" />}
title="No corpora"
description="Create a corpus and ingest documents to start retrieving."
/>
) : (
<ul className="divide-y">
{data.corpora.slice(0, 6).map((c) => (
<li key={c.id} className="flex items-center gap-3 px-5 py-3">
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{c.display_name}</div>
<div className="truncate font-mono text-xs text-muted-foreground">{c.id}</div>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{c.document_count.toLocaleString()} docs
</span>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
);
}

// 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 (
<Link href="/status" className="mb-6 block">
Expand All @@ -185,6 +243,9 @@ function SystemStatusBanner() {
{up}/{health.components.length} components healthy · uptime {Math.floor(health.uptime_s / 3600)}h
</span>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
<span className="hidden items-center gap-1 sm:flex">
<ShieldCheck className="h-3.5 w-3.5" /> Governance
</span>
<span className="hidden items-center gap-1 sm:flex">
<Gauge className="h-3.5 w-3.5" /> Metrics
</span>
Expand Down
11 changes: 8 additions & 3 deletions apps/admin-ui/src/app/trace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"
/>
</div>
Expand All @@ -250,7 +253,9 @@ export default function QueryTracePage() {
</Button>
</form>
<p className="mt-2 text-xs text-muted-foreground">
Paste the <Mono>request_id</Mono> returned by <Mono>POST /v1/query</Mono>.
Paste the top-level <Mono>request_id</Mono> (a UUID) from a{" "}
<Mono>POST /v1/query</Mono> response — <strong>not</strong> the{" "}
<Mono>trace_id</Mono> nested under <Mono>trace</Mono>.
</p>
</Card>

Expand Down
Loading
Loading