From 6141696a0e763ba5f183589406cc70c218fb8a71 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 18:00:00 -0600 Subject: [PATCH 01/18] fix(frontend): make typecheck actually type-check files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsc --noEmit` is a no-op on this solution-style tsconfig (`files: []` + project references) — `npx tsc --noEmit --listFiles` emitted 0 files, so the typecheck gate in `frontend-ci` was vacuous. Switch to `tsc -b`, which builds both referenced configs (`tsconfig.app.json` and `tsconfig.node.json`, both already `noEmit: true`) and now checks 1440 files. Verified: introduced a deliberate `string` to `number` assignment in main.tsx and confirmed `npm run typecheck` failed with `TS2322: Type 'string' is not assignable to type 'number'.`, then reverted and confirmed it passes again. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 004b8bb4..900d54e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", From ecb2011f6910fb17114f0f504a495b6778af0990 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 18:08:44 -0600 Subject: [PATCH 02/18] fix(frontend): make coverage honest, monotonic, and single-sourced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v8 only instrumented files a test happened to import (no coverage.include), so the 40% gate measured an import-driven denominator (45.92% lines, 1404/3057) rather than the whole src/ tree (7.87%, 1404/17839), and was non-monotonic: importing a large untested file into its first test could lower the reported number below the gate. Set coverage.include to 'src/**/*.{ts,tsx}' with excludes for test files, main.tsx, and .d.ts files, so the denominator is fixed and adding a test can only raise the number. Measured with the plan 009 test tranche in place: statements 8.1% (1673/20643), branches 6.97% (1347/19312), functions 6.37% (401/6289), lines 8.37% (1493/17837). Thresholds set just below each, rounded down: statements 8, branches 6, functions 6, lines 8 — single-sourced in vitest.config.ts. Remove the duplicated --coverage.thresholds.lines=40 flag from Makefile's frontend-test target and frontend/package.json's ci script, and fix the stale coverage figures in CLAUDE.md and the Makefile comment. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- Makefile | 8 +++++--- frontend/package.json | 2 +- frontend/vitest.config.ts | 24 ++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6afec983..7b796a72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8001 --workers 4 make backend-ci # Unit tests (50% coverage gate) + tier-1 integration tests make backend-static # Ruff lint + Bandit security scan make backend-backlog # Mypy typecheck + pip-audit (non-blocking) -make frontend-ci # Typecheck, lint, audit, Vitest (35% line coverage gate), build +make frontend-ci # Typecheck, lint, audit, Vitest (coverage gate over whole src/ tree, currently ~8% lines — see frontend/vitest.config.ts), build make ci # backend-ci + frontend-ci make release-check # ci + backend-static + Docker builds diff --git a/Makefile b/Makefile index 07b1b15b..2a2beec1 100644 --- a/Makefile +++ b/Makefile @@ -78,10 +78,12 @@ frontend-typecheck: frontend-lint: cd $(FRONTEND_DIR) && npm run lint -# Coverage gate set just below current measured (~44%). Bump as more -# components/hooks/api modules get tests. +# Coverage denominator is the whole frontend/src tree (see vitest.config.ts), +# not just what a test happens to import, so this number is honest and +# monotonic. Thresholds live in vitest.config.ts (single source) — bump them +# there as more components/hooks/api modules get tests. frontend-test: - cd $(FRONTEND_DIR) && npm run test:coverage -- --coverage.thresholds.lines=40 + cd $(FRONTEND_DIR) && npm run test:coverage frontend-build: cd $(FRONTEND_DIR) && npm run build diff --git a/frontend/package.json b/frontend/package.json index 900d54e4..9a4e0063 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage --coverage.reporter=text", "test:e2e": "playwright test", - "ci": "npm run typecheck && npm run lint && npm run test:coverage -- --coverage.thresholds.lines=40 && npm run build" + "ci": "npm run typecheck && npm run lint && npm run test:coverage && npm run build" }, "dependencies": { "@sentry/react": "^10.63.0", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index a24e2daa..0d6fb733 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -8,5 +8,29 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test-setup.ts'], include: ['src/**/*.test.{ts,tsx}'], + coverage: { + // Denominator is the whole src/ tree, not just what a given test + // happens to import. This makes the number honest (no more counting + // only files pulled in transitively by tests) and, crucially, + // monotonic: adding a test can only ever raise it, never lower it by + // widening the denominator. See plan 009 for the incident this fixed. + include: ['src/**/*.{ts,tsx}'], + exclude: [ + '**/*.test.{ts,tsx}', + 'src/main.tsx', + '**/*.d.ts', + ], + // Thresholds are single-sourced here (not duplicated in Makefile or + // package.json). Set just below the honest whole-src measurement at + // the time they were last raised — ratchet upward as more + // components/hooks/api modules get tests. Do not lower these to make + // a change pass; add tests instead. + thresholds: { + statements: 8, + branches: 6, + functions: 6, + lines: 8, + }, + }, }, }) From b4f007c11b7c87dda8f842f4c30b8bcfff2c9aca Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Mon, 27 Jul 2026 11:20:38 -0600 Subject: [PATCH 03/18] fix(frontend): correct coverage thresholds to the honest measured baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thresholds set alongside the whole-src-tree coverage denominator (statements/lines 8%, branches/functions 6%) were measured wrong — actual whole-src coverage is ~6.4% lines / 6.1% statements / 5.1% functions / 5.2% branches, so CI failed on every push regardless of changes. Lower the thresholds to sit just below the real numbers (matching the stated intent: 'set just below the honest whole-src measurement'), and correct CLAUDE.md's stale ~8% note to match. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- frontend/vitest.config.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b796a72..1032446c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8001 --workers 4 make backend-ci # Unit tests (50% coverage gate) + tier-1 integration tests make backend-static # Ruff lint + Bandit security scan make backend-backlog # Mypy typecheck + pip-audit (non-blocking) -make frontend-ci # Typecheck, lint, audit, Vitest (coverage gate over whole src/ tree, currently ~8% lines — see frontend/vitest.config.ts), build +make frontend-ci # Typecheck, lint, audit, Vitest (coverage gate over whole src/ tree, currently ~6% lines — see frontend/vitest.config.ts), build make ci # backend-ci + frontend-ci make release-check # ci + backend-static + Docker builds diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 0d6fb733..747bc58e 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -25,11 +25,16 @@ export default defineConfig({ // the time they were last raised — ratchet upward as more // components/hooks/api modules get tests. Do not lower these to make // a change pass; add tests instead. + // + // Measured whole-src baseline when this gate went live: ~6.4% lines / + // 6.1% statements / 5.1% functions / 5.2% branches (previously + // mis-measured as ~8%/8%/6%/6%, which failed CI on the honest number — + // see the fix that corrected these to the real figures). thresholds: { - statements: 8, - branches: 6, - functions: 6, - lines: 8, + statements: 6, + branches: 5, + functions: 5, + lines: 6, }, }, }, From 5a17b73ec3c80aeede44e0de603a1b8b6af4d2ca Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:11:18 -0600 Subject: [PATCH 04/18] refactor(admin): extract shared admin primitives and formatters Move formatNumber/formatDuration and the shared UI primitives (StatusBadge, RoleBadge, TrendDelta, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, DayOption) out of Admin.tsx into frontend/src/components/admin/shared/{format.ts,primitives.tsx} as named exports, and import them back in. Pure move, no logic changes. Co-Authored-By: Claude Fable 5 --- .../src/components/admin/shared/format.ts | 15 ++ .../components/admin/shared/primitives.tsx | 210 ++++++++++++++++ frontend/src/pages/Admin.tsx | 229 +----------------- 3 files changed, 230 insertions(+), 224 deletions(-) create mode 100644 frontend/src/components/admin/shared/format.ts create mode 100644 frontend/src/components/admin/shared/primitives.tsx diff --git a/frontend/src/components/admin/shared/format.ts b/frontend/src/components/admin/shared/format.ts new file mode 100644 index 00000000..6a21dea2 --- /dev/null +++ b/frontend/src/components/admin/shared/format.ts @@ -0,0 +1,15 @@ +export function formatNumber(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toString() +} + +export function formatDuration(ms: number | null): string { + if (ms === null || ms === undefined) return '-' + if (ms < 1000) return `${ms}ms` + const secs = ms / 1000 + if (secs < 60) return `${secs.toFixed(1)}s` + const mins = Math.floor(secs / 60) + const remainSecs = Math.round(secs % 60) + return `${mins}m ${remainSecs}s` +} diff --git a/frontend/src/components/admin/shared/primitives.tsx b/frontend/src/components/admin/shared/primitives.tsx new file mode 100644 index 00000000..b59ff747 --- /dev/null +++ b/frontend/src/components/admin/shared/primitives.tsx @@ -0,0 +1,210 @@ +import { + BarChart3, TrendingUp, TrendingDown, ChevronDown, ChevronUp, ArrowUpDown, Search, Download, RefreshCw, +} from 'lucide-react' + +export function StatusBadge({ status }: { status: string }) { + const colors: Record = { + completed: { bg: '#dcfce7', text: '#166534' }, + failed: { bg: '#fee2e2', text: '#991b1b' }, + error: { bg: '#fee2e2', text: '#991b1b' }, + running: { bg: '#dbeafe', text: '#1e40af' }, + queued: { bg: '#e0e7ff', text: '#3730a3' }, + canceled: { bg: '#fef3c7', text: '#92400e' }, + } + const c = colors[status] || { bg: '#f3f4f6', text: '#374151' } + return ( + + {status} + + ) +} + +export function RoleBadge({ role }: { role: string }) { + const colors: Record = { + admin: { bg: '#fef3c7', text: '#92400e' }, + staff: { bg: '#dcfce7', text: '#166534' }, + examiner: { bg: '#dbeafe', text: '#1e40af' }, + } + const c = colors[role] || { bg: '#f3f4f6', text: '#374151' } + return ( + + {role} + + ) +} + +export function TrendDelta({ current, previous, invert }: { current: number; previous: number; invert?: boolean }) { + if (previous === 0 && current === 0) return null + const pct = previous === 0 ? 100 : Math.round(((current - previous) / previous) * 100) + const isUp = pct > 0 + const isGood = invert ? !isUp : isUp + if (pct === 0) return 0% + return ( + + {isUp ? : } + {isUp ? '+' : ''}{pct}% + + ) +} + +export function KpiCard({ label, value, icon: Icon, color, trend }: { + label: string; value: string | number; icon: typeof BarChart3; color: string + trend?: { current: number; previous: number; invert?: boolean } +}) { + return ( +
+
+ +
+
+
{label}
+
+
{value}
+ {trend && } +
+
+
+ ) +} + +export function UserAvatar({ name }: { name: string | null }) { + const letter = (name || '?')[0].toUpperCase() + const hue = (letter.charCodeAt(0) * 37) % 360 + return ( +
+ {letter} +
+ ) +} + +export function SortableHeader({ label, sortKey, currentSort, onSort, align = 'left' }: { + label: string; sortKey: string + currentSort: { key: string; dir: 'asc' | 'desc' } + onSort: (key: string) => void + align?: 'left' | 'right' | 'center' +}) { + const active = currentSort.key === sortKey + return ( + + + + ) +} + +export function SearchInput({ value, onChange, placeholder, ariaLabel }: { value: string; onChange: (v: string) => void; placeholder: string; ariaLabel?: string }) { + return ( +
+
+ ) +} + +export function ExportButton({ onClick }: { onClick: () => void }) { + return ( + + ) +} + +// Default day options used by every analytics tab. Backend caps at 730d +// (MAX_ANALYTICS_DAYS) so the longest preset stays comfortably below that. +const DAY_OPTIONS = [7, 14, 30, 90, 180, 365] as const +export type DayOption = number | 'all' + +export function TimeRangeSelector({ + value, + onChange, + options = DAY_OPTIONS as readonly number[], + includeAll = false, + onRefresh, +}: { + value: DayOption + onChange: (v: DayOption) => void + options?: readonly number[] + includeAll?: boolean + onRefresh?: () => void +}) { + const opts: DayOption[] = includeAll ? [...options, 'all'] : [...options] + return ( +
+ Time Range: + {opts.map(d => { + const active = value === d + const label = d === 'all' ? 'All time' : d >= 365 ? `${Math.round(d / 365)}y` : `${d}d` + return ( + + ) + })} + {onRefresh && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 883a6ddc..261ae535 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -4,7 +4,7 @@ import { Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, ChevronLeft, ChevronRight, RefreshCw, MessageSquare, Search, Zap, CheckCircle2, XCircle, Clock, Download, TrendingUp, TrendingDown, - ChevronDown, ChevronUp, ArrowUpDown, Play, Minus, AlertCircle, + ChevronDown, ChevronUp, Play, Minus, AlertCircle, ArrowLeft, FileText, FolderTree, X, Check, Mail, Send, Link, UserPlus, Star, Award, Unlock, KeyRound, PackageOpen, BookOpen, @@ -78,6 +78,10 @@ import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' +import { formatNumber, formatDuration } from '../components/admin/shared/format' +import { + StatusBadge, RoleBadge, TrendDelta, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, type DayOption, +} from '../components/admin/shared/primitives' function applyThemeToDOM(theme: ThemeConfig) { const root = document.documentElement @@ -110,12 +114,6 @@ const TABS: { key: Tab; label: string; icon: typeof BarChart3 }[] = [ const CHART_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'] -function formatNumber(n: number): string { - if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' - if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' - return n.toString() -} - function parseUtcDate(d: string): Date { // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') @@ -132,16 +130,6 @@ function formatDateTime(d: string | null): string { return parseUtcDate(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) } -function formatDuration(ms: number | null): string { - if (ms === null || ms === undefined) return '-' - if (ms < 1000) return `${ms}ms` - const secs = ms / 1000 - if (secs < 60) return `${secs.toFixed(1)}s` - const mins = Math.floor(secs / 60) - const remainSecs = Math.round(secs % 60) - return `${mins}m ${remainSecs}s` -} - function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { const escape = (v: string | number | null) => { if (v === null || v === undefined) return '' @@ -158,213 +146,6 @@ function downloadCSV(filename: string, headers: string[], rows: (string | number URL.revokeObjectURL(url) } -function StatusBadge({ status }: { status: string }) { - const colors: Record = { - completed: { bg: '#dcfce7', text: '#166534' }, - failed: { bg: '#fee2e2', text: '#991b1b' }, - error: { bg: '#fee2e2', text: '#991b1b' }, - running: { bg: '#dbeafe', text: '#1e40af' }, - queued: { bg: '#e0e7ff', text: '#3730a3' }, - canceled: { bg: '#fef3c7', text: '#92400e' }, - } - const c = colors[status] || { bg: '#f3f4f6', text: '#374151' } - return ( - - {status} - - ) -} - -function RoleBadge({ role }: { role: string }) { - const colors: Record = { - admin: { bg: '#fef3c7', text: '#92400e' }, - staff: { bg: '#dcfce7', text: '#166534' }, - examiner: { bg: '#dbeafe', text: '#1e40af' }, - } - const c = colors[role] || { bg: '#f3f4f6', text: '#374151' } - return ( - - {role} - - ) -} - -function TrendDelta({ current, previous, invert }: { current: number; previous: number; invert?: boolean }) { - if (previous === 0 && current === 0) return null - const pct = previous === 0 ? 100 : Math.round(((current - previous) / previous) * 100) - const isUp = pct > 0 - const isGood = invert ? !isUp : isUp - if (pct === 0) return 0% - return ( - - {isUp ? : } - {isUp ? '+' : ''}{pct}% - - ) -} - -function KpiCard({ label, value, icon: Icon, color, trend }: { - label: string; value: string | number; icon: typeof BarChart3; color: string - trend?: { current: number; previous: number; invert?: boolean } -}) { - return ( -
-
- -
-
-
{label}
-
-
{value}
- {trend && } -
-
-
- ) -} - -function UserAvatar({ name }: { name: string | null }) { - const letter = (name || '?')[0].toUpperCase() - const hue = (letter.charCodeAt(0) * 37) % 360 - return ( -
- {letter} -
- ) -} - -function SortableHeader({ label, sortKey, currentSort, onSort, align = 'left' }: { - label: string; sortKey: string - currentSort: { key: string; dir: 'asc' | 'desc' } - onSort: (key: string) => void - align?: 'left' | 'right' | 'center' -}) { - const active = currentSort.key === sortKey - return ( - - - - ) -} - -function SearchInput({ value, onChange, placeholder, ariaLabel }: { value: string; onChange: (v: string) => void; placeholder: string; ariaLabel?: string }) { - return ( -
-
- ) -} - -function ExportButton({ onClick }: { onClick: () => void }) { - return ( - - ) -} - -// Default day options used by every analytics tab. Backend caps at 730d -// (MAX_ANALYTICS_DAYS) so the longest preset stays comfortably below that. -const DAY_OPTIONS = [7, 14, 30, 90, 180, 365] as const -type DayOption = number | 'all' - -function TimeRangeSelector({ - value, - onChange, - options = DAY_OPTIONS as readonly number[], - includeAll = false, - onRefresh, -}: { - value: DayOption - onChange: (v: DayOption) => void - options?: readonly number[] - includeAll?: boolean - onRefresh?: () => void -}) { - const opts: DayOption[] = includeAll ? [...options, 'all'] : [...options] - return ( -
- Time Range: - {opts.map(d => { - const active = value === d - const label = d === 'all' ? 'All time' : d >= 365 ? `${Math.round(d / 365)}y` : `${d}d` - return ( - - ) - })} - {onRefresh && ( - - )} -
- ) -} - // ────────────────────────────────────────── // Usage Tab // ────────────────────────────────────────── From 0b6392f2ba94ffa119f8a1e7a8238f84537967bf Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:14:15 -0600 Subject: [PATCH 05/18] refactor(admin): extract AuditTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/AuditTab.tsx | 109 ++++++++++++++++++++ frontend/src/pages/Admin.tsx | 110 +-------------------- 2 files changed, 110 insertions(+), 109 deletions(-) create mode 100644 frontend/src/components/admin/AuditTab.tsx diff --git a/frontend/src/components/admin/AuditTab.tsx b/frontend/src/components/admin/AuditTab.tsx new file mode 100644 index 00000000..ebe2c8ba --- /dev/null +++ b/frontend/src/components/admin/AuditTab.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react' +import { ChevronLeft, ChevronRight, Download } from 'lucide-react' + +import * as auditApi from '../../api/audit' +import type { AuditLogEntry } from '../../api/audit' + +export function AuditTab() { + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(0) + const [loading, setLoading] = useState(true) + const [actionFilter, setActionFilter] = useState('') + const [resourceTypeFilter, setResourceTypeFilter] = useState('') + const limit = 25 + + const load = useCallback(async () => { + setLoading(true) + try { + const data = await auditApi.queryAuditLog({ action: actionFilter || undefined, resource_type: resourceTypeFilter || undefined, skip: page * limit, limit }) + setEntries(data.entries) + setTotal(data.total) + } catch { /* ignore */ } finally { setLoading(false) } + }, [page, actionFilter, resourceTypeFilter]) + + useEffect(() => { load() }, [load]) + + const ACTION_COLORS: Record = { + 'document.create': '#dcfce7', 'document.delete': '#fee2e2', + 'extraction.run': '#dbeafe', 'workflow.run': '#f3e8ff', + 'workflow.approve': '#dcfce7', 'workflow.reject': '#fee2e2', + 'user.login': '#f3f4f6', 'config.update': '#ffedd5', + } + const totalPages = Math.ceil(total / limit) + + return ( +
+
+

Audit Log ({total} entries)

+ + Export CSV + +
+
+ { setActionFilter(e.target.value); setPage(0) }} + placeholder="Filter by action…" style={{ padding: '6px 10px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }} /> + +
+
+ + + + {['Time','Action','Actor','Resource','Details'].map(h => ( + + ))} + + + + {loading ? ( + + ) : entries.length === 0 ? ( + + ) : entries.map(entry => ( + + + + + + + + ))} + +
{h}
Loading…
No entries found
+ {entry.timestamp ? new Date(entry.timestamp).toLocaleDateString() + ' ' + new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '-'} + + + {entry.action} + + {entry.actor_user_id} + {entry.resource_name || entry.resource_id || '-'} + {entry.resource_type} + + {Object.keys(entry.detail).length > 0 ? JSON.stringify(entry.detail).slice(0, 80) : '-'} +
+
+ {totalPages > 1 && ( +
+ Page {page + 1} of {totalPages} +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 261ae535..98b2974e 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -67,7 +67,6 @@ import { fileToConstrainedDataUrl } from '../utils/imageResize' import { ModelCharacterBars } from '../components/ModelEffortPicker' import type { ModelInfo } from '../types/workflow' import * as auditApi from '../api/audit' -import type { AuditLogEntry } from '../api/audit' import { getAuthConfig } from '../api/auth' import { UpdateBanner } from '../components/admin/UpdateBanner' import { CatalogUpdateBanner } from '../components/admin/CatalogUpdateBanner' @@ -75,6 +74,7 @@ import { CatalogTab } from '../components/admin/CatalogTab' import { ApiKeysTab } from '../components/admin/ApiKeysTab' import { ComplianceTab } from '../components/admin/ComplianceTab' import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' +import { AuditTab } from '../components/admin/AuditTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' @@ -7123,114 +7123,6 @@ function OrganizationsTab() { ) } -// --------------------------------------------------------------------------- -// Audit Log Tab -// --------------------------------------------------------------------------- - -function AuditTab() { - const [entries, setEntries] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(0) - const [loading, setLoading] = useState(true) - const [actionFilter, setActionFilter] = useState('') - const [resourceTypeFilter, setResourceTypeFilter] = useState('') - const limit = 25 - - const load = useCallback(async () => { - setLoading(true) - try { - const data = await auditApi.queryAuditLog({ action: actionFilter || undefined, resource_type: resourceTypeFilter || undefined, skip: page * limit, limit }) - setEntries(data.entries) - setTotal(data.total) - } catch { /* ignore */ } finally { setLoading(false) } - }, [page, actionFilter, resourceTypeFilter]) - - useEffect(() => { load() }, [load]) - - const ACTION_COLORS: Record = { - 'document.create': '#dcfce7', 'document.delete': '#fee2e2', - 'extraction.run': '#dbeafe', 'workflow.run': '#f3e8ff', - 'workflow.approve': '#dcfce7', 'workflow.reject': '#fee2e2', - 'user.login': '#f3f4f6', 'config.update': '#ffedd5', - } - const totalPages = Math.ceil(total / limit) - - return ( -
-
-

Audit Log ({total} entries)

- - Export CSV - -
-
- { setActionFilter(e.target.value); setPage(0) }} - placeholder="Filter by action…" style={{ padding: '6px 10px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }} /> - -
-
- - - - {['Time','Action','Actor','Resource','Details'].map(h => ( - - ))} - - - - {loading ? ( - - ) : entries.length === 0 ? ( - - ) : entries.map(entry => ( - - - - - - - - ))} - -
{h}
Loading…
No entries found
- {entry.timestamp ? new Date(entry.timestamp).toLocaleDateString() + ' ' + new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '-'} - - - {entry.action} - - {entry.actor_user_id} - {entry.resource_name || entry.resource_id || '-'} - {entry.resource_type} - - {Object.keys(entry.detail).length > 0 ? JSON.stringify(entry.detail).slice(0, 80) : '-'} -
-
- {totalPages > 1 && ( -
- Page {page + 1} of {totalPages} -
- - -
-
- )} -
- ) -} - // ────────────────────────────────────────── // Certifications Tab — track user progress through Vandal Workflow Architect // ────────────────────────────────────────── From 7df6a38bb41ab81ee559d5bfa9f39d4e0dc968f8 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:18:19 -0600 Subject: [PATCH 06/18] refactor(admin): extract CertificationsTab into its own file Co-Authored-By: Claude Fable 5 --- .../components/admin/CertificationsTab.tsx | 218 ++++++++++++++++++ frontend/src/pages/Admin.tsx | 192 +-------------- 2 files changed, 220 insertions(+), 190 deletions(-) create mode 100644 frontend/src/components/admin/CertificationsTab.tsx diff --git a/frontend/src/components/admin/CertificationsTab.tsx b/frontend/src/components/admin/CertificationsTab.tsx new file mode 100644 index 00000000..f7679736 --- /dev/null +++ b/frontend/src/components/admin/CertificationsTab.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Award, Lock, RefreshCw, Unlock } from 'lucide-react' +import { + getCertificationProgressList, setCertificationUnlock, + type CertificationProgressItem, +} from '../../api/admin' +import { formatNumber } from './shared/format' +import { ExportButton, SearchInput, UserAvatar } from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDate(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function CertificationsTab() { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [busyUser, setBusyUser] = useState(null) + + const refresh = useCallback(async () => { + setLoading(true) + try { + const data = await getCertificationProgressList() + setItems(data) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const filtered = useMemo(() => { + if (!search.trim()) return items + const q = search.toLowerCase() + return items.filter(p => + (p.name || '').toLowerCase().includes(q) || + (p.email || '').toLowerCase().includes(q) || + (p.user_id || '').toLowerCase().includes(q) + ) + }, [items, search]) + + const toggleUnlock = async (item: CertificationProgressItem) => { + setBusyUser(item.user_id) + try { + await setCertificationUnlock(item.user_id, !item.unlocked) + setItems(prev => prev.map(p => + p.user_id === item.user_id ? { ...p, unlocked: !item.unlocked } : p + )) + } finally { + setBusyUser(null) + } + } + + const handleExport = () => { + downloadCSV('certifications.csv', + ['User', 'Email', 'Level', 'Total XP', 'Modules Completed', 'Modules Total', 'Certified', 'Certified At', 'Streak', 'Last Activity', 'Unlocked'], + filtered.map(p => [ + p.name || p.user_id, p.email, + p.level, p.total_xp, + p.modules_completed, p.modules_total, + p.certified ? 'yes' : 'no', + p.certified_at, + p.streak_days, + p.last_activity_date, + p.unlocked ? 'yes' : 'no', + ]) + ) + } + + if (loading) return
Loading certification progress...
+ + return ( +
+
+

Certifications

+

+ Users who have started the Vandal Workflow Architect certification and where they are in the program. +

+
+ +
+ +
+ + +
+ +
+
+ Certification Progress ({filtered.length}) +
+ {filtered.length === 0 ? ( +
No users have started the certification yet.
+ ) : ( + + + + + + + + + + + + + + {filtered.map(p => { + const pct = p.modules_total > 0 ? (p.modules_completed / p.modules_total) * 100 : 0 + return ( + + + + + + + + + + ) + })} + +
UserLevelXPModulesCertifiedLast ActiveDebug Unlock
+
+ +
+
{p.name || 'Unknown'}
+
{p.email || p.user_id}
+
+
+
+ + {p.level} + + {formatNumber(p.total_xp)} +
+
+
+
+ + {p.modules_completed}/{p.modules_total} + +
+
+ {p.certified ? ( + + Yes + + ) : ( + + )} + + {p.last_activity_date || formatDate(p.updated_at)} + + +
+ )} +
+ +
+ Note: The unlock toggle is a debugging aid. It lets a user select any unit + in the certification program without completing the prerequisites — it does not mark + modules as completed or grant XP. +
+
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 98b2974e..abf7de16 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -6,7 +6,7 @@ import { CheckCircle2, XCircle, Clock, Download, TrendingUp, TrendingDown, ChevronDown, ChevronUp, Play, Minus, AlertCircle, ArrowLeft, FileText, FolderTree, X, Check, - Mail, Send, Link, UserPlus, Star, Award, Unlock, KeyRound, PackageOpen, + Mail, Send, Link, UserPlus, Star, Award, KeyRound, PackageOpen, BookOpen, } from 'lucide-react' import { @@ -33,7 +33,6 @@ import { adminListAllTeams, adminCreateTeam, adminAddUserToTeam, adminRemoveUserFromTeam, getIsolatedUsers, updateUserRoles, getEmailAnalytics, - getCertificationProgressList, setCertificationUnlock, } from '../api/admin' import { getTeamMembers } from '../api/teams' import * as orgApi from '../api/organizations' @@ -60,7 +59,6 @@ import type { QualityAlert, QualityItem, QualityItemDetail, AdminTeamItem, IsolatedUserItem, EmailAnalyticsResponse, - CertificationProgressItem, } from '../api/admin' import { relativeTime } from '../utils/time' import { fileToConstrainedDataUrl } from '../utils/imageResize' @@ -75,6 +73,7 @@ import { ApiKeysTab } from '../components/admin/ApiKeysTab' import { ComplianceTab } from '../components/admin/ComplianceTab' import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { AuditTab } from '../components/admin/AuditTab' +import { CertificationsTab } from '../components/admin/CertificationsTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' @@ -7123,193 +7122,6 @@ function OrganizationsTab() { ) } -// ────────────────────────────────────────── -// Certifications Tab — track user progress through Vandal Workflow Architect -// ────────────────────────────────────────── - -function CertificationsTab() { - const [items, setItems] = useState([]) - const [loading, setLoading] = useState(true) - const [search, setSearch] = useState('') - const [busyUser, setBusyUser] = useState(null) - - const refresh = useCallback(async () => { - setLoading(true) - try { - const data = await getCertificationProgressList() - setItems(data) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { refresh() }, [refresh]) - - const filtered = useMemo(() => { - if (!search.trim()) return items - const q = search.toLowerCase() - return items.filter(p => - (p.name || '').toLowerCase().includes(q) || - (p.email || '').toLowerCase().includes(q) || - (p.user_id || '').toLowerCase().includes(q) - ) - }, [items, search]) - - const toggleUnlock = async (item: CertificationProgressItem) => { - setBusyUser(item.user_id) - try { - await setCertificationUnlock(item.user_id, !item.unlocked) - setItems(prev => prev.map(p => - p.user_id === item.user_id ? { ...p, unlocked: !item.unlocked } : p - )) - } finally { - setBusyUser(null) - } - } - - const handleExport = () => { - downloadCSV('certifications.csv', - ['User', 'Email', 'Level', 'Total XP', 'Modules Completed', 'Modules Total', 'Certified', 'Certified At', 'Streak', 'Last Activity', 'Unlocked'], - filtered.map(p => [ - p.name || p.user_id, p.email, - p.level, p.total_xp, - p.modules_completed, p.modules_total, - p.certified ? 'yes' : 'no', - p.certified_at, - p.streak_days, - p.last_activity_date, - p.unlocked ? 'yes' : 'no', - ]) - ) - } - - if (loading) return
Loading certification progress...
- - return ( -
-
-

Certifications

-

- Users who have started the Vandal Workflow Architect certification and where they are in the program. -

-
- -
- -
- - -
- -
-
- Certification Progress ({filtered.length}) -
- {filtered.length === 0 ? ( -
No users have started the certification yet.
- ) : ( - - - - - - - - - - - - - - {filtered.map(p => { - const pct = p.modules_total > 0 ? (p.modules_completed / p.modules_total) * 100 : 0 - return ( - - - - - - - - - - ) - })} - -
UserLevelXPModulesCertifiedLast ActiveDebug Unlock
-
- -
-
{p.name || 'Unknown'}
-
{p.email || p.user_id}
-
-
-
- - {p.level} - - {formatNumber(p.total_xp)} -
-
-
-
- - {p.modules_completed}/{p.modules_total} - -
-
- {p.certified ? ( - - Yes - - ) : ( - - )} - - {p.last_activity_date || formatDate(p.updated_at)} - - -
- )} -
- -
- Note: The unlock toggle is a debugging aid. It lets a user select any unit - in the certification program without completing the prerequisites — it does not mark - modules as completed or grant XP. -
-
- ) -} - export default function Admin() { const { user } = useAuth() const { currentTeam } = useTeams() From f0036e8b8db7dc406d1c99c9c9b41898b0083b20 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:22:55 -0600 Subject: [PATCH 07/18] refactor(admin): extract UsageTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/UsageTab.tsx | 221 +++++++++++++++++++++ frontend/src/pages/Admin.tsx | 206 +------------------ 2 files changed, 227 insertions(+), 200 deletions(-) create mode 100644 frontend/src/components/admin/UsageTab.tsx diff --git a/frontend/src/components/admin/UsageTab.tsx b/frontend/src/components/admin/UsageTab.tsx new file mode 100644 index 00000000..b5ce5d08 --- /dev/null +++ b/frontend/src/components/admin/UsageTab.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState } from 'react' +import { + MessageSquare, Search, Zap, CheckCircle2, XCircle, Users, AlertCircle, +} from 'lucide-react' +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, Legend, +} from 'recharts' +import { + getUsageStats, getUsageTimeseries, +} from '../../api/admin' +import type { UsageStats, TimeseriesResponse } from '../../api/admin' +import { formatNumber } from './shared/format' +import { TrendDelta, KpiCard, ExportButton, TimeRangeSelector } from './shared/primitives' + +const CHART_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'] + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function UsageTab() { + const [stats, setStats] = useState(null) + const [timeseries, setTimeseries] = useState(null) + const [days, setDays] = useState(30) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + setError(null) + Promise.all([getUsageStats(days), getUsageTimeseries(days)]) + .then(([s, ts]) => { setStats(s); setTimeseries(ts) }) + .catch(e => setError(e?.message || 'Failed to load usage data')) + .finally(() => setLoading(false)) + }, [days]) + + useEffect(() => { load() }, [load]) + + const prev = timeseries?.previous_period + + // Token donut data + const tokenDonut = stats ? [ + { name: 'Input', value: stats.tokens_in }, + { name: 'Output', value: stats.tokens_out }, + ] : [] + + // Workflow status donut + const workflowDonut = stats ? [ + { name: 'Completed', value: stats.workflows_completed }, + { name: 'Failed', value: stats.workflows_failed }, + { name: 'Other', value: Math.max(0, stats.workflows_started - stats.workflows_completed - stats.workflows_failed) }, + ].filter(d => d.value > 0) : [] + + const handleExport = () => { + if (!stats) return + const dayRows = (timeseries?.days ?? []).map(d => [ + d.date, d.conversations, d.search_runs, d.workflows_started, + d.workflows_completed, d.workflows_failed, d.tokens_in, d.tokens_out, d.active_users, + ]) + const summaryRows: (string | number | null)[][] = [ + ['SUMMARY', '', '', '', '', '', '', '', ''], + ['Window (days)', days, '', '', '', '', '', '', ''], + ['Conversations', stats.conversations, '', '', '', '', '', '', ''], + ['Search runs', stats.search_runs, '', '', '', '', '', '', ''], + ['Workflows started', stats.workflows_started, '', '', '', '', '', '', ''], + ['Workflows completed', stats.workflows_completed, '', '', '', '', '', '', ''], + ['Workflows failed', stats.workflows_failed, '', '', '', '', '', '', ''], + ['Tokens in', stats.tokens_in, '', '', '', '', '', '', ''], + ['Tokens out', stats.tokens_out, '', '', '', '', '', '', ''], + ['Active users', stats.active_users, '', '', '', '', '', '', ''], + ['Active teams', stats.active_teams, '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', '', ''], + ['DAILY', '', '', '', '', '', '', '', ''], + ] + downloadCSV( + `usage-${days}d.csv`, + ['Date', 'Conversations', 'Searches', 'Workflows Started', 'Workflows Completed', 'Workflows Failed', 'Tokens In', 'Tokens Out', 'Active Users'], + [...summaryRows, ...dayRows], + ) + } + + if (loading && !stats) return
Loading usage data...
+ + if (error && !stats) return ( +
+ +
{error}
+
+ ) + + return ( +
+ {/* Time range selector */} +
+ setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> +
+ +
+ + {stats && ( + <> + {/* KPI Grid with trend deltas */} +
+ + + + + + +
+ + {/* Daily Activity Chart */} + {timeseries && timeseries.days.length > 0 && ( +
+
Daily Activity
+ + + + v.slice(5)} /> + + + + + + + +
+ )} + + {/* Token + Workflow donut charts side by side */} +
+
+
Token Breakdown
+
+
+
Input Tokens
+
{formatNumber(stats.tokens_in)}
+
+
+
Output Tokens
+
{formatNumber(stats.tokens_out)}
+
+
+ {(stats.tokens_in + stats.tokens_out) > 0 && ( + + + + {tokenDonut.map((_, i) => )} + + formatNumber(Number(v ?? 0))} contentStyle={{ borderRadius: 8, fontSize: 12 }} /> + + + + )} +
+ +
+
Workflow Status
+
+
+
Success Rate
+
+ {stats.workflows_started > 0 ? `${Math.round((stats.workflows_completed / stats.workflows_started) * 100)}%` : '-'} +
+
+
+
Total
+
{formatNumber(stats.tokens_in + stats.tokens_out)}
+
+
+ {workflowDonut.length > 0 && ( + + + + {workflowDonut.map((_, i) => )} + + + + + + )} +
+
+ + {/* Summary cards */} +
+
+
Active Teams
+
+
{stats.active_teams}
+ {prev && } +
+
in the last {days} days
+
+
+
Active Users
+
+
{stats.active_users}
+ {prev && } +
+
in the last {days} days
+
+
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index abf7de16..4730fdf6 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react' import { Shield, ShieldCheck, BarChart3, Users, Building2, Workflow, Settings, Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, ChevronLeft, - ChevronRight, RefreshCw, MessageSquare, Search, Zap, + ChevronRight, RefreshCw, MessageSquare, Zap, CheckCircle2, XCircle, Clock, Download, TrendingUp, TrendingDown, ChevronDown, ChevronUp, Play, Minus, AlertCircle, ArrowLeft, FileText, FolderTree, X, Check, @@ -11,7 +11,7 @@ import { } from 'lucide-react' import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - PieChart, Pie, Cell, Legend, + Legend, LineChart, Line, } from 'recharts' import { PageLayout } from '../components/layout/PageLayout' @@ -23,7 +23,7 @@ import type { ThemeConfig } from '../api/config' import { useBranding, DEFAULT_ORG_NAME, DEFAULT_ICON_URL } from '../contexts/BrandingContext' import { useToast } from '../contexts/ToastContext' import { - getUsageStats, getUsageTimeseries, getUserLeaderboard, getTeamLeaderboard, + getUserLeaderboard, getTeamLeaderboard, getTeamDetail, getUserDetail, getUserHistory, getWorkflowEvents, getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, @@ -52,7 +52,7 @@ import { POST_SURVEY_FIELDS } from '../components/survey/postSurveyFields' import { PRE_SURVEY_FIELDS } from './Demo' import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { - UsageStats, TimeseriesResponse, UserLeaderboardItem, TeamLeaderboardItem, + UserLeaderboardItem, TeamLeaderboardItem, TeamDetailResponse, UserDetailResponse, UserHistoryItem, PaginatedWorkflows, SystemConfigData, QualitySummary, QualityTimelinePoint, RegressionResult, @@ -73,13 +73,14 @@ import { ApiKeysTab } from '../components/admin/ApiKeysTab' import { ComplianceTab } from '../components/admin/ComplianceTab' import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { AuditTab } from '../components/admin/AuditTab' +import { UsageTab } from '../components/admin/UsageTab' import { CertificationsTab } from '../components/admin/CertificationsTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' import { formatNumber, formatDuration } from '../components/admin/shared/format' import { - StatusBadge, RoleBadge, TrendDelta, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, type DayOption, + StatusBadge, RoleBadge, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, type DayOption, } from '../components/admin/shared/primitives' function applyThemeToDOM(theme: ThemeConfig) { @@ -111,8 +112,6 @@ const TABS: { key: Tab; label: string; icon: typeof BarChart3 }[] = [ { key: 'config', label: 'Config', icon: Settings }, ] -const CHART_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'] - function parseUtcDate(d: string): Date { // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') @@ -145,199 +144,6 @@ function downloadCSV(filename: string, headers: string[], rows: (string | number URL.revokeObjectURL(url) } -// ────────────────────────────────────────── -// Usage Tab -// ────────────────────────────────────────── - -function UsageTab() { - const [stats, setStats] = useState(null) - const [timeseries, setTimeseries] = useState(null) - const [days, setDays] = useState(30) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const load = useCallback(() => { - setLoading(true) - setError(null) - Promise.all([getUsageStats(days), getUsageTimeseries(days)]) - .then(([s, ts]) => { setStats(s); setTimeseries(ts) }) - .catch(e => setError(e?.message || 'Failed to load usage data')) - .finally(() => setLoading(false)) - }, [days]) - - useEffect(() => { load() }, [load]) - - const prev = timeseries?.previous_period - - // Token donut data - const tokenDonut = stats ? [ - { name: 'Input', value: stats.tokens_in }, - { name: 'Output', value: stats.tokens_out }, - ] : [] - - // Workflow status donut - const workflowDonut = stats ? [ - { name: 'Completed', value: stats.workflows_completed }, - { name: 'Failed', value: stats.workflows_failed }, - { name: 'Other', value: Math.max(0, stats.workflows_started - stats.workflows_completed - stats.workflows_failed) }, - ].filter(d => d.value > 0) : [] - - const handleExport = () => { - if (!stats) return - const dayRows = (timeseries?.days ?? []).map(d => [ - d.date, d.conversations, d.search_runs, d.workflows_started, - d.workflows_completed, d.workflows_failed, d.tokens_in, d.tokens_out, d.active_users, - ]) - const summaryRows: (string | number | null)[][] = [ - ['SUMMARY', '', '', '', '', '', '', '', ''], - ['Window (days)', days, '', '', '', '', '', '', ''], - ['Conversations', stats.conversations, '', '', '', '', '', '', ''], - ['Search runs', stats.search_runs, '', '', '', '', '', '', ''], - ['Workflows started', stats.workflows_started, '', '', '', '', '', '', ''], - ['Workflows completed', stats.workflows_completed, '', '', '', '', '', '', ''], - ['Workflows failed', stats.workflows_failed, '', '', '', '', '', '', ''], - ['Tokens in', stats.tokens_in, '', '', '', '', '', '', ''], - ['Tokens out', stats.tokens_out, '', '', '', '', '', '', ''], - ['Active users', stats.active_users, '', '', '', '', '', '', ''], - ['Active teams', stats.active_teams, '', '', '', '', '', '', ''], - ['', '', '', '', '', '', '', '', ''], - ['DAILY', '', '', '', '', '', '', '', ''], - ] - downloadCSV( - `usage-${days}d.csv`, - ['Date', 'Conversations', 'Searches', 'Workflows Started', 'Workflows Completed', 'Workflows Failed', 'Tokens In', 'Tokens Out', 'Active Users'], - [...summaryRows, ...dayRows], - ) - } - - if (loading && !stats) return
Loading usage data...
- - if (error && !stats) return ( -
- -
{error}
-
- ) - - return ( -
- {/* Time range selector */} -
- setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> -
- -
- - {stats && ( - <> - {/* KPI Grid with trend deltas */} -
- - - - - - -
- - {/* Daily Activity Chart */} - {timeseries && timeseries.days.length > 0 && ( -
-
Daily Activity
- - - - v.slice(5)} /> - - - - - - - -
- )} - - {/* Token + Workflow donut charts side by side */} -
-
-
Token Breakdown
-
-
-
Input Tokens
-
{formatNumber(stats.tokens_in)}
-
-
-
Output Tokens
-
{formatNumber(stats.tokens_out)}
-
-
- {(stats.tokens_in + stats.tokens_out) > 0 && ( - - - - {tokenDonut.map((_, i) => )} - - formatNumber(Number(v ?? 0))} contentStyle={{ borderRadius: 8, fontSize: 12 }} /> - - - - )} -
- -
-
Workflow Status
-
-
-
Success Rate
-
- {stats.workflows_started > 0 ? `${Math.round((stats.workflows_completed / stats.workflows_started) * 100)}%` : '-'} -
-
-
-
Total
-
{formatNumber(stats.tokens_in + stats.tokens_out)}
-
-
- {workflowDonut.length > 0 && ( - - - - {workflowDonut.map((_, i) => )} - - - - - - )} -
-
- - {/* Summary cards */} -
-
-
Active Teams
-
-
{stats.active_teams}
- {prev && } -
-
in the last {days} days
-
-
-
Active Users
-
-
{stats.active_users}
- {prev && } -
-
in the last {days} days
-
-
- - )} -
- ) -} - // ────────────────────────────────────────── // Users Tab // ────────────────────────────────────────── From 24b5ee1c8122c684825e38613eecbacc9975e305 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:27:03 -0600 Subject: [PATCH 08/18] refactor(admin): extract WorkflowsTab into its own file Co-Authored-By: Claude Fable 5 --- .../src/components/admin/WorkflowsTab.tsx | 258 ++++++++++++++++++ frontend/src/pages/Admin.tsx | 236 +--------------- 2 files changed, 262 insertions(+), 232 deletions(-) create mode 100644 frontend/src/components/admin/WorkflowsTab.tsx diff --git a/frontend/src/components/admin/WorkflowsTab.tsx b/frontend/src/components/admin/WorkflowsTab.tsx new file mode 100644 index 00000000..909be081 --- /dev/null +++ b/frontend/src/components/admin/WorkflowsTab.tsx @@ -0,0 +1,258 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react' + +import { getWorkflowEvents, type PaginatedWorkflows } from '../../api/admin' +import { formatDuration, formatNumber } from './shared/format' +import { ExportButton, SearchInput, StatusBadge, UserAvatar } from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDateTime(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function WorkflowsTab() { + const [data, setData] = useState(null) + const [page, setPage] = useState(1) + const [status, setStatus] = useState('') + const [search, setSearch] = useState('') + const [searchInput, setSearchInput] = useState('') + const [loading, setLoading] = useState(true) + const [expandedId, setExpandedId] = useState(null) + const searchDebounce = useRef | undefined>(undefined) + + const load = useCallback(() => { + setLoading(true) + getWorkflowEvents(page, status || undefined, search || undefined).then(setData).catch(() => setData(null)).finally(() => setLoading(false)) + }, [page, status, search]) + + useEffect(() => { load() }, [load]) + + const handleSearchChange = (v: string) => { + setSearchInput(v) + if (searchDebounce.current) clearTimeout(searchDebounce.current) + searchDebounce.current = setTimeout(() => { setSearch(v); setPage(1) }, 400) + } + + const filters = ['', 'completed', 'running', 'failed', 'queued', 'canceled'] + + const handleExport = () => { + if (!data) return + downloadCSV('workflows.csv', + ['Status', 'Workflow', 'User', 'Team', 'Steps', 'Tokens', 'Duration (ms)', 'Started'], + data.items.map(ev => [ + ev.status, ev.title, ev.user_name || ev.user_id, ev.team_name || ev.team_id, + `${ev.steps_completed}/${ev.steps_total}`, ev.tokens_in + ev.tokens_out, + ev.duration_ms, ev.started_at, + ]) + ) + } + + const summary = data?.summary + + return ( +
+ {/* Summary stats row */} + {summary && ( +
+ {[ + { label: 'Total', value: formatNumber(summary.total), color: '#374151' }, + { label: 'Success Rate', value: `${summary.success_rate}%`, color: '#16a34a' }, + { label: 'Avg Duration', value: formatDuration(summary.avg_duration_ms), color: '#3b82f6' }, + { label: 'Failed', value: formatNumber(summary.failed), color: '#dc2626' }, + { label: 'Total Tokens', value: formatNumber(summary.total_tokens), color: '#8b5cf6' }, + ].map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ )} + + {/* Filters + search */} +
+ {filters.map(f => ( + + ))} +
+ + +
+ +
+ {loading && !data ? ( +
Loading workflows...
+ ) : !data || data.items.length === 0 ? ( +
No workflow events found.
+ ) : ( + <> + + + + + + + + + + + + + + {data.items.map(ev => { + const isExpanded = expandedId === ev.id + return ( + setExpandedId(isExpanded ? null : ev.id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpandedId(isExpanded ? null : ev.id) } }} style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }}> + + + + + + + + + + ) + })} + +
+ StatusWorkflowUserStepsTokensDurationStarted
+ {isExpanded ? : } + {ev.title || 'Untitled'} +
+ +
+
{ev.user_name || 'Unknown'}
+ {ev.team_name &&
{ev.team_name}
} +
+
+
{ev.steps_completed}/{ev.steps_total} + {formatNumber(ev.tokens_in + ev.tokens_out)} + {formatDuration(ev.duration_ms)}{formatDateTime(ev.started_at)}
+ + {/* Expanded detail - rendered below table as an info panel */} + {expandedId && (() => { + const ev = data.items.find(e => e.id === expandedId) + if (!ev) return null + return ( +
+
+
+
User ID
+
{ev.user_id}
+
+
+
Email
+
{ev.user_email || '-'}
+
+
+
Team
+
{ev.team_name || ev.team_id || '-'}
+
+
+
Finished
+
{formatDateTime(ev.finished_at)}
+
+
+
Input Tokens
+
{formatNumber(ev.tokens_in)}
+
+
+
Output Tokens
+
{formatNumber(ev.tokens_out)}
+
+
+
Duration
+
{formatDuration(ev.duration_ms)}
+
+
+
Steps
+
{ev.steps_completed} / {ev.steps_total}
+
+
+ {ev.error && ( +
+ {ev.error} +
+ )} +
+ ) + })()} + + {/* Pagination */} + {data.pages > 1 && ( +
+ + Page {data.page} of {data.pages} ({data.total} total) + +
+ + +
+
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 4730fdf6..e653636c 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react' import { Shield, ShieldCheck, BarChart3, Users, Building2, Workflow, Settings, - Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, ChevronLeft, + Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, ChevronRight, RefreshCw, MessageSquare, Zap, CheckCircle2, XCircle, Clock, Download, TrendingUp, TrendingDown, ChevronDown, ChevronUp, Play, Minus, AlertCircle, @@ -25,7 +25,7 @@ import { useToast } from '../contexts/ToastContext' import { getUserLeaderboard, getTeamLeaderboard, getTeamDetail, getUserDetail, getUserHistory, - getWorkflowEvents, getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, + getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, getQualitySummary, getQualityTimeline, runRegressionSuite, @@ -54,7 +54,7 @@ import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { UserLeaderboardItem, TeamLeaderboardItem, TeamDetailResponse, UserDetailResponse, UserHistoryItem, - PaginatedWorkflows, SystemConfigData, + SystemConfigData, QualitySummary, QualityTimelinePoint, RegressionResult, QualityAlert, QualityItem, QualityItemDetail, AdminTeamItem, IsolatedUserItem, @@ -74,6 +74,7 @@ import { ComplianceTab } from '../components/admin/ComplianceTab' import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { AuditTab } from '../components/admin/AuditTab' import { UsageTab } from '../components/admin/UsageTab' +import { WorkflowsTab } from '../components/admin/WorkflowsTab' import { CertificationsTab } from '../components/admin/CertificationsTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' @@ -1330,235 +1331,6 @@ function TeamsTab() { ) } -// ────────────────────────────────────────── -// Workflows Tab -// ────────────────────────────────────────── - -function WorkflowsTab() { - const [data, setData] = useState(null) - const [page, setPage] = useState(1) - const [status, setStatus] = useState('') - const [search, setSearch] = useState('') - const [searchInput, setSearchInput] = useState('') - const [loading, setLoading] = useState(true) - const [expandedId, setExpandedId] = useState(null) - const searchDebounce = useRef | undefined>(undefined) - - const load = useCallback(() => { - setLoading(true) - getWorkflowEvents(page, status || undefined, search || undefined).then(setData).catch(() => setData(null)).finally(() => setLoading(false)) - }, [page, status, search]) - - useEffect(() => { load() }, [load]) - - const handleSearchChange = (v: string) => { - setSearchInput(v) - if (searchDebounce.current) clearTimeout(searchDebounce.current) - searchDebounce.current = setTimeout(() => { setSearch(v); setPage(1) }, 400) - } - - const filters = ['', 'completed', 'running', 'failed', 'queued', 'canceled'] - - const handleExport = () => { - if (!data) return - downloadCSV('workflows.csv', - ['Status', 'Workflow', 'User', 'Team', 'Steps', 'Tokens', 'Duration (ms)', 'Started'], - data.items.map(ev => [ - ev.status, ev.title, ev.user_name || ev.user_id, ev.team_name || ev.team_id, - `${ev.steps_completed}/${ev.steps_total}`, ev.tokens_in + ev.tokens_out, - ev.duration_ms, ev.started_at, - ]) - ) - } - - const summary = data?.summary - - return ( -
- {/* Summary stats row */} - {summary && ( -
- {[ - { label: 'Total', value: formatNumber(summary.total), color: '#374151' }, - { label: 'Success Rate', value: `${summary.success_rate}%`, color: '#16a34a' }, - { label: 'Avg Duration', value: formatDuration(summary.avg_duration_ms), color: '#3b82f6' }, - { label: 'Failed', value: formatNumber(summary.failed), color: '#dc2626' }, - { label: 'Total Tokens', value: formatNumber(summary.total_tokens), color: '#8b5cf6' }, - ].map(s => ( -
-
{s.label}
-
{s.value}
-
- ))} -
- )} - - {/* Filters + search */} -
- {filters.map(f => ( - - ))} -
- - -
- -
- {loading && !data ? ( -
Loading workflows...
- ) : !data || data.items.length === 0 ? ( -
No workflow events found.
- ) : ( - <> - - - - - - - - - - - - - - {data.items.map(ev => { - const isExpanded = expandedId === ev.id - return ( - setExpandedId(isExpanded ? null : ev.id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpandedId(isExpanded ? null : ev.id) } }} style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }}> - - - - - - - - - - ) - })} - -
- StatusWorkflowUserStepsTokensDurationStarted
- {isExpanded ? : } - {ev.title || 'Untitled'} -
- -
-
{ev.user_name || 'Unknown'}
- {ev.team_name &&
{ev.team_name}
} -
-
-
{ev.steps_completed}/{ev.steps_total} - {formatNumber(ev.tokens_in + ev.tokens_out)} - {formatDuration(ev.duration_ms)}{formatDateTime(ev.started_at)}
- - {/* Expanded detail - rendered below table as an info panel */} - {expandedId && (() => { - const ev = data.items.find(e => e.id === expandedId) - if (!ev) return null - return ( -
-
-
-
User ID
-
{ev.user_id}
-
-
-
Email
-
{ev.user_email || '-'}
-
-
-
Team
-
{ev.team_name || ev.team_id || '-'}
-
-
-
Finished
-
{formatDateTime(ev.finished_at)}
-
-
-
Input Tokens
-
{formatNumber(ev.tokens_in)}
-
-
-
Output Tokens
-
{formatNumber(ev.tokens_out)}
-
-
-
Duration
-
{formatDuration(ev.duration_ms)}
-
-
-
Steps
-
{ev.steps_completed} / {ev.steps_total}
-
-
- {ev.error && ( -
- {ev.error} -
- )} -
- ) - })()} - - {/* Pagination */} - {data.pages > 1 && ( -
- - Page {data.page} of {data.pages} ({data.total} total) - -
- - -
-
- )} - - )} -
-
- ) -} - // ────────────────────────────────────────── // Quality Tab // ────────────────────────────────────────── From 73f7015e1a777acf0a3b7de716161439e139c86b Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:33:11 -0600 Subject: [PATCH 09/18] refactor(admin): extract QualityTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/QualityTab.tsx | 556 +++++++++++++++++++ frontend/src/pages/Admin.tsx | 538 +----------------- 2 files changed, 559 insertions(+), 535 deletions(-) create mode 100644 frontend/src/components/admin/QualityTab.tsx diff --git a/frontend/src/components/admin/QualityTab.tsx b/frontend/src/components/admin/QualityTab.tsx new file mode 100644 index 00000000..59f6a073 --- /dev/null +++ b/frontend/src/components/admin/QualityTab.tsx @@ -0,0 +1,556 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + AlertCircle, BarChart3, CheckCircle2, Clock, Minus, Play, RefreshCw, + ShieldCheck, TrendingDown, TrendingUp, XCircle, +} from 'lucide-react' +import { + CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, +} from 'recharts' + +import { + acknowledgeAlert, getQualityAlerts, getQualityItemDetail, getQualityItems, + getQualitySummary, getQualityTimeline, getSystemConfig, runRegressionSuite, + type QualityAlert, type QualityItem, type QualityItemDetail, type QualitySummary, + type QualityTimelinePoint, type RegressionResult, type SystemConfigData, +} from '../../api/admin' +import { relativeTime } from '../../utils/time' +import { ExportButton, KpiCard, SortableHeader } from './shared/primitives' + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function QualityTab() { + const [summary, setSummary] = useState(null) + const [timeline, setTimeline] = useState([]) + const [days, setDays] = useState(90) + const [loading, setLoading] = useState(true) + const [regressionResult, setRegressionResult] = useState(null) + const [regressionRunning, setRegressionRunning] = useState(false) + const [regressionModel, setRegressionModel] = useState('') + const [cfg, setCfg] = useState(null) + + // Alert feed state + const [alerts, setAlerts] = useState([]) + + // Per-item quality state + const [qualityItems, setQualityItems] = useState([]) + const [expandedItem, setExpandedItem] = useState<{ kind: string; id: string } | null>(null) + const [itemDetail, setItemDetail] = useState(null) + const [itemSort, setItemSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'score', dir: 'asc' }) + + const load = useCallback(() => { + setLoading(true) + Promise.all([ + getQualitySummary(), + getQualityTimeline(days), + getSystemConfig(), + getQualityAlerts(50, false), + getQualityItems('score', 'asc', 100), + ]).then(([s, t, c, a, qi]) => { + setSummary(s) + setTimeline(t.timeline) + setCfg(c) + setAlerts(a.alerts) + setQualityItems(qi.items) + }).finally(() => setLoading(false)) + }, [days]) + + useEffect(() => { load() }, [load]) + + const handleRunRegression = async () => { + setRegressionRunning(true) + try { + const result = await runRegressionSuite(regressionModel || undefined) + setRegressionResult(result) + } finally { + setRegressionRunning(false) + } + } + + const handleAcknowledgeAlert = async (uuid: string) => { + await acknowledgeAlert(uuid) + setAlerts(prev => prev.filter(a => a.uuid !== uuid)) + } + + const handleExpandItem = async (kind: string, id: string) => { + if (expandedItem?.kind === kind && expandedItem?.id === id) { + setExpandedItem(null) + setItemDetail(null) + return + } + setExpandedItem({ kind, id }) + setItemDetail(null) + const detail = await getQualityItemDetail(kind, id) + setItemDetail(detail) + } + + const handleItemSort = (key: string) => { + setItemSort(prev => ({ + key, + dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc', + })) + } + + const sortedQualityItems = useMemo(() => { + const list = [...qualityItems] + list.sort((a, b) => { + let cmp = 0 + switch (itemSort.key) { + case 'name': cmp = a.display_name.localeCompare(b.display_name); break + case 'kind': cmp = a.item_kind.localeCompare(b.item_kind); break + case 'score': cmp = (a.quality_score ?? -1) - (b.quality_score ?? -1); break + case 'tier': cmp = (a.quality_tier || '').localeCompare(b.quality_tier || ''); break + case 'last_validated': cmp = (a.last_validated_at || '').localeCompare(b.last_validated_at || ''); break + case 'runs': cmp = a.validation_run_count - b.validation_run_count; break + } + return itemSort.dir === 'asc' ? cmp : -cmp + }) + return list + }, [qualityItems, itemSort]) + + if (loading) return
Loading quality data...
+ + return ( +
+ {/* Alert Feed Panel */} + {alerts.length > 0 && ( +
+
+ + Quality Alerts ({alerts.length}) +
+
+ {alerts.map(alert => { + const severityColors: Record = { + info: { bg: '#eff6ff', text: '#1e40af', border: '#bfdbfe' }, + warning: { bg: '#fffbeb', text: '#92400e', border: '#fde68a' }, + critical: { bg: '#fef2f2', text: '#991b1b', border: '#fecaca' }, + } + const sc = severityColors[alert.severity] || severityColors.info + return ( +
+ + {alert.severity} + +
+
{alert.item_name}
+
+ {alert.message} + {(alert.alert_type === 'regression' || alert.alert_type === 'baseline_drift') && alert.previous_score != null && alert.current_score != null && ( + + {alert.previous_score} → {alert.current_score} + + )} + {alert.alert_type === 'baseline_drift' && ( + + Drift + + )} +
+
+ + {alert.created_at ? relativeTime(alert.created_at) : '-'} + + +
+ ) + })} +
+
+ )} + + {/* Summary KPI Cards */} +
+ + + + +
+ + {/* Quality Timeline Chart */} +
+
+

Quality Timeline

+
+ + downloadCSV( + `quality-timeline-${days}d.csv`, + ['Date', 'Avg Score', 'Run Count', 'Items Validated'], + timeline.map(p => [p.date, p.avg_score, p.run_count, p.items_validated]), + )} /> +
+
+ {timeline.length === 0 ? ( +
+ No validation data yet. Run validation on items to see the timeline. +
+ ) : ( + + + + + + [`${Number(value ?? 0)}%`, 'Avg Score']} + /> + + + + )} +
+ + {/* Regression Suite Panel */} +
+

Regression Suite

+

+ Run validation on all verified items to detect quality regressions after model or configuration changes. +

+
+ + +
+ + {regressionResult && ( +
+
+ Total: {regressionResult.total_items} + Succeeded: {regressionResult.succeeded} + Failed: {regressionResult.failed} +
+
+ + + + + + + + + + + + + {regressionResult.results.map((r, i) => ( + + + + + + + + + ))} + +
NameKindScoreGradeDeltaStatus
{r.name} + {r.kind} + + {r.score != null ? `${r.score}%` : '-'} + + {r.grade || '-'} + 0 ? '#16a34a' : r.delta < 0 ? '#dc2626' : '#9ca3af', + }}> + {r.delta == null ? '-' : r.delta > 0 ? `+${r.delta}` : r.delta} + + {r.status === 'ok' ? ( + + ) : ( + {r.status} + )} +
+
+
+ )} +
+ + {/* Per-Item Quality Table */} +
+
+ Per-Item Quality ({qualityItems.length}) +
+ {qualityItems.length === 0 ? ( +
+ No quality items found. Validate items to see them here. +
+ ) : ( +
+ + + + + + + + + + + + + + {sortedQualityItems.map(item => { + const isExpanded = expandedItem?.kind === item.item_kind && expandedItem?.id === item.item_id + const scoreColor = item.quality_score == null ? '#9ca3af' + : item.quality_score >= 90 ? '#16a34a' + : item.quality_score >= 70 ? '#2563eb' + : item.quality_score >= 50 ? '#f59e0b' + : '#dc2626' + const tierColors: Record = { + excellent: { bg: '#dcfce7', text: '#166534' }, + good: { bg: '#dbeafe', text: '#1e40af' }, + fair: { bg: '#fef3c7', text: '#92400e' }, + poor: { bg: '#fee2e2', text: '#991b1b' }, + } + const tc = tierColors[item.quality_tier || ''] || { bg: '#f3f4f6', text: '#374151' } + return ( + + handleExpandItem(item.item_kind, item.item_id)} + style={{ + borderBottom: '1px solid #f3f4f6', cursor: 'pointer', + background: isExpanded ? '#f9fafb' : undefined, + }} + > + + + + + + + + + {/* Per-Item Drill-Down */} + {isExpanded && ( + + + + )} + + ) + })} + +
TierTrendStale
{item.display_name} + {item.item_kind} + + {item.quality_score != null ? `${item.quality_score}%` : '-'} + + {item.quality_tier ? ( + + {item.quality_tier} + + ) : '-'} + + {item.trend === 'up' && } + {item.trend === 'down' && } + {item.trend === 'flat' && } + + {item.last_validated_at ? relativeTime(item.last_validated_at) : '-'} + + {item.stale && } +
+
+ {!itemDetail ? ( +
+ Loading detail... +
+ ) : ( +
+ {/* Score Timeline Chart */} +
+
Score Timeline
+ {itemDetail.history.length === 0 ? ( +
+ No history available. +
+ ) : ( + + ({ + date: h.created_at.slice(0, 10), + score: h.score, + grade: h.grade, + }))}> + + + + [`${Number(value ?? 0)}%`, 'Score']} + /> + + + + )} +
+ {/* Model Comparison */} +
+
Model Comparison
+ {itemDetail.model_comparison.length === 0 ? ( +
+ No model data available. +
+ ) : ( +
+ {itemDetail.model_comparison.map((mc, i) => ( +
+
+
{mc.model}
+
{mc.run_count} run{mc.run_count !== 1 ? 's' : ''}
+
+
= 90 ? '#16a34a' : mc.avg_score >= 70 ? '#2563eb' : mc.avg_score >= 50 ? '#f59e0b' : '#dc2626', + }}> + {mc.avg_score}% +
+
+ ))} +
+ )} +
+
+ )} +
+
+
+ )} +
+ + {/* Monitoring Status */} +
+

Monitoring Status

+
+
+
+ {qualityItems.length} +
+
Total Monitored Items
+
+
+
+ {alerts.length} +
+
Items with Alerts
+
+
+
+ {qualityItems.filter(i => i.stale).length} +
+
Stale Items
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index e653636c..3dc83223 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -3,8 +3,8 @@ import { Shield, ShieldCheck, BarChart3, Users, Building2, Workflow, Settings, Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, ChevronRight, RefreshCw, MessageSquare, Zap, - CheckCircle2, XCircle, Clock, Download, TrendingUp, TrendingDown, - ChevronDown, ChevronUp, Play, Minus, AlertCircle, + CheckCircle2, XCircle, Download, + ChevronDown, ChevronUp, Play, AlertCircle, ArrowLeft, FileText, FolderTree, X, Check, Mail, Send, Link, UserPlus, Star, Award, KeyRound, PackageOpen, BookOpen, @@ -12,7 +12,6 @@ import { import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, - LineChart, Line, } from 'recharts' import { PageLayout } from '../components/layout/PageLayout' import { useConfirm } from '../components/shared/useConfirm' @@ -28,8 +27,6 @@ import { getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, - getQualitySummary, getQualityTimeline, runRegressionSuite, - getQualityAlerts, acknowledgeAlert, getQualityItems, getQualityItemDetail, adminListAllTeams, adminCreateTeam, adminAddUserToTeam, adminRemoveUserFromTeam, getIsolatedUsers, updateUserRoles, getEmailAnalytics, @@ -55,12 +52,9 @@ import type { UserLeaderboardItem, TeamLeaderboardItem, TeamDetailResponse, UserDetailResponse, UserHistoryItem, SystemConfigData, - QualitySummary, QualityTimelinePoint, RegressionResult, - QualityAlert, QualityItem, QualityItemDetail, AdminTeamItem, IsolatedUserItem, EmailAnalyticsResponse, } from '../api/admin' -import { relativeTime } from '../utils/time' import { fileToConstrainedDataUrl } from '../utils/imageResize' import { ModelCharacterBars } from '../components/ModelEffortPicker' import type { ModelInfo } from '../types/workflow' @@ -75,6 +69,7 @@ import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { AuditTab } from '../components/admin/AuditTab' import { UsageTab } from '../components/admin/UsageTab' import { WorkflowsTab } from '../components/admin/WorkflowsTab' +import { QualityTab } from '../components/admin/QualityTab' import { CertificationsTab } from '../components/admin/CertificationsTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' @@ -1331,533 +1326,6 @@ function TeamsTab() { ) } -// ────────────────────────────────────────── -// Quality Tab -// ────────────────────────────────────────── - -function QualityTab() { - const [summary, setSummary] = useState(null) - const [timeline, setTimeline] = useState([]) - const [days, setDays] = useState(90) - const [loading, setLoading] = useState(true) - const [regressionResult, setRegressionResult] = useState(null) - const [regressionRunning, setRegressionRunning] = useState(false) - const [regressionModel, setRegressionModel] = useState('') - const [cfg, setCfg] = useState(null) - - // Alert feed state - const [alerts, setAlerts] = useState([]) - - // Per-item quality state - const [qualityItems, setQualityItems] = useState([]) - const [expandedItem, setExpandedItem] = useState<{ kind: string; id: string } | null>(null) - const [itemDetail, setItemDetail] = useState(null) - const [itemSort, setItemSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'score', dir: 'asc' }) - - const load = useCallback(() => { - setLoading(true) - Promise.all([ - getQualitySummary(), - getQualityTimeline(days), - getSystemConfig(), - getQualityAlerts(50, false), - getQualityItems('score', 'asc', 100), - ]).then(([s, t, c, a, qi]) => { - setSummary(s) - setTimeline(t.timeline) - setCfg(c) - setAlerts(a.alerts) - setQualityItems(qi.items) - }).finally(() => setLoading(false)) - }, [days]) - - useEffect(() => { load() }, [load]) - - const handleRunRegression = async () => { - setRegressionRunning(true) - try { - const result = await runRegressionSuite(regressionModel || undefined) - setRegressionResult(result) - } finally { - setRegressionRunning(false) - } - } - - const handleAcknowledgeAlert = async (uuid: string) => { - await acknowledgeAlert(uuid) - setAlerts(prev => prev.filter(a => a.uuid !== uuid)) - } - - const handleExpandItem = async (kind: string, id: string) => { - if (expandedItem?.kind === kind && expandedItem?.id === id) { - setExpandedItem(null) - setItemDetail(null) - return - } - setExpandedItem({ kind, id }) - setItemDetail(null) - const detail = await getQualityItemDetail(kind, id) - setItemDetail(detail) - } - - const handleItemSort = (key: string) => { - setItemSort(prev => ({ - key, - dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc', - })) - } - - const sortedQualityItems = useMemo(() => { - const list = [...qualityItems] - list.sort((a, b) => { - let cmp = 0 - switch (itemSort.key) { - case 'name': cmp = a.display_name.localeCompare(b.display_name); break - case 'kind': cmp = a.item_kind.localeCompare(b.item_kind); break - case 'score': cmp = (a.quality_score ?? -1) - (b.quality_score ?? -1); break - case 'tier': cmp = (a.quality_tier || '').localeCompare(b.quality_tier || ''); break - case 'last_validated': cmp = (a.last_validated_at || '').localeCompare(b.last_validated_at || ''); break - case 'runs': cmp = a.validation_run_count - b.validation_run_count; break - } - return itemSort.dir === 'asc' ? cmp : -cmp - }) - return list - }, [qualityItems, itemSort]) - - if (loading) return
Loading quality data...
- - return ( -
- {/* Alert Feed Panel */} - {alerts.length > 0 && ( -
-
- - Quality Alerts ({alerts.length}) -
-
- {alerts.map(alert => { - const severityColors: Record = { - info: { bg: '#eff6ff', text: '#1e40af', border: '#bfdbfe' }, - warning: { bg: '#fffbeb', text: '#92400e', border: '#fde68a' }, - critical: { bg: '#fef2f2', text: '#991b1b', border: '#fecaca' }, - } - const sc = severityColors[alert.severity] || severityColors.info - return ( -
- - {alert.severity} - -
-
{alert.item_name}
-
- {alert.message} - {(alert.alert_type === 'regression' || alert.alert_type === 'baseline_drift') && alert.previous_score != null && alert.current_score != null && ( - - {alert.previous_score} → {alert.current_score} - - )} - {alert.alert_type === 'baseline_drift' && ( - - Drift - - )} -
-
- - {alert.created_at ? relativeTime(alert.created_at) : '-'} - - -
- ) - })} -
-
- )} - - {/* Summary KPI Cards */} -
- - - - -
- - {/* Quality Timeline Chart */} -
-
-

Quality Timeline

-
- - downloadCSV( - `quality-timeline-${days}d.csv`, - ['Date', 'Avg Score', 'Run Count', 'Items Validated'], - timeline.map(p => [p.date, p.avg_score, p.run_count, p.items_validated]), - )} /> -
-
- {timeline.length === 0 ? ( -
- No validation data yet. Run validation on items to see the timeline. -
- ) : ( - - - - - - [`${Number(value ?? 0)}%`, 'Avg Score']} - /> - - - - )} -
- - {/* Regression Suite Panel */} -
-

Regression Suite

-

- Run validation on all verified items to detect quality regressions after model or configuration changes. -

-
- - -
- - {regressionResult && ( -
-
- Total: {regressionResult.total_items} - Succeeded: {regressionResult.succeeded} - Failed: {regressionResult.failed} -
-
- - - - - - - - - - - - - {regressionResult.results.map((r, i) => ( - - - - - - - - - ))} - -
NameKindScoreGradeDeltaStatus
{r.name} - {r.kind} - - {r.score != null ? `${r.score}%` : '-'} - - {r.grade || '-'} - 0 ? '#16a34a' : r.delta < 0 ? '#dc2626' : '#9ca3af', - }}> - {r.delta == null ? '-' : r.delta > 0 ? `+${r.delta}` : r.delta} - - {r.status === 'ok' ? ( - - ) : ( - {r.status} - )} -
-
-
- )} -
- - {/* Per-Item Quality Table */} -
-
- Per-Item Quality ({qualityItems.length}) -
- {qualityItems.length === 0 ? ( -
- No quality items found. Validate items to see them here. -
- ) : ( -
- - - - - - - - - - - - - - {sortedQualityItems.map(item => { - const isExpanded = expandedItem?.kind === item.item_kind && expandedItem?.id === item.item_id - const scoreColor = item.quality_score == null ? '#9ca3af' - : item.quality_score >= 90 ? '#16a34a' - : item.quality_score >= 70 ? '#2563eb' - : item.quality_score >= 50 ? '#f59e0b' - : '#dc2626' - const tierColors: Record = { - excellent: { bg: '#dcfce7', text: '#166534' }, - good: { bg: '#dbeafe', text: '#1e40af' }, - fair: { bg: '#fef3c7', text: '#92400e' }, - poor: { bg: '#fee2e2', text: '#991b1b' }, - } - const tc = tierColors[item.quality_tier || ''] || { bg: '#f3f4f6', text: '#374151' } - return ( - - handleExpandItem(item.item_kind, item.item_id)} - style={{ - borderBottom: '1px solid #f3f4f6', cursor: 'pointer', - background: isExpanded ? '#f9fafb' : undefined, - }} - > - - - - - - - - - {/* Per-Item Drill-Down */} - {isExpanded && ( - - - - )} - - ) - })} - -
TierTrendStale
{item.display_name} - {item.item_kind} - - {item.quality_score != null ? `${item.quality_score}%` : '-'} - - {item.quality_tier ? ( - - {item.quality_tier} - - ) : '-'} - - {item.trend === 'up' && } - {item.trend === 'down' && } - {item.trend === 'flat' && } - - {item.last_validated_at ? relativeTime(item.last_validated_at) : '-'} - - {item.stale && } -
-
- {!itemDetail ? ( -
- Loading detail... -
- ) : ( -
- {/* Score Timeline Chart */} -
-
Score Timeline
- {itemDetail.history.length === 0 ? ( -
- No history available. -
- ) : ( - - ({ - date: h.created_at.slice(0, 10), - score: h.score, - grade: h.grade, - }))}> - - - - [`${Number(value ?? 0)}%`, 'Score']} - /> - - - - )} -
- {/* Model Comparison */} -
-
Model Comparison
- {itemDetail.model_comparison.length === 0 ? ( -
- No model data available. -
- ) : ( -
- {itemDetail.model_comparison.map((mc, i) => ( -
-
-
{mc.model}
-
{mc.run_count} run{mc.run_count !== 1 ? 's' : ''}
-
-
= 90 ? '#16a34a' : mc.avg_score >= 70 ? '#2563eb' : mc.avg_score >= 50 ? '#f59e0b' : '#dc2626', - }}> - {mc.avg_score}% -
-
- ))} -
- )} -
-
- )} -
-
-
- )} -
- - {/* Monitoring Status */} -
-

Monitoring Status

-
-
-
- {qualityItems.length} -
-
Total Monitored Items
-
-
-
- {alerts.length} -
-
Items with Alerts
-
-
-
- {qualityItems.filter(i => i.stale).length} -
-
Stale Items
-
-
-
-
- ) -} - // ────────────────────────────────────────── // Model connectivity diagnostics // ────────────────────────────────────────── From 1f192768ac4aa3241d0aa9ab525a738e8951342b Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:40:32 -0600 Subject: [PATCH 10/18] refactor(admin): extract UsersTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/UsersTab.tsx | 530 +++++++++++++++++++++ frontend/src/pages/Admin.tsx | 495 +------------------ 2 files changed, 536 insertions(+), 489 deletions(-) create mode 100644 frontend/src/components/admin/UsersTab.tsx diff --git a/frontend/src/components/admin/UsersTab.tsx b/frontend/src/components/admin/UsersTab.tsx new file mode 100644 index 00000000..a7f08e8e --- /dev/null +++ b/frontend/src/components/admin/UsersTab.tsx @@ -0,0 +1,530 @@ +import { useEffect, useState, useCallback, useMemo } from 'react' +import { + ArrowLeft, Check, MessageSquare, CheckCircle2, Cpu, FileText, XCircle, Zap, + Download, AlertCircle, +} from 'lucide-react' +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' +import { + getUserLeaderboard, getUserDetail, getUserHistory, updateUserRoles, +} from '../../api/admin' +import type { + UserLeaderboardItem, UserDetailResponse, UserHistoryItem, +} from '../../api/admin' +import * as auditApi from '../../api/audit' +import { useAuth } from '../../hooks/useAuth' +import { formatNumber, formatDuration } from './shared/format' +import { + StatusBadge, RoleBadge, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, type DayOption, +} from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDate(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function formatDateTime(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +type UserSortKey = 'tokens_total' | 'workflows_run' | 'conversations' | 'last_active' | 'name' + +function UserDrillDown({ userId, onBack }: { userId: string; onBack: () => void }) { + const { user: currentUser } = useAuth() + const [data, setData] = useState(null) + const [days, setDays] = useState(30) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [savingRoles, setSavingRoles] = useState(false) + + const load = useCallback(() => { + setLoading(true) + setError(null) + getUserDetail(userId, days).then(setData).catch(e => setError(e?.message || 'Failed to load')).finally(() => setLoading(false)) + }, [userId, days]) + + useEffect(() => { load() }, [load]) + + const prev = data?.previous_period + + if (loading && !data) return
Loading user details...
+ if (error) return ( +
+ +
Error: {error}
+
+ ) + if (!data) return null + + return ( +
+ {/* Back + header */} +
+ +
+
+ +
+
+ {data.name || 'Unknown'} + {data.is_admin && } + {data.is_staff && } + {data.is_examiner && } +
+
{data.email || data.user_id}
+
+
+ + {/* Role management (admin only) */} + {currentUser?.is_admin && ( +
+
Platform Roles
+
+ {(['is_admin', 'is_staff', 'is_examiner'] as const).map(role => { + const label = role === 'is_admin' ? 'Admin' : role === 'is_staff' ? 'Staff' : 'Examiner' + const active = !!data[role] + return ( + + ) + })} +
+
+ )} + + {/* Time range */} +
+ setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> +
+ { + const dayRows = data.timeseries.map(d => [ + d.date, d.conversations, d.search_runs, d.workflows_started, + d.workflows_completed, d.workflows_failed, d.tokens_in, d.tokens_out, + ]) + const wfRows = data.recent_workflows.map(ev => [ + ev.started_at, ev.status, ev.title, formatDuration(ev.duration_ms), + ev.tokens_in + ev.tokens_out, + ]) + downloadCSV( + `user-${data.email || data.user_id}-${days}d.csv`, + ['Section', 'A', 'B', 'C', 'D', 'E', 'F', 'G'], + [ + ['SUMMARY', '', '', '', '', '', '', ''], + ['Conversations', data.conversations, '', '', '', '', '', ''], + ['Workflows Started', data.workflows_started, '', '', '', '', '', ''], + ['Workflows Completed', data.workflows_completed, '', '', '', '', '', ''], + ['Workflows Failed', data.workflows_failed, '', '', '', '', '', ''], + ['Tokens In', data.tokens_in, '', '', '', '', '', ''], + ['Tokens Out', data.tokens_out, '', '', '', '', '', ''], + ['Documents', data.document_count, '', '', '', '', '', ''], + ['', '', '', '', '', '', '', ''], + ['DAILY', 'Date', 'Conversations', 'Searches', 'WF Started', 'WF Completed', 'WF Failed', 'Tokens In/Out'], + ...dayRows.map(r => ['', ...r]), + ['', '', '', '', '', '', '', ''], + ['RECENT WORKFLOWS', 'Started', 'Status', 'Title', 'Duration', 'Tokens', '', ''], + ...wfRows.map(r => ['', ...r]), + ], + ) + }} /> +
+ + {/* KPI Grid */} +
+ + + + + + +
+ + {/* Daily Activity Chart */} + {data.timeseries.length > 0 && ( +
+
Daily Activity
+ + + + v.slice(5)} /> + + + + + + + +
+ )} + + {/* Recent Workflows */} + {data.recent_workflows.length > 0 && ( +
+
+ Recent Workflows +
+ + + + + + + + + + + + {data.recent_workflows.map(ev => ( + + + + + + + + ))} + +
StatusWorkflowDurationTokensStarted
{ev.title || '-'}{formatDuration(ev.duration_ms)}{formatNumber(ev.tokens_in + ev.tokens_out)}{formatDateTime(ev.started_at)}
+
+ )} + + {/* Full activity history (audit trail + activity telemetry) */} + +
+ ) +} + +const HISTORY_PAGE_SIZE = 50 + +function SourceBadge({ source }: { source: 'audit' | 'activity' }) { + const c = source === 'audit' + ? { bg: '#e2e8f0', text: '#334155', label: 'Audit' } + : { bg: '#dbeafe', text: '#1e40af', label: 'Activity' } + return ( + + {c.label} + + ) +} + +function UserActivityHistory({ userId, email }: { userId: string; email: string | null }) { + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [capped, setCapped] = useState(false) + const [days, setDays] = useState(90) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + + // Reload from scratch whenever the time range changes. + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + getUserHistory(userId, days, 0, HISTORY_PAGE_SIZE) + .then(res => { + if (cancelled) return + setItems(res.items) + setTotal(res.total) + setCapped(res.capped) + }) + .catch(e => { if (!cancelled) setError(e?.message || 'Failed to load history') }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [userId, days]) + + const loadMore = useCallback(() => { + setLoadingMore(true) + getUserHistory(userId, days, items.length, HISTORY_PAGE_SIZE) + .then(res => { + setItems(prev => [...prev, ...res.items]) + setTotal(res.total) + setCapped(res.capped) + }) + .catch(e => setError(e?.message || 'Failed to load history')) + .finally(() => setLoadingMore(false)) + }, [userId, days, items.length]) + + const startTime = useMemo( + () => new Date(Date.now() - days * 86400000).toISOString(), + [days], + ) + + return ( +
+
+
Activity History
+
+ setDays(typeof v === 'number' ? v : 90)} /> + + Export audit trail + +
+ + {capped && ( +
+ Showing the most recent events only — narrow the time range to see older history. +
+ )} + + {loading ? ( +
Loading activity history...
+ ) : error ? ( +
Error: {error}
+ ) : items.length === 0 ? ( +
No recorded activity in this period.
+ ) : ( + <> + + + + + + + + + + + + + {items.map((it, i) => ( + + + + + + + + + ))} + +
WhenSourceActionResourceStatusIP
{formatDateTime(it.timestamp)}{it.action} + {it.title || (it.resource_type ? {it.resource_type} : '-')} + {it.status ? : }{it.ip_address || '—'}
+
+ Showing {items.length} of {total}{email ? ` · ${email}` : ''} +
+ {items.length < total && ( + + )} +
+ + )} +
+ ) +} + +export function UsersTab() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [sort, setSort] = useState<{ key: UserSortKey; dir: 'asc' | 'desc' }>({ key: 'tokens_total', dir: 'desc' }) + const [selectedUserId, setSelectedUserId] = useState(null) + const [days, setDays] = useState('all') + + const load = useCallback(() => { + setLoading(true) + const arg = typeof days === 'number' ? days : undefined + getUserLeaderboard(arg).then(setUsers).catch(() => setUsers([])).finally(() => setLoading(false)) + }, [days]) + + useEffect(() => { load() }, [load]) + + const handleSort = (key: string) => { + setSort(prev => ({ + key: key as UserSortKey, + dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc', + })) + } + + const filtered = useMemo(() => { + let list = users + if (search.trim()) { + const q = search.toLowerCase() + list = list.filter(u => + (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q) + ) + } + const sorted = [...list].sort((a, b) => { + let cmp = 0 + switch (sort.key) { + case 'name': cmp = (a.name || '').localeCompare(b.name || ''); break + case 'tokens_total': cmp = a.tokens_total - b.tokens_total; break + case 'workflows_run': cmp = a.workflows_run - b.workflows_run; break + case 'conversations': cmp = a.conversations - b.conversations; break + case 'last_active': cmp = (a.last_active || '').localeCompare(b.last_active || ''); break + } + return sort.dir === 'asc' ? cmp : -cmp + }) + return sorted + }, [users, search, sort]) + + const maxTokens = users.length > 0 ? Math.max(...users.map(u => u.tokens_total), 1) : 1 + + const handleExport = () => { + downloadCSV('users.csv', + ['#', 'Name', 'Email', 'Roles', 'Tokens', 'Workflows', 'Conversations', 'Last Active'], + filtered.map((u, i) => [ + i + 1, u.name, u.email, + [u.is_admin ? 'admin' : '', u.is_staff ? 'staff' : '', u.is_examiner ? 'examiner' : ''].filter(Boolean).join(', '), + u.tokens_total, u.workflows_run, u.conversations, u.last_active, + ]) + ) + } + + if (loading) return
Loading users...
+ + if (selectedUserId) { + return setSelectedUserId(null)} /> + } + + return ( +
+
+ +
+
+ +
+ +
+ +
+
+ User Leaderboard ({filtered.length}) {days !== 'all' && · last {days} days} +
+ {filtered.length === 0 ? ( +
No users found.
+ ) : ( + + + + + + + + + + + + + {filtered.map((u, i) => ( + setSelectedUserId(u.user_id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedUserId(u.user_id) } }} style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }} onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#f9fafb')} onMouseLeave={e => (e.currentTarget.style.backgroundColor = '')}> + + + + + + + + ))} + +
#
{i + 1} +
+ +
+
+ {u.name || 'Unknown'} + {u.is_admin && } + {u.is_staff && } + {u.is_examiner && } +
+
{u.email || u.user_id}
+
+
+
+
+
+
+
+ + {formatNumber(u.tokens_total)} + +
+
{u.workflows_run}{u.conversations}{formatDate(u.last_active)}
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 3dc83223..78ad0a3f 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -5,7 +5,7 @@ import { ChevronRight, RefreshCw, MessageSquare, Zap, CheckCircle2, XCircle, Download, ChevronDown, ChevronUp, Play, AlertCircle, - ArrowLeft, FileText, FolderTree, X, Check, + ArrowLeft, FileText, FolderTree, X, Mail, Send, Link, UserPlus, Star, Award, KeyRound, PackageOpen, BookOpen, } from 'lucide-react' @@ -22,13 +22,12 @@ import type { ThemeConfig } from '../api/config' import { useBranding, DEFAULT_ORG_NAME, DEFAULT_ICON_URL } from '../contexts/BrandingContext' import { useToast } from '../contexts/ToastContext' import { - getUserLeaderboard, getTeamLeaderboard, - getTeamDetail, getUserDetail, getUserHistory, + getTeamLeaderboard, + getTeamDetail, getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, adminListAllTeams, adminCreateTeam, adminAddUserToTeam, adminRemoveUserFromTeam, getIsolatedUsers, - updateUserRoles, getEmailAnalytics, } from '../api/admin' import { getTeamMembers } from '../api/teams' @@ -49,8 +48,8 @@ import { POST_SURVEY_FIELDS } from '../components/survey/postSurveyFields' import { PRE_SURVEY_FIELDS } from './Demo' import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { - UserLeaderboardItem, TeamLeaderboardItem, - TeamDetailResponse, UserDetailResponse, UserHistoryItem, + TeamLeaderboardItem, + TeamDetailResponse, SystemConfigData, AdminTeamItem, IsolatedUserItem, EmailAnalyticsResponse, @@ -58,7 +57,6 @@ import type { import { fileToConstrainedDataUrl } from '../utils/imageResize' import { ModelCharacterBars } from '../components/ModelEffortPicker' import type { ModelInfo } from '../types/workflow' -import * as auditApi from '../api/audit' import { getAuthConfig } from '../api/auth' import { UpdateBanner } from '../components/admin/UpdateBanner' import { CatalogUpdateBanner } from '../components/admin/CatalogUpdateBanner' @@ -67,6 +65,7 @@ import { ApiKeysTab } from '../components/admin/ApiKeysTab' import { ComplianceTab } from '../components/admin/ComplianceTab' import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { AuditTab } from '../components/admin/AuditTab' +import { UsersTab } from '../components/admin/UsersTab' import { UsageTab } from '../components/admin/UsageTab' import { WorkflowsTab } from '../components/admin/WorkflowsTab' import { QualityTab } from '../components/admin/QualityTab' @@ -140,488 +139,6 @@ function downloadCSV(filename: string, headers: string[], rows: (string | number URL.revokeObjectURL(url) } -// ────────────────────────────────────────── -// Users Tab -// ────────────────────────────────────────── - -type UserSortKey = 'tokens_total' | 'workflows_run' | 'conversations' | 'last_active' | 'name' - -function UserDrillDown({ userId, onBack }: { userId: string; onBack: () => void }) { - const { user: currentUser } = useAuth() - const [data, setData] = useState(null) - const [days, setDays] = useState(30) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [savingRoles, setSavingRoles] = useState(false) - - const load = useCallback(() => { - setLoading(true) - setError(null) - getUserDetail(userId, days).then(setData).catch(e => setError(e?.message || 'Failed to load')).finally(() => setLoading(false)) - }, [userId, days]) - - useEffect(() => { load() }, [load]) - - const prev = data?.previous_period - - if (loading && !data) return
Loading user details...
- if (error) return ( -
- -
Error: {error}
-
- ) - if (!data) return null - - return ( -
- {/* Back + header */} -
- -
-
- -
-
- {data.name || 'Unknown'} - {data.is_admin && } - {data.is_staff && } - {data.is_examiner && } -
-
{data.email || data.user_id}
-
-
- - {/* Role management (admin only) */} - {currentUser?.is_admin && ( -
-
Platform Roles
-
- {(['is_admin', 'is_staff', 'is_examiner'] as const).map(role => { - const label = role === 'is_admin' ? 'Admin' : role === 'is_staff' ? 'Staff' : 'Examiner' - const active = !!data[role] - return ( - - ) - })} -
-
- )} - - {/* Time range */} -
- setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> -
- { - const dayRows = data.timeseries.map(d => [ - d.date, d.conversations, d.search_runs, d.workflows_started, - d.workflows_completed, d.workflows_failed, d.tokens_in, d.tokens_out, - ]) - const wfRows = data.recent_workflows.map(ev => [ - ev.started_at, ev.status, ev.title, formatDuration(ev.duration_ms), - ev.tokens_in + ev.tokens_out, - ]) - downloadCSV( - `user-${data.email || data.user_id}-${days}d.csv`, - ['Section', 'A', 'B', 'C', 'D', 'E', 'F', 'G'], - [ - ['SUMMARY', '', '', '', '', '', '', ''], - ['Conversations', data.conversations, '', '', '', '', '', ''], - ['Workflows Started', data.workflows_started, '', '', '', '', '', ''], - ['Workflows Completed', data.workflows_completed, '', '', '', '', '', ''], - ['Workflows Failed', data.workflows_failed, '', '', '', '', '', ''], - ['Tokens In', data.tokens_in, '', '', '', '', '', ''], - ['Tokens Out', data.tokens_out, '', '', '', '', '', ''], - ['Documents', data.document_count, '', '', '', '', '', ''], - ['', '', '', '', '', '', '', ''], - ['DAILY', 'Date', 'Conversations', 'Searches', 'WF Started', 'WF Completed', 'WF Failed', 'Tokens In/Out'], - ...dayRows.map(r => ['', ...r]), - ['', '', '', '', '', '', '', ''], - ['RECENT WORKFLOWS', 'Started', 'Status', 'Title', 'Duration', 'Tokens', '', ''], - ...wfRows.map(r => ['', ...r]), - ], - ) - }} /> -
- - {/* KPI Grid */} -
- - - - - - -
- - {/* Daily Activity Chart */} - {data.timeseries.length > 0 && ( -
-
Daily Activity
- - - - v.slice(5)} /> - - - - - - - -
- )} - - {/* Recent Workflows */} - {data.recent_workflows.length > 0 && ( -
-
- Recent Workflows -
- - - - - - - - - - - - {data.recent_workflows.map(ev => ( - - - - - - - - ))} - -
StatusWorkflowDurationTokensStarted
{ev.title || '-'}{formatDuration(ev.duration_ms)}{formatNumber(ev.tokens_in + ev.tokens_out)}{formatDateTime(ev.started_at)}
-
- )} - - {/* Full activity history (audit trail + activity telemetry) */} - -
- ) -} - -const HISTORY_PAGE_SIZE = 50 - -function SourceBadge({ source }: { source: 'audit' | 'activity' }) { - const c = source === 'audit' - ? { bg: '#e2e8f0', text: '#334155', label: 'Audit' } - : { bg: '#dbeafe', text: '#1e40af', label: 'Activity' } - return ( - - {c.label} - - ) -} - -function UserActivityHistory({ userId, email }: { userId: string; email: string | null }) { - const [items, setItems] = useState([]) - const [total, setTotal] = useState(0) - const [capped, setCapped] = useState(false) - const [days, setDays] = useState(90) - const [loading, setLoading] = useState(true) - const [loadingMore, setLoadingMore] = useState(false) - const [error, setError] = useState(null) - - // Reload from scratch whenever the time range changes. - useEffect(() => { - let cancelled = false - setLoading(true) - setError(null) - getUserHistory(userId, days, 0, HISTORY_PAGE_SIZE) - .then(res => { - if (cancelled) return - setItems(res.items) - setTotal(res.total) - setCapped(res.capped) - }) - .catch(e => { if (!cancelled) setError(e?.message || 'Failed to load history') }) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [userId, days]) - - const loadMore = useCallback(() => { - setLoadingMore(true) - getUserHistory(userId, days, items.length, HISTORY_PAGE_SIZE) - .then(res => { - setItems(prev => [...prev, ...res.items]) - setTotal(res.total) - setCapped(res.capped) - }) - .catch(e => setError(e?.message || 'Failed to load history')) - .finally(() => setLoadingMore(false)) - }, [userId, days, items.length]) - - const startTime = useMemo( - () => new Date(Date.now() - days * 86400000).toISOString(), - [days], - ) - - return ( -
-
-
Activity History
-
- setDays(typeof v === 'number' ? v : 90)} /> - - Export audit trail - -
- - {capped && ( -
- Showing the most recent events only — narrow the time range to see older history. -
- )} - - {loading ? ( -
Loading activity history...
- ) : error ? ( -
Error: {error}
- ) : items.length === 0 ? ( -
No recorded activity in this period.
- ) : ( - <> - - - - - - - - - - - - - {items.map((it, i) => ( - - - - - - - - - ))} - -
WhenSourceActionResourceStatusIP
{formatDateTime(it.timestamp)}{it.action} - {it.title || (it.resource_type ? {it.resource_type} : '-')} - {it.status ? : }{it.ip_address || '—'}
-
- Showing {items.length} of {total}{email ? ` · ${email}` : ''} -
- {items.length < total && ( - - )} -
- - )} -
- ) -} - -function UsersTab() { - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) - const [search, setSearch] = useState('') - const [sort, setSort] = useState<{ key: UserSortKey; dir: 'asc' | 'desc' }>({ key: 'tokens_total', dir: 'desc' }) - const [selectedUserId, setSelectedUserId] = useState(null) - const [days, setDays] = useState('all') - - const load = useCallback(() => { - setLoading(true) - const arg = typeof days === 'number' ? days : undefined - getUserLeaderboard(arg).then(setUsers).catch(() => setUsers([])).finally(() => setLoading(false)) - }, [days]) - - useEffect(() => { load() }, [load]) - - const handleSort = (key: string) => { - setSort(prev => ({ - key: key as UserSortKey, - dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc', - })) - } - - const filtered = useMemo(() => { - let list = users - if (search.trim()) { - const q = search.toLowerCase() - list = list.filter(u => - (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q) - ) - } - const sorted = [...list].sort((a, b) => { - let cmp = 0 - switch (sort.key) { - case 'name': cmp = (a.name || '').localeCompare(b.name || ''); break - case 'tokens_total': cmp = a.tokens_total - b.tokens_total; break - case 'workflows_run': cmp = a.workflows_run - b.workflows_run; break - case 'conversations': cmp = a.conversations - b.conversations; break - case 'last_active': cmp = (a.last_active || '').localeCompare(b.last_active || ''); break - } - return sort.dir === 'asc' ? cmp : -cmp - }) - return sorted - }, [users, search, sort]) - - const maxTokens = users.length > 0 ? Math.max(...users.map(u => u.tokens_total), 1) : 1 - - const handleExport = () => { - downloadCSV('users.csv', - ['#', 'Name', 'Email', 'Roles', 'Tokens', 'Workflows', 'Conversations', 'Last Active'], - filtered.map((u, i) => [ - i + 1, u.name, u.email, - [u.is_admin ? 'admin' : '', u.is_staff ? 'staff' : '', u.is_examiner ? 'examiner' : ''].filter(Boolean).join(', '), - u.tokens_total, u.workflows_run, u.conversations, u.last_active, - ]) - ) - } - - if (loading) return
Loading users...
- - if (selectedUserId) { - return setSelectedUserId(null)} /> - } - - return ( -
-
- -
-
- -
- -
- -
-
- User Leaderboard ({filtered.length}) {days !== 'all' && · last {days} days} -
- {filtered.length === 0 ? ( -
No users found.
- ) : ( - - - - - - - - - - - - - {filtered.map((u, i) => ( - setSelectedUserId(u.user_id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedUserId(u.user_id) } }} style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }} onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#f9fafb')} onMouseLeave={e => (e.currentTarget.style.backgroundColor = '')}> - - - - - - - - ))} - -
#
{i + 1} -
- -
-
- {u.name || 'Unknown'} - {u.is_admin && } - {u.is_staff && } - {u.is_examiner && } -
-
{u.email || u.user_id}
-
-
-
-
-
-
-
- - {formatNumber(u.tokens_total)} - -
-
{u.workflows_run}{u.conversations}{formatDate(u.last_active)}
- )} -
-
- ) -} - // ────────────────────────────────────────── // Teams Tab + Drill-Down // ────────────────────────────────────────── From cc73e984259566bb55619ca25b4ac316dad4a50b Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:46:32 -0600 Subject: [PATCH 11/18] refactor(admin): extract TeamsTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/TeamsTab.tsx | 755 +++++++++++++++++++++ frontend/src/pages/Admin.tsx | 720 +------------------- 2 files changed, 761 insertions(+), 714 deletions(-) create mode 100644 frontend/src/components/admin/TeamsTab.tsx diff --git a/frontend/src/components/admin/TeamsTab.tsx b/frontend/src/components/admin/TeamsTab.tsx new file mode 100644 index 00000000..94c63729 --- /dev/null +++ b/frontend/src/components/admin/TeamsTab.tsx @@ -0,0 +1,755 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + ArrowLeft, Building2, CheckCircle2, ChevronDown, ChevronUp, Cpu, FileText, MessageSquare, Plus, Users, XCircle, +} from 'lucide-react' +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' + +import { useConfirm } from '../shared/useConfirm' +import { getTeamMembers } from '../../api/teams' +import { + getTeamLeaderboard, + getTeamDetail, + getSystemConfig, updateSystemConfig, + adminListAllTeams, adminCreateTeam, adminAddUserToTeam, adminRemoveUserFromTeam, getIsolatedUsers, + type TeamLeaderboardItem, + type TeamDetailResponse, + type AdminTeamItem, type IsolatedUserItem, +} from '../../api/admin' +import { formatNumber, formatDuration } from './shared/format' +import { + StatusBadge, RoleBadge, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, type DayOption, +} from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDate(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function formatDateTime(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +type TeamSortKey = 'name' | 'tokens_total' | 'workflows_completed' | 'active_users' | 'member_count' | 'avg_latency_ms' + +function TeamDrillDown({ teamId, onBack }: { teamId: string; onBack: () => void }) { + const [data, setData] = useState(null) + const [days, setDays] = useState(30) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + setError(null) + getTeamDetail(teamId, days).then(setData).catch(e => setError(e?.message || 'Failed to load')).finally(() => setLoading(false)) + }, [teamId, days]) + + useEffect(() => { load() }, [load]) + + const prev = data?.previous_period + const maxMemberTokens = data?.members.length ? Math.max(...data.members.map(m => m.tokens_total), 1) : 1 + + if (loading && !data) return
Loading team details...
+ if (error) return ( +
+ +
Error: {error}
+
+ ) + if (!data) return null + + return ( +
+ {/* Back + header */} +
+ +
+
+
+ +
+ {data.name} +
+ + {/* Time range */} +
+ setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> +
+ { + const dayRows = data.timeseries.map(d => [ + d.date, d.conversations, d.search_runs, d.workflows_started, + d.workflows_completed, d.workflows_failed, d.tokens_in, d.tokens_out, d.active_users, + ]) + const memberRows = data.members.map(m => [ + m.name || m.user_id, m.email || '', m.role, + m.tokens_total, m.workflows_run, m.conversations, m.last_active, + ]) + downloadCSV( + `team-${data.name}-${days}d.csv`, + ['Section', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], + [ + ['SUMMARY', '', '', '', '', '', '', '', ''], + ['Conversations', data.conversations, '', '', '', '', '', '', ''], + ['Workflows Started', data.workflows_started, '', '', '', '', '', '', ''], + ['Workflows Completed', data.workflows_completed, '', '', '', '', '', '', ''], + ['Workflows Failed', data.workflows_failed, '', '', '', '', '', '', ''], + ['Tokens In', data.tokens_in, '', '', '', '', '', '', ''], + ['Tokens Out', data.tokens_out, '', '', '', '', '', '', ''], + ['Active Users', data.active_users, '', '', '', '', '', '', ''], + ['Documents', data.document_count, '', '', '', '', '', '', ''], + ['', '', '', '', '', '', '', '', ''], + ['DAILY', 'Date', 'Conversations', 'Searches', 'WF Started', 'WF Completed', 'WF Failed', 'Tokens In', 'Tokens Out'], + ...dayRows.map(r => ['', ...r]), + ['', '', '', '', '', '', '', '', ''], + ['MEMBERS', 'Name', 'Email', 'Role', 'Tokens', 'Workflows', 'Conversations', 'Last Active', ''], + ...memberRows.map(r => ['', ...r, '']), + ], + ) + }} /> +
+ + {/* KPI Grid */} +
+ + + + + + +
+ + {/* Daily Activity Chart */} + {data.timeseries.length > 0 && ( +
+
Daily Activity
+ + + + v.slice(5)} /> + + + + + + + +
+ )} + + {/* Members Table */} + {data.members.length > 0 && ( +
+
+ Members ({data.members.length}) +
+ + + + + + + + + + + + + {data.members.map(m => ( + + + + + + + + + ))} + +
MemberRoleToken UsageWorkflowsChatsLast Active
+
+ +
+
{m.name || 'Unknown'}
+
{m.email || m.user_id}
+
+
+
+
+
+
+
+ + {formatNumber(m.tokens_total)} + +
+
{m.workflows_run}{m.conversations}{formatDate(m.last_active)}
+
+ )} + + {/* Recent Workflows */} + {data.recent_workflows.length > 0 && ( +
+
+ Recent Workflows +
+ + + + + + + + + + + + + {data.recent_workflows.map(ev => ( + + + + + + + + + ))} + +
StatusWorkflowUserDurationTokensStarted
{ev.title || '-'}{ev.user_name || ev.user_id}{formatDuration(ev.duration_ms)}{formatNumber(ev.tokens_in + ev.tokens_out)}{formatDateTime(ev.started_at)}
+
+ )} +
+ ) +} + +export function TeamsTab() { + const confirm = useConfirm() + const [subTab, setSubTab] = useState<'manage' | 'stats' | 'isolated'>('manage') + + // ── Manage sub-tab state ────────────────────────────────────────────────── + const [allTeams, setAllTeams] = useState([]) + const [loadingAll, setLoadingAll] = useState(true) + const [newTeamName, setNewTeamName] = useState('') + const [creating, setCreating] = useState(false) + const [expandedTeamUuid, setExpandedTeamUuid] = useState(null) + const [teamMembers, setTeamMembers] = useState>({}) + const [addUserInputs, setAddUserInputs] = useState>({}) + const [addUserLoading, setAddUserLoading] = useState>({}) + const [defaultTeamUuid, setDefaultTeamUuid] = useState('') + const [settingDefault, setSettingDefault] = useState(false) + + // ── Stats sub-tab state ─────────────────────────────────────────────────── + const [statsTeams, setStatsTeams] = useState([]) + const [loadingStats, setLoadingStats] = useState(false) + const [search, setSearch] = useState('') + const [sort, setSort] = useState<{ key: TeamSortKey; dir: 'asc' | 'desc' }>({ key: 'tokens_total', dir: 'desc' }) + const [selectedTeamId, setSelectedTeamId] = useState(null) + const [statsDays, setStatsDays] = useState('all') + + // ── Isolated sub-tab state ─────────────────────────────────────────────── + const [isolated, setIsolated] = useState([]) + const [isolatedLoaded, setIsolatedLoaded] = useState(false) + const [loadingIsolated, setLoadingIsolated] = useState(false) + const [assignTargets, setAssignTargets] = useState>({}) + const [assignLoading, setAssignLoading] = useState>({}) + + // Per-team add-user error messages + const [addUserErrors, setAddUserErrors] = useState>({}) + + const refreshAllTeams = useCallback(() => { + setLoadingAll(true) + adminListAllTeams().then(t => { + setAllTeams(t) + const def = t.find(x => x.is_default) + if (def) setDefaultTeamUuid(def.uuid) + }).catch(() => setAllTeams([])).finally(() => setLoadingAll(false)) + }, []) + + const refreshIsolated = useCallback(() => { + setLoadingIsolated(true) + getIsolatedUsers().then(users => { + setIsolated(users) + setIsolatedLoaded(true) + }).catch(() => setIsolatedLoaded(true)).finally(() => setLoadingIsolated(false)) + }, []) + + useEffect(() => { + refreshAllTeams() + refreshIsolated() // Load eagerly so badge shows immediately + getSystemConfig().then(cfg => { + if (cfg.default_team_id) setDefaultTeamUuid(cfg.default_team_id) + }).catch(() => {}) + }, [refreshAllTeams, refreshIsolated]) + + const refreshStats = useCallback(() => { + setLoadingStats(true) + const arg = typeof statsDays === 'number' ? statsDays : undefined + getTeamLeaderboard(arg).then(setStatsTeams).catch(() => setStatsTeams([])).finally(() => setLoadingStats(false)) + }, [statsDays]) + + useEffect(() => { + if (subTab === 'stats') { + refreshStats() + } + }, [subTab, refreshStats]) + + const handleCreateTeam = async () => { + if (!newTeamName.trim()) return + setCreating(true) + try { + await adminCreateTeam(newTeamName.trim()) + setNewTeamName('') + refreshAllTeams() + } finally { + setCreating(false) + } + } + + const handleSetDefault = async (teamUuid: string) => { + setSettingDefault(true) + try { + await updateSystemConfig({ default_team_id: teamUuid === defaultTeamUuid ? '' : teamUuid }) + setDefaultTeamUuid(teamUuid === defaultTeamUuid ? '' : teamUuid) + refreshAllTeams() + } finally { + setSettingDefault(false) + } + } + + const handleExpandTeam = async (teamUuid: string) => { + if (expandedTeamUuid === teamUuid) { + setExpandedTeamUuid(null) + return + } + setExpandedTeamUuid(teamUuid) + if (!teamMembers[teamUuid]) { + const members = await getTeamMembers(teamUuid) + setTeamMembers(prev => ({ ...prev, [teamUuid]: members })) + } + } + + const handleAddUser = async (teamUuid: string) => { + const userId = (addUserInputs[teamUuid] || '').trim() + if (!userId) return + setAddUserErrors(prev => ({ ...prev, [teamUuid]: '' })) + setAddUserLoading(prev => ({ ...prev, [teamUuid]: true })) + try { + await adminAddUserToTeam(teamUuid, userId) + setAddUserInputs(prev => ({ ...prev, [teamUuid]: '' })) + const members = await getTeamMembers(teamUuid) + setTeamMembers(prev => ({ ...prev, [teamUuid]: members })) + refreshAllTeams() + refreshIsolated() + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'User not found' + setAddUserErrors(prev => ({ ...prev, [teamUuid]: msg })) + } finally { + setAddUserLoading(prev => ({ ...prev, [teamUuid]: false })) + } + } + + const handleRemoveUser = async (teamUuid: string, userId: string, userName: string) => { + const ok = await confirm({ + title: 'Remove user from team?', + message: ( + <> + Are you sure you want to remove {userName} from this team? They will lose access to the team's content. + + ), + confirmLabel: 'Remove', + destructive: true, + }) + if (!ok) return + await adminRemoveUserFromTeam(teamUuid, userId) + const members = await getTeamMembers(teamUuid) + setTeamMembers(prev => ({ ...prev, [teamUuid]: members })) + refreshAllTeams() + refreshIsolated() + } + + const handleAssignIsolated = async (userId: string) => { + const teamUuid = assignTargets[userId] + if (!teamUuid) return + setAssignLoading(prev => ({ ...prev, [userId]: true })) + try { + await adminAddUserToTeam(teamUuid, userId) + setIsolated(prev => prev.filter(u => u.user_id !== userId)) + } catch { + // assignment failed — leave user in list + } finally { + setAssignLoading(prev => ({ ...prev, [userId]: false })) + } + } + + // Stats tab helpers + const handleSort = (key: string) => { + setSort(prev => ({ key: key as TeamSortKey, dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc' })) + } + const filteredStats = useMemo(() => { + let list = statsTeams + if (search.trim()) { + const q = search.toLowerCase() + list = list.filter(t => t.name.toLowerCase().includes(q)) + } + return [...list].sort((a, b) => { + let cmp = 0 + switch (sort.key) { + case 'name': cmp = a.name.localeCompare(b.name); break + case 'tokens_total': cmp = a.tokens_total - b.tokens_total; break + case 'workflows_completed': cmp = a.workflows_completed - b.workflows_completed; break + case 'active_users': cmp = a.active_users - b.active_users; break + case 'member_count': cmp = a.member_count - b.member_count; break + case 'avg_latency_ms': cmp = (a.avg_latency_ms || 0) - (b.avg_latency_ms || 0); break + } + return sort.dir === 'asc' ? cmp : -cmp + }) + }, [statsTeams, search, sort]) + const maxTokens = statsTeams.length > 0 ? Math.max(...statsTeams.map(t => t.tokens_total), 1) : 1 + + if (selectedTeamId) { + return setSelectedTeamId(null)} /> + } + + const subTabStyle = (key: string) => ({ + padding: '6px 14px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer', border: 'none', + background: subTab === key ? 'var(--highlight-color, #eab308)' : 'transparent', + color: subTab === key ? '#000' : '#6b7280', + fontFamily: 'inherit', + } as React.CSSProperties) + + return ( +
+ {/* Sub-tab bar */} +
{ + const keys: Array<'manage' | 'stats' | 'isolated'> = ['manage', 'stats', 'isolated'] + const idx = keys.indexOf(subTab) + if (idx < 0) return + let next = idx + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % keys.length + else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + keys.length) % keys.length + else if (e.key === 'Home') next = 0 + else if (e.key === 'End') next = keys.length - 1 + else return + e.preventDefault() + setSubTab(keys[next]) + const btns = e.currentTarget.querySelectorAll('[role="tab"]') + btns[next]?.focus() + }} + style={{ display: 'flex', alignItems: 'center', gap: 4, background: '#f9fafb', borderRadius: 10, padding: 4, width: 'fit-content' }} + > + + + +
+ + {/* ── Manage Teams ─────────────────────────────────────────── */} + {subTab === 'manage' && ( +
+ {/* Create team */} +
+
Create New Team
+
+ setNewTeamName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateTeam()} + placeholder="Team name (e.g. Research Administration)" + style={{ flex: 1, padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, fontFamily: 'inherit' }} + /> + +
+
+ + {/* Teams list */} +
+
+ All Teams ({allTeams.length}) + + Click a team to manage its members. Star to set as the default for new users. + +
+ {loadingAll ? ( +
Loading...
+ ) : allTeams.length === 0 ? ( +
No teams yet.
+ ) : allTeams.map(team => ( +
+ {/* Team row */} +
handleExpandTeam(team.uuid)} + onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#fafafa')} + onMouseLeave={e => (e.currentTarget.style.backgroundColor = '')} + > +
+ +
+
+
+ {team.name} + {team.is_default && ( + + Default + + )} +
+
+ {team.member_count} member{team.member_count !== 1 ? 's' : ''} +
+
+ + {expandedTeamUuid === team.uuid ? : } +
+ + {/* Expanded member panel */} + {expandedTeamUuid === team.uuid && ( +
+ {/* Add user */} +
+
+ { + setAddUserInputs(prev => ({ ...prev, [team.uuid]: e.target.value })) + setAddUserErrors(prev => ({ ...prev, [team.uuid]: '' })) + }} + onKeyDown={e => e.key === 'Enter' && handleAddUser(team.uuid)} + placeholder="User ID or email address..." + style={{ + flex: 1, padding: '6px 10px', fontSize: 13, fontFamily: 'inherit', + border: `1px solid ${addUserErrors[team.uuid] ? '#fca5a5' : '#d1d5db'}`, + borderRadius: 6, + }} + /> + +
+ {addUserErrors[team.uuid] && ( +
{addUserErrors[team.uuid]}
+ )} +
+ + {/* Members list */} + {(teamMembers[team.uuid] || []).length === 0 ? ( +
No members yet.
+ ) : ( +
+ {(teamMembers[team.uuid] || []).map(m => ( +
+
+ {m.name || m.user_id} + {m.email && {m.email}} +
+ + {m.role} + + {m.role !== 'owner' && ( + + )} +
+ ))} +
+ )} +
+ )} +
+ ))} +
+
+ )} + + {/* ── Usage Stats ──────────────────────────────────────────── */} + {subTab === 'stats' && ( +
+
+ +
+
+ +
+ downloadCSV( + `teams-${statsDays === 'all' ? 'all' : statsDays + 'd'}.csv`, + ['Team', 'Tokens', 'Workflows', 'Active Users', 'Members', 'Avg Latency (ms)'], + filteredStats.map(t => [t.name, t.tokens_total, t.workflows_completed, t.active_users, t.member_count, t.avg_latency_ms]) + )} /> +
+
+
+ Team Leaderboard ({filteredStats.length}) {statsDays !== 'all' && · last {statsDays} days} +
+ {loadingStats ? ( +
Loading...
+ ) : filteredStats.length === 0 ? ( +
No teams found.
+ ) : ( + + + + + + + + + + + + + {filteredStats.map((t) => ( + setSelectedTeamId(t.team_id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTeamId(t.team_id) } }} style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }} onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#f9fafb')} onMouseLeave={e => (e.currentTarget.style.backgroundColor = '')}> + + + + + + + + ))} + +
+
+
+ +
+
{t.name}
+
+
+
+
+
+
+ {formatNumber(t.tokens_total)} +
+
{t.workflows_completed}{t.active_users}{t.member_count}{formatDuration(t.avg_latency_ms)}
+ )} +
+
+ )} + + {/* ── Isolated Users ───────────────────────────────────────── */} + {subTab === 'isolated' && ( +
+
+ Isolated Users (only on their personal team) ({isolated.length}) +
+ {loadingIsolated && !isolatedLoaded ? ( +
Loading...
+ ) : isolated.length === 0 ? ( +
+ No isolated users. Everyone is on at least one shared team. +
+ ) : isolated.map(u => ( +
+
+
{u.name || u.user_id}
+ {u.email &&
{u.email}
} +
+ + +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 78ad0a3f..f5036f94 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -5,7 +5,7 @@ import { ChevronRight, RefreshCw, MessageSquare, Zap, CheckCircle2, XCircle, Download, ChevronDown, ChevronUp, Play, AlertCircle, - ArrowLeft, FileText, FolderTree, X, + FileText, FolderTree, X, Mail, Send, Link, UserPlus, Star, Award, KeyRound, PackageOpen, BookOpen, } from 'lucide-react' @@ -22,15 +22,12 @@ import type { ThemeConfig } from '../api/config' import { useBranding, DEFAULT_ORG_NAME, DEFAULT_ICON_URL } from '../contexts/BrandingContext' import { useToast } from '../contexts/ToastContext' import { - getTeamLeaderboard, - getTeamDetail, getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, - adminListAllTeams, adminCreateTeam, adminAddUserToTeam, adminRemoveUserFromTeam, getIsolatedUsers, + adminListAllTeams, getEmailAnalytics, } from '../api/admin' -import { getTeamMembers } from '../api/teams' import * as orgApi from '../api/organizations' import type { Organization, OrgMember, OrgTeam } from '../api/organizations' import { @@ -48,10 +45,8 @@ import { POST_SURVEY_FIELDS } from '../components/survey/postSurveyFields' import { PRE_SURVEY_FIELDS } from './Demo' import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { - TeamLeaderboardItem, - TeamDetailResponse, SystemConfigData, - AdminTeamItem, IsolatedUserItem, + AdminTeamItem, EmailAnalyticsResponse, } from '../api/admin' import { fileToConstrainedDataUrl } from '../utils/imageResize' @@ -63,6 +58,7 @@ import { CatalogUpdateBanner } from '../components/admin/CatalogUpdateBanner' import { CatalogTab } from '../components/admin/CatalogTab' import { ApiKeysTab } from '../components/admin/ApiKeysTab' import { ComplianceTab } from '../components/admin/ComplianceTab' +import { TeamsTab } from '../components/admin/TeamsTab' import { KnowledgeBasesTab } from '../components/admin/KnowledgeBasesTab' import { AuditTab } from '../components/admin/AuditTab' import { UsersTab } from '../components/admin/UsersTab' @@ -73,9 +69,9 @@ import { CertificationsTab } from '../components/admin/CertificationsTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' -import { formatNumber, formatDuration } from '../components/admin/shared/format' +import { formatNumber } from '../components/admin/shared/format' import { - StatusBadge, RoleBadge, KpiCard, UserAvatar, SortableHeader, SearchInput, ExportButton, TimeRangeSelector, type DayOption, + KpiCard, SearchInput, ExportButton, TimeRangeSelector, } from '../components/admin/shared/primitives' function applyThemeToDOM(theme: ThemeConfig) { @@ -139,710 +135,6 @@ function downloadCSV(filename: string, headers: string[], rows: (string | number URL.revokeObjectURL(url) } -// ────────────────────────────────────────── -// Teams Tab + Drill-Down -// ────────────────────────────────────────── - -type TeamSortKey = 'name' | 'tokens_total' | 'workflows_completed' | 'active_users' | 'member_count' | 'avg_latency_ms' - -function TeamDrillDown({ teamId, onBack }: { teamId: string; onBack: () => void }) { - const [data, setData] = useState(null) - const [days, setDays] = useState(30) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const load = useCallback(() => { - setLoading(true) - setError(null) - getTeamDetail(teamId, days).then(setData).catch(e => setError(e?.message || 'Failed to load')).finally(() => setLoading(false)) - }, [teamId, days]) - - useEffect(() => { load() }, [load]) - - const prev = data?.previous_period - const maxMemberTokens = data?.members.length ? Math.max(...data.members.map(m => m.tokens_total), 1) : 1 - - if (loading && !data) return
Loading team details...
- if (error) return ( -
- -
Error: {error}
-
- ) - if (!data) return null - - return ( -
- {/* Back + header */} -
- -
-
-
- -
- {data.name} -
- - {/* Time range */} -
- setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> -
- { - const dayRows = data.timeseries.map(d => [ - d.date, d.conversations, d.search_runs, d.workflows_started, - d.workflows_completed, d.workflows_failed, d.tokens_in, d.tokens_out, d.active_users, - ]) - const memberRows = data.members.map(m => [ - m.name || m.user_id, m.email || '', m.role, - m.tokens_total, m.workflows_run, m.conversations, m.last_active, - ]) - downloadCSV( - `team-${data.name}-${days}d.csv`, - ['Section', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], - [ - ['SUMMARY', '', '', '', '', '', '', '', ''], - ['Conversations', data.conversations, '', '', '', '', '', '', ''], - ['Workflows Started', data.workflows_started, '', '', '', '', '', '', ''], - ['Workflows Completed', data.workflows_completed, '', '', '', '', '', '', ''], - ['Workflows Failed', data.workflows_failed, '', '', '', '', '', '', ''], - ['Tokens In', data.tokens_in, '', '', '', '', '', '', ''], - ['Tokens Out', data.tokens_out, '', '', '', '', '', '', ''], - ['Active Users', data.active_users, '', '', '', '', '', '', ''], - ['Documents', data.document_count, '', '', '', '', '', '', ''], - ['', '', '', '', '', '', '', '', ''], - ['DAILY', 'Date', 'Conversations', 'Searches', 'WF Started', 'WF Completed', 'WF Failed', 'Tokens In', 'Tokens Out'], - ...dayRows.map(r => ['', ...r]), - ['', '', '', '', '', '', '', '', ''], - ['MEMBERS', 'Name', 'Email', 'Role', 'Tokens', 'Workflows', 'Conversations', 'Last Active', ''], - ...memberRows.map(r => ['', ...r, '']), - ], - ) - }} /> -
- - {/* KPI Grid */} -
- - - - - - -
- - {/* Daily Activity Chart */} - {data.timeseries.length > 0 && ( -
-
Daily Activity
- - - - v.slice(5)} /> - - - - - - - -
- )} - - {/* Members Table */} - {data.members.length > 0 && ( -
-
- Members ({data.members.length}) -
- - - - - - - - - - - - - {data.members.map(m => ( - - - - - - - - - ))} - -
MemberRoleToken UsageWorkflowsChatsLast Active
-
- -
-
{m.name || 'Unknown'}
-
{m.email || m.user_id}
-
-
-
-
-
-
-
- - {formatNumber(m.tokens_total)} - -
-
{m.workflows_run}{m.conversations}{formatDate(m.last_active)}
-
- )} - - {/* Recent Workflows */} - {data.recent_workflows.length > 0 && ( -
-
- Recent Workflows -
- - - - - - - - - - - - - {data.recent_workflows.map(ev => ( - - - - - - - - - ))} - -
StatusWorkflowUserDurationTokensStarted
{ev.title || '-'}{ev.user_name || ev.user_id}{formatDuration(ev.duration_ms)}{formatNumber(ev.tokens_in + ev.tokens_out)}{formatDateTime(ev.started_at)}
-
- )} -
- ) -} - -function TeamsTab() { - const confirm = useConfirm() - const [subTab, setSubTab] = useState<'manage' | 'stats' | 'isolated'>('manage') - - // ── Manage sub-tab state ────────────────────────────────────────────────── - const [allTeams, setAllTeams] = useState([]) - const [loadingAll, setLoadingAll] = useState(true) - const [newTeamName, setNewTeamName] = useState('') - const [creating, setCreating] = useState(false) - const [expandedTeamUuid, setExpandedTeamUuid] = useState(null) - const [teamMembers, setTeamMembers] = useState>({}) - const [addUserInputs, setAddUserInputs] = useState>({}) - const [addUserLoading, setAddUserLoading] = useState>({}) - const [defaultTeamUuid, setDefaultTeamUuid] = useState('') - const [settingDefault, setSettingDefault] = useState(false) - - // ── Stats sub-tab state ─────────────────────────────────────────────────── - const [statsTeams, setStatsTeams] = useState([]) - const [loadingStats, setLoadingStats] = useState(false) - const [search, setSearch] = useState('') - const [sort, setSort] = useState<{ key: TeamSortKey; dir: 'asc' | 'desc' }>({ key: 'tokens_total', dir: 'desc' }) - const [selectedTeamId, setSelectedTeamId] = useState(null) - const [statsDays, setStatsDays] = useState('all') - - // ── Isolated sub-tab state ─────────────────────────────────────────────── - const [isolated, setIsolated] = useState([]) - const [isolatedLoaded, setIsolatedLoaded] = useState(false) - const [loadingIsolated, setLoadingIsolated] = useState(false) - const [assignTargets, setAssignTargets] = useState>({}) - const [assignLoading, setAssignLoading] = useState>({}) - - // Per-team add-user error messages - const [addUserErrors, setAddUserErrors] = useState>({}) - - const refreshAllTeams = useCallback(() => { - setLoadingAll(true) - adminListAllTeams().then(t => { - setAllTeams(t) - const def = t.find(x => x.is_default) - if (def) setDefaultTeamUuid(def.uuid) - }).catch(() => setAllTeams([])).finally(() => setLoadingAll(false)) - }, []) - - const refreshIsolated = useCallback(() => { - setLoadingIsolated(true) - getIsolatedUsers().then(users => { - setIsolated(users) - setIsolatedLoaded(true) - }).catch(() => setIsolatedLoaded(true)).finally(() => setLoadingIsolated(false)) - }, []) - - useEffect(() => { - refreshAllTeams() - refreshIsolated() // Load eagerly so badge shows immediately - getSystemConfig().then(cfg => { - if (cfg.default_team_id) setDefaultTeamUuid(cfg.default_team_id) - }).catch(() => {}) - }, [refreshAllTeams, refreshIsolated]) - - const refreshStats = useCallback(() => { - setLoadingStats(true) - const arg = typeof statsDays === 'number' ? statsDays : undefined - getTeamLeaderboard(arg).then(setStatsTeams).catch(() => setStatsTeams([])).finally(() => setLoadingStats(false)) - }, [statsDays]) - - useEffect(() => { - if (subTab === 'stats') { - refreshStats() - } - }, [subTab, refreshStats]) - - const handleCreateTeam = async () => { - if (!newTeamName.trim()) return - setCreating(true) - try { - await adminCreateTeam(newTeamName.trim()) - setNewTeamName('') - refreshAllTeams() - } finally { - setCreating(false) - } - } - - const handleSetDefault = async (teamUuid: string) => { - setSettingDefault(true) - try { - await updateSystemConfig({ default_team_id: teamUuid === defaultTeamUuid ? '' : teamUuid }) - setDefaultTeamUuid(teamUuid === defaultTeamUuid ? '' : teamUuid) - refreshAllTeams() - } finally { - setSettingDefault(false) - } - } - - const handleExpandTeam = async (teamUuid: string) => { - if (expandedTeamUuid === teamUuid) { - setExpandedTeamUuid(null) - return - } - setExpandedTeamUuid(teamUuid) - if (!teamMembers[teamUuid]) { - const members = await getTeamMembers(teamUuid) - setTeamMembers(prev => ({ ...prev, [teamUuid]: members })) - } - } - - const handleAddUser = async (teamUuid: string) => { - const userId = (addUserInputs[teamUuid] || '').trim() - if (!userId) return - setAddUserErrors(prev => ({ ...prev, [teamUuid]: '' })) - setAddUserLoading(prev => ({ ...prev, [teamUuid]: true })) - try { - await adminAddUserToTeam(teamUuid, userId) - setAddUserInputs(prev => ({ ...prev, [teamUuid]: '' })) - const members = await getTeamMembers(teamUuid) - setTeamMembers(prev => ({ ...prev, [teamUuid]: members })) - refreshAllTeams() - refreshIsolated() - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'User not found' - setAddUserErrors(prev => ({ ...prev, [teamUuid]: msg })) - } finally { - setAddUserLoading(prev => ({ ...prev, [teamUuid]: false })) - } - } - - const handleRemoveUser = async (teamUuid: string, userId: string, userName: string) => { - const ok = await confirm({ - title: 'Remove user from team?', - message: ( - <> - Are you sure you want to remove {userName} from this team? They will lose access to the team's content. - - ), - confirmLabel: 'Remove', - destructive: true, - }) - if (!ok) return - await adminRemoveUserFromTeam(teamUuid, userId) - const members = await getTeamMembers(teamUuid) - setTeamMembers(prev => ({ ...prev, [teamUuid]: members })) - refreshAllTeams() - refreshIsolated() - } - - const handleAssignIsolated = async (userId: string) => { - const teamUuid = assignTargets[userId] - if (!teamUuid) return - setAssignLoading(prev => ({ ...prev, [userId]: true })) - try { - await adminAddUserToTeam(teamUuid, userId) - setIsolated(prev => prev.filter(u => u.user_id !== userId)) - } catch { - // assignment failed — leave user in list - } finally { - setAssignLoading(prev => ({ ...prev, [userId]: false })) - } - } - - // Stats tab helpers - const handleSort = (key: string) => { - setSort(prev => ({ key: key as TeamSortKey, dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc' })) - } - const filteredStats = useMemo(() => { - let list = statsTeams - if (search.trim()) { - const q = search.toLowerCase() - list = list.filter(t => t.name.toLowerCase().includes(q)) - } - return [...list].sort((a, b) => { - let cmp = 0 - switch (sort.key) { - case 'name': cmp = a.name.localeCompare(b.name); break - case 'tokens_total': cmp = a.tokens_total - b.tokens_total; break - case 'workflows_completed': cmp = a.workflows_completed - b.workflows_completed; break - case 'active_users': cmp = a.active_users - b.active_users; break - case 'member_count': cmp = a.member_count - b.member_count; break - case 'avg_latency_ms': cmp = (a.avg_latency_ms || 0) - (b.avg_latency_ms || 0); break - } - return sort.dir === 'asc' ? cmp : -cmp - }) - }, [statsTeams, search, sort]) - const maxTokens = statsTeams.length > 0 ? Math.max(...statsTeams.map(t => t.tokens_total), 1) : 1 - - if (selectedTeamId) { - return setSelectedTeamId(null)} /> - } - - const subTabStyle = (key: string) => ({ - padding: '6px 14px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer', border: 'none', - background: subTab === key ? 'var(--highlight-color, #eab308)' : 'transparent', - color: subTab === key ? '#000' : '#6b7280', - fontFamily: 'inherit', - } as React.CSSProperties) - - return ( -
- {/* Sub-tab bar */} -
{ - const keys: Array<'manage' | 'stats' | 'isolated'> = ['manage', 'stats', 'isolated'] - const idx = keys.indexOf(subTab) - if (idx < 0) return - let next = idx - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % keys.length - else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + keys.length) % keys.length - else if (e.key === 'Home') next = 0 - else if (e.key === 'End') next = keys.length - 1 - else return - e.preventDefault() - setSubTab(keys[next]) - const btns = e.currentTarget.querySelectorAll('[role="tab"]') - btns[next]?.focus() - }} - style={{ display: 'flex', alignItems: 'center', gap: 4, background: '#f9fafb', borderRadius: 10, padding: 4, width: 'fit-content' }} - > - - - -
- - {/* ── Manage Teams ─────────────────────────────────────────── */} - {subTab === 'manage' && ( -
- {/* Create team */} -
-
Create New Team
-
- setNewTeamName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCreateTeam()} - placeholder="Team name (e.g. Research Administration)" - style={{ flex: 1, padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, fontFamily: 'inherit' }} - /> - -
-
- - {/* Teams list */} -
-
- All Teams ({allTeams.length}) - - Click a team to manage its members. Star to set as the default for new users. - -
- {loadingAll ? ( -
Loading...
- ) : allTeams.length === 0 ? ( -
No teams yet.
- ) : allTeams.map(team => ( -
- {/* Team row */} -
handleExpandTeam(team.uuid)} - onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#fafafa')} - onMouseLeave={e => (e.currentTarget.style.backgroundColor = '')} - > -
- -
-
-
- {team.name} - {team.is_default && ( - - Default - - )} -
-
- {team.member_count} member{team.member_count !== 1 ? 's' : ''} -
-
- - {expandedTeamUuid === team.uuid ? : } -
- - {/* Expanded member panel */} - {expandedTeamUuid === team.uuid && ( -
- {/* Add user */} -
-
- { - setAddUserInputs(prev => ({ ...prev, [team.uuid]: e.target.value })) - setAddUserErrors(prev => ({ ...prev, [team.uuid]: '' })) - }} - onKeyDown={e => e.key === 'Enter' && handleAddUser(team.uuid)} - placeholder="User ID or email address..." - style={{ - flex: 1, padding: '6px 10px', fontSize: 13, fontFamily: 'inherit', - border: `1px solid ${addUserErrors[team.uuid] ? '#fca5a5' : '#d1d5db'}`, - borderRadius: 6, - }} - /> - -
- {addUserErrors[team.uuid] && ( -
{addUserErrors[team.uuid]}
- )} -
- - {/* Members list */} - {(teamMembers[team.uuid] || []).length === 0 ? ( -
No members yet.
- ) : ( -
- {(teamMembers[team.uuid] || []).map(m => ( -
-
- {m.name || m.user_id} - {m.email && {m.email}} -
- - {m.role} - - {m.role !== 'owner' && ( - - )} -
- ))} -
- )} -
- )} -
- ))} -
-
- )} - - {/* ── Usage Stats ──────────────────────────────────────────── */} - {subTab === 'stats' && ( -
-
- -
-
- -
- downloadCSV( - `teams-${statsDays === 'all' ? 'all' : statsDays + 'd'}.csv`, - ['Team', 'Tokens', 'Workflows', 'Active Users', 'Members', 'Avg Latency (ms)'], - filteredStats.map(t => [t.name, t.tokens_total, t.workflows_completed, t.active_users, t.member_count, t.avg_latency_ms]) - )} /> -
-
-
- Team Leaderboard ({filteredStats.length}) {statsDays !== 'all' && · last {statsDays} days} -
- {loadingStats ? ( -
Loading...
- ) : filteredStats.length === 0 ? ( -
No teams found.
- ) : ( - - - - - - - - - - - - - {filteredStats.map((t) => ( - setSelectedTeamId(t.team_id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTeamId(t.team_id) } }} style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }} onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#f9fafb')} onMouseLeave={e => (e.currentTarget.style.backgroundColor = '')}> - - - - - - - - ))} - -
-
-
- -
-
{t.name}
-
-
-
-
-
-
- {formatNumber(t.tokens_total)} -
-
{t.workflows_completed}{t.active_users}{t.member_count}{formatDuration(t.avg_latency_ms)}
- )} -
-
- )} - - {/* ── Isolated Users ───────────────────────────────────────── */} - {subTab === 'isolated' && ( -
-
- Isolated Users (only on their personal team) ({isolated.length}) -
- {loadingIsolated && !isolatedLoaded ? ( -
Loading...
- ) : isolated.length === 0 ? ( -
- No isolated users. Everyone is on at least one shared team. -
- ) : isolated.map(u => ( -
-
-
{u.name || u.user_id}
- {u.email &&
{u.email}
} -
- - -
- ))} -
- )} -
- ) -} - // ────────────────────────────────────────── // Model connectivity diagnostics // ────────────────────────────────────────── From 25a5bc9e3d7690106b84ef3ab914eb089ff50ab5 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:53:51 -0600 Subject: [PATCH 12/18] refactor(admin): extract EmailAnalyticsTab into its own file Co-Authored-By: Claude Fable 5 --- .../components/admin/EmailAnalyticsTab.tsx | 204 ++++++++++++++++++ frontend/src/pages/Admin.tsx | 180 +--------------- 2 files changed, 206 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/admin/EmailAnalyticsTab.tsx diff --git a/frontend/src/components/admin/EmailAnalyticsTab.tsx b/frontend/src/components/admin/EmailAnalyticsTab.tsx new file mode 100644 index 00000000..2fd5a5b9 --- /dev/null +++ b/frontend/src/components/admin/EmailAnalyticsTab.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Send, XCircle, CheckCircle2, AlertCircle, +} from 'lucide-react' +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + Legend, +} from 'recharts' +import { getEmailAnalytics } from '../../api/admin' +import type { EmailAnalyticsResponse } from '../../api/admin' +import { formatNumber } from './shared/format' +import { + KpiCard, ExportButton, TimeRangeSelector, +} from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDateTime(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function EmailAnalyticsTab() { + const [data, setData] = useState(null) + const [days, setDays] = useState(30) + const [loading, setLoading] = useState(true) + + const load = useCallback(() => { + setLoading(true) + getEmailAnalytics(days) + .then(d => setData(d)) + .finally(() => setLoading(false)) + }, [days]) + + useEffect(() => { load() }, [load]) + + if (loading && !data) { + return
Loading email analytics...
+ } + if (!data) return null + + const successPct = (data.success_rate * 100).toFixed(1) + const overallHealthColor = + data.total_sent + data.total_failed === 0 ? '#6b7280' + : data.success_rate >= 0.99 ? '#22c55e' + : data.success_rate >= 0.9 ? '#f59e0b' : '#ef4444' + + const handleExport = () => { + const dailyRows = data.by_day.map(p => [p.date, p.sent, p.failed]) + const typeRows = data.by_type.map(t => [t.email_type, t.sent, t.failed, (t.success_rate * 100).toFixed(2) + '%']) + const failureRows = data.recent_failures.map(f => [f.created_at, f.recipient, f.email_type, f.provider, f.subject, f.error || '']) + downloadCSV( + `email-analytics-${days}d.csv`, + ['Section', 'A', 'B', 'C', 'D', 'E'], + [ + ['SUMMARY', '', '', '', '', ''], + ['Window (days)', days, '', '', '', ''], + ['Total Sent', data.total_sent, '', '', '', ''], + ['Total Failed', data.total_failed, '', '', '', ''], + ['Success Rate', (data.success_rate * 100).toFixed(2) + '%', '', '', '', ''], + ['Providers', data.providers.join('; '), '', '', '', ''], + ['', '', '', '', '', ''], + ['DAILY', 'Date', 'Sent', 'Failed', '', ''], + ...dailyRows.map(r => ['', ...r, '', '']), + ['', '', '', '', '', ''], + ['BY TYPE', 'Type', 'Sent', 'Failed', 'Success Rate', ''], + ...typeRows.map(r => ['', ...r, '']), + ['', '', '', '', '', ''], + ['RECENT FAILURES', 'When', 'Recipient', 'Type', 'Provider', 'Error'], + ...failureRows.map(r => ['', r[0], r[1], r[2], r[3], `${r[4]}: ${r[5]}`]), + ], + ) + } + + return ( +
+
+ setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> +
+ + {data.providers.length > 0 && ( + + Provider{data.providers.length > 1 ? 's' : ''}: {data.providers.join(', ')} + + )} +
+ + {/* KPIs */} +
+ + + +
+ + {/* Daily chart */} +
+
Daily Email Volume
+ + + + v.slice(5)} /> + + + + + + + +
+ + {/* By type */} +
+
+ By Email Type +
+ {data.by_type.length === 0 ? ( +
+ No emails sent in this window. +
+ ) : ( + + + + + + + + + + + {data.by_type.map(row => { + const rate = row.success_rate * 100 + const color = row.sent + row.failed === 0 ? '#6b7280' + : row.success_rate >= 0.99 ? '#22c55e' + : row.success_rate >= 0.9 ? '#f59e0b' : '#ef4444' + return ( + + + + + + + ) + })} + +
TypeSentFailedSuccess Rate
{row.email_type}{row.sent} 0 ? '#ef4444' : '#6b7280' }}>{row.failed}{rate.toFixed(1)}%
+ )} +
+ + {/* Recent failures */} +
+
+ Recent Failures +
+ {data.recent_failures.length === 0 ? ( +
+ No failures in this window. Deliverability is healthy. +
+ ) : ( + + + + + + + + + + + {data.recent_failures.map((f, i) => ( + + + + + + + ))} + +
WhenRecipientTypeError
{formatDateTime(f.created_at)}{f.recipient}{f.email_type}{f.error || '-'}
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index f5036f94..b0104916 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -9,10 +9,6 @@ import { Mail, Send, Link, UserPlus, Star, Award, KeyRound, PackageOpen, BookOpen, } from 'lucide-react' -import { - AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - Legend, -} from 'recharts' import { PageLayout } from '../components/layout/PageLayout' import { useConfirm } from '../components/shared/useConfirm' import { useAuth } from '../hooks/useAuth' @@ -26,7 +22,6 @@ import { addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, adminListAllTeams, - getEmailAnalytics, } from '../api/admin' import * as orgApi from '../api/organizations' import type { Organization, OrgMember, OrgTeam } from '../api/organizations' @@ -47,7 +42,6 @@ import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { SystemConfigData, AdminTeamItem, - EmailAnalyticsResponse, } from '../api/admin' import { fileToConstrainedDataUrl } from '../utils/imageResize' import { ModelCharacterBars } from '../components/ModelEffortPicker' @@ -66,13 +60,11 @@ import { UsageTab } from '../components/admin/UsageTab' import { WorkflowsTab } from '../components/admin/WorkflowsTab' import { QualityTab } from '../components/admin/QualityTab' import { CertificationsTab } from '../components/admin/CertificationsTab' +import { EmailAnalyticsTab } from '../components/admin/EmailAnalyticsTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' -import { formatNumber } from '../components/admin/shared/format' -import { - KpiCard, SearchInput, ExportButton, TimeRangeSelector, -} from '../components/admin/shared/primitives' +import { SearchInput } from '../components/admin/shared/primitives' function applyThemeToDOM(theme: ThemeConfig) { const root = document.documentElement @@ -114,11 +106,6 @@ function formatDate(d: string | null): string { return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } -function formatDateTime(d: string | null): string { - if (!d) return '-' - return parseUtcDate(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) -} - function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { const escape = (v: string | number | null) => { if (v === null || v === undefined) return '' @@ -4035,169 +4022,6 @@ function TrialCheckinsSection() { ) } -function EmailAnalyticsTab() { - const [data, setData] = useState(null) - const [days, setDays] = useState(30) - const [loading, setLoading] = useState(true) - - const load = useCallback(() => { - setLoading(true) - getEmailAnalytics(days) - .then(d => setData(d)) - .finally(() => setLoading(false)) - }, [days]) - - useEffect(() => { load() }, [load]) - - if (loading && !data) { - return
Loading email analytics...
- } - if (!data) return null - - const successPct = (data.success_rate * 100).toFixed(1) - const overallHealthColor = - data.total_sent + data.total_failed === 0 ? '#6b7280' - : data.success_rate >= 0.99 ? '#22c55e' - : data.success_rate >= 0.9 ? '#f59e0b' : '#ef4444' - - const handleExport = () => { - const dailyRows = data.by_day.map(p => [p.date, p.sent, p.failed]) - const typeRows = data.by_type.map(t => [t.email_type, t.sent, t.failed, (t.success_rate * 100).toFixed(2) + '%']) - const failureRows = data.recent_failures.map(f => [f.created_at, f.recipient, f.email_type, f.provider, f.subject, f.error || '']) - downloadCSV( - `email-analytics-${days}d.csv`, - ['Section', 'A', 'B', 'C', 'D', 'E'], - [ - ['SUMMARY', '', '', '', '', ''], - ['Window (days)', days, '', '', '', ''], - ['Total Sent', data.total_sent, '', '', '', ''], - ['Total Failed', data.total_failed, '', '', '', ''], - ['Success Rate', (data.success_rate * 100).toFixed(2) + '%', '', '', '', ''], - ['Providers', data.providers.join('; '), '', '', '', ''], - ['', '', '', '', '', ''], - ['DAILY', 'Date', 'Sent', 'Failed', '', ''], - ...dailyRows.map(r => ['', ...r, '', '']), - ['', '', '', '', '', ''], - ['BY TYPE', 'Type', 'Sent', 'Failed', 'Success Rate', ''], - ...typeRows.map(r => ['', ...r, '']), - ['', '', '', '', '', ''], - ['RECENT FAILURES', 'When', 'Recipient', 'Type', 'Provider', 'Error'], - ...failureRows.map(r => ['', r[0], r[1], r[2], r[3], `${r[4]}: ${r[5]}`]), - ], - ) - } - - return ( -
-
- setDays(typeof v === 'number' ? v : 30)} onRefresh={load} /> -
- - {data.providers.length > 0 && ( - - Provider{data.providers.length > 1 ? 's' : ''}: {data.providers.join(', ')} - - )} -
- - {/* KPIs */} -
- - - -
- - {/* Daily chart */} -
-
Daily Email Volume
- - - - v.slice(5)} /> - - - - - - - -
- - {/* By type */} -
-
- By Email Type -
- {data.by_type.length === 0 ? ( -
- No emails sent in this window. -
- ) : ( - - - - - - - - - - - {data.by_type.map(row => { - const rate = row.success_rate * 100 - const color = row.sent + row.failed === 0 ? '#6b7280' - : row.success_rate >= 0.99 ? '#22c55e' - : row.success_rate >= 0.9 ? '#f59e0b' : '#ef4444' - return ( - - - - - - - ) - })} - -
TypeSentFailedSuccess Rate
{row.email_type}{row.sent} 0 ? '#ef4444' : '#6b7280' }}>{row.failed}{rate.toFixed(1)}%
- )} -
- - {/* Recent failures */} -
-
- Recent Failures -
- {data.recent_failures.length === 0 ? ( -
- No failures in this window. Deliverability is healthy. -
- ) : ( - - - - - - - - - - - {data.recent_failures.map((f, i) => ( - - - - - - - ))} - -
WhenRecipientTypeError
{formatDateTime(f.created_at)}{f.recipient}{f.email_type}{f.error || '-'}
- )} -
-
- ) -} - function SurveyResponsesSection() { const [responses, setResponses] = useState([]) const [loading, setLoading] = useState(true) From f60d7390e80b1b4f69d329419d78882677d09841 Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 10:58:55 -0600 Subject: [PATCH 13/18] refactor(admin): extract OrganizationsTab into its own file Co-Authored-By: Claude Fable 5 --- .../src/components/admin/OrganizationsTab.tsx | 555 ++++++++++++++++++ frontend/src/pages/Admin.tsx | 553 +---------------- 2 files changed, 557 insertions(+), 551 deletions(-) create mode 100644 frontend/src/components/admin/OrganizationsTab.tsx diff --git a/frontend/src/components/admin/OrganizationsTab.tsx b/frontend/src/components/admin/OrganizationsTab.tsx new file mode 100644 index 00000000..748c8151 --- /dev/null +++ b/frontend/src/components/admin/OrganizationsTab.tsx @@ -0,0 +1,555 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react' +import { + Users, Building2, Plus, Trash2, Pencil, ChevronRight, Download, AlertCircle, FolderTree, X, +} from 'lucide-react' +import { useConfirm } from '../shared/useConfirm' +import { adminListAllTeams } from '../../api/admin' +import type { AdminTeamItem } from '../../api/admin' +import * as orgApi from '../../api/organizations' +import type { Organization, OrgMember, OrgTeam } from '../../api/organizations' + +const ORG_TYPE_LABELS: Record = { + university: 'University', college: 'College', central_office: 'Central Office', + department: 'Department', unit: 'Unit', +} +const ORG_TYPE_COLORS: Record = { + university: 'bg-purple-100 text-purple-800', college: 'bg-blue-100 text-blue-800', + central_office: 'bg-amber-100 text-amber-800', department: 'bg-green-100 text-green-800', + unit: 'bg-gray-100 text-gray-800', +} +const VALID_CHILD_TYPES: Record = { + university: ['college', 'central_office'], college: ['department'], + central_office: ['department'], department: ['unit'], unit: [], +} +const DEPTH_TYPE_DEFAULTS = ['university', 'college', 'department', 'unit'] as const + +function OrgNodeRow({ + org, depth = 0, onEdit, onDelete, onAddChild, onTypeChange, onDrop, onReload, onSelect, selectedUuid, +}: { + org: Organization; depth?: number + onEdit: (o: Organization) => void; onDelete: (o: Organization) => void + onAddChild: (parentId: string, parentType: string) => void + onTypeChange: (uuid: string, newType: string) => void + onDrop: (draggedUuid: string, targetUuid: string) => void + onReload: () => void + onSelect: (o: Organization) => void + selectedUuid: string | null +}) { + const [expanded, setExpanded] = useState(depth < 2) + const [dragOver, setDragOver] = useState(false) + const hasChildren = org.children && org.children.length > 0 + const childTypes = VALID_CHILD_TYPES[org.org_type] || [] + const totalMembers = (org.user_count || 0) + (org.team_count || 0) + const isSelected = selectedUuid === org.uuid + + return ( +
+
{ e.dataTransfer.setData('text/plain', org.uuid); e.dataTransfer.effectAllowed = 'move' }} + onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onDrop={e => { e.preventDefault(); setDragOver(false); const uuid = e.dataTransfer.getData('text/plain'); if (uuid && uuid !== org.uuid) onDrop(uuid, org.uuid) }} + > + + + + {totalMembers > 0 && ( + + )} +
+ {childTypes.length > 0 && ( + + )} + + +
+
+ {expanded && hasChildren && org.children!.map(child => ( + + ))} +
+ ) +} + +function OrgMemberPanel({ org, onClose, onReload }: { org: Organization; onClose: () => void; onReload: () => void }) { + const [members, setMembers] = useState<{ users: OrgMember[]; teams: OrgTeam[] } | null>(null) + const [loading, setLoading] = useState(true) + const [searchQ, setSearchQ] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searching, setSearching] = useState(false) + const [teams, setTeams] = useState<{ uuid: string; name: string }[]>([]) + + const loadMembers = useCallback(async () => { + try { const data = await orgApi.getOrgMembers(org.uuid); setMembers(data) } + catch { setMembers({ users: [], teams: [] }) } + finally { setLoading(false) } + }, [org.uuid]) + + useEffect(() => { loadMembers() }, [loadMembers]) + + // Load all teams for the dropdown + useEffect(() => { + adminListAllTeams().then(data => setTeams(data.map((t: AdminTeamItem) => ({ uuid: t.uuid, name: t.name })))) + .catch(() => {}) + }, []) + + // Debounced user search + useEffect(() => { + if (!searchQ.trim()) { setSearchResults([]); return } + const timer = setTimeout(async () => { + setSearching(true) + try { const data = await orgApi.searchUsers(searchQ.trim()); setSearchResults(data.users) } + catch { setSearchResults([]) } + finally { setSearching(false) } + }, 300) + return () => clearTimeout(timer) + }, [searchQ]) + + const assignUser = async (userId: string) => { + await orgApi.assignUserToOrg(org.uuid, userId) + setSearchQ(''); setSearchResults([]); loadMembers(); onReload() + } + const unassignUser = async (userId: string) => { + await orgApi.unassignUserFromOrg(org.uuid, userId) + loadMembers(); onReload() + } + const assignTeam = async (teamUuid: string) => { + await orgApi.assignTeamToOrg(org.uuid, teamUuid) + loadMembers(); onReload() + } + const unassignTeam = async (teamUuid: string) => { + await orgApi.unassignTeamFromOrg(org.uuid, teamUuid) + loadMembers(); onReload() + } + + const memberUserIds = new Set(members?.users.map(u => u.user_id) || []) + const memberTeamUuids = new Set(members?.teams.map(t => t.uuid) || []) + + return ( +
+
+

+ Members of “{org.name}” +

+ +
+ + {/* Add user search */} +
+ + setSearchQ(e.target.value)} + placeholder="Search by name or email..." className="w-full rounded border border-gray-300 px-3 py-1.5 text-sm" /> + {searchQ.trim() && ( +
+ {searching ?
Searching...
+ : searchResults.length === 0 ?
No results
+ : searchResults.map(u => ( +
+ {u.name || u.user_id}{u.email ? ` (${u.email})` : ''} + {memberUserIds.has(u.user_id) ? Assigned + : } +
+ ))} +
+ )} +
+ + {/* Add team dropdown */} +
+ + +
+ + {/* Current members */} + {loading ?
Loading...
: ( +
+ {(members?.users.length || 0) > 0 && ( +
+
Users ({members!.users.length})
+ {members!.users.map(u => ( +
+ {u.name || u.user_id}{u.email ? ` (${u.email})` : ''} + +
+ ))} +
+ )} + {(members?.teams.length || 0) > 0 && ( +
+
Teams ({members!.teams.length})
+ {members!.teams.map(t => ( +
+ {t.name} + +
+ ))} +
+ )} + {!members?.users.length && !members?.teams.length && ( +
No users or teams assigned yet.
+ )} +
+ )} +
+ ) +} + +function parseCSV(text: string): { name: string; parent_name: string; org_type: string }[] { + const lines = text.split('\n').map(l => l.trim()).filter(Boolean) + if (lines.length < 2) return [] + const header = lines[0].toLowerCase().split(',').map(h => h.trim()) + const nameIdx = header.findIndex(h => h === 'name') + const parentIdx = header.findIndex(h => h === 'parent' || h === 'parent_name') + if (nameIdx < 0) return [] + + const rows: { name: string; parent_name: string; org_type: string }[] = [] + // Build depth map for auto-type assignment + const depthOf: Record = {} + + for (let i = 1; i < lines.length; i++) { + const cols = lines[i].split(',').map(c => c.trim()) + const name = cols[nameIdx] || '' + const parent = parentIdx >= 0 ? (cols[parentIdx] || '') : '' + if (!name) continue + const parentDepth = parent ? (depthOf[parent] ?? 0) : -1 + const myDepth = parentDepth + 1 + depthOf[name] = myDepth + const autoType = DEPTH_TYPE_DEFAULTS[Math.min(myDepth, DEPTH_TYPE_DEFAULTS.length - 1)] + rows.push({ name, parent_name: parent, org_type: autoType }) + } + return rows +} + +function ImportDialog({ onClose, onImported }: { onClose: () => void; onImported: () => void }) { + const [rows, setRows] = useState<{ name: string; parent_name: string; org_type: string }[]>([]) + const [importing, setImporting] = useState(false) + const [error, setError] = useState(null) + const fileRef = useRef(null) + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + const text = ev.target?.result as string + const parsed = parseCSV(text) + if (parsed.length === 0) { setError('No valid rows found. CSV must have a "name" column.'); return } + setError(null) + setRows(parsed) + } + reader.readAsText(file) + } + + const handleImport = async () => { + setImporting(true); setError(null) + try { + await orgApi.importOrganizations(rows) + onImported(); onClose() + } catch (e) { setError(e instanceof Error ? e.message : 'Import failed') } + finally { setImporting(false) } + } + + const updateRow = (idx: number, field: 'name' | 'parent_name' | 'org_type', value: string) => { + setRows(prev => prev.map((r, i) => i === idx ? { ...r, [field]: value } : r)) + } + const removeRow = (idx: number) => setRows(prev => prev.filter((_, i) => i !== idx)) + + return ( +
+
+
+

Import Organization Structure

+ +
+
+ {rows.length === 0 ? ( +
+

+ Upload a CSV with your university's organizational structure. The CSV should have columns: +

+
+ name,parent
+ University of Idaho,
+ College of Engineering,University of Idaho
+ College of Science,University of Idaho
+ Department of Computer Science,College of Engineering
+ Department of Physics,College of Science +
+

+ The name column is required. The parent column references the parent node by name. Rows without a parent become root nodes. Types (university, college, department, unit) are auto-detected based on depth. +

+ + +
+ ) : ( +
+

+ {rows.length} organizations to import. Review and edit types before importing. +

+
+ + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + ))} + +
NameParentTypeActions
+ updateRow(i, 'name', e.target.value)} + className="w-full border-0 bg-transparent text-sm p-0 focus:ring-0" /> + {r.parent_name || '(root)'} + + + +
+
+
+ +
+
+ )} + {error && ( +
+ {error} +
+ )} +
+ {rows.length > 0 && ( +
+ + +
+ )} +
+
+ ) +} + +export function OrganizationsTab() { + const confirm = useConfirm() + const [tree, setTree] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const [createParentId, setCreateParentId] = useState() + const [editOrg, setEditOrg] = useState(null) + const [formName, setFormName] = useState('') + const [formType, setFormType] = useState('department') + const [allowedTypes, setAllowedTypes] = useState(Object.keys(ORG_TYPE_LABELS)) + const [showImport, setShowImport] = useState(false) + const [selectedOrg, setSelectedOrg] = useState(null) + + const loadTree = async () => { + setError(null) + try { const data = await orgApi.getOrgTree(); setTree(data.tree) } + catch (e) { setError(e instanceof Error ? e.message : 'Failed to load tree') } + finally { setLoading(false) } + } + useEffect(() => { loadTree() }, []) + + const handleCreate = async () => { + if (!formName.trim()) return + setError(null) + try { + await orgApi.createOrganization({ name: formName.trim(), org_type: formType, parent_id: createParentId }) + setShowCreate(false); setFormName(''); setCreateParentId(undefined); loadTree() + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create organization') } + } + const handleUpdate = async () => { + if (!editOrg || !formName.trim()) return + setError(null) + try { await orgApi.updateOrganization(editOrg.uuid, { name: formName.trim() }); setEditOrg(null); setFormName(''); loadTree() } + catch (e) { setError(e instanceof Error ? e.message : 'Failed to update') } + } + const handleDelete = async (org: Organization) => { + const ok = await confirm({ + title: 'Delete organization?', + message: ( + <> + Are you sure you want to delete {org.name}? Any child organizations will be re-parented to its parent. + + ), + confirmLabel: 'Delete', + destructive: true, + }) + if (!ok) return + setError(null) + if (selectedOrg?.uuid === org.uuid) setSelectedOrg(null) + try { await orgApi.deleteOrganization(org.uuid); loadTree() } + catch (e) { setError(e instanceof Error ? e.message : 'Failed to delete') } + } + const handleTypeChange = async (uuid: string, newType: string) => { + setError(null) + try { await orgApi.updateOrgType(uuid, newType); loadTree() } + catch (e) { setError(e instanceof Error ? e.message : 'Failed to change type') } + } + const handleDrop = async (draggedUuid: string, targetUuid: string) => { + setError(null) + try { await orgApi.moveOrganization(draggedUuid, targetUuid); loadTree() } + catch (e) { setError(e instanceof Error ? e.message : 'Failed to move') } + } + + return ( +
+ {/* Header */} +
+
+ +

Organization Hierarchy

+
+
+ + +
+
+ + {/* Explanation */} +
+ What is this? The org hierarchy models your university's structure + (University → Colleges → Departments → Units). When you assign users and teams to org nodes, it controls + what verified items and knowledge bases they can see. Users in a department see items scoped to their department and + any parent college/university. Drag nodes to rearrange, click a node to manage its members. +
+ + {error && ( +
+ {error} +
+ )} + + {/* Create/Edit form */} + {(showCreate || editOrg) && ( +
+

{editOrg ? `Rename: ${editOrg.name}` : createParentId ? 'Add child node' : 'Create organization'}

+
+
+ setFormName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && (editOrg ? handleUpdate() : handleCreate())} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" placeholder="e.g., College of Science" autoFocus /> +
+ {!editOrg && ( + + )} + + +
+
+ )} + + {/* Tree + member panel side by side */} +
+
+ {loading ? ( +
Loading...
+ ) : tree.length === 0 ? ( +
+ +

No organizations yet

+

Create a root “University” node to get started, or import your structure from a CSV file.

+
+ ) : ( +
+ {tree.map(org => ( + { setEditOrg(o); setFormName(o.name) }} + onDelete={handleDelete} + onAddChild={(parentId, parentType) => { + const ct = VALID_CHILD_TYPES[parentType] || [] + setShowCreate(true); setCreateParentId(parentId); setFormName('') + setAllowedTypes(ct.length > 0 ? ct : Object.keys(ORG_TYPE_LABELS)) + setFormType(ct[0] || 'department') + }} + onTypeChange={handleTypeChange} + onDrop={handleDrop} + onReload={loadTree} + onSelect={o => setSelectedOrg(prev => prev?.uuid === o.uuid ? null : o)} + selectedUuid={selectedOrg?.uuid || null} + /> + ))} +
+ )} +
+ + {/* Member management panel */} + {selectedOrg && ( +
+ setSelectedOrg(null)} onReload={loadTree} /> +
+ )} +
+ + {/* Import dialog */} + {showImport && setShowImport(false)} onImported={loadTree} />} +
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index b0104916..c5987773 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react' +import React, { useEffect, useState, useCallback, useMemo } from 'react' import { Shield, ShieldCheck, BarChart3, Users, Building2, Workflow, Settings, Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, @@ -21,10 +21,7 @@ import { getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, - adminListAllTeams, } from '../api/admin' -import * as orgApi from '../api/organizations' -import type { Organization, OrgMember, OrgTeam } from '../api/organizations' import { getDemoStats, getDemoApplications, releaseDemoUser, activateDemoUser, restartDemoTrial, promoteDemoUser, @@ -41,7 +38,6 @@ import { PRE_SURVEY_FIELDS } from './Demo' import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { SystemConfigData, - AdminTeamItem, } from '../api/admin' import { fileToConstrainedDataUrl } from '../utils/imageResize' import { ModelCharacterBars } from '../components/ModelEffortPicker' @@ -58,6 +54,7 @@ import { AuditTab } from '../components/admin/AuditTab' import { UsersTab } from '../components/admin/UsersTab' import { UsageTab } from '../components/admin/UsageTab' import { WorkflowsTab } from '../components/admin/WorkflowsTab' +import { OrganizationsTab } from '../components/admin/OrganizationsTab' import { QualityTab } from '../components/admin/QualityTab' import { CertificationsTab } from '../components/admin/CertificationsTab' import { EmailAnalyticsTab } from '../components/admin/EmailAnalyticsTab' @@ -4255,552 +4252,6 @@ function SurveyResponsesSection() { ) } -const ORG_TYPE_LABELS: Record = { - university: 'University', college: 'College', central_office: 'Central Office', - department: 'Department', unit: 'Unit', -} -const ORG_TYPE_COLORS: Record = { - university: 'bg-purple-100 text-purple-800', college: 'bg-blue-100 text-blue-800', - central_office: 'bg-amber-100 text-amber-800', department: 'bg-green-100 text-green-800', - unit: 'bg-gray-100 text-gray-800', -} -const VALID_CHILD_TYPES: Record = { - university: ['college', 'central_office'], college: ['department'], - central_office: ['department'], department: ['unit'], unit: [], -} -const DEPTH_TYPE_DEFAULTS = ['university', 'college', 'department', 'unit'] as const - -function OrgNodeRow({ - org, depth = 0, onEdit, onDelete, onAddChild, onTypeChange, onDrop, onReload, onSelect, selectedUuid, -}: { - org: Organization; depth?: number - onEdit: (o: Organization) => void; onDelete: (o: Organization) => void - onAddChild: (parentId: string, parentType: string) => void - onTypeChange: (uuid: string, newType: string) => void - onDrop: (draggedUuid: string, targetUuid: string) => void - onReload: () => void - onSelect: (o: Organization) => void - selectedUuid: string | null -}) { - const [expanded, setExpanded] = useState(depth < 2) - const [dragOver, setDragOver] = useState(false) - const hasChildren = org.children && org.children.length > 0 - const childTypes = VALID_CHILD_TYPES[org.org_type] || [] - const totalMembers = (org.user_count || 0) + (org.team_count || 0) - const isSelected = selectedUuid === org.uuid - - return ( -
-
{ e.dataTransfer.setData('text/plain', org.uuid); e.dataTransfer.effectAllowed = 'move' }} - onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOver(true) }} - onDragLeave={() => setDragOver(false)} - onDrop={e => { e.preventDefault(); setDragOver(false); const uuid = e.dataTransfer.getData('text/plain'); if (uuid && uuid !== org.uuid) onDrop(uuid, org.uuid) }} - > - - - - {totalMembers > 0 && ( - - )} -
- {childTypes.length > 0 && ( - - )} - - -
-
- {expanded && hasChildren && org.children!.map(child => ( - - ))} -
- ) -} - -function OrgMemberPanel({ org, onClose, onReload }: { org: Organization; onClose: () => void; onReload: () => void }) { - const [members, setMembers] = useState<{ users: OrgMember[]; teams: OrgTeam[] } | null>(null) - const [loading, setLoading] = useState(true) - const [searchQ, setSearchQ] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [searching, setSearching] = useState(false) - const [teams, setTeams] = useState<{ uuid: string; name: string }[]>([]) - - const loadMembers = useCallback(async () => { - try { const data = await orgApi.getOrgMembers(org.uuid); setMembers(data) } - catch { setMembers({ users: [], teams: [] }) } - finally { setLoading(false) } - }, [org.uuid]) - - useEffect(() => { loadMembers() }, [loadMembers]) - - // Load all teams for the dropdown - useEffect(() => { - adminListAllTeams().then(data => setTeams(data.map((t: AdminTeamItem) => ({ uuid: t.uuid, name: t.name })))) - .catch(() => {}) - }, []) - - // Debounced user search - useEffect(() => { - if (!searchQ.trim()) { setSearchResults([]); return } - const timer = setTimeout(async () => { - setSearching(true) - try { const data = await orgApi.searchUsers(searchQ.trim()); setSearchResults(data.users) } - catch { setSearchResults([]) } - finally { setSearching(false) } - }, 300) - return () => clearTimeout(timer) - }, [searchQ]) - - const assignUser = async (userId: string) => { - await orgApi.assignUserToOrg(org.uuid, userId) - setSearchQ(''); setSearchResults([]); loadMembers(); onReload() - } - const unassignUser = async (userId: string) => { - await orgApi.unassignUserFromOrg(org.uuid, userId) - loadMembers(); onReload() - } - const assignTeam = async (teamUuid: string) => { - await orgApi.assignTeamToOrg(org.uuid, teamUuid) - loadMembers(); onReload() - } - const unassignTeam = async (teamUuid: string) => { - await orgApi.unassignTeamFromOrg(org.uuid, teamUuid) - loadMembers(); onReload() - } - - const memberUserIds = new Set(members?.users.map(u => u.user_id) || []) - const memberTeamUuids = new Set(members?.teams.map(t => t.uuid) || []) - - return ( -
-
-

- Members of “{org.name}” -

- -
- - {/* Add user search */} -
- - setSearchQ(e.target.value)} - placeholder="Search by name or email..." className="w-full rounded border border-gray-300 px-3 py-1.5 text-sm" /> - {searchQ.trim() && ( -
- {searching ?
Searching...
- : searchResults.length === 0 ?
No results
- : searchResults.map(u => ( -
- {u.name || u.user_id}{u.email ? ` (${u.email})` : ''} - {memberUserIds.has(u.user_id) ? Assigned - : } -
- ))} -
- )} -
- - {/* Add team dropdown */} -
- - -
- - {/* Current members */} - {loading ?
Loading...
: ( -
- {(members?.users.length || 0) > 0 && ( -
-
Users ({members!.users.length})
- {members!.users.map(u => ( -
- {u.name || u.user_id}{u.email ? ` (${u.email})` : ''} - -
- ))} -
- )} - {(members?.teams.length || 0) > 0 && ( -
-
Teams ({members!.teams.length})
- {members!.teams.map(t => ( -
- {t.name} - -
- ))} -
- )} - {!members?.users.length && !members?.teams.length && ( -
No users or teams assigned yet.
- )} -
- )} -
- ) -} - -function parseCSV(text: string): { name: string; parent_name: string; org_type: string }[] { - const lines = text.split('\n').map(l => l.trim()).filter(Boolean) - if (lines.length < 2) return [] - const header = lines[0].toLowerCase().split(',').map(h => h.trim()) - const nameIdx = header.findIndex(h => h === 'name') - const parentIdx = header.findIndex(h => h === 'parent' || h === 'parent_name') - if (nameIdx < 0) return [] - - const rows: { name: string; parent_name: string; org_type: string }[] = [] - // Build depth map for auto-type assignment - const depthOf: Record = {} - - for (let i = 1; i < lines.length; i++) { - const cols = lines[i].split(',').map(c => c.trim()) - const name = cols[nameIdx] || '' - const parent = parentIdx >= 0 ? (cols[parentIdx] || '') : '' - if (!name) continue - const parentDepth = parent ? (depthOf[parent] ?? 0) : -1 - const myDepth = parentDepth + 1 - depthOf[name] = myDepth - const autoType = DEPTH_TYPE_DEFAULTS[Math.min(myDepth, DEPTH_TYPE_DEFAULTS.length - 1)] - rows.push({ name, parent_name: parent, org_type: autoType }) - } - return rows -} - -function ImportDialog({ onClose, onImported }: { onClose: () => void; onImported: () => void }) { - const [rows, setRows] = useState<{ name: string; parent_name: string; org_type: string }[]>([]) - const [importing, setImporting] = useState(false) - const [error, setError] = useState(null) - const fileRef = useRef(null) - - const handleFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - const reader = new FileReader() - reader.onload = (ev) => { - const text = ev.target?.result as string - const parsed = parseCSV(text) - if (parsed.length === 0) { setError('No valid rows found. CSV must have a "name" column.'); return } - setError(null) - setRows(parsed) - } - reader.readAsText(file) - } - - const handleImport = async () => { - setImporting(true); setError(null) - try { - await orgApi.importOrganizations(rows) - onImported(); onClose() - } catch (e) { setError(e instanceof Error ? e.message : 'Import failed') } - finally { setImporting(false) } - } - - const updateRow = (idx: number, field: 'name' | 'parent_name' | 'org_type', value: string) => { - setRows(prev => prev.map((r, i) => i === idx ? { ...r, [field]: value } : r)) - } - const removeRow = (idx: number) => setRows(prev => prev.filter((_, i) => i !== idx)) - - return ( -
-
-
-

Import Organization Structure

- -
-
- {rows.length === 0 ? ( -
-

- Upload a CSV with your university's organizational structure. The CSV should have columns: -

-
- name,parent
- University of Idaho,
- College of Engineering,University of Idaho
- College of Science,University of Idaho
- Department of Computer Science,College of Engineering
- Department of Physics,College of Science -
-

- The name column is required. The parent column references the parent node by name. Rows without a parent become root nodes. Types (university, college, department, unit) are auto-detected based on depth. -

- - -
- ) : ( -
-

- {rows.length} organizations to import. Review and edit types before importing. -

-
- - - - - - - - - - - {rows.map((r, i) => ( - - - - - - - ))} - -
NameParentTypeActions
- updateRow(i, 'name', e.target.value)} - className="w-full border-0 bg-transparent text-sm p-0 focus:ring-0" /> - {r.parent_name || '(root)'} - - - -
-
-
- -
-
- )} - {error && ( -
- {error} -
- )} -
- {rows.length > 0 && ( -
- - -
- )} -
-
- ) -} - -function OrganizationsTab() { - const confirm = useConfirm() - const [tree, setTree] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [showCreate, setShowCreate] = useState(false) - const [createParentId, setCreateParentId] = useState() - const [editOrg, setEditOrg] = useState(null) - const [formName, setFormName] = useState('') - const [formType, setFormType] = useState('department') - const [allowedTypes, setAllowedTypes] = useState(Object.keys(ORG_TYPE_LABELS)) - const [showImport, setShowImport] = useState(false) - const [selectedOrg, setSelectedOrg] = useState(null) - - const loadTree = async () => { - setError(null) - try { const data = await orgApi.getOrgTree(); setTree(data.tree) } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to load tree') } - finally { setLoading(false) } - } - useEffect(() => { loadTree() }, []) - - const handleCreate = async () => { - if (!formName.trim()) return - setError(null) - try { - await orgApi.createOrganization({ name: formName.trim(), org_type: formType, parent_id: createParentId }) - setShowCreate(false); setFormName(''); setCreateParentId(undefined); loadTree() - } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create organization') } - } - const handleUpdate = async () => { - if (!editOrg || !formName.trim()) return - setError(null) - try { await orgApi.updateOrganization(editOrg.uuid, { name: formName.trim() }); setEditOrg(null); setFormName(''); loadTree() } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to update') } - } - const handleDelete = async (org: Organization) => { - const ok = await confirm({ - title: 'Delete organization?', - message: ( - <> - Are you sure you want to delete {org.name}? Any child organizations will be re-parented to its parent. - - ), - confirmLabel: 'Delete', - destructive: true, - }) - if (!ok) return - setError(null) - if (selectedOrg?.uuid === org.uuid) setSelectedOrg(null) - try { await orgApi.deleteOrganization(org.uuid); loadTree() } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to delete') } - } - const handleTypeChange = async (uuid: string, newType: string) => { - setError(null) - try { await orgApi.updateOrgType(uuid, newType); loadTree() } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to change type') } - } - const handleDrop = async (draggedUuid: string, targetUuid: string) => { - setError(null) - try { await orgApi.moveOrganization(draggedUuid, targetUuid); loadTree() } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to move') } - } - - return ( -
- {/* Header */} -
-
- -

Organization Hierarchy

-
-
- - -
-
- - {/* Explanation */} -
- What is this? The org hierarchy models your university's structure - (University → Colleges → Departments → Units). When you assign users and teams to org nodes, it controls - what verified items and knowledge bases they can see. Users in a department see items scoped to their department and - any parent college/university. Drag nodes to rearrange, click a node to manage its members. -
- - {error && ( -
- {error} -
- )} - - {/* Create/Edit form */} - {(showCreate || editOrg) && ( -
-

{editOrg ? `Rename: ${editOrg.name}` : createParentId ? 'Add child node' : 'Create organization'}

-
-
- setFormName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && (editOrg ? handleUpdate() : handleCreate())} - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" placeholder="e.g., College of Science" autoFocus /> -
- {!editOrg && ( - - )} - - -
-
- )} - - {/* Tree + member panel side by side */} -
-
- {loading ? ( -
Loading...
- ) : tree.length === 0 ? ( -
- -

No organizations yet

-

Create a root “University” node to get started, or import your structure from a CSV file.

-
- ) : ( -
- {tree.map(org => ( - { setEditOrg(o); setFormName(o.name) }} - onDelete={handleDelete} - onAddChild={(parentId, parentType) => { - const ct = VALID_CHILD_TYPES[parentType] || [] - setShowCreate(true); setCreateParentId(parentId); setFormName('') - setAllowedTypes(ct.length > 0 ? ct : Object.keys(ORG_TYPE_LABELS)) - setFormType(ct[0] || 'department') - }} - onTypeChange={handleTypeChange} - onDrop={handleDrop} - onReload={loadTree} - onSelect={o => setSelectedOrg(prev => prev?.uuid === o.uuid ? null : o)} - selectedUuid={selectedOrg?.uuid || null} - /> - ))} -
- )} -
- - {/* Member management panel */} - {selectedOrg && ( -
- setSelectedOrg(null)} onReload={loadTree} /> -
- )} -
- - {/* Import dialog */} - {showImport && setShowImport(false)} onImported={loadTree} />} -
- ) -} - export default function Admin() { const { user } = useAuth() const { currentTeam } = useTeams() From 1a1cc2713fe875fa8d999dd339a2ec51d4f2a3ff Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 11:08:57 -0600 Subject: [PATCH 14/18] refactor(admin): extract DemoTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/DemoTab.tsx | 1437 ++++++++++++++++++++ frontend/src/pages/Admin.tsx | 1443 +-------------------- 2 files changed, 1442 insertions(+), 1438 deletions(-) create mode 100644 frontend/src/components/admin/DemoTab.tsx diff --git a/frontend/src/components/admin/DemoTab.tsx b/frontend/src/components/admin/DemoTab.tsx new file mode 100644 index 00000000..6f131821 --- /dev/null +++ b/frontend/src/components/admin/DemoTab.tsx @@ -0,0 +1,1437 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react' +import { + ChevronRight, RefreshCw, MessageSquare, Download, ChevronDown, + Mail, Send, Link, UserPlus, Award, +} from 'lucide-react' +import { useConfirm } from '../shared/useConfirm' +import { useToast } from '../../contexts/ToastContext' +import { + getDemoStats, getDemoApplications, releaseDemoUser, activateDemoUser, restartDemoTrial, + promoteDemoUser, + getPostExperienceResponses, sendTestEmail, adminResendCredentials, adminGetMagicLink, + adminAddDemoUser, +} from '../../api/demo' +import { getAdminPromptOverview, adminUpdatePrompt, type PromptOverview } from '../../api/feedbackPrompt' +import * as supportApi from '../../api/support' +import type { SupportTicket, SupportTicketSummary } from '../../types/support' +import type { DemoAdminStats, DemoApplication as DemoApp, PostExperienceResponseAdmin } from '../../types/demo' +import { POST_SURVEY_FIELDS } from '../survey/postSurveyFields' +import { PRE_SURVEY_FIELDS } from '../../pages/Demo' +import { SurveyFieldRenderer } from '../survey/SurveyFieldRenderer' +import { SearchInput } from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDate(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function DemoResponseDetail({ responses }: { responses: Record }) { + if (!responses || Object.keys(responses).length === 0) { + return
No onboarding responses recorded.
+ } + + // Group fields by section using the PRE_SURVEY_FIELDS definitions + const sections: { name: string; items: { label: string; value: string }[] }[] = [] + let currentSection = '' + let currentItems: { label: string; value: string }[] = [] + + for (const field of PRE_SURVEY_FIELDS) { + if (field.type === 'info') continue + if (field.section && field.section !== currentSection) { + if (currentItems.length > 0) sections.push({ name: currentSection, items: currentItems }) + currentSection = field.section + currentItems = [] + } + + // Handle likert_group: each statement is a sub-key + if (field.type === 'likert_group' && field.statements) { + for (const stmt of field.statements) { + const val = responses[stmt.key] + if (val !== undefined && val !== null && val !== '') { + currentItems.push({ label: stmt.label, value: String(val) }) + } + } + continue + } + + const val = responses[field.key] + if (val === undefined || val === null || val === '') continue + const display = Array.isArray(val) ? val.join(', ') : String(val) + currentItems.push({ label: field.label, value: display }) + } + if (currentItems.length > 0) sections.push({ name: currentSection, items: currentItems }) + + // Also show any keys not in PRE_SURVEY_FIELDS (future-proofing) + const knownKeys = new Set(PRE_SURVEY_FIELDS.flatMap(f => + f.type === 'likert_group' && f.statements ? f.statements.map(s => s.key) : [f.key] + )) + const extraItems: { label: string; value: string }[] = [] + for (const [key, val] of Object.entries(responses)) { + if (knownKeys.has(key) || val === undefined || val === null || val === '') continue + const display = Array.isArray(val) ? val.join(', ') : String(val) + extraItems.push({ label: key, value: display }) + } + if (extraItems.length > 0) sections.push({ name: 'Other', items: extraItems }) + + const likertLabels: Record = { + '1': 'Strongly Disagree', + '2': 'Disagree', + '3': 'Neutral', + '4': 'Agree', + '5': 'Strongly Agree', + } + + return ( +
+
Onboarding Responses
+ {sections.map((section) => ( +
+
+ {section.name} +
+
+ {section.items.map((item) => ( +
+ {item.label} + + {likertLabels[item.value] || (item.value.match(/^\d+$/) && item.label.toLowerCase().includes('minute') ? `${item.value} min` : item.value)} + +
+ ))} +
+
+ ))} +
+ ) +} + +export function DemoTab() { + const confirm = useConfirm() + const { toast } = useToast() + const [subTab, setSubTab] = useState<'applications' | 'surveys'>('applications') + const [stats, setStats] = useState(null) + const [apps, setApps] = useState([]) + const [statusFilter, setStatusFilter] = useState('') + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [expandedUuid, setExpandedUuid] = useState(null) + + const loadData = useCallback(async () => { + setLoading(true) + try { + const [s, a] = await Promise.all([ + getDemoStats(), + getDemoApplications(statusFilter || undefined), + ]) + setStats(s) + setApps(a) + } catch { + // ignore + } finally { + setLoading(false) + } + }, [statusFilter]) + + useEffect(() => { loadData() }, [loadData]) + + // Client-side text search over the (status-filtered) applications. + const filteredApps = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return apps + return apps.filter(app => + app.name.toLowerCase().includes(q) + || (app.title ?? '').toLowerCase().includes(q) + || app.email.toLowerCase().includes(q) + || app.organization.toLowerCase().includes(q), + ) + }, [apps, search]) + + const [actionLoading, setActionLoading] = useState(null) + + async function handleExport() { + setActionLoading('export') + try { + // Fetch all applications (unfiltered) and post-survey responses + const [allApps, postResponses] = await Promise.all([ + getDemoApplications(), + getPostExperienceResponses(), + ]) + + // Index post-survey responses by email for matching + const postByEmail = new Map>() + for (const pr of postResponses) { + postByEmail.set(pr.email, pr.responses) + } + + // Build pre-survey column definitions (key + label) + const preCols: { key: string; label: string }[] = [] + for (const f of PRE_SURVEY_FIELDS) { + if (f.type === 'info') continue + if (f.type === 'likert_group' && f.statements) { + for (const s of f.statements) preCols.push({ key: s.key, label: `Pre: ${s.label}` }) + } else { + preCols.push({ key: f.key, label: `Pre: ${f.label}` }) + } + } + + // Build post-survey column definitions + const postCols: { key: string; label: string }[] = [] + for (const f of POST_SURVEY_FIELDS) { + if (f.type === 'info') continue + if (f.type === 'likert_group' && f.statements) { + for (const s of f.statements) postCols.push({ key: s.key, label: `Post: ${s.label}` }) + } else { + postCols.push({ key: f.key, label: `Post: ${f.label}` }) + } + } + + const headers = [ + 'Name', 'Title', 'Email', 'Organization', 'Status', + 'Applied', 'Activated', 'Credentials Sent', 'First Login', + 'Expires', 'Post-Survey Completed', + ...preCols.map(c => c.label), + ...postCols.map(c => c.label), + ] + + const rows = allApps.map(app => { + const pre = app.questionnaire_responses || {} + const post = postByEmail.get(app.email) || {} + const fmt = (v: unknown) => { + if (v === null || v === undefined || v === '') return null + return Array.isArray(v) ? v.join('; ') : String(v) + } + return [ + app.name, + app.title || null, + app.email, + app.organization, + app.status, + app.created_at ? formatDate(app.created_at) : null, + app.activated_at ? formatDate(app.activated_at) : null, + app.credentials_sent_at ? formatDate(app.credentials_sent_at) : null, + app.last_login_at ? formatDate(app.last_login_at) : 'Never', + app.expires_at ? formatDate(app.expires_at) : null, + app.post_questionnaire_completed ? 'Yes' : 'No', + ...preCols.map(c => fmt(pre[c.key])), + ...postCols.map(c => fmt(post[c.key])), + ] as (string | number | null)[] + }) + + downloadCSV('demo_export.csv', headers, rows) + } catch { + toast('Failed to export demo data', 'error') + } finally { + setActionLoading(null) + } + } + + async function handleActivate(uuid: string) { + await activateDemoUser(uuid) + loadData() + } + + async function handleRelease(uuid: string) { + await releaseDemoUser(uuid) + loadData() + } + + async function handleRestartTrial(uuid: string) { + const ok = await confirm({ + title: 'Restart trial?', + message: 'Restart this user\'s trial? They will get a fresh 14-day trial period starting now.', + confirmLabel: 'Restart trial', + }) + if (!ok) return + setActionLoading(`restart-${uuid}`) + try { + await restartDemoTrial(uuid) + loadData() + } catch { + toast('Failed to restart trial', 'error') + } finally { + setActionLoading(null) + } + } + + async function handlePromote(uuid: string, email: string) { + const ok = await confirm({ + title: 'Promote to full user?', + message: ( + <> + Promote {email} to a permanent full user? Their trial expiry + will be cleared and they'll keep their account, data, and team membership. + This cannot be reversed from this screen. + + ), + confirmLabel: 'Promote', + }) + if (!ok) return + setActionLoading(`promote-${uuid}`) + try { + await promoteDemoUser(uuid) + loadData() + } catch { + toast('Failed to promote user', 'error') + } finally { + setActionLoading(null) + } + } + + async function handleTestEmail(email: string) { + setActionLoading(`test-${email}`) + try { + await sendTestEmail(email) + toast(`Test email sent to ${email}`, 'success') + } catch { + toast('Failed to send test email. Check SMTP configuration.', 'error') + } finally { + setActionLoading(null) + } + } + + async function handleResendCredentials(uuid: string, email: string) { + const ok = await confirm({ + title: 'Resend credentials?', + message: ( + <> + Resend credentials to {email}? This will reset their password. + + ), + confirmLabel: 'Resend', + destructive: true, + }) + if (!ok) return + setActionLoading(`resend-${uuid}`) + try { + await adminResendCredentials(uuid) + toast(`Credentials resent to ${email}`, 'success') + } catch { + toast('Failed to resend credentials', 'error') + } finally { + setActionLoading(null) + } + } + + async function handleCopyMagicLink(uuid: string) { + setActionLoading(`magic-${uuid}`) + try { + const result = await adminGetMagicLink(uuid) + await navigator.clipboard.writeText(result.url) + toast('Magic link copied to clipboard! It expires in 24 hours and can only be used once.', 'success') + } catch { + toast('Failed to generate magic link', 'error') + } finally { + setActionLoading(null) + } + } + + // --- Add user form --- + const [showAddUser, setShowAddUser] = useState(false) + const [addUserForm, setAddUserForm] = useState({ first_name: '', last_name: '', email: '' }) + const [addUserError, setAddUserError] = useState(null) + + async function handleAddUser(e: React.FormEvent) { + e.preventDefault() + setAddUserError(null) + setActionLoading('add-user') + try { + await adminAddDemoUser(addUserForm) + setAddUserForm({ first_name: '', last_name: '', email: '' }) + setShowAddUser(false) + loadData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to add user' + setAddUserError(msg) + } finally { + setActionLoading(null) + } + } + + const statusColors: Record = { + pending: { bg: '#fef3c7', text: '#92400e' }, + active: { bg: '#dcfce7', text: '#166534' }, + expired: { bg: '#fee2e2', text: '#991b1b' }, + completed: { bg: '#dbeafe', text: '#1e40af' }, + } + + return ( +
+
+

Demo Program

+ {subTab === 'applications' && ( +
+ + + +
+ )} +
+ + {/* Sub-tab bar */} +
+ + +
+ + {subTab === 'surveys' && } + + {subTab === 'applications' && ( + <> + {/* Add user form */} + {showAddUser && ( +
+
Add User to Trial
+
+
+ + setAddUserForm({ ...addUserForm, first_name: e.target.value })} + style={{ + width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', + fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box', + }} + /> +
+
+ + setAddUserForm({ ...addUserForm, last_name: e.target.value })} + style={{ + width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', + fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box', + }} + /> +
+
+ + setAddUserForm({ ...addUserForm, email: e.target.value })} + style={{ + width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', + fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box', + }} + /> +
+ +
+ {addUserError && ( +
{addUserError}
+ )} +
+ )} + + {/* Stats cards */} + {stats && ( +
+ {[ + { label: 'Total', value: stats.total_applications, color: '#6b7280' }, + { label: 'Active', value: stats.active_count, color: '#16a34a' }, + { label: 'Waitlist', value: stats.waitlist_count, color: '#d97706' }, + { label: 'Expired', value: stats.expired_count, color: '#dc2626' }, + { label: 'Completed', value: stats.completed_count, color: '#2563eb' }, + ].map((card) => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+ )} + + {/* Organization breakdown */} + {stats && stats.by_organization.length > 0 && ( +
+

By Organization

+
+ {stats.by_organization.map((org) => ( + + {org.organization}: {org.count} + + ))} +
+
+ )} + + {/* Filter */} +
+ {['', 'pending', 'active', 'expired', 'completed'].map((s) => ( + + ))} +
+ +
+
+ + {/* Applications table */} + {loading ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + + + + + + {filteredApps.length === 0 && ( + + + + )} + {filteredApps.map((app) => { + const sc = statusColors[app.status] || { bg: '#f3f4f6', text: '#374151' } + const isExpanded = expandedUuid === app.uuid + return ( + + setExpandedUuid(isExpanded ? null : app.uuid)} + style={{ borderBottom: isExpanded ? 'none' : '1px solid #f3f4f6', cursor: 'pointer' }} + > + + + + + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + {apps.length === 0 && ( + + + + )} + +
NameEmailOrganizationStatusAppliedCredentials SentFirst LoginExpiresActions
+ {search.trim() ? 'No applications match your search.' : 'No applications found.'} +
+ {isExpanded ? '▼' : '▶'} + {app.name} + {app.title && {app.title}} + {app.email}{app.organization} + + {app.status} + + + {formatDate(app.created_at)} + + {app.credentials_sent_at ? formatDate(app.credentials_sent_at) : '-'} + + {app.last_login_at ? ( + {formatDate(app.last_login_at)} + ) : ( + Never + )} + + {app.expires_at ? formatDate(app.expires_at) : '-'} + +
e.stopPropagation()}> + {app.status === 'pending' && ( + + )} + {(app.status === 'expired' || app.status === 'completed') && !app.admin_released && ( + + )} + {(app.status === 'active' || app.status === 'expired' || app.status === 'completed') && ( + + )} + {(app.status === 'active' || app.status === 'expired' || app.status === 'completed') && app.user_is_demo && ( + + )} + {(app.status === 'active' || app.status === 'expired' || app.status === 'completed') && !app.user_is_demo && ( + + Full user + + )} + + {app.status === 'active' && ( + <> + + + + )} + {app.admin_released && ( + Released + )} + {app.post_questionnaire_completed && ( + Feedback done + )} +
+
+ +
+ No applications found +
+
+ )} + + {/* Trial Check-ins */} + + + + )} +
+ ) +} + +function CheckInConversationsSection() { + const [tickets, setTickets] = useState([]) + const [loading, setLoading] = useState(true) + const [statusFilter, setStatusFilter] = useState<'all' | 'open' | 'in_progress' | 'closed'>('all') + const [activeUuid, setActiveUuid] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + try { + const status = statusFilter === 'all' ? undefined : statusFilter + const res = await supportApi.listTickets(status, 200, 0, undefined, undefined, 'feedback_prompt') + setTickets(res.tickets) + } catch { + // ignore + } finally { + setLoading(false) + } + }, [statusFilter]) + + useEffect(() => { load() }, [load]) + + const statusColors: Record = { + open: '#f59e0b', + in_progress: '#3b82f6', + closed: '#9ca3af', + } + + const fmtTime = (iso: string | null) => { + if (!iso) return '' + const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (diff < 60) return 'just now' + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` + } + + return ( +
+
+
+

Check-ins

+

+ Conversations from trial check-in prompts. These do not appear in the Support Center. +

+
+
+ {(['all', 'open', 'in_progress', 'closed'] as const).map(s => ( + + ))} + +
+
+ + {loading ? ( +
Loading...
+ ) : tickets.length === 0 ? ( +
+ No check-in conversations yet. +
+ ) : ( +
+ + + + + + + + + + + + {tickets.map((t) => { + const isExpanded = activeUuid === t.uuid + const subject = t.subject.replace(/^\[Check-in\]\s*/, '') + return ( + + setActiveUuid(isExpanded ? null : t.uuid)} + style={{ borderBottom: isExpanded ? 'none' : '1px solid #f3f4f6', cursor: 'pointer' }} + > + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
SubjectUserStatusMessagesLast activity
+ {isExpanded ? '▼' : '▶'} + {subject} + {t.user_name || t.user_id} + + {t.status.replace('_', ' ')} + + {t.message_count} + {fmtTime(t.last_message_at || t.updated_at)} +
+ +
+
+ )} +
+ ) +} + +function CheckInConversation({ ticketUuid, onUpdate }: { ticketUuid: string; onUpdate: () => void }) { + const [ticket, setTicket] = useState(null) + const [loading, setLoading] = useState(true) + const [reply, setReply] = useState('') + const [sending, setSending] = useState(false) + + const loadTicket = useCallback(async () => { + try { + const data = await supportApi.getTicket(ticketUuid) + setTicket(data) + } catch { + // ignore + } finally { + setLoading(false) + } + }, [ticketUuid]) + + useEffect(() => { + loadTicket() + supportApi.markTicketRead(ticketUuid).catch(() => {}) + }, [loadTicket, ticketUuid]) + + const handleSend = async () => { + if (!reply.trim() || sending) return + setSending(true) + try { + const updated = await supportApi.addMessage(ticketUuid, reply.trim()) + setTicket(updated) + setReply('') + onUpdate() + } catch { + // ignore + } finally { + setSending(false) + } + } + + const handleStatusChange = async (next: string) => { + try { + const updated = await supportApi.updateTicket(ticketUuid, { status: next }) + setTicket(updated) + onUpdate() + } catch { + // ignore + } + } + + if (loading) { + return
Loading conversation...
+ } + + if (!ticket) { + return
Failed to load ticket.
+ } + + return ( +
+
+
+ {ticket.user_email && {ticket.user_email} · } + opened {ticket.created_at ? new Date(ticket.created_at).toLocaleString() : ''} +
+ {ticket.status !== 'closed' ? ( + + ) : ( + + )} +
+ +
+ {ticket.messages.map((m) => { + const isSupport = m.is_support_reply + return ( +
+
+
+ {m.user_name || m.user_id} + {isSupport && Team} +
+
{m.content}
+
+ {m.created_at ? new Date(m.created_at).toLocaleString() : ''} +
+
+
+ ) + })} +
+ + {ticket.status !== 'closed' ? ( +
+ setReply(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }} + placeholder="Reply to this check-in..." + style={{ + flex: 1, padding: '8px 12px', fontSize: 13, + border: '1px solid #d1d5db', borderRadius: 8, outline: 'none', fontFamily: 'inherit', + }} + /> + +
+ ) : ( +
+ This conversation is closed. Reopen to send a reply. +
+ )} +
+ ) +} + +function TrialCheckinsSection() { + const [prompts, setPrompts] = useState([]) + const [loading, setLoading] = useState(true) + + const loadPrompts = useCallback(async () => { + setLoading(true) + try { + const data = await getAdminPromptOverview() + setPrompts(data) + } catch { + // ignore + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { loadPrompts() }, [loadPrompts]) + + async function toggleEnabled(slug: string, enabled: boolean) { + await adminUpdatePrompt(slug, { enabled }) + loadPrompts() + } + + const stageColors: Record = { + early: { bg: '#dbeafe', text: '#1e40af' }, + mid: { bg: '#fef3c7', text: '#92400e' }, + late: { bg: '#fee2e2', text: '#991b1b' }, + } + + return ( +
+
+

Trial Check-ins

+ +
+

+ Proactive check-in prompts delivered through the support panel during the trial. + Responses appear as support tickets. +

+ + {loading ? ( +
Loading...
+ ) : prompts.length === 0 ? ( +
+ No prompts configured. They will be seeded on next server restart. +
+ ) : ( +
+ + + + + + + + + + + + + + + {prompts.map((p) => { + const sc = stageColors[p.stage] || { bg: '#f3f4f6', text: '#374151' } + return ( + + + + + + + + + + + ) + })} + +
StageSubjectQuestionShownRespondedDismissedRateEnabled
+ + {p.stage} + + {p.subject} + + {p.question_text.length > 80 ? p.question_text.slice(0, 80) + '...' : p.question_text} + + {p.stats.shown} + {p.stats.responded} + {p.stats.dismissed} + {p.stats.shown > 0 ? `${Math.round(p.stats.response_rate * 100)}%` : '-'} + + +
+
+ )} +
+ ) +} + +function SurveyResponsesSection() { + const [responses, setResponses] = useState([]) + const [loading, setLoading] = useState(true) + const [expandedUuid, setExpandedUuid] = useState(null) + const [showPreview, setShowPreview] = useState(false) + const [previewAnswers, setPreviewAnswers] = useState>({}) + const previewSections = useMemo(() => { + const sections: { name: string; fields: typeof POST_SURVEY_FIELDS }[] = [] + let current: { name: string; fields: typeof POST_SURVEY_FIELDS } | null = null + for (const f of POST_SURVEY_FIELDS) { + const sec = f.section || '' + if (!current || current.name !== sec) { + current = { name: sec, fields: [] } + sections.push(current) + } + current.fields.push(f) + } + return sections + }, []) + + const loadData = useCallback(async () => { + setLoading(true) + try { + const data = await getPostExperienceResponses() + setResponses(data) + } catch { + // ignore + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { loadData() }, [loadData]) + + function renderValue(val: unknown): string { + if (val === null || val === undefined) return '-' + if (Array.isArray(val)) return val.join(', ') + if (typeof val === 'object') { + return Object.entries(val as Record) + .map(([k, v]) => `${k}: ${v}`) + .join('; ') + } + return String(val) + } + + return ( +
+
+

Survey Responses

+
+ + +
+
+ + {showPreview && ( +
+
+ +

+ Post-Survey Preview +

+

+ This is what participants see after their demo expires. +

+
+ {previewSections.map((sec) => ( +
+
+ {sec.name} +
+
+ {sec.fields.map((field) => ( +
+ + setPreviewAnswers(prev => ({ ...prev, [k]: v }))} + /> +
+ ))} +
+
+ ))} +
+ )} + + {loading ? ( +
Loading...
+ ) : responses.length === 0 ? ( +
+ No survey responses yet. +
+ ) : ( +
+ + + + + + + + + + + {responses.map((resp) => ( + + setExpandedUuid(expandedUuid === resp.uuid ? null : resp.uuid)} + style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }} + > + + + + + + {expandedUuid === resp.uuid && ( + + + + )} + + ))} + +
NameEmailOrganizationSubmitted
+
+ {expandedUuid === resp.uuid ? : } + {resp.name} +
+
{resp.email}{resp.organization} + {formatDate(resp.created_at)} +
+ {/* Pre-Survey (Questionnaire) */} + {Object.keys(resp.questionnaire_responses).length > 0 && ( +
+
+ Pre-Survey +
+
+ {resp.title && ( + +
title
+
{resp.title}
+
+ )} + {Object.entries(resp.questionnaire_responses).map(([key, val]) => ( + +
+ {key.replace(/_/g, ' ')} +
+
+ {renderValue(val)} +
+
+ ))} +
+
+ )} + + {/* Post-Survey (Feedback) */} +
+
+ Post-Survey +
+
+ {Object.entries(resp.responses).map(([key, val]) => ( + +
+ {key.replace(/_/g, ' ')} +
+
+ {renderValue(val)} +
+
+ ))} +
+
+
+
+ )} +
+ ) +} + diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index c5987773..cd11e5b8 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,12 +1,12 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react' +import React, { useEffect, useState, useCallback } from 'react' import { Shield, ShieldCheck, BarChart3, Users, Building2, Workflow, Settings, Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, - ChevronRight, RefreshCw, MessageSquare, Zap, - CheckCircle2, XCircle, Download, + RefreshCw, Zap, + CheckCircle2, XCircle, ChevronDown, ChevronUp, Play, AlertCircle, FileText, FolderTree, X, - Mail, Send, Link, UserPlus, Star, Award, KeyRound, PackageOpen, + Mail, Star, Award, KeyRound, PackageOpen, BookOpen, } from 'lucide-react' import { PageLayout } from '../components/layout/PageLayout' @@ -16,26 +16,12 @@ import { useTeams } from '../hooks/useTeams' import { getThemeConfig, updateThemeConfig } from '../api/config' import type { ThemeConfig } from '../api/config' import { useBranding, DEFAULT_ORG_NAME, DEFAULT_ICON_URL } from '../contexts/BrandingContext' -import { useToast } from '../contexts/ToastContext' import { getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, } from '../api/admin' -import { - getDemoStats, getDemoApplications, releaseDemoUser, activateDemoUser, restartDemoTrial, - promoteDemoUser, - getPostExperienceResponses, sendTestEmail, adminResendCredentials, adminGetMagicLink, - adminAddDemoUser, -} from '../api/demo' import type { TestPromptResult, ModelTestResult, ReadinessReport, ReadinessItem } from '../api/admin' -import { getAdminPromptOverview, adminUpdatePrompt, type PromptOverview } from '../api/feedbackPrompt' -import * as supportApi from '../api/support' -import type { SupportTicket, SupportTicketSummary } from '../types/support' -import type { DemoAdminStats, DemoApplication as DemoApp, PostExperienceResponseAdmin } from '../types/demo' -import { POST_SURVEY_FIELDS } from '../components/survey/postSurveyFields' -import { PRE_SURVEY_FIELDS } from './Demo' -import { SurveyFieldRenderer } from '../components/survey/SurveyFieldRenderer' import type { SystemConfigData, } from '../api/admin' @@ -58,10 +44,10 @@ import { OrganizationsTab } from '../components/admin/OrganizationsTab' import { QualityTab } from '../components/admin/QualityTab' import { CertificationsTab } from '../components/admin/CertificationsTab' import { EmailAnalyticsTab } from '../components/admin/EmailAnalyticsTab' +import { DemoTab } from '../components/admin/DemoTab' import { TelemetryTab } from '../components/admin/TelemetryTab' import { TelemetryOptInBanner } from '../components/admin/TelemetryOptInBanner' import { getFeatureFlags } from '../api/config' -import { SearchInput } from '../components/admin/shared/primitives' function applyThemeToDOM(theme: ThemeConfig) { const root = document.documentElement @@ -92,33 +78,6 @@ const TABS: { key: Tab; label: string; icon: typeof BarChart3 }[] = [ { key: 'config', label: 'Config', icon: Settings }, ] -function parseUtcDate(d: string): Date { - // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC - if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') - return new Date(d) -} - -function formatDate(d: string | null): string { - if (!d) return '-' - return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) -} - -function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { - const escape = (v: string | number | null) => { - if (v === null || v === undefined) return '' - const s = String(v) - return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s - } - const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') - const blob = new Blob([csv], { type: 'text/csv' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) -} - // ────────────────────────────────────────── // Model connectivity diagnostics // ────────────────────────────────────────── @@ -2860,1398 +2819,6 @@ ${playgroundResult.request.user_prompt}`} // Main Admin Component // ────────────────────────────────────────── -// --------------------------------------------------------------------------- -// Demo Program Tab -// --------------------------------------------------------------------------- - -function DemoResponseDetail({ responses }: { responses: Record }) { - if (!responses || Object.keys(responses).length === 0) { - return
No onboarding responses recorded.
- } - - // Group fields by section using the PRE_SURVEY_FIELDS definitions - const sections: { name: string; items: { label: string; value: string }[] }[] = [] - let currentSection = '' - let currentItems: { label: string; value: string }[] = [] - - for (const field of PRE_SURVEY_FIELDS) { - if (field.type === 'info') continue - if (field.section && field.section !== currentSection) { - if (currentItems.length > 0) sections.push({ name: currentSection, items: currentItems }) - currentSection = field.section - currentItems = [] - } - - // Handle likert_group: each statement is a sub-key - if (field.type === 'likert_group' && field.statements) { - for (const stmt of field.statements) { - const val = responses[stmt.key] - if (val !== undefined && val !== null && val !== '') { - currentItems.push({ label: stmt.label, value: String(val) }) - } - } - continue - } - - const val = responses[field.key] - if (val === undefined || val === null || val === '') continue - const display = Array.isArray(val) ? val.join(', ') : String(val) - currentItems.push({ label: field.label, value: display }) - } - if (currentItems.length > 0) sections.push({ name: currentSection, items: currentItems }) - - // Also show any keys not in PRE_SURVEY_FIELDS (future-proofing) - const knownKeys = new Set(PRE_SURVEY_FIELDS.flatMap(f => - f.type === 'likert_group' && f.statements ? f.statements.map(s => s.key) : [f.key] - )) - const extraItems: { label: string; value: string }[] = [] - for (const [key, val] of Object.entries(responses)) { - if (knownKeys.has(key) || val === undefined || val === null || val === '') continue - const display = Array.isArray(val) ? val.join(', ') : String(val) - extraItems.push({ label: key, value: display }) - } - if (extraItems.length > 0) sections.push({ name: 'Other', items: extraItems }) - - const likertLabels: Record = { - '1': 'Strongly Disagree', - '2': 'Disagree', - '3': 'Neutral', - '4': 'Agree', - '5': 'Strongly Agree', - } - - return ( -
-
Onboarding Responses
- {sections.map((section) => ( -
-
- {section.name} -
-
- {section.items.map((item) => ( -
- {item.label} - - {likertLabels[item.value] || (item.value.match(/^\d+$/) && item.label.toLowerCase().includes('minute') ? `${item.value} min` : item.value)} - -
- ))} -
-
- ))} -
- ) -} - -function DemoTab() { - const confirm = useConfirm() - const { toast } = useToast() - const [subTab, setSubTab] = useState<'applications' | 'surveys'>('applications') - const [stats, setStats] = useState(null) - const [apps, setApps] = useState([]) - const [statusFilter, setStatusFilter] = useState('') - const [search, setSearch] = useState('') - const [loading, setLoading] = useState(true) - const [expandedUuid, setExpandedUuid] = useState(null) - - const loadData = useCallback(async () => { - setLoading(true) - try { - const [s, a] = await Promise.all([ - getDemoStats(), - getDemoApplications(statusFilter || undefined), - ]) - setStats(s) - setApps(a) - } catch { - // ignore - } finally { - setLoading(false) - } - }, [statusFilter]) - - useEffect(() => { loadData() }, [loadData]) - - // Client-side text search over the (status-filtered) applications. - const filteredApps = useMemo(() => { - const q = search.trim().toLowerCase() - if (!q) return apps - return apps.filter(app => - app.name.toLowerCase().includes(q) - || (app.title ?? '').toLowerCase().includes(q) - || app.email.toLowerCase().includes(q) - || app.organization.toLowerCase().includes(q), - ) - }, [apps, search]) - - const [actionLoading, setActionLoading] = useState(null) - - async function handleExport() { - setActionLoading('export') - try { - // Fetch all applications (unfiltered) and post-survey responses - const [allApps, postResponses] = await Promise.all([ - getDemoApplications(), - getPostExperienceResponses(), - ]) - - // Index post-survey responses by email for matching - const postByEmail = new Map>() - for (const pr of postResponses) { - postByEmail.set(pr.email, pr.responses) - } - - // Build pre-survey column definitions (key + label) - const preCols: { key: string; label: string }[] = [] - for (const f of PRE_SURVEY_FIELDS) { - if (f.type === 'info') continue - if (f.type === 'likert_group' && f.statements) { - for (const s of f.statements) preCols.push({ key: s.key, label: `Pre: ${s.label}` }) - } else { - preCols.push({ key: f.key, label: `Pre: ${f.label}` }) - } - } - - // Build post-survey column definitions - const postCols: { key: string; label: string }[] = [] - for (const f of POST_SURVEY_FIELDS) { - if (f.type === 'info') continue - if (f.type === 'likert_group' && f.statements) { - for (const s of f.statements) postCols.push({ key: s.key, label: `Post: ${s.label}` }) - } else { - postCols.push({ key: f.key, label: `Post: ${f.label}` }) - } - } - - const headers = [ - 'Name', 'Title', 'Email', 'Organization', 'Status', - 'Applied', 'Activated', 'Credentials Sent', 'First Login', - 'Expires', 'Post-Survey Completed', - ...preCols.map(c => c.label), - ...postCols.map(c => c.label), - ] - - const rows = allApps.map(app => { - const pre = app.questionnaire_responses || {} - const post = postByEmail.get(app.email) || {} - const fmt = (v: unknown) => { - if (v === null || v === undefined || v === '') return null - return Array.isArray(v) ? v.join('; ') : String(v) - } - return [ - app.name, - app.title || null, - app.email, - app.organization, - app.status, - app.created_at ? formatDate(app.created_at) : null, - app.activated_at ? formatDate(app.activated_at) : null, - app.credentials_sent_at ? formatDate(app.credentials_sent_at) : null, - app.last_login_at ? formatDate(app.last_login_at) : 'Never', - app.expires_at ? formatDate(app.expires_at) : null, - app.post_questionnaire_completed ? 'Yes' : 'No', - ...preCols.map(c => fmt(pre[c.key])), - ...postCols.map(c => fmt(post[c.key])), - ] as (string | number | null)[] - }) - - downloadCSV('demo_export.csv', headers, rows) - } catch { - toast('Failed to export demo data', 'error') - } finally { - setActionLoading(null) - } - } - - async function handleActivate(uuid: string) { - await activateDemoUser(uuid) - loadData() - } - - async function handleRelease(uuid: string) { - await releaseDemoUser(uuid) - loadData() - } - - async function handleRestartTrial(uuid: string) { - const ok = await confirm({ - title: 'Restart trial?', - message: 'Restart this user\'s trial? They will get a fresh 14-day trial period starting now.', - confirmLabel: 'Restart trial', - }) - if (!ok) return - setActionLoading(`restart-${uuid}`) - try { - await restartDemoTrial(uuid) - loadData() - } catch { - toast('Failed to restart trial', 'error') - } finally { - setActionLoading(null) - } - } - - async function handlePromote(uuid: string, email: string) { - const ok = await confirm({ - title: 'Promote to full user?', - message: ( - <> - Promote {email} to a permanent full user? Their trial expiry - will be cleared and they'll keep their account, data, and team membership. - This cannot be reversed from this screen. - - ), - confirmLabel: 'Promote', - }) - if (!ok) return - setActionLoading(`promote-${uuid}`) - try { - await promoteDemoUser(uuid) - loadData() - } catch { - toast('Failed to promote user', 'error') - } finally { - setActionLoading(null) - } - } - - async function handleTestEmail(email: string) { - setActionLoading(`test-${email}`) - try { - await sendTestEmail(email) - toast(`Test email sent to ${email}`, 'success') - } catch { - toast('Failed to send test email. Check SMTP configuration.', 'error') - } finally { - setActionLoading(null) - } - } - - async function handleResendCredentials(uuid: string, email: string) { - const ok = await confirm({ - title: 'Resend credentials?', - message: ( - <> - Resend credentials to {email}? This will reset their password. - - ), - confirmLabel: 'Resend', - destructive: true, - }) - if (!ok) return - setActionLoading(`resend-${uuid}`) - try { - await adminResendCredentials(uuid) - toast(`Credentials resent to ${email}`, 'success') - } catch { - toast('Failed to resend credentials', 'error') - } finally { - setActionLoading(null) - } - } - - async function handleCopyMagicLink(uuid: string) { - setActionLoading(`magic-${uuid}`) - try { - const result = await adminGetMagicLink(uuid) - await navigator.clipboard.writeText(result.url) - toast('Magic link copied to clipboard! It expires in 24 hours and can only be used once.', 'success') - } catch { - toast('Failed to generate magic link', 'error') - } finally { - setActionLoading(null) - } - } - - // --- Add user form --- - const [showAddUser, setShowAddUser] = useState(false) - const [addUserForm, setAddUserForm] = useState({ first_name: '', last_name: '', email: '' }) - const [addUserError, setAddUserError] = useState(null) - - async function handleAddUser(e: React.FormEvent) { - e.preventDefault() - setAddUserError(null) - setActionLoading('add-user') - try { - await adminAddDemoUser(addUserForm) - setAddUserForm({ first_name: '', last_name: '', email: '' }) - setShowAddUser(false) - loadData() - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'Failed to add user' - setAddUserError(msg) - } finally { - setActionLoading(null) - } - } - - const statusColors: Record = { - pending: { bg: '#fef3c7', text: '#92400e' }, - active: { bg: '#dcfce7', text: '#166534' }, - expired: { bg: '#fee2e2', text: '#991b1b' }, - completed: { bg: '#dbeafe', text: '#1e40af' }, - } - - return ( -
-
-

Demo Program

- {subTab === 'applications' && ( -
- - - -
- )} -
- - {/* Sub-tab bar */} -
- - -
- - {subTab === 'surveys' && } - - {subTab === 'applications' && ( - <> - {/* Add user form */} - {showAddUser && ( -
-
Add User to Trial
-
-
- - setAddUserForm({ ...addUserForm, first_name: e.target.value })} - style={{ - width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', - fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box', - }} - /> -
-
- - setAddUserForm({ ...addUserForm, last_name: e.target.value })} - style={{ - width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', - fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box', - }} - /> -
-
- - setAddUserForm({ ...addUserForm, email: e.target.value })} - style={{ - width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', - fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box', - }} - /> -
- -
- {addUserError && ( -
{addUserError}
- )} -
- )} - - {/* Stats cards */} - {stats && ( -
- {[ - { label: 'Total', value: stats.total_applications, color: '#6b7280' }, - { label: 'Active', value: stats.active_count, color: '#16a34a' }, - { label: 'Waitlist', value: stats.waitlist_count, color: '#d97706' }, - { label: 'Expired', value: stats.expired_count, color: '#dc2626' }, - { label: 'Completed', value: stats.completed_count, color: '#2563eb' }, - ].map((card) => ( -
-
{card.label}
-
{card.value}
-
- ))} -
- )} - - {/* Organization breakdown */} - {stats && stats.by_organization.length > 0 && ( -
-

By Organization

-
- {stats.by_organization.map((org) => ( - - {org.organization}: {org.count} - - ))} -
-
- )} - - {/* Filter */} -
- {['', 'pending', 'active', 'expired', 'completed'].map((s) => ( - - ))} -
- -
-
- - {/* Applications table */} - {loading ? ( -
Loading...
- ) : ( -
- - - - - - - - - - - - - - - - {filteredApps.length === 0 && ( - - - - )} - {filteredApps.map((app) => { - const sc = statusColors[app.status] || { bg: '#f3f4f6', text: '#374151' } - const isExpanded = expandedUuid === app.uuid - return ( - - setExpandedUuid(isExpanded ? null : app.uuid)} - style={{ borderBottom: isExpanded ? 'none' : '1px solid #f3f4f6', cursor: 'pointer' }} - > - - - - - - - - - - - {isExpanded && ( - - - - )} - - ) - })} - {apps.length === 0 && ( - - - - )} - -
NameEmailOrganizationStatusAppliedCredentials SentFirst LoginExpiresActions
- {search.trim() ? 'No applications match your search.' : 'No applications found.'} -
- {isExpanded ? '▼' : '▶'} - {app.name} - {app.title && {app.title}} - {app.email}{app.organization} - - {app.status} - - - {formatDate(app.created_at)} - - {app.credentials_sent_at ? formatDate(app.credentials_sent_at) : '-'} - - {app.last_login_at ? ( - {formatDate(app.last_login_at)} - ) : ( - Never - )} - - {app.expires_at ? formatDate(app.expires_at) : '-'} - -
e.stopPropagation()}> - {app.status === 'pending' && ( - - )} - {(app.status === 'expired' || app.status === 'completed') && !app.admin_released && ( - - )} - {(app.status === 'active' || app.status === 'expired' || app.status === 'completed') && ( - - )} - {(app.status === 'active' || app.status === 'expired' || app.status === 'completed') && app.user_is_demo && ( - - )} - {(app.status === 'active' || app.status === 'expired' || app.status === 'completed') && !app.user_is_demo && ( - - Full user - - )} - - {app.status === 'active' && ( - <> - - - - )} - {app.admin_released && ( - Released - )} - {app.post_questionnaire_completed && ( - Feedback done - )} -
-
- -
- No applications found -
-
- )} - - {/* Trial Check-ins */} - - - - )} -
- ) -} - -function CheckInConversationsSection() { - const [tickets, setTickets] = useState([]) - const [loading, setLoading] = useState(true) - const [statusFilter, setStatusFilter] = useState<'all' | 'open' | 'in_progress' | 'closed'>('all') - const [activeUuid, setActiveUuid] = useState(null) - - const load = useCallback(async () => { - setLoading(true) - try { - const status = statusFilter === 'all' ? undefined : statusFilter - const res = await supportApi.listTickets(status, 200, 0, undefined, undefined, 'feedback_prompt') - setTickets(res.tickets) - } catch { - // ignore - } finally { - setLoading(false) - } - }, [statusFilter]) - - useEffect(() => { load() }, [load]) - - const statusColors: Record = { - open: '#f59e0b', - in_progress: '#3b82f6', - closed: '#9ca3af', - } - - const fmtTime = (iso: string | null) => { - if (!iso) return '' - const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) - if (diff < 60) return 'just now' - if (diff < 3600) return `${Math.floor(diff / 60)}m ago` - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` - return `${Math.floor(diff / 86400)}d ago` - } - - return ( -
-
-
-

Check-ins

-

- Conversations from trial check-in prompts. These do not appear in the Support Center. -

-
-
- {(['all', 'open', 'in_progress', 'closed'] as const).map(s => ( - - ))} - -
-
- - {loading ? ( -
Loading...
- ) : tickets.length === 0 ? ( -
- No check-in conversations yet. -
- ) : ( -
- - - - - - - - - - - - {tickets.map((t) => { - const isExpanded = activeUuid === t.uuid - const subject = t.subject.replace(/^\[Check-in\]\s*/, '') - return ( - - setActiveUuid(isExpanded ? null : t.uuid)} - style={{ borderBottom: isExpanded ? 'none' : '1px solid #f3f4f6', cursor: 'pointer' }} - > - - - - - - - {isExpanded && ( - - - - )} - - ) - })} - -
SubjectUserStatusMessagesLast activity
- {isExpanded ? '▼' : '▶'} - {subject} - {t.user_name || t.user_id} - - {t.status.replace('_', ' ')} - - {t.message_count} - {fmtTime(t.last_message_at || t.updated_at)} -
- -
-
- )} -
- ) -} - -function CheckInConversation({ ticketUuid, onUpdate }: { ticketUuid: string; onUpdate: () => void }) { - const [ticket, setTicket] = useState(null) - const [loading, setLoading] = useState(true) - const [reply, setReply] = useState('') - const [sending, setSending] = useState(false) - - const loadTicket = useCallback(async () => { - try { - const data = await supportApi.getTicket(ticketUuid) - setTicket(data) - } catch { - // ignore - } finally { - setLoading(false) - } - }, [ticketUuid]) - - useEffect(() => { - loadTicket() - supportApi.markTicketRead(ticketUuid).catch(() => {}) - }, [loadTicket, ticketUuid]) - - const handleSend = async () => { - if (!reply.trim() || sending) return - setSending(true) - try { - const updated = await supportApi.addMessage(ticketUuid, reply.trim()) - setTicket(updated) - setReply('') - onUpdate() - } catch { - // ignore - } finally { - setSending(false) - } - } - - const handleStatusChange = async (next: string) => { - try { - const updated = await supportApi.updateTicket(ticketUuid, { status: next }) - setTicket(updated) - onUpdate() - } catch { - // ignore - } - } - - if (loading) { - return
Loading conversation...
- } - - if (!ticket) { - return
Failed to load ticket.
- } - - return ( -
-
-
- {ticket.user_email && {ticket.user_email} · } - opened {ticket.created_at ? new Date(ticket.created_at).toLocaleString() : ''} -
- {ticket.status !== 'closed' ? ( - - ) : ( - - )} -
- -
- {ticket.messages.map((m) => { - const isSupport = m.is_support_reply - return ( -
-
-
- {m.user_name || m.user_id} - {isSupport && Team} -
-
{m.content}
-
- {m.created_at ? new Date(m.created_at).toLocaleString() : ''} -
-
-
- ) - })} -
- - {ticket.status !== 'closed' ? ( -
- setReply(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }} - placeholder="Reply to this check-in..." - style={{ - flex: 1, padding: '8px 12px', fontSize: 13, - border: '1px solid #d1d5db', borderRadius: 8, outline: 'none', fontFamily: 'inherit', - }} - /> - -
- ) : ( -
- This conversation is closed. Reopen to send a reply. -
- )} -
- ) -} - -function TrialCheckinsSection() { - const [prompts, setPrompts] = useState([]) - const [loading, setLoading] = useState(true) - - const loadPrompts = useCallback(async () => { - setLoading(true) - try { - const data = await getAdminPromptOverview() - setPrompts(data) - } catch { - // ignore - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { loadPrompts() }, [loadPrompts]) - - async function toggleEnabled(slug: string, enabled: boolean) { - await adminUpdatePrompt(slug, { enabled }) - loadPrompts() - } - - const stageColors: Record = { - early: { bg: '#dbeafe', text: '#1e40af' }, - mid: { bg: '#fef3c7', text: '#92400e' }, - late: { bg: '#fee2e2', text: '#991b1b' }, - } - - return ( -
-
-

Trial Check-ins

- -
-

- Proactive check-in prompts delivered through the support panel during the trial. - Responses appear as support tickets. -

- - {loading ? ( -
Loading...
- ) : prompts.length === 0 ? ( -
- No prompts configured. They will be seeded on next server restart. -
- ) : ( -
- - - - - - - - - - - - - - - {prompts.map((p) => { - const sc = stageColors[p.stage] || { bg: '#f3f4f6', text: '#374151' } - return ( - - - - - - - - - - - ) - })} - -
StageSubjectQuestionShownRespondedDismissedRateEnabled
- - {p.stage} - - {p.subject} - - {p.question_text.length > 80 ? p.question_text.slice(0, 80) + '...' : p.question_text} - - {p.stats.shown} - {p.stats.responded} - {p.stats.dismissed} - {p.stats.shown > 0 ? `${Math.round(p.stats.response_rate * 100)}%` : '-'} - - -
-
- )} -
- ) -} - -function SurveyResponsesSection() { - const [responses, setResponses] = useState([]) - const [loading, setLoading] = useState(true) - const [expandedUuid, setExpandedUuid] = useState(null) - const [showPreview, setShowPreview] = useState(false) - const [previewAnswers, setPreviewAnswers] = useState>({}) - const previewSections = useMemo(() => { - const sections: { name: string; fields: typeof POST_SURVEY_FIELDS }[] = [] - let current: { name: string; fields: typeof POST_SURVEY_FIELDS } | null = null - for (const f of POST_SURVEY_FIELDS) { - const sec = f.section || '' - if (!current || current.name !== sec) { - current = { name: sec, fields: [] } - sections.push(current) - } - current.fields.push(f) - } - return sections - }, []) - - const loadData = useCallback(async () => { - setLoading(true) - try { - const data = await getPostExperienceResponses() - setResponses(data) - } catch { - // ignore - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { loadData() }, [loadData]) - - function renderValue(val: unknown): string { - if (val === null || val === undefined) return '-' - if (Array.isArray(val)) return val.join(', ') - if (typeof val === 'object') { - return Object.entries(val as Record) - .map(([k, v]) => `${k}: ${v}`) - .join('; ') - } - return String(val) - } - - return ( -
-
-

Survey Responses

-
- - -
-
- - {showPreview && ( -
-
- -

- Post-Survey Preview -

-

- This is what participants see after their demo expires. -

-
- {previewSections.map((sec) => ( -
-
- {sec.name} -
-
- {sec.fields.map((field) => ( -
- - setPreviewAnswers(prev => ({ ...prev, [k]: v }))} - /> -
- ))} -
-
- ))} -
- )} - - {loading ? ( -
Loading...
- ) : responses.length === 0 ? ( -
- No survey responses yet. -
- ) : ( -
- - - - - - - - - - - {responses.map((resp) => ( - - setExpandedUuid(expandedUuid === resp.uuid ? null : resp.uuid)} - style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }} - > - - - - - - {expandedUuid === resp.uuid && ( - - - - )} - - ))} - -
NameEmailOrganizationSubmitted
-
- {expandedUuid === resp.uuid ? : } - {resp.name} -
-
{resp.email}{resp.organization} - {formatDate(resp.created_at)} -
- {/* Pre-Survey (Questionnaire) */} - {Object.keys(resp.questionnaire_responses).length > 0 && ( -
-
- Pre-Survey -
-
- {resp.title && ( - -
title
-
{resp.title}
-
- )} - {Object.entries(resp.questionnaire_responses).map(([key, val]) => ( - -
- {key.replace(/_/g, ' ')} -
-
- {renderValue(val)} -
-
- ))} -
-
- )} - - {/* Post-Survey (Feedback) */} -
-
- Post-Survey -
-
- {Object.entries(resp.responses).map(([key, val]) => ( - -
- {key.replace(/_/g, ' ')} -
-
- {renderValue(val)} -
-
- ))} -
-
-
-
- )} -
- ) -} - export default function Admin() { const { user } = useAuth() const { currentTeam } = useTeams() From 70e143a2fc153ea19f51b423127ba10ab4a490cb Mon Sep 17 00:00:00 2001 From: Zachary Rossmiller Date: Fri, 24 Jul 2026 11:18:38 -0600 Subject: [PATCH 15/18] refactor(admin): extract ConfigTab into its own file Co-Authored-By: Claude Fable 5 --- frontend/src/components/admin/ConfigTab.tsx | 2770 ++++++++++++++++++ frontend/src/pages/Admin.tsx | 2773 +------------------ 2 files changed, 2775 insertions(+), 2768 deletions(-) create mode 100644 frontend/src/components/admin/ConfigTab.tsx diff --git a/frontend/src/components/admin/ConfigTab.tsx b/frontend/src/components/admin/ConfigTab.tsx new file mode 100644 index 00000000..1bd36b9f --- /dev/null +++ b/frontend/src/components/admin/ConfigTab.tsx @@ -0,0 +1,2770 @@ +import { useEffect, useState, useCallback } from 'react' +import { + ShieldCheck, Users, Settings, + Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, + RefreshCw, + CheckCircle2, XCircle, + ChevronDown, ChevronUp, Play, AlertCircle, + X, Star, +} from 'lucide-react' +import { useConfirm } from '../shared/useConfirm' +import { getThemeConfig, updateThemeConfig } from '../../api/config' +import type { ThemeConfig } from '../../api/config' +import { useBranding, DEFAULT_ORG_NAME, DEFAULT_ICON_URL } from '../../contexts/BrandingContext' +import { + getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, + addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, + updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, +} from '../../api/admin' +import type { TestPromptResult, ModelTestResult, ReadinessReport, ReadinessItem } from '../../api/admin' +import type { + SystemConfigData, +} from '../../api/admin' +import { fileToConstrainedDataUrl } from '../../utils/imageResize' +import { ModelCharacterBars } from '../ModelEffortPicker' +import type { ModelInfo } from '../../types/workflow' + +function applyThemeToDOM(theme: ThemeConfig) { + const root = document.documentElement + root.style.setProperty('--highlight-color', theme.highlight_color) + root.style.setProperty('--ui-radius', theme.ui_radius) +} + +const MAX_LOGO_BYTES = 500_000 // matches backend cap on the encoded data URL + +// ────────────────────────────────────────── +// Model connectivity diagnostics +// ────────────────────────────────────────── + +// Renders the step-by-step result of a model "Test" — on success, why the +// hook-up is healthy (protocol, endpoint, latency, tokens, the actual reply); +// on failure, a classified error with a plain-English cause and suggested fix. +function ModelTestDiagnostics({ result }: { result: ModelTestResult }) { + const [showRaw, setShowRaw] = useState(false) + const accent = result.ok ? '#16a34a' : '#dc2626' + return ( +
+
+ {result.summary} +
+ + {/* Step-by-step checks */} +
+ {result.checks.map((c, idx) => ( +
+ {c.ok + ? + : } + + {c.label}: {c.detail} + +
+ ))} +
+ + {/* Success facts */} + {result.ok && ( +
+ {result.protocol && } + {result.endpoint && } + {typeof result.latency_ms === 'number' && } + {result.tokens?.total != null && } +
+ )} + {result.ok && result.response_preview && ( +
+ reply: {result.response_preview} +
+ )} + + {/* Failure guidance */} + {!result.ok && result.error && ( +
+
+ +
+
{result.error.title}
+
{result.error.why}
+
+
+
+ Try this: {result.error.fix} +
+ {result.error.raw && ( +
+ + {showRaw && ( +
+                  {result.error.raw}
+                
+ )} +
+ )} +
+ )} +
+ ) +} + +function DiagFact({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + {value} + + ) +} + +// ────────────────────────────────────────── +// Setup readiness checklist +// ────────────────────────────────────────── + +// A graded "is this install set up" surface. A dismissible banner auto-shows +// while a blocker (no working LLM) is unresolved; the full checklist always +// lives at the top of the config page. `onJump` scrolls to the relevant +// section so each item is one click from being fixed. +function SetupChecklist({ report, onJump, onDismiss }: { report: ReadinessReport; onJump: (target: string) => void; onDismiss?: () => void }) { + const sevColor: Record = { blocker: '#dc2626', recommended: '#d97706', optional: '#6b7280' } + const statusPill = (item: ReadinessItem) => { + if (item.status === 'configured') return { label: 'Done', bg: '#dcfce7', fg: '#166534' } + if (item.status === 'incomplete') return { label: 'Needs attention', bg: '#fef9c3', fg: '#854d0e' } + return item.severity === 'blocker' + ? { label: 'Required', bg: '#fee2e2', fg: '#991b1b' } + : { label: 'Recommended', bg: '#ffedd5', fg: '#9a3412' } + } + return ( +
+
+ {report.ready + ? + : } + + {report.ready ? 'System ready' : 'Finish setting up your workspace'} + + {!report.ready && report.blockers_remaining > 0 && ( + + {report.blockers_remaining} blocker{report.blockers_remaining > 1 ? 's' : ''} left + + )} +
+ {onDismiss && ( + + )} +
+
+ {report.items.map(item => { + const pill = statusPill(item) + const done = item.status === 'configured' + return ( +
+
+ {done + ? + :
} +
+
+
+ {item.title} + {pill.label} +
+
{item.summary}
+ {!done &&
Unlocks: {item.unlocks}
} +
+ {!done && ( + + )} +
+ ) + })} +
+
+ ) +} + +// ────────────────────────────────────────── +// Config Tab +// ────────────────────────────────────────── + +// The editable shape of a single LLM model config row. +type ModelDraft = { + name: string + tag: string + external: boolean + thinking: boolean + endpoint: string + api_protocol: string + api_key: string + speed: string + tier: string + privacy: string + supports_structured: boolean + multimodal: boolean + supports_pdf: boolean + context_window: number + // Optional per-model overrides. 0 = unset (backend uses system default / + // computed value). + request_timeout_seconds: number + response_reserve_tokens: number +} + +const EMPTY_MODEL_DRAFT: ModelDraft = { + name: '', tag: '', external: false, thinking: false, endpoint: '', api_protocol: '', api_key: '', + speed: '', tier: '', privacy: '', supports_structured: true, multimodal: false, supports_pdf: false, + context_window: 128000, request_timeout_seconds: 0, response_reserve_tokens: 0, +} + +// Provider presets power the "Add a Model" wizard. Selecting one fills in the +// technical fields (protocol, endpoint, external/privacy flags, sensible +// capability defaults) so admins only supply a model name and, for hosted APIs, +// a key. `apply` is merged into the draft; everything stays editable under +// "Advanced settings". +type ModelProviderPreset = { + id: string + label: string + blurb: string + needsKey: boolean + needsEndpoint: boolean + keyPlaceholder?: string + keyHelp?: string + namePlaceholder: string + nameSuggestions?: string[] + endpointPlaceholder?: string + apply: Partial +} + +const MODEL_PROVIDERS: ModelProviderPreset[] = [ + { + id: 'google', + label: 'Google (Gemini)', + blurb: "Gemini models via Google AI Studio. Native integration — just a model name and key.", + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'AIza… (AI Studio API key)', + keyHelp: 'Create a key at aistudio.google.com → API keys.', + namePlaceholder: 'gemini-2.5-flash', + nameSuggestions: ['gemini-2.5-flash', 'gemini-2.5-pro'], + apply: { api_protocol: 'google', external: true, privacy: 'external', endpoint: '', tag: 'google', multimodal: true, supports_pdf: true, context_window: 1048576 }, + }, + { + id: 'openai', + label: 'OpenAI', + blurb: 'GPT models from the OpenAI API.', + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'sk-…', + namePlaceholder: 'gpt-4o', + nameSuggestions: ['gpt-4o', 'gpt-4o-mini'], + apply: { api_protocol: 'openai', external: true, privacy: 'external', endpoint: 'https://api.openai.com/v1', tag: 'openai', multimodal: true }, + }, + { + id: 'anthropic', + label: 'Anthropic (Claude)', + blurb: 'Claude models via the native Anthropic API.', + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'sk-ant-…', + namePlaceholder: 'claude-…', + apply: { api_protocol: 'anthropic', external: true, privacy: 'external', endpoint: '', tag: 'anthropic', multimodal: true }, + }, + { + id: 'openrouter', + label: 'OpenRouter', + blurb: 'Any model routed through OpenRouter.', + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'sk-or-…', + namePlaceholder: 'anthropic/claude-…', + apply: { api_protocol: 'openrouter', external: true, privacy: 'external', endpoint: '', tag: 'openrouter' }, + }, + { + id: 'ollama', + label: 'Ollama (self-hosted)', + blurb: 'A model served locally by Ollama.', + needsKey: false, + needsEndpoint: true, + namePlaceholder: 'llama3.1', + nameSuggestions: ['llama3.1', 'mistral'], + endpointPlaceholder: 'http://localhost:11434/v1', + apply: { api_protocol: 'ollama', external: false, privacy: 'internal', endpoint: 'http://localhost:11434/v1', tag: 'ollama' }, + }, + { + id: 'vllm', + label: 'vLLM (self-hosted)', + blurb: 'A model served by your own vLLM instance.', + needsKey: false, + needsEndpoint: true, + namePlaceholder: 'qwen3', + nameSuggestions: ['qwen3'], + endpointPlaceholder: 'http://localhost:8000/v1', + apply: { api_protocol: 'vllm', external: false, privacy: 'internal', endpoint: '', tag: 'vllm' }, + }, + { + id: 'custom', + label: 'Custom / OpenAI-compatible', + blurb: 'Any other OpenAI-compatible endpoint. Full manual control.', + needsKey: true, + needsEndpoint: true, + keyPlaceholder: 'API key (if required)', + namePlaceholder: 'model name', + endpointPlaceholder: 'https://…/v1', + apply: { api_protocol: 'openai', external: true, privacy: 'external', endpoint: '', tag: 'custom' }, + }, +] + +// Best-effort match of an existing saved model back to a provider preset, so the +// Edit flow lands on the right guided fields. +function inferProviderId(m: { api_protocol?: string; external?: boolean; endpoint?: string }): string { + const proto = (m.api_protocol || '').toLowerCase() + if (proto === 'google') return 'google' + if (proto === 'anthropic') return 'anthropic' + if (proto === 'openrouter') return 'openrouter' + if (proto === 'ollama') return 'ollama' + if (proto === 'vllm') return 'vllm' + if (proto === 'openai' && (m.endpoint || '').includes('api.openai.com')) return 'openai' + return 'custom' +} + +export function ConfigTab() { + const confirm = useConfirm() + const [cfg, setCfg] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + // Theme state + const branding = useBranding() + const [themeColor, setThemeColor] = useState('#eab308') + const [themeRadius, setThemeRadius] = useState(12) + const [themeOrgName, setThemeOrgName] = useState('') + const [themeLogo, setThemeLogo] = useState('') + const [themeLogoError, setThemeLogoError] = useState(null) + const [themeIcon, setThemeIcon] = useState('') + const [themeIconError, setThemeIconError] = useState(null) + const [themeIconHideInNav, setThemeIconHideInNav] = useState(false) + const [themeSaving, setThemeSaving] = useState(false) + const [themeSaved, setThemeSaved] = useState(false) + + // Extraction config + const [extractionMode, setExtractionMode] = useState('one_pass') + const [chunkingEnabled, setChunkingEnabled] = useState(false) + const [maxKeysPerChunk, setMaxKeysPerChunk] = useState(10) + const [repetitionEnabled, setRepetitionEnabled] = useState(false) + const [onePassThinking, setOnePassThinking] = useState(true) + const [onePassStructured, setOnePassStructured] = useState(true) + const [onePassModel, setOnePassModel] = useState('') + const [twoPassP1Thinking, setTwoPassP1Thinking] = useState(true) + const [twoPassP1Structured, setTwoPassP1Structured] = useState(false) + const [twoPassP1Model, setTwoPassP1Model] = useState('') + const [twoPassP2Thinking, setTwoPassP2Thinking] = useState(false) + const [twoPassP2Structured, setTwoPassP2Structured] = useState(true) + const [twoPassP2Model, setTwoPassP2Model] = useState('') + const [useImages, setUseImages] = useState(false) + + // Quality config + const [requireValidation, setRequireValidation] = useState(false) + const [minAccuracy, setMinAccuracy] = useState(70) + const [minConsistency, setMinConsistency] = useState(80) + const [minWorkflowGrade, setMinWorkflowGrade] = useState('C') + const [excellentThreshold, setExcellentThreshold] = useState(90) + const [goodThreshold, setGoodThreshold] = useState(70) + const [fairThreshold, setFairThreshold] = useState(50) + + // Endpoints + const [ocrEndpoint, setOcrEndpoint] = useState('') + const [ocrApiKey, setOcrApiKey] = useState('') + const [ocrTesting, setOcrTesting] = useState(false) + const [ocrTestResult, setOcrTestResult] = useState<{ ok: boolean; message: string } | null>(null) + const [modelTesting, setModelTesting] = useState(null) + const [modelTestResults, setModelTestResults] = useState>({}) + const [expandedModelTest, setExpandedModelTest] = useState(null) + + // System readiness / setup checklist + const [readiness, setReadiness] = useState(null) + const [setupDismissed, setSetupDismissed] = useState(false) + const refreshReadiness = useCallback(async () => { + try { + setReadiness(await getReadiness()) + } catch { + // Readiness is advisory — never block the config page on it. + } + }, []) + + // Prompt playground + const [playgroundModel, setPlaygroundModel] = useState('') + const [playgroundSystem, setPlaygroundSystem] = useState('') + const [playgroundUser, setPlaygroundUser] = useState('') + const [playgroundSending, setPlaygroundSending] = useState(false) + const [playgroundResult, setPlaygroundResult] = useState(null) + const [playgroundError, setPlaygroundError] = useState(null) + + // Auth + const [authMethods, setAuthMethods] = useState(['password']) + const [authSaving, setAuthSaving] = useState(false) + + // Add/edit model form + const [showModelForm, setShowModelForm] = useState(false) + const [editingModelIndex, setEditingModelIndex] = useState(null) + const [savingModel, setSavingModel] = useState(false) + const [newModel, setNewModel] = useState({ ...EMPTY_MODEL_DRAFT }) + const [probingContext, setProbingContext] = useState(false) + const [probeResult, setProbeResult] = useState<{ ok: boolean; message: string } | null>(null) + // Add-a-Model wizard: step 1 = pick provider, step 2 = configure + save + test. + const [wizardStep, setWizardStep] = useState<1 | 2>(1) + const [wizardProviderId, setWizardProviderId] = useState(null) + const [modelTest, setModelTest] = useState(null) + const [wizardTesting, setWizardTesting] = useState(false) + + // Support contacts + const [supportContacts, setSupportContacts] = useState<{ user_id: string; email: string; name: string }[]>([]) + const [showAddContact, setShowAddContact] = useState(false) + const [newContact, setNewContact] = useState({ user_id: '', email: '', name: '' }) + + // Compliance activation + const [complianceEnabled, setComplianceEnabled] = useState(false) + const [complianceCheckOnUpload, setComplianceCheckOnUpload] = useState(true) + const [complianceRules, setComplianceRules] = useState('') + const [complianceChunkSize, setComplianceChunkSize] = useState(8000) + const [complianceChunkOverlap, setComplianceChunkOverlap] = useState(200) + const [complianceSaving, setComplianceSaving] = useState(false) + const [complianceSaved, setComplianceSaved] = useState(false) + + // Retention policy + type RetentionPolicyForm = { retention_days: number; soft_delete_grace_days: number; warning_days_before?: number } + const [retentionEnabled, setRetentionEnabled] = useState(false) + const [retentionPolicies, setRetentionPolicies] = useState>({}) + const [activityRetentionDays, setActivityRetentionDays] = useState(180) + const [chatRetentionDays, setChatRetentionDays] = useState(365) + const [workflowResultRetentionDays, setWorkflowResultRetentionDays] = useState(365) + const [staleActivityMinutes, setStaleActivityMinutes] = useState(30) + const [retentionSaving, setRetentionSaving] = useState(false) + const [retentionSaved, setRetentionSaved] = useState(false) + + // Add/edit provider form + const [showAddProvider, setShowAddProvider] = useState(false) + const [newProvider, setNewProvider] = useState({ provider: 'oauth', display_name: '', client_id: '', client_secret: '', redirect_uri: '', tenant_id: '', idp_entity_id: '', idp_sso_url: '', idp_x509_cert: '' }) + const [editingProviderIndex, setEditingProviderIndex] = useState(null) + const [editingProvider, setEditingProvider] = useState({ provider: 'oauth', display_name: '', client_id: '', client_secret: '', redirect_uri: '', tenant_id: '', idp_entity_id: '', idp_sso_url: '', idp_x509_cert: '' }) + const [samlMeta, setSamlMeta] = useState('') + const [samlMetaBusy, setSamlMetaBusy] = useState(false) + const [samlMetaError, setSamlMetaError] = useState('') + const [providerError, setProviderError] = useState('') + + /** Return a message if the provider form is missing a required field, else ''. */ + const providerValidationError = (p: { provider: string; display_name: string; client_id: string; idp_entity_id: string; idp_sso_url: string; idp_x509_cert: string }): string => { + if (!p.display_name.trim()) return 'Display name is required.' + if (p.provider === 'saml') { + if (!p.idp_entity_id.trim() || !p.idp_sso_url.trim() || !p.idp_x509_cert.trim()) { + return 'SAML requires the IdP Entity ID, SSO URL, and x509 certificate (use "Fetch & fill" to import them).' + } + } else if (!p.client_id.trim()) { + return 'Client ID is required.' + } + return '' + } + + const handleImportSamlMetadata = async () => { + const raw = samlMeta.trim() + if (!raw) return + setSamlMetaBusy(true) + setSamlMetaError('') + try { + const body = raw.startsWith('<') ? { metadata_xml: raw } : { metadata_url: raw } + const idp = await parseSamlMetadata(body) + setNewProvider(p => ({ ...p, idp_entity_id: idp.idp_entity_id, idp_sso_url: idp.idp_sso_url, idp_x509_cert: idp.idp_x509_cert })) + } catch (e) { + setSamlMetaError(e instanceof Error ? e.message : 'Could not read metadata') + } finally { + setSamlMetaBusy(false) + } + } + + useEffect(() => { void refreshReadiness() }, [refreshReadiness]) + + useEffect(() => { + setLoading(true) + getSystemConfig().then(c => { + setCfg(c) + setThemeColor(c.highlight_color || '#eab308') + setThemeRadius(parseInt(c.ui_radius) || 12) + setOcrEndpoint(c.ocr_endpoint || '') + setOcrApiKey(c.ocr_api_key || '') + setAuthMethods(c.auth_methods || ['password']) + setSupportContacts((c as unknown as Record).support_contacts as typeof supportContacts || []) + // Extraction config + const ec = c.extraction_config || {} + setExtractionMode((ec as Record).mode as string || 'one_pass') + const chunking = (ec as Record).chunking as Record || {} + setChunkingEnabled(!!chunking.enabled) + setMaxKeysPerChunk((chunking.max_keys_per_chunk as number) || 10) + setRepetitionEnabled(!!((ec as Record).repetition as Record)?.enabled) + setUseImages(!!(ec as Record).use_images) + const onePass = (ec as Record).one_pass as Record || {} + setOnePassThinking(onePass.thinking !== false) + setOnePassStructured((onePass.structured_output ?? onePass.structured) !== false) + setOnePassModel((onePass.model as string) || '') + const twoPass = (ec as Record).two_pass as Record || {} + const pass1 = (twoPass.pass1 as Record ?? twoPass.pass_1 as Record) || {} + const pass2 = (twoPass.pass2 as Record ?? twoPass.pass_2 as Record) || {} + setTwoPassP1Thinking(pass1.thinking !== false) + setTwoPassP1Structured(!!(pass1.structured_output ?? pass1.structured)) + setTwoPassP1Model((pass1.model as string) || '') + setTwoPassP2Thinking(!!(pass2.thinking)) + setTwoPassP2Structured((pass2.structured_output ?? pass2.structured) !== false) + setTwoPassP2Model((pass2.model as string) || '') + // Quality config + const qc = (c.quality_config || {}) as Record + const gates = (qc.verification_gates || {}) as Record + setRequireValidation(!!gates.require_validation) + setMinAccuracy(Math.round(((gates.min_extraction_accuracy as number) ?? 0.7) * 100)) + setMinConsistency(Math.round(((gates.min_extraction_consistency as number) ?? 0.8) * 100)) + setMinWorkflowGrade((gates.min_workflow_grade as string) || 'C') + const tiers = (qc.quality_tiers || {}) as Record> + setExcellentThreshold((tiers.excellent?.min_score as number) ?? 90) + setGoodThreshold((tiers.good?.min_score as number) ?? 70) + setFairThreshold((tiers.fair?.min_score as number) ?? 50) + // Compliance config + const comp = c.compliance_config || ({} as Partial) + setComplianceEnabled(!!comp.enabled) + setComplianceCheckOnUpload(comp.check_on_upload !== false) + setComplianceRules(comp.rules || '') + setComplianceChunkSize(comp.chunk_size || 8000) + setComplianceChunkOverlap(comp.chunk_overlap ?? 200) + // Retention config + const rc = (c.retention_config || {}) as Record + setRetentionEnabled(!!rc.enabled) + setRetentionPolicies((rc.policies as Record) || {}) + setActivityRetentionDays((rc.activity_retention_days as number) ?? 180) + setChatRetentionDays((rc.chat_retention_days as number) ?? 365) + setWorkflowResultRetentionDays((rc.workflow_result_retention_days as number) ?? 365) + setStaleActivityMinutes((rc.activity_stale_threshold_minutes as number) ?? 30) + }).catch(() => {}).finally(() => setLoading(false)) + + getThemeConfig().then(t => { + setThemeColor(t.highlight_color) + setThemeRadius(parseInt(t.ui_radius) || 12) + setThemeOrgName(t.org_name || '') + setThemeLogo(t.logo_data_url || '') + setThemeIcon(t.icon_data_url || '') + setThemeIconHideInNav(!!t.icon_hide_in_nav) + }).catch(() => {}) + }, []) + + const handleSaveConfig = async () => { + setSaving(true) + setSaved(false) + setError(null) + try { + await updateSystemConfig({ + extraction_config: { + mode: extractionMode, + one_pass: { thinking: onePassThinking, structured: onePassStructured, model: onePassModel || '' }, + two_pass: { + pass_1: { thinking: twoPassP1Thinking, structured: twoPassP1Structured, model: twoPassP1Model || '' }, + pass_2: { thinking: twoPassP2Thinking, structured: twoPassP2Structured, model: twoPassP2Model || '' }, + }, + chunking: { enabled: chunkingEnabled, max_keys_per_chunk: maxKeysPerChunk }, + repetition: { enabled: repetitionEnabled }, + use_images: useImages, + }, + quality_config: { + verification_gates: { + require_validation: requireValidation, + min_extraction_accuracy: minAccuracy / 100, + min_extraction_consistency: minConsistency / 100, + min_workflow_grade: minWorkflowGrade, + }, + quality_tiers: { + excellent: { min_score: excellentThreshold }, + good: { min_score: goodThreshold }, + fair: { min_score: fairThreshold }, + }, + }, + ocr_endpoint: ocrEndpoint, + ocr_api_key: ocrApiKey, + }) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + void refreshReadiness() + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const handleSaveTheme = async () => { + setThemeSaving(true) + setThemeSaved(false) + try { + const updated = await updateThemeConfig({ + highlight_color: themeColor, + ui_radius: `${themeRadius}px`, + org_name: themeOrgName.trim(), + logo_data_url: themeLogo, + icon_data_url: themeIcon, + icon_hide_in_nav: themeIconHideInNav, + }) + applyThemeToDOM(updated) + await branding.refresh() + setThemeSaved(true) + setTimeout(() => setThemeSaved(false), 3000) + } finally { + setThemeSaving(false) + } + } + + const handleLogoFile = async (file: File | null) => { + setThemeLogoError(null) + if (!file) return + if (!file.type.startsWith('image/')) { + setThemeLogoError('Please choose an image file (PNG, SVG, JPG).') + return + } + try { + // Auto-downscale oversized raster images so large exports "just work". + const dataUrl = await fileToConstrainedDataUrl(file, { maxBytes: MAX_LOGO_BYTES, maxDimension: 1024 }) + // Safety net for the rare oversized SVG, which is passed through unresized. + if (dataUrl.length > MAX_LOGO_BYTES) { + setThemeLogoError(`Image too large — keep encoded size under ${Math.round(MAX_LOGO_BYTES / 1024)} KB.`) + return + } + setThemeLogo(dataUrl) + } catch { + setThemeLogoError('Could not process the selected image. Try a different file.') + } + } + + const handleIconFile = async (file: File | null) => { + setThemeIconError(null) + if (!file) return + if (!file.type.startsWith('image/')) { + setThemeIconError('Please choose an image file (PNG, SVG, JPG).') + return + } + try { + // Icons/mascots are square-ish and small in the UI, so a tighter max + // dimension keeps them well under the cap while staying crisp. + const dataUrl = await fileToConstrainedDataUrl(file, { maxBytes: MAX_LOGO_BYTES, maxDimension: 512 }) + // Safety net for the rare oversized SVG, which is passed through unresized. + if (dataUrl.length > MAX_LOGO_BYTES) { + setThemeIconError(`Image too large — keep encoded size under ${Math.round(MAX_LOGO_BYTES / 1024)} KB.`) + return + } + setThemeIcon(dataUrl) + } catch { + setThemeIconError('Could not process the selected image. Try a different file.') + } + } + + const handleProbeContextWindow = async () => { + setProbingContext(true) + setProbeResult(null) + try { + const result = await probeModel({ + name: newModel.name, + endpoint: newModel.endpoint, + api_protocol: newModel.api_protocol, + api_key: newModel.api_key, + existing_model_index: editingModelIndex, + }) + if (result.context_window && result.context_window > 0) { + setNewModel(prev => ({ ...prev, context_window: result.context_window as number })) + setProbeResult({ ok: true, message: `Detected ${result.context_window.toLocaleString()} tokens (${result.source}).` }) + } else { + setProbeResult({ ok: false, message: result.detail || `No context length reported (${result.source}).` }) + } + } catch (e) { + setProbeResult({ ok: false, message: e instanceof Error ? e.message : 'Probe failed' }) + } finally { + setProbingContext(false) + } + } + + // Open the wizard fresh for a new model (provider-picker step). + const openAddModelWizard = () => { + setNewModel({ ...EMPTY_MODEL_DRAFT }) + setProbeResult(null) + setModelTest(null) + setWizardProviderId(null) + setWizardStep(1) + setEditingModelIndex(null) + setError(null) + setShowModelForm(true) + } + + const closeModelForm = () => { + setNewModel({ ...EMPTY_MODEL_DRAFT }) + setProbeResult(null) + setModelTest(null) + setWizardProviderId(null) + setWizardStep(1) + setShowModelForm(false) + setEditingModelIndex(null) + setError(null) + } + + // Wizard step 1 → 2: apply the provider's preset onto a clean draft. Starting + // from EMPTY (keeping only a model name the admin may have typed) prevents a + // previously-picked provider's flags — e.g. Google's 1M context window — from + // leaking in when they switch providers via "Change". + const selectProvider = (p: ModelProviderPreset) => { + setWizardProviderId(p.id) + setNewModel(prev => ({ ...EMPTY_MODEL_DRAFT, name: prev.name, ...p.apply })) + setProbeResult(null) + setModelTest(null) + setError(null) + setWizardStep(2) + } + + const handleSaveModel = async () => { + if (!newModel.name.trim()) { + setError('Enter a model name') + return + } + if (!newModel.tag.trim()) { + setError('A tag is required (set one under Advanced settings)') + return + } + setSavingModel(true) + setError(null) + setModelTest(null) + try { + let res + let savedIndex: number + if (editingModelIndex !== null) { + res = await updateModel(editingModelIndex, newModel) + savedIndex = editingModelIndex + } else { + res = await addModel(newModel) + savedIndex = res.models.length - 1 + } + if (cfg) { + const resDefault = (res as { default_model?: string }).default_model + setCfg({ + ...cfg, + available_models: res.models, + ...(resDefault !== undefined ? { default_model: resDefault } : {}), + }) + } + // The model is now saved — subsequent edits/tests target its index. + setEditingModelIndex(savedIndex) + void refreshReadiness() + // Auto-run a connection test so the admin gets a clear pass/fail without + // having to know where the test button lives. + setWizardTesting(true) + try { + const t = await testModel(savedIndex) + setModelTest(t) + } catch (e) { + setModelTest({ + ok: false, + checks: [], + summary: 'Saved, but the connection test could not run.', + error: { category: 'client', title: 'Test request failed', why: e instanceof Error ? e.message : 'Unknown error', fix: 'The model is saved. Re-run the test from the model list, or check your network.', raw: '' }, + }) + } finally { + setWizardTesting(false) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save model') + } finally { + setSavingModel(false) + } + } + + const handleEditModel = (index: number) => { + const m = cfg?.available_models[index] + if (!m) return + setNewModel({ + name: m.name, + tag: m.tag, + external: m.external, + thinking: m.thinking, + endpoint: m.endpoint || '', + api_protocol: m.api_protocol || '', + api_key: m.api_key || '', + speed: m.speed || '', + tier: m.tier || '', + privacy: m.privacy || '', + supports_structured: m.supports_structured !== false, + multimodal: !!m.multimodal, + supports_pdf: !!m.supports_pdf, + context_window: typeof m.context_window === 'number' && m.context_window > 0 ? m.context_window : 128000, + request_timeout_seconds: typeof m.request_timeout_seconds === 'number' && m.request_timeout_seconds > 0 ? m.request_timeout_seconds : 0, + response_reserve_tokens: typeof m.response_reserve_tokens === 'number' && m.response_reserve_tokens > 0 ? m.response_reserve_tokens : 0, + }) + setProbeResult(null) + setModelTest(null) + setWizardProviderId(inferProviderId(m)) + setWizardStep(2) // edit skips the provider picker + setEditingModelIndex(index) + setShowModelForm(true) + } + + const handleDeleteModel = async (index: number) => { + const model = cfg?.available_models?.[index] + const ok = await confirm({ + title: 'Delete model?', + message: ( + <> + Are you sure you want to delete the model {model?.name || 'this model'}? Workflows and chats configured to use it will fail until reconfigured. + + ), + confirmLabel: 'Delete', + destructive: true, + }) + if (!ok) return + try { + const res = await deleteModel(index) + if (cfg) { + const models = [...cfg.available_models] + models.splice(index, 1) + setCfg({ + ...cfg, + available_models: models, + ...(res.default_model !== undefined ? { default_model: res.default_model } : {}), + }) + } + // Dropping a model can clear the only configured LLM — re-grade setup. + setModelTestResults(prev => { const next = { ...prev }; delete next[index]; return next }) + void refreshReadiness() + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete model') + } + } + + const handleSetDefaultModel = async (name: string) => { + try { + // Toggle off if clicking the current default. + const next = cfg?.default_model === name ? '' : name + const res = await setDefaultModel(next) + if (cfg) setCfg({ ...cfg, default_model: res.default_model }) + void refreshReadiness() + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to set default model') + } + } + + const handleTestOcr = async () => { + setOcrTesting(true) + setOcrTestResult(null) + try { + // Send the form's current values so unsaved edits are what gets tested; + // an untouched key field holds the "***" sentinel, meaning the saved key. + const res = await testOcr({ ocr_endpoint: ocrEndpoint, ocr_api_key: ocrApiKey }) + setOcrTestResult({ ok: true, message: res.message }) + } catch (e) { + setOcrTestResult({ ok: false, message: e instanceof Error ? e.message : 'Test failed' }) + } finally { + setOcrTesting(false) + } + } + + const handleTestModel = async (index: number) => { + setModelTesting(index) + setModelTestResults(prev => { const next = { ...prev }; delete next[index]; return next }) + try { + const res = await testModel(index) + setModelTestResults(prev => ({ ...prev, [index]: res })) + // Auto-expand so the admin sees the breakdown — especially on failure. + setExpandedModelTest(index) + // A successful test means readiness may have changed. + if (res.ok) void refreshReadiness() + } catch (e) { + // Transport-level failure (network/permission) — synthesize a result. + const message = e instanceof Error ? e.message : 'Test failed' + setModelTestResults(prev => ({ + ...prev, + [index]: { + ok: false, + checks: [{ label: 'Request', ok: false, detail: message }], + summary: message, + error: { category: 'transport', title: 'Could not run the test', why: message, fix: 'Check that you are still signed in as an admin and the backend is reachable.', raw: message }, + }, + })) + setExpandedModelTest(index) + } finally { + setModelTesting(null) + } + } + + const handleSendPlaygroundPrompt = async () => { + if (!playgroundUser.trim()) return + setPlaygroundSending(true) + setPlaygroundError(null) + setPlaygroundResult(null) + try { + const res = await testPrompt({ + model_name: playgroundModel || cfg?.default_model || '', + system_prompt: playgroundSystem, + user_prompt: playgroundUser, + }) + setPlaygroundResult(res) + } catch (e) { + setPlaygroundError(e instanceof Error ? e.message : 'Request failed') + } finally { + setPlaygroundSending(false) + } + } + + const handleSaveAuthMethods = async () => { + setAuthSaving(true) + try { + await updateAuthMethods(authMethods) + void refreshReadiness() + } finally { + setAuthSaving(false) + } + } + + const handleAddProvider = async () => { + const validationError = providerValidationError(newProvider) + if (validationError) { setProviderError(validationError); return } + setProviderError('') + try { + await addOAuthProvider(newProvider as unknown as Record) + // Refresh config + const c = await getSystemConfig() + setCfg(c) + setNewProvider({ provider: 'oauth', display_name: '', client_id: '', client_secret: '', redirect_uri: '', tenant_id: '', idp_entity_id: '', idp_sso_url: '', idp_x509_cert: '' }) + setSamlMeta('') + setShowAddProvider(false) + } catch (e) { + setProviderError(e instanceof Error ? e.message : 'Failed to add provider') + } + } + + const handleDeleteProvider = async (index: number) => { + const provider = cfg?.oauth_providers?.[index] as Record | undefined + const name = (provider?.display_name as string) || (provider?.provider as string) || 'this provider' + const ok = await confirm({ + title: 'Delete OAuth provider?', + message: ( + <> + Are you sure you want to delete {name}? Users authenticating through this provider will no longer be able to sign in via it. + + ), + confirmLabel: 'Delete', + destructive: true, + }) + if (!ok) return + try { + await deleteOAuthProvider(index) + const c = await getSystemConfig() + setCfg(c) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete provider') + } + } + + const handleEditProvider = (index: number) => { + const p = cfg?.oauth_providers?.[index] as Record | undefined + if (!p) return + setProviderError('') + setEditingProviderIndex(index) + setEditingProvider({ + provider: (p.provider as string) || 'oauth', + display_name: (p.display_name as string) || '', + client_id: (p.client_id as string) || '', + client_secret: '***', + redirect_uri: (p.redirect_uri as string) || '', + tenant_id: (p.tenant_id as string) || '', + idp_entity_id: (p.idp_entity_id as string) || '', + idp_sso_url: (p.idp_sso_url as string) || '', + idp_x509_cert: (p.idp_x509_cert as string) || '', + }) + setShowAddProvider(false) + } + + const handleUpdateProvider = async () => { + if (editingProviderIndex === null) return + const validationError = providerValidationError(editingProvider) + if (validationError) { setProviderError(validationError); return } + setProviderError('') + try { + await updateOAuthProvider(editingProviderIndex, editingProvider as unknown as Record) + const c = await getSystemConfig() + setCfg(c) + setEditingProviderIndex(null) + } catch (e) { + setProviderError(e instanceof Error ? e.message : 'Failed to update provider') + } + } + + const saveSupportContacts = async (contacts: typeof supportContacts) => { + try { + await updateSystemConfig({ support_contacts: contacts } as Record) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save support contacts') + } + } + + if (loading) return
Loading config...
+ + const sectionStyle = { + background: '#fff', border: '1px solid #e5e7eb', borderRadius: 'var(--ui-radius, 12px)', overflow: 'hidden' as const, + } + const sectionHeaderStyle = { + padding: '14px 20px', borderBottom: '1px solid #e5e7eb', fontSize: 15, fontWeight: 600 as const, + display: 'flex', alignItems: 'center', gap: 10, + } + const sectionBodyStyle = { padding: 20 } + const labelStyle = { display: 'block', fontSize: 13, fontWeight: 500 as const, color: '#374151', marginBottom: 6 } + const inputStyle = { + width: '100%', padding: '8px 12px', borderRadius: 'var(--ui-radius, 12px)', border: '1px solid #d1d5db', + fontSize: 14, outline: 'none', + } + const checkStyle = { marginRight: 8, accentColor: 'var(--highlight-color, #eab308)' } + + return ( +
+ {/* Sticky save bar */} +
+ + System Configuration + +
+ {saved && Configuration saved!} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Setup readiness — auto-shows while a blocker is unresolved; once the + system is ready it can be dismissed for the session. */} + {readiness && !(readiness.ready && setupDismissed) && ( + { + const id = `cfg-${target}` + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + // First-run: if the admin is being sent to connect their first model, + // drop them straight into the guided wizard instead of leaving them to + // find the "Add Model" button. Only when none exists and it isn't open. + if (target === 'models' && !(cfg?.available_models && cfg.available_models.length > 0) && !showModelForm) { + openAddModelWizard() + } + }} + onDismiss={readiness.ready ? () => setSetupDismissed(true) : undefined} + /> + )} + + {/* Available Models */} +
+
+ Available Models +
+ +
+
+ {cfg?.available_models && cfg.available_models.length > 0 ? ( +
+ {cfg.available_models.map((m, i) => { + const test = modelTestResults[i] + const expanded = expandedModelTest === i + return ( +
+
+
+ {/* Identity & capability badges */} +
+ {m.name} + {m.tag} + {cfg?.default_model === m.name && ( + + Default + + )} + {m.external && ( + External + )} + {m.thinking && ( + Thinking + )} + {m.multimodal && ( + Multimodal + )} + {m.supports_pdf && ( + PDF Input + )} + {m.api_protocol && ( + {m.api_protocol} + )} + {m.api_key && ( + API Key ✓ + )} + {m.endpoint && ( + {m.endpoint} + )} +
+ {/* Characteristic bars (replaces speed / tier / privacy pills) */} + +
+
+ {test && ( + + )} + + + + +
+
+ {expanded && test && } +
+ ) + })} +
+ ) : ( +
No models configured.
+ )} + + {showModelForm && (() => { + const prov = MODEL_PROVIDERS.find(p => p.id === wizardProviderId) ?? null + const isEditing = editingModelIndex !== null + const needsKey = prov?.needsKey ?? true + const needsEndpoint = prov?.needsEndpoint ?? false + const secondaryBtn = { + padding: '8px 16px', borderRadius: 'var(--ui-radius, 12px)', border: '1px solid #d1d5db', + background: '#fff', fontSize: 13, cursor: 'pointer', + } as const + const primaryBtn = { + padding: '8px 16px', borderRadius: 'var(--ui-radius, 12px)', border: 'none', + background: 'var(--highlight-color, #eab308)', color: 'var(--highlight-text-color, #000)', + fontSize: 13, fontWeight: 600, cursor: 'pointer', + } as const + const checkboxLabel = { display: 'flex', alignItems: 'center', fontSize: 14, cursor: 'pointer' } as const + return ( +
+
+
{isEditing ? 'Edit model' : 'Add a model'}
+ {!isEditing && ( +
+ {wizardStep === 1 ? 'Step 1 of 2 · Choose a provider' : 'Step 2 of 2 · Configure'} +
+ )} +
+ + {/* STEP 1 — provider picker (new models only) */} + {wizardStep === 1 && !isEditing && ( + <> +
+ Choose where this model runs — we’ll fill in the technical settings for you. +
+
+ {MODEL_PROVIDERS.map(p => ( + + ))} +
+
+ +
+ + )} + + {/* STEP 2 — configure, save, test */} + {wizardStep === 2 && ( + <> + {prov && ( +
+ {prov.label} + {!isEditing && ( + + )} +
+ )} + + {/* Guided fields */} +
+
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, name: v })) }} placeholder={prov?.namePlaceholder ?? 'model name'} style={inputStyle} /> + {prov?.nameSuggestions && prov.nameSuggestions.length > 0 && ( +
+ {prov.nameSuggestions.map(s => ( + + ))} +
+ )} +
+ + {needsKey && ( +
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, api_key: v })) }} placeholder={prov?.keyPlaceholder ?? 'API key'} style={inputStyle} /> + {prov?.keyHelp &&
{prov.keyHelp}
} +
+ )} + + {needsEndpoint && ( +
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, endpoint: v })) }} placeholder={prov?.endpointPlaceholder ?? 'https://…/v1'} style={inputStyle} /> +
+ )} + +
+ + +
+
+ + {/* Advanced settings — everything from the old form lives here */} +
+ Advanced settings +
+
+
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, tag: v })) }} placeholder="provider" style={inputStyle} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {!needsEndpoint && ( +
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, endpoint: v })) }} placeholder="https://..." style={inputStyle} /> +
+ )} +
+ +
+ +
+ { + const v = parseInt(e.target.value, 10) + setNewModel(prev => ({ ...prev, context_window: Number.isFinite(v) && v > 0 ? v : 0 })) + setProbeResult(null) + }} + placeholder="e.g. 65536" + style={{ ...inputStyle, flex: 1 }} + /> + +
+
+ The serving cap (e.g. vLLM’s --max-model-len), not the model card’s theoretical max. Compaction and the oversize-doc check use this to decide what fits. +
+ {probeResult && ( +
+ {probeResult.message} +
+ )} +
+ +
+
+ + { const v = parseInt(e.target.value, 10); setNewModel(prev => ({ ...prev, request_timeout_seconds: Number.isFinite(v) && v > 0 ? v : 0 })) }} + placeholder="system default" + style={inputStyle} + /> +
+ Overrides the shared LLM timeout for this model — raise it for slow self-hosted models. Blank = system default. +
+
+
+ + { const v = parseInt(e.target.value, 10); setNewModel(prev => ({ ...prev, response_reserve_tokens: Number.isFinite(v) && v > 0 ? v : 0 })) }} + placeholder="auto" + style={inputStyle} + /> +
+ Tokens reserved for the model’s answer; also caps runaway reasoning. More output room means less input room. Blank = scaled to the context window. +
+
+
+ +
+ + + {newModel.multimodal && ( + + )} +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {wizardTesting && ( +
+ Testing connection… +
+ )} + {modelTest && !wizardTesting && ( +
+ +
+ )} + +
+ {!modelTest ? ( + <> + + {!isEditing && } + + + ) : ( + <> + + + + )} +
+ + )} +
+ ) + })()} +
+
+ + {/* Prompt Playground */} +
+
+ Prompt Playground + + — send a prompt to a configured model and see the raw round-trip + +
+
+
+
+
+ +