diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 4913b20..d5f766a 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -28,8 +28,13 @@ const DomainSettings = dynamic(
{ ssr: false, loading: () =>
Loading…
}
)
-const VALID_TABS = ["general", "radar", "janitor", "domains"] as const
-const ADMIN_ONLY_TABS = ["radar", "janitor", "domains"] as const
+const SchemaAuditSettings = dynamic(
+ () => import("@/app/settings/schema-audit").then((m) => m.SchemaAuditSettings),
+ { ssr: false, loading: () => Loading…
}
+)
+
+const VALID_TABS = ["general", "radar", "janitor", "domains", "audit"] as const
+const ADMIN_ONLY_TABS = ["radar", "janitor", "domains", "audit"] as const
type TabId = (typeof VALID_TABS)[number]
function resolveTab(raw: string | null, isAdmin: boolean): TabId {
@@ -118,6 +123,7 @@ function SettingsContent() {
{isAdmin && Schedule}
{isAdmin && Janitors}
{isAdmin && Domains}
+ {isAdmin && Audit}
@@ -193,6 +199,12 @@ function SettingsContent() {
/>
)}
+
+ {isAdmin && (
+
+
+
+ )}
diff --git a/src/app/settings/schema-audit.tsx b/src/app/settings/schema-audit.tsx
new file mode 100644
index 0000000..99416d1
--- /dev/null
+++ b/src/app/settings/schema-audit.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { Button } from "@/components/ui/button"
+import { type AuditCategory, type AuditEntry, type SchemaAuditData, getSchemaAudit } from "@/lib/graph-api"
+import { isMocksEnabled, MOCK_SCHEMA_AUDIT } from "@/lib/mock-data"
+
+// ── Badge config ───────────────────────────────────────────────────────────────
+
+const CATEGORY_CONFIG = {
+ healthy: {
+ label: "✓ Healthy",
+ className: "bg-green-500/15 text-green-400 border border-green-500/30",
+ },
+ orphaned: {
+ label: "✕ Orphaned",
+ className: "bg-red-500/15 text-red-400 border border-red-500/30",
+ },
+ unused: {
+ label: "◌ Unused",
+ className: "bg-amber-500/15 text-amber-400 border border-amber-500/30",
+ },
+} as const
+
+// ── Sub-components ─────────────────────────────────────────────────────────────
+
+function AuditEntryRow({ entry, badgeClass }: { entry: AuditEntry; badgeClass: string }) {
+ return (
+
+ {entry.name}
+
+ {entry.count}
+
+
+ )
+}
+
+function CategoryGroup({
+ category,
+ entries,
+}: {
+ category: keyof typeof CATEGORY_CONFIG
+ entries: AuditEntry[]
+}) {
+ const config = CATEGORY_CONFIG[category]
+
+ if (entries.length === 0) {
+ return (
+
+
+ {config.label}
+
+
—
+
+ )
+ }
+
+ return (
+
+
+ {config.label}
+
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+ )
+}
+
+function AuditSection({
+ title,
+ category,
+}: {
+ title: string
+ category: AuditCategory
+}) {
+ return (
+
+
+ {title}
+
+
+
+
+
+ )
+}
+
+// ── Main component ─────────────────────────────────────────────────────────────
+
+export function SchemaAuditSettings({ open }: { open: boolean }) {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ if (isMocksEnabled()) {
+ setData(MOCK_SCHEMA_AUDIT)
+ return
+ }
+ const result = await getSchemaAudit()
+ setData(result)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load audit data")
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (open) load()
+ }, [open, load])
+
+ if (loading && !data) {
+ return Loading…
+ }
+
+ if (error && !data) {
+ return (
+
+ )
+ }
+
+ if (!data) return null
+
+ return (
+
+
+
+ Live Neo4j database vs schema definitions. Counts are namespace-scoped.
+
+
+
+
+
+
+ )
+}
diff --git a/src/lib/__tests__/schema-audit.test.tsx b/src/lib/__tests__/schema-audit.test.tsx
new file mode 100644
index 0000000..d62ef94
--- /dev/null
+++ b/src/lib/__tests__/schema-audit.test.tsx
@@ -0,0 +1,272 @@
+/**
+ * Tests for src/app/settings/schema-audit.tsx
+ *
+ * Covers:
+ * - Loading state renders indicator
+ * - Mock mode returns MOCK_SCHEMA_AUDIT without hitting the API
+ * - All three badge variants (healthy/orphaned/unused) render
+ * - Count pills display correct values
+ * - Error state renders message and Retry button
+ * - Refresh button re-fetches data
+ * - Component does not fetch when open=false
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { render, screen, waitFor, act } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+
+const mockGetSchemaAudit = vi.fn()
+
+vi.mock("@/lib/graph-api", () => ({
+ getSchemaAudit: (...args: unknown[]) => mockGetSchemaAudit(...args),
+}))
+
+const mockIsMocksEnabled = vi.fn(() => false)
+const MOCK_AUDIT = {
+ node_labels: {
+ healthy: [
+ { name: "Topic", count: 312 },
+ { name: "Person", count: 84 },
+ { name: "Episode", count: 57 },
+ ],
+ orphaned: [
+ { name: "LegacyTag", count: 7 },
+ { name: "OldContent", count: 2 },
+ ],
+ unused: [
+ { name: "AgentSession", count: 0 },
+ { name: "EvalSet", count: 0 },
+ ],
+ },
+ relationship_types: {
+ healthy: [
+ { name: "RELATED_TO", count: 540 },
+ { name: "MENTIONED_IN", count: 201 },
+ ],
+ orphaned: [{ name: "OLD_LINK", count: 3 }],
+ unused: [
+ { name: "WORKS_AT", count: 0 },
+ { name: "HAS_PRICE", count: 0 },
+ ],
+ },
+}
+
+vi.mock("@/lib/mock-data", () => ({
+ isMocksEnabled: () => mockIsMocksEnabled(),
+ MOCK_SCHEMA_AUDIT: MOCK_AUDIT,
+}))
+
+async function renderAuditSettings(open = true) {
+ const { SchemaAuditSettings } = await import("@/app/settings/schema-audit")
+ return render()
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsMocksEnabled.mockReturnValue(false)
+})
+
+// ── Loading state ─────────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – loading state", () => {
+ it("renders loading indicator while fetching", async () => {
+ let resolveAudit!: (v: unknown) => void
+ mockGetSchemaAudit.mockReturnValue(new Promise((r) => { resolveAudit = r }))
+
+ await renderAuditSettings(true)
+
+ expect(screen.getByText(/Loading/i)).toBeInTheDocument()
+
+ // cleanup
+ act(() => resolveAudit(MOCK_AUDIT))
+ await waitFor(() => expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument())
+ })
+})
+
+// ── Mock mode ─────────────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – mock mode", () => {
+ it("returns MOCK_SCHEMA_AUDIT without calling getSchemaAudit", async () => {
+ mockIsMocksEnabled.mockReturnValue(true)
+
+ await renderAuditSettings(true)
+
+ await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument())
+ expect(mockGetSchemaAudit).not.toHaveBeenCalled()
+ })
+
+ it("displays all node label entries from mock data", async () => {
+ mockIsMocksEnabled.mockReturnValue(true)
+
+ await renderAuditSettings(true)
+
+ await waitFor(() => {
+ expect(screen.getByText("Topic")).toBeInTheDocument()
+ expect(screen.getByText("Person")).toBeInTheDocument()
+ expect(screen.getByText("Episode")).toBeInTheDocument()
+ expect(screen.getByText("LegacyTag")).toBeInTheDocument()
+ expect(screen.getByText("OldContent")).toBeInTheDocument()
+ expect(screen.getByText("AgentSession")).toBeInTheDocument()
+ expect(screen.getByText("EvalSet")).toBeInTheDocument()
+ })
+ })
+
+ it("displays all relationship type entries from mock data", async () => {
+ mockIsMocksEnabled.mockReturnValue(true)
+
+ await renderAuditSettings(true)
+
+ await waitFor(() => {
+ expect(screen.getByText("RELATED_TO")).toBeInTheDocument()
+ expect(screen.getByText("MENTIONED_IN")).toBeInTheDocument()
+ expect(screen.getByText("OLD_LINK")).toBeInTheDocument()
+ expect(screen.getByText("WORKS_AT")).toBeInTheDocument()
+ expect(screen.getByText("HAS_PRICE")).toBeInTheDocument()
+ })
+ })
+})
+
+// ── Badge variants ────────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – badge variants", () => {
+ beforeEach(() => {
+ mockIsMocksEnabled.mockReturnValue(true)
+ })
+
+ it("renders ✓ Healthy badge (green)", async () => {
+ await renderAuditSettings(true)
+ await waitFor(() => {
+ const badges = screen.getAllByText("✓ Healthy")
+ expect(badges.length).toBeGreaterThan(0)
+ expect(badges[0]).toHaveClass("text-green-400")
+ })
+ })
+
+ it("renders ✕ Orphaned badge (red)", async () => {
+ await renderAuditSettings(true)
+ await waitFor(() => {
+ const badges = screen.getAllByText("✕ Orphaned")
+ expect(badges.length).toBeGreaterThan(0)
+ expect(badges[0]).toHaveClass("text-red-400")
+ })
+ })
+
+ it("renders ◌ Unused badge (amber)", async () => {
+ await renderAuditSettings(true)
+ await waitFor(() => {
+ const badges = screen.getAllByText("◌ Unused")
+ expect(badges.length).toBeGreaterThan(0)
+ expect(badges[0]).toHaveClass("text-amber-400")
+ })
+ })
+})
+
+// ── Count pills ───────────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – count pills", () => {
+ beforeEach(() => {
+ mockIsMocksEnabled.mockReturnValue(true)
+ })
+
+ it("displays correct count for healthy node label", async () => {
+ await renderAuditSettings(true)
+ await waitFor(() => {
+ // "312" count for Topic
+ expect(screen.getByText("312")).toBeInTheDocument()
+ })
+ })
+
+ it("displays correct count for orphaned relationship type", async () => {
+ await renderAuditSettings(true)
+ await waitFor(() => {
+ // "3" count for OLD_LINK
+ expect(screen.getByText("3")).toBeInTheDocument()
+ })
+ })
+
+ it("displays 0 count for unused entries", async () => {
+ await renderAuditSettings(true)
+ await waitFor(() => {
+ // Multiple "0" pills for unused entries
+ const zeroPills = screen.getAllByText("0")
+ expect(zeroPills.length).toBeGreaterThanOrEqual(4) // AgentSession, EvalSet, WORKS_AT, HAS_PRICE
+ })
+ })
+})
+
+// ── Error state ───────────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – error state", () => {
+ it("renders error message and Retry button on failure", async () => {
+ mockGetSchemaAudit.mockRejectedValue(new Error("Network error"))
+
+ await renderAuditSettings(true)
+
+ await waitFor(() => {
+ expect(screen.getByText("Network error")).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument()
+ })
+ })
+
+ it("Retry button re-fetches data", async () => {
+ mockGetSchemaAudit
+ .mockRejectedValueOnce(new Error("Network error"))
+ .mockResolvedValueOnce(MOCK_AUDIT)
+
+ await renderAuditSettings(true)
+
+ await waitFor(() => expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument())
+
+ const user = userEvent.setup()
+ await user.click(screen.getByRole("button", { name: /Retry/i }))
+
+ await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument())
+ expect(mockGetSchemaAudit).toHaveBeenCalledTimes(2)
+ })
+})
+
+// ── Refresh button ────────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – Refresh button", () => {
+ it("Refresh button re-fetches when data is loaded", async () => {
+ mockIsMocksEnabled.mockReturnValue(true)
+
+ await renderAuditSettings(true)
+ await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument())
+
+ const user = userEvent.setup()
+ await user.click(screen.getByRole("button", { name: /Refresh/i }))
+
+ // Still shows data after refresh in mock mode
+ await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument())
+ })
+})
+
+// ── open=false does not fetch ─────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – open=false", () => {
+ it("does not fetch data when open is false", async () => {
+ mockGetSchemaAudit.mockResolvedValue(MOCK_AUDIT)
+
+ await renderAuditSettings(false)
+
+ // Nothing rendered, no fetch
+ expect(mockGetSchemaAudit).not.toHaveBeenCalled()
+ expect(screen.queryByText("Topic")).not.toBeInTheDocument()
+ })
+})
+
+// ── Section headers ───────────────────────────────────────────────────────────
+
+describe("SchemaAuditSettings – section headers", () => {
+ it("renders Node Labels and Relationship Types section headers", async () => {
+ mockIsMocksEnabled.mockReturnValue(true)
+
+ await renderAuditSettings(true)
+
+ await waitFor(() => {
+ expect(screen.getByText("Node Labels")).toBeInTheDocument()
+ expect(screen.getByText("Relationship Types")).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/lib/__tests__/settings-page.test.tsx b/src/lib/__tests__/settings-page.test.tsx
index ff820af..28674b0 100644
--- a/src/lib/__tests__/settings-page.test.tsx
+++ b/src/lib/__tests__/settings-page.test.tsx
@@ -60,6 +60,10 @@ vi.mock("@/components/modals/domain-settings", () => ({
DomainSettings: ({ open }: { open: boolean }) =>
open ? Domains
: null,
}))
+vi.mock("@/app/settings/schema-audit", () => ({
+ SchemaAuditSettings: ({ open }: { open: boolean }) =>
+ open ? Audit
: null,
+}))
// next/dynamic is used in the page; replace with a passthrough so mocked modules are used
vi.mock("next/dynamic", () => ({
@@ -190,13 +194,14 @@ describe("SettingsPage – General tab", () => {
// ── Admin-only tabs ───────────────────────────────────────────────────────────
describe("SettingsPage – admin-only tabs", () => {
- it("shows Schedule, Janitors, Domains tabs for admins", async () => {
+ it("shows Schedule, Janitors, Domains, Audit tabs for admins", async () => {
userState.isAuthenticated = true
userState.isAdmin = true
await renderPage()
expect(screen.getByRole("tab", { name: /Schedule/i })).toBeInTheDocument()
expect(screen.getByRole("tab", { name: /Janitors/i })).toBeInTheDocument()
expect(screen.getByRole("tab", { name: /Domains/i })).toBeInTheDocument()
+ expect(screen.getByRole("tab", { name: /Audit/i })).toBeInTheDocument()
})
it("does not show admin-only tabs for non-admins (not yet authenticated)", async () => {
@@ -207,6 +212,7 @@ describe("SettingsPage – admin-only tabs", () => {
expect(screen.queryByRole("tab", { name: /Schedule/i })).not.toBeInTheDocument()
expect(screen.queryByRole("tab", { name: /Janitors/i })).not.toBeInTheDocument()
expect(screen.queryByRole("tab", { name: /Domains/i })).not.toBeInTheDocument()
+ expect(screen.queryByRole("tab", { name: /Audit/i })).not.toBeInTheDocument()
})
})
@@ -246,6 +252,13 @@ describe("SettingsPage – tab query param", () => {
expect(domainsTab).toHaveAttribute("aria-selected", "true")
})
+ it("activates audit tab for ?tab=audit", async () => {
+ mockSearchParams = { get: (k: string) => (k === "tab" ? "audit" : null) }
+ await renderPage()
+ const auditTab = screen.getByRole("tab", { name: /Audit/i })
+ expect(auditTab).toHaveAttribute("aria-selected", "true")
+ })
+
it("falls back to general for unknown ?tab value", async () => {
mockSearchParams = { get: (k: string) => (k === "tab" ? "unknown-tab-xyz" : null) }
await renderPage()
diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts
index 9728527..39d0fdf 100644
--- a/src/lib/graph-api.ts
+++ b/src/lib/graph-api.ts
@@ -518,6 +518,17 @@ export interface SchemaDomainsResponse {
hidden_domains: string[]
}
+export type AuditEntry = { name: string; count: number }
+export type AuditCategory = {
+ healthy: AuditEntry[]
+ orphaned: AuditEntry[]
+ unused: AuditEntry[]
+}
+export type SchemaAuditData = {
+ node_labels: AuditCategory
+ relationship_types: AuditCategory
+}
+
// Returns the available domain roots for this namespace plus the hidden_types
// and hidden_domains lists (schema types/domains excluded from Domain_* labeling).
export async function getSchemaDomains(
@@ -778,3 +789,7 @@ export async function dismissReview(
signal
)
}
+
+export async function getSchemaAudit(): Promise {
+ return api.get("/schema/audit")
+}
diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts
index 705229b..a367ab3 100644
--- a/src/lib/mock-data.ts
+++ b/src/lib/mock-data.ts
@@ -1,4 +1,4 @@
-import type { GraphNode, GraphEdge, GraphData, Review, StakworkRun } from "./graph-api"
+import type { GraphNode, GraphEdge, GraphData, Review, StakworkRun, SchemaAuditData } from "./graph-api"
import type { CreatorInsightsResponse } from "./creator-insights"
export const MOCK_NODES: GraphNode[] = [
@@ -899,6 +899,35 @@ export const MOCK_DOMAINS = {
hidden_domains: [] as string[],
}
+export const MOCK_SCHEMA_AUDIT: SchemaAuditData = {
+ node_labels: {
+ healthy: [
+ { name: "Topic", count: 312 },
+ { name: "Person", count: 84 },
+ { name: "Episode", count: 57 },
+ ],
+ orphaned: [
+ { name: "LegacyTag", count: 7 },
+ { name: "OldContent", count: 2 },
+ ],
+ unused: [
+ { name: "AgentSession", count: 0 },
+ { name: "EvalSet", count: 0 },
+ ],
+ },
+ relationship_types: {
+ healthy: [
+ { name: "RELATED_TO", count: 540 },
+ { name: "MENTIONED_IN", count: 201 },
+ ],
+ orphaned: [{ name: "OLD_LINK", count: 3 }],
+ unused: [
+ { name: "WORKS_AT", count: 0 },
+ { name: "HAS_PRICE", count: 0 },
+ ],
+ },
+}
+
// Enriched Topic node for Deep Research mock UI (graphRAG-style)
export const MOCK_DEEP_RESEARCH_TOPIC: GraphNode = {
ref_id: "n-graphrag-deep",