Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin-ai/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DIDI AI Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
{
"name": "didi-ai-platform-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1",
"@mui/x-data-grid": "^8.0.0",
"@react-keycloak/web": "^3.4.0",
"@tanstack/react-query": "^5.62.0",
"keycloak-js": "^26.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.7.1",
"recharts": "^3.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^7.1.7"
}
}

View file

@ -0,0 +1,79 @@
import { Route, Routes } from 'react-router-dom';
import AppShell from '@/layout/AppShell';
import Overview from '@/pages/Overview';
import Stub from '@/pages/Stub';
import ModulePage from '@/pages/modules/ModulePage';
import Unauthorized from '@/pages/Unauthorized';
import ProtectedRoute from '@/auth/ProtectedRoute';
import BrainAtoms from '@/pages/brain/Atoms';
import BrainCache from '@/pages/brain/Cache';
import BrainStats from '@/pages/brain/Stats';
import BrainTaxonomy from '@/pages/brain/Taxonomy';
import BrainFacts from '@/pages/brain/Facts';
import BrainInvalidate from '@/pages/brain/Invalidate';
import History from '@/pages/insights/History';
import Cost from '@/pages/insights/Cost';
import Providers from '@/pages/insights/Providers';
import Archive from '@/pages/insights/Archive';
import AuditLog from '@/pages/system/AuditLog';
import Catalog from '@/pages/system/Catalog';
import Settings from '@/pages/system/Settings';
import LiveStatus from '@/pages/operations/Live';
import Monitoring from '@/pages/operations/Monitoring';
import SchemaOverrides from '@/pages/system/SchemaOverrides';
export default function App() {
return (
<Routes>
<Route path="/unauthorized" element={<Unauthorized />} />
<Route
path="/"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<Route index element={<Overview />} />
{/* Operations */}
<Route path="operations/live" element={<LiveStatus />} />
<Route path="operations/monitoring" element={<Monitoring />} />
{/* Modules — single generic page driven by moduleId */}
<Route path="modules/web" element={<ModulePage moduleId="web" />} />
<Route path="modules/llm" element={<ModulePage moduleId="llm" />} />
<Route path="modules/embeddings" element={<ModulePage moduleId="embeddings" />} />
<Route path="modules/rerank" element={<ModulePage moduleId="rerank" />} />
<Route path="modules/audio" element={<ModulePage moduleId="audio" />} />
<Route path="modules/video" element={<ModulePage moduleId="video" />} />
<Route path="modules/catalog" element={<ModulePage moduleId="catalog" />} />
<Route path="modules/gateway" element={<ModulePage moduleId="gateway" />} />
{/* Brain Admin */}
<Route path="brain/atoms" element={<BrainAtoms />} />
<Route path="brain/cache" element={<BrainCache />} />
<Route path="brain/facts" element={<BrainFacts />} />
<Route path="brain/invalidate" element={<BrainInvalidate />} />
<Route path="brain/stats" element={<BrainStats />} />
<Route path="brain/taxonomy" element={<BrainTaxonomy />} />
<Route path="brain/settings" element={<ModulePage moduleId="brain" />} />
{/* Insights */}
<Route path="insights/history" element={<History />} />
<Route path="insights/cost" element={<Cost />} />
<Route path="insights/providers" element={<Providers />} />
<Route path="insights/archive" element={<Archive />} />
{/* System */}
<Route path="system/catalog" element={<Catalog />} />
<Route path="system/audit" element={<AuditLog />} />
<Route path="system/settings" element={<Settings />} />
<Route path="system/schema" element={<SchemaOverrides />} />
{/* 404 */}
<Route path="*" element={<Stub title="Not found" />} />
</Route>
</Routes>
);
}

View file

@ -0,0 +1,100 @@
// Thin fetch wrapper. All API calls go to the same FastAPI on /api/*.
// Auth source priority:
// 1. Keycloak token (if available via window-level singleton)
// 2. localStorage 'didi.ai_platform.bearer' (legacy/dev)
//
// API base path: when SPA is served at /admin-ai/, FastAPI also exposes
// the JSON API at /admin-ai/api/*. We use Vite's BASE_URL (which == the
// SPA's mount path) so the fetch URL is always sibling to the SPA root,
// regardless of whether the user reached it directly or through the
// reverse-proxy on admin-dashboard nginx.
import { keycloak } from '@/auth/keycloak';
const TOKEN_LS_KEY = 'didi.ai_platform.bearer';
// Vite injects this at build time. e.g. '/admin-ai/'. Strip trailing slash
// so we can prepend cleanly.
const BASE_PATH = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
function resolveUrl(path: string): string {
// Absolute URLs (http://...) pass through unchanged.
if (/^https?:\/\//i.test(path)) return path;
// Already prefixed with BASE_PATH? leave as-is.
if (BASE_PATH && path.startsWith(BASE_PATH + '/')) return path;
// Otherwise, prepend BASE_PATH.
return `${BASE_PATH}${path.startsWith('/') ? '' : '/'}${path}`;
}
export function getBearerToken(): string | null {
// Keycloak instance is the source of truth when initialized
if (keycloak.token) return keycloak.token;
return localStorage.getItem(TOKEN_LS_KEY);
}
export function setBearerToken(token: string | null): void {
if (token === null) {
localStorage.removeItem(TOKEN_LS_KEY);
} else {
localStorage.setItem(TOKEN_LS_KEY, token);
}
}
interface ApiOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
export async function api<T>(path: string, opts: ApiOptions = {}): Promise<T> {
const token = getBearerToken();
const headers: Record<string, string> = {
Accept: 'application/json',
...((opts.headers as Record<string, string>) ?? {}),
};
if (token) headers.Authorization = `Bearer ${token}`;
let body: BodyInit | undefined;
if (opts.body !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(opts.body);
}
const res = await fetch(resolveUrl(path), { ...opts, headers, body });
if (!res.ok) {
let detail: string;
try {
const j = await res.json();
detail = j.detail ?? JSON.stringify(j);
} catch {
detail = await res.text();
}
if (res.status === 401) {
// Notify auth context — it'll trigger logout/re-login
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
const err = new Error(`${res.status} ${res.statusText}: ${detail}`);
(err as Error & { status: number }).status = res.status;
throw err;
}
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return (await res.json()) as T;
return undefined as T;
}
// Convenience helpers
export const apiGet = <T>(path: string) => api<T>(path, { method: 'GET' });
export const apiPut = <T>(path: string, body: unknown) => api<T>(path, { method: 'PUT', body });
export const apiPost = <T>(path: string, body?: unknown) =>
api<T>(path, { method: 'POST', body });
export const apiPatch = <T>(path: string, body: unknown) =>
api<T>(path, { method: 'PATCH', body });
export const apiDelete = <T>(path: string) => api<T>(path, { method: 'DELETE' });
// Brain admin client — lives at didibrain-api:8090 BUT the dashboard FastAPI
// proxies /api/brain/*, so we just use api*.
export const BRAIN_BASE = (() => {
return (
(import.meta.env.VITE_BRAIN_URL as string | undefined) ??
'http://localhost:8090'
);
})();

View file

@ -0,0 +1,168 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { Box, CircularProgress, Stack, Typography } from '@mui/material';
import { keycloak, REQUIRED_ROLE, STAGING_MODE } from '@/auth/keycloak';
interface KeycloakUser {
sub?: string;
email?: string;
preferred_username?: string;
name?: string;
[key: string]: unknown;
}
interface AuthContextValue {
initialized: boolean;
isAuthenticated: boolean;
user: KeycloakUser | null;
token: string | undefined;
hasRole: (role: string) => boolean;
login: () => void;
logout: () => void;
stagingMode: boolean;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
const TOKEN_LS_KEY = 'didi.ai_platform.bearer';
interface ProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: ProviderProps) {
const [initialized, setInitialized] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_LS_KEY);
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
}
if (keycloak.authenticated) {
keycloak.logout();
} else {
window.location.reload();
}
}, []);
useEffect(() => {
if (STAGING_MODE) {
console.warn(
'[Auth] STAGING_MODE — Keycloak bypassed. DO NOT use in production.',
);
setAuthenticated(true);
setInitialized(true);
return;
}
let cancelled = false;
(async () => {
try {
const ok = await keycloak.init({
onLoad: 'login-required',
checkLoginIframe: false,
pkceMethod: 'S256',
});
if (cancelled) return;
setAuthenticated(ok);
if (ok && keycloak.token) {
localStorage.setItem(TOKEN_LS_KEY, keycloak.token);
// Refresh every 30s if token expires within 70s
refreshIntervalRef.current = setInterval(() => {
keycloak
.updateToken(70)
.then((refreshed) => {
if (refreshed && keycloak.token) {
localStorage.setItem(TOKEN_LS_KEY, keycloak.token);
}
})
.catch(() => {
console.warn('[Auth] token refresh failed → re-login');
keycloak.login();
});
}, 30_000);
}
} catch (e) {
console.error('[Auth] init failed', e);
if (!cancelled) setAuthenticated(false);
} finally {
if (!cancelled) setInitialized(true);
}
})();
return () => {
cancelled = true;
if (refreshIntervalRef.current) clearInterval(refreshIntervalRef.current);
};
}, []);
// React to 401 events from api/client.ts
useEffect(() => {
if (STAGING_MODE) return;
const handler = () => {
console.log('[Auth] auth:unauthorized → logout');
logout();
};
window.addEventListener('auth:unauthorized', handler);
return () => window.removeEventListener('auth:unauthorized', handler);
}, [logout]);
const value: AuthContextValue = {
initialized,
isAuthenticated: authenticated,
user: (keycloak.tokenParsed as KeycloakUser | undefined) ?? null,
token: keycloak.token,
hasRole: (role: string) => {
if (STAGING_MODE) return true;
return (
keycloak.hasRealmRole(role) ||
keycloak.hasResourceRole(role) ||
false
);
},
login: () => keycloak.login(),
logout,
stagingMode: STAGING_MODE,
};
if (!initialized) {
return (
<Box
sx={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Stack alignItems="center" spacing={2}>
<CircularProgress />
<Typography variant="body2" color="text.secondary">
Loading authentication
</Typography>
</Stack>
</Box>
);
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export { REQUIRED_ROLE };

View file

@ -0,0 +1,41 @@
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import { useAuth, REQUIRED_ROLE } from '@/auth/AuthContext';
interface Props {
children: React.ReactNode;
requiredRole?: string;
}
export default function ProtectedRoute({ children, requiredRole }: Props) {
const { initialized, isAuthenticated, hasRole } = useAuth();
if (!initialized) {
return (
<Box
sx={{
display: 'flex',
minHeight: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
// Keycloak with onLoad=login-required already redirects, so this branch
// is mostly for staging mode + double-safety.
return <Navigate to="/" replace />;
}
const role = requiredRole ?? REQUIRED_ROLE;
if (role && !hasRole(role)) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
}

View file

@ -0,0 +1,17 @@
import Keycloak from 'keycloak-js';
// Single shared Keycloak instance (module singleton).
// Configured via Vite envs (VITE_KEYCLOAK_URL etc.) or same-origin /auth.
const url = import.meta.env.VITE_KEYCLOAK_URL ?? '/auth';
const realm = import.meta.env.VITE_KEYCLOAK_REALM ?? 'didi-clients';
const clientId =
import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'ai-platform-dashboard';
export const STAGING_MODE =
import.meta.env.VITE_STAGING_MODE === 'true' ||
import.meta.env.VITE_STAGING_MODE === '1';
export const REQUIRED_ROLE =
import.meta.env.VITE_KEYCLOAK_REQUIRED_ROLE ?? 'admin';
export const keycloak = new Keycloak({ url, realm, clientId });

View file

@ -0,0 +1,243 @@
import { useEffect, useState } from 'react';
import {
Box,
Button,
Chip,
IconButton,
MenuItem,
Select,
Stack,
Switch,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SaveIcon from '@mui/icons-material/Save';
import RestoreIcon from '@mui/icons-material/Restore';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import type { ConfigItem, ConfigSchema } from '@/types/config';
interface Props {
configKey: string;
meta: ConfigSchema;
item: ConfigItem;
onSave: (value: unknown) => Promise<void>;
onReset: () => Promise<void>;
saving?: boolean;
}
function csvToArray(v: unknown): string[] {
if (Array.isArray(v)) return v.map(String);
if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
return [];
}
export default function ConfigField({ configKey, meta, item, onSave, onReset, saving }: Props) {
const [draft, setDraft] = useState<unknown>(item.value);
const [error, setError] = useState<string | null>(null);
// Re-sync if upstream value changes (e.g., after refetch)
useEffect(() => {
setDraft(item.value);
}, [item.value]);
const dirty = JSON.stringify(draft) !== JSON.stringify(item.value);
const validate = (v: unknown): string | null => {
if (meta.type === 'int' || meta.type === 'float') {
const n = Number(v);
if (Number.isNaN(n)) return 'Not a number';
if (meta.min !== undefined && n < meta.min) return `Min ${meta.min}`;
if (meta.max !== undefined && n > meta.max) return `Max ${meta.max}`;
}
return null;
};
const handleSave = async () => {
const err = validate(draft);
if (err) {
setError(err);
return;
}
setError(null);
try {
let value = draft;
if (meta.type === 'int') value = Number(draft);
if (meta.type === 'float') value = Number(draft);
if (meta.type === 'csv') value = csvToArray(draft).join(',');
await onSave(value);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
}
};
const renderInput = () => {
switch (meta.type) {
case 'bool':
return (
<Switch
checked={Boolean(draft)}
onChange={(e) => setDraft(e.target.checked)}
disabled={saving}
/>
);
case 'enum':
return (
<Select
value={String(draft ?? '')}
onChange={(e) => setDraft(e.target.value)}
size="small"
sx={{ minWidth: 200 }}
disabled={saving}
>
{(meta.options ?? []).map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
);
case 'int':
case 'float':
return (
<TextField
type="number"
size="small"
value={draft ?? ''}
onChange={(e) => setDraft(e.target.value)}
inputProps={{
min: meta.min,
max: meta.max,
step: meta.type === 'float' ? 0.01 : 1,
}}
sx={{ width: 160 }}
disabled={saving}
error={Boolean(error)}
helperText={error ?? ''}
/>
);
case 'csv':
return (
<TextField
size="small"
value={Array.isArray(draft) ? draft.join(',') : String(draft ?? '')}
onChange={(e) => setDraft(e.target.value)}
placeholder="comma,separated,values"
sx={{ minWidth: 320 }}
disabled={saving}
/>
);
case 'text':
return (
<TextField
multiline
minRows={4}
maxRows={20}
size="small"
value={String(draft ?? '')}
onChange={(e) => setDraft(e.target.value)}
sx={{ minWidth: 480, width: '100%' }}
disabled={saving}
/>
);
case 'string':
default:
return (
<TextField
size="small"
value={String(draft ?? '')}
onChange={(e) => setDraft(e.target.value)}
sx={{ minWidth: 320 }}
disabled={saving}
/>
);
}
};
const isMultiline = meta.type === 'text';
return (
<Box
sx={{
display: 'flex',
flexDirection: isMultiline ? 'column' : { xs: 'column', md: 'row' },
gap: 2,
alignItems: isMultiline ? 'stretch' : { xs: 'flex-start', md: 'center' },
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ flex: '0 0 320px', minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{meta.label}
</Typography>
{meta.restart_required && (
<Tooltip title="Restart required for this change to take effect">
<Chip
size="small"
label="restart"
color="warning"
variant="outlined"
icon={<RestartAltIcon sx={{ fontSize: 14 }} />}
sx={{ height: 20, '& .MuiChip-label': { fontSize: 10, px: 0.75 } }}
/>
</Tooltip>
)}
{item.is_override && (
<Tooltip title={`Default: ${JSON.stringify(meta.default)}`}>
<Chip
size="small"
label="override"
color="primary"
sx={{ height: 20, '& .MuiChip-label': { fontSize: 10, px: 0.75 } }}
/>
</Tooltip>
)}
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
{meta.description}
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontFamily: 'monospace', color: '#666' }}>
{configKey}
</Typography>
</Box>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{renderInput()}
</Box>
<Stack direction="row" spacing={0.5} sx={{ flex: '0 0 auto' }}>
<Tooltip title={dirty ? 'Save changes' : 'No changes'}>
<span>
<IconButton size="small" color="primary" onClick={handleSave} disabled={!dirty || saving}>
<SaveIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.is_override ? 'Reset to default' : 'Already at default'}>
<span>
<IconButton
size="small"
onClick={onReset}
disabled={!item.is_override || saving}
color="warning"
>
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
{error && !['int', 'float'].includes(meta.type) && (
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', color: 'error.main' }}>
<WarningAmberIcon fontSize="small" />
<Typography variant="caption">{error}</Typography>
</Stack>
)}
</Box>
);
}

View file

@ -0,0 +1,36 @@
import { Card, CardContent, Typography } from '@mui/material';
import type { ReactNode } from 'react';
/**
* Shared KPI tile (previously duplicated locally in Live/Overview). Use for
* consistent metric tiles across pages.
*/
export default function KpiTile({
label,
value,
color,
hint,
}: {
label: string;
value: ReactNode;
color?: string;
hint?: string;
}) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="h2" sx={{ mt: 0.5, color: color ?? 'primary.main' }}>
{value}
</Typography>
{hint && (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,197 @@
import { useMemo, useState } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Chip,
CircularProgress,
Stack,
Typography,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import ConfigField from '@/components/ConfigField';
import { apiDelete, apiGet, apiPut } from '@/api/client';
import type { ConfigItem, ConfigResponse, ConfigSchema } from '@/types/config';
interface Props {
/** Module name to filter on (matches `module` field in schema). */
moduleId: string;
/**
* Optional category sort order. Categories not listed here go alphabetically
* after the configured ones.
*/
categoryOrder?: string[];
}
interface CategoryGroup {
category: string;
items: Array<{ key: string; meta: ConfigSchema; item: ConfigItem }>;
}
export default function ModuleConfigForm({ moduleId, categoryOrder }: Props) {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{ kind: 'ok' | 'err'; msg: string } | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['config'],
queryFn: () => apiGet<ConfigResponse>('/api/config'),
});
const saveMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
apiPut<{ key: string; value: unknown }>(`/api/config/${encodeURIComponent(key)}`, {
value,
}),
onSuccess: (_d, vars) => {
queryClient.invalidateQueries({ queryKey: ['config'] });
setFeedback({ kind: 'ok', msg: `Saved ${vars.key}` });
},
onError: (e: unknown) => {
setFeedback({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
throw e;
},
});
const resetMutation = useMutation({
mutationFn: (key: string) => apiDelete(`/api/config/${encodeURIComponent(key)}`),
onSuccess: (_d, key) => {
queryClient.invalidateQueries({ queryKey: ['config'] });
setFeedback({ kind: 'ok', msg: `Reverted ${key} to default` });
},
onError: (e: unknown) => {
setFeedback({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
},
});
const groups: CategoryGroup[] = useMemo(() => {
if (!data) return [];
const filtered = Object.entries(data.schema)
.filter(([_, meta]) => meta.module === moduleId)
.map(([key, meta]) => ({
key,
meta,
item: data.items[key],
}));
const byCategory = new Map<string, typeof filtered>();
for (const f of filtered) {
const cat = f.meta.category;
if (!byCategory.has(cat)) byCategory.set(cat, []);
byCategory.get(cat)!.push(f);
}
const ordered: CategoryGroup[] = [];
const seen = new Set<string>();
if (categoryOrder) {
for (const cat of categoryOrder) {
if (byCategory.has(cat)) {
ordered.push({ category: cat, items: byCategory.get(cat)! });
seen.add(cat);
}
}
}
for (const cat of [...byCategory.keys()].sort()) {
if (!seen.has(cat)) {
ordered.push({ category: cat, items: byCategory.get(cat)! });
}
}
return ordered;
}, [data, moduleId, categoryOrder]);
if (isLoading) {
return (
<Stack alignItems="center" sx={{ py: 6 }}>
<CircularProgress />
</Stack>
);
}
if (error) {
return <Alert severity="error">Failed to load config: {String(error)}</Alert>;
}
if (groups.length === 0) {
return (
<Alert severity="info">
No config keys defined for module &quot;{moduleId}&quot;. (Either the module has no
editable knobs, or schema isn&apos;t loaded yet.)
</Alert>
);
}
const totalKeys = groups.reduce((n, g) => n + g.items.length, 0);
const totalOverrides = groups.reduce(
(n, g) => n + g.items.filter((i) => i.item.is_override).length,
0,
);
return (
<Box>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Chip label={`${totalKeys} keys`} size="small" />
<Chip
label={`${totalOverrides} overrides`}
color={totalOverrides > 0 ? 'primary' : 'default'}
size="small"
/>
</Stack>
{feedback && (
<Alert
severity={feedback.kind === 'ok' ? 'success' : 'error'}
onClose={() => setFeedback(null)}
sx={{ mb: 2 }}
>
{feedback.msg}
</Alert>
)}
{groups.map((group) => (
<Accordion key={group.category} defaultExpanded sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h3" sx={{ textTransform: 'capitalize' }}>
{group.category.replace(/_/g, ' ')}
</Typography>
<Chip
size="small"
label={`${group.items.length}`}
sx={{ ml: 2, height: 20, '& .MuiChip-label': { fontSize: 10 } }}
/>
{group.items.some((i) => i.item.is_override) && (
<Chip
size="small"
color="primary"
label="has overrides"
sx={{
ml: 1,
height: 20,
'& .MuiChip-label': { fontSize: 10 },
}}
/>
)}
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{group.items.map(({ key, meta, item }) => (
<ConfigField
key={key}
configKey={key}
meta={meta}
item={item}
saving={saveMutation.isPending || resetMutation.isPending}
onSave={async (value) => {
await saveMutation.mutateAsync({ key, value });
}}
onReset={async () => {
await resetMutation.mutateAsync(key);
}}
/>
))}
</AccordionDetails>
</Accordion>
))}
</Box>
);
}

View file

@ -0,0 +1,29 @@
import { Chip } from '@mui/material';
import type { ChipProps } from '@mui/material';
/**
* Shared health-status chip. Centralizes the green/amber/red mapping that was
* previously copy-pasted across pages (Live, ModulePage, Atoms) single source
* of truth for status colors and labels.
*/
export type HealthStatus = 'healthy' | 'degraded' | 'down' | 'unknown' | string;
const COLOR: Record<string, ChipProps['color']> = {
healthy: 'success',
degraded: 'warning',
down: 'error',
unknown: 'default',
};
export default function StatusChip({
status,
size = 'small',
label,
}: {
status: HealthStatus;
size?: ChipProps['size'];
label?: string;
}) {
const color = COLOR[status] ?? 'default';
return <Chip label={label ?? status} color={color} size={size} />;
}

View file

@ -0,0 +1,307 @@
import { useState } from 'react';
import { Link as RouterLink, Outlet, useLocation } from 'react-router-dom';
import {
AppBar,
Avatar,
Box,
Chip,
CssBaseline,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Toolbar,
Tooltip,
Typography,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LogoutIcon from '@mui/icons-material/Logout';
import { useAuth } from '@/auth/AuthContext';
import DashboardIcon from '@mui/icons-material/Dashboard';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
import LanguageIcon from '@mui/icons-material/Language';
import PsychologyIcon from '@mui/icons-material/Psychology';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import SortIcon from '@mui/icons-material/Sort';
import VideoFileIcon from '@mui/icons-material/VideoFile';
import HubIcon from '@mui/icons-material/Hub';
import LockIcon from '@mui/icons-material/Lock';
import MemoryIcon from '@mui/icons-material/Memory';
import StorageIcon from '@mui/icons-material/Storage';
import InsightsIcon from '@mui/icons-material/Insights';
import HistoryIcon from '@mui/icons-material/History';
import PaidIcon from '@mui/icons-material/Paid';
import ArchiveIcon from '@mui/icons-material/Archive';
import SettingsIcon from '@mui/icons-material/Settings';
import ListAltIcon from '@mui/icons-material/ListAlt';
import FactCheckIcon from '@mui/icons-material/FactCheck';
import SchemaIcon from '@mui/icons-material/Schema';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
const DRAWER_WIDTH = 260;
interface NavItem {
label: string;
to: string;
icon: React.ReactNode;
}
interface NavSection {
title: string;
items: NavItem[];
}
const SECTIONS: NavSection[] = [
{
title: 'Operations',
items: [
{ label: 'Overview', to: '/', icon: <DashboardIcon fontSize="small" /> },
{ label: 'AI Monitoring', to: '/operations/monitoring', icon: <HealthAndSafetyIcon fontSize="small" /> },
{ label: 'Live Status', to: '/operations/live', icon: <MonitorHeartIcon fontSize="small" /> },
],
},
{
title: 'Modules',
items: [
{ label: 'Web Search', to: '/modules/web', icon: <LanguageIcon fontSize="small" /> },
{ label: 'LLM Inference', to: '/modules/llm', icon: <PsychologyIcon fontSize="small" /> },
{ label: 'Embeddings', to: '/modules/embeddings', icon: <GraphicEqIcon fontSize="small" /> },
{ label: 'Rerank', to: '/modules/rerank', icon: <SortIcon fontSize="small" /> },
{ label: 'Audio', to: '/modules/audio', icon: <RecordVoiceOverIcon fontSize="small" /> },
{ label: 'Video Analysis', to: '/modules/video', icon: <VideoFileIcon fontSize="small" /> },
{ label: 'Catalog', to: '/modules/catalog', icon: <HubIcon fontSize="small" /> },
{ label: 'Gateway', to: '/modules/gateway', icon: <LockIcon fontSize="small" /> },
],
},
{
title: 'Brain Admin',
items: [
{ label: 'Atoms', to: '/brain/atoms', icon: <MemoryIcon fontSize="small" /> },
{ label: 'Verification Cache', to: '/brain/cache', icon: <StorageIcon fontSize="small" /> },
{ label: 'Fact Status', to: '/brain/facts', icon: <FactCheckIcon fontSize="small" /> },
{ label: 'Invalidate', to: '/brain/invalidate', icon: <DeleteSweepIcon fontSize="small" /> },
{ label: 'Stats', to: '/brain/stats', icon: <InsightsIcon fontSize="small" /> },
{ label: 'Taxonomy', to: '/brain/taxonomy', icon: <ListAltIcon fontSize="small" /> },
{ label: 'Settings', to: '/brain/settings', icon: <SettingsIcon fontSize="small" /> },
],
},
{
title: 'Insights',
items: [
{ label: 'History', to: '/insights/history', icon: <HistoryIcon fontSize="small" /> },
{ label: 'Cost', to: '/insights/cost', icon: <PaidIcon fontSize="small" /> },
{ label: 'Providers', to: '/insights/providers', icon: <HubIcon fontSize="small" /> },
{ label: 'Archive', to: '/insights/archive', icon: <ArchiveIcon fontSize="small" /> },
],
},
{
title: 'System',
items: [
{ label: 'Catalog', to: '/system/catalog', icon: <HubIcon fontSize="small" /> },
{ label: 'Audit Log', to: '/system/audit', icon: <ListAltIcon fontSize="small" /> },
{ label: 'Schema Overrides', to: '/system/schema', icon: <SchemaIcon fontSize="small" /> },
{ label: 'Settings', to: '/system/settings', icon: <SettingsIcon fontSize="small" /> },
],
},
];
export default function AppShell() {
const [mobileOpen, setMobileOpen] = useState(false);
const [userMenuAnchor, setUserMenuAnchor] = useState<HTMLElement | null>(null);
const location = useLocation();
const { user, logout, stagingMode } = useAuth();
const userEmail = user?.email ?? user?.preferred_username ?? '—';
const userInitial = (userEmail[0] ?? '?').toUpperCase();
const drawer = (
<Box>
<Toolbar sx={{ px: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', py: 1 }}>
<Typography variant="h2" sx={{ fontWeight: 700, lineHeight: 1 }}>
DIDI
</Typography>
<Typography variant="caption" color="text.secondary">
AI Platform Admin
</Typography>
</Box>
</Toolbar>
<Divider />
<List sx={{ pt: 0 }} dense>
{SECTIONS.map((section) => (
<Box key={section.title}>
<ListSubheader
sx={{
bgcolor: 'transparent',
color: 'text.secondary',
fontSize: 11,
letterSpacing: 1,
textTransform: 'uppercase',
lineHeight: 2,
mt: 1,
}}
>
{section.title}
</ListSubheader>
{section.items.map((item) => {
const selected =
item.to === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.to);
return (
<ListItem key={item.to} disablePadding>
<ListItemButton
component={RouterLink}
to={item.to}
selected={selected}
sx={{
mx: 1,
borderRadius: 1.5,
'&.Mui-selected': {
bgcolor: 'primary.main',
color: 'common.white',
'& .MuiListItemIcon-root': { color: 'common.white' },
'&:hover': { bgcolor: 'primary.dark' },
},
}}
>
<ListItemIcon sx={{ minWidth: 32, color: 'text.secondary' }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ fontSize: 13 }}
/>
</ListItemButton>
</ListItem>
);
})}
</Box>
))}
</List>
</Box>
);
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { sm: `${DRAWER_WIDTH}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
edge="start"
onClick={() => setMobileOpen(!mobileOpen)}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h3" sx={{ flexGrow: 1, fontWeight: 500 }}>
AI Platform
</Typography>
{stagingMode && (
<Chip
label="STAGING"
size="small"
color="warning"
sx={{ mr: 2, fontWeight: 600 }}
/>
)}
<Tooltip title={userEmail}>
<IconButton
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
sx={{ p: 0.5 }}
>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'primary.main', fontSize: 14 }}>
{userInitial}
</Avatar>
</IconButton>
</Tooltip>
<Menu
anchorEl={userMenuAnchor}
open={Boolean(userMenuAnchor)}
onClose={() => setUserMenuAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Box sx={{ px: 2, py: 1, minWidth: 220 }}>
<Typography variant="caption" color="text.secondary">
Signed in as
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{userEmail}
</Typography>
</Box>
<Divider />
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
logout();
}}
>
<LogoutIcon fontSize="small" sx={{ mr: 1 }} />
Log out
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: DRAWER_WIDTH }, flexShrink: { sm: 0 } }}
>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { width: DRAWER_WIDTH },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { width: DRAWER_WIDTH, boxSizing: 'border-box' },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` },
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
);
}

View file

@ -0,0 +1,35 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CssBaseline, ThemeProvider } from '@mui/material';
import App from '@/App';
import { theme } from '@/theme';
import { AuthProvider } from '@/auth/AuthContext';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/admin-ai">
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
);

View file

@ -0,0 +1,230 @@
import { Box, Card, CardContent, Chip, CircularProgress, Grid, Typography } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '@/api/client';
interface SummaryResponse {
window_hours: number;
total_requests: number;
total_errors: number;
error_rate: number;
avg_duration_ms: number;
total_cost_usd: number;
by_tier?: Record<string, number>;
by_provider?: Record<string, number>;
by_endpoint?: Record<string, number>;
}
interface ConfigResponse {
items: Record<string, { value: unknown; default: unknown; is_override: boolean }>;
schema: Record<string, { module: string; restart_required: boolean }>;
}
function StatTile({
label,
value,
hint,
color = 'primary.main',
}: {
label: string;
value: React.ReactNode;
hint?: string;
color?: string;
}) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="h2" sx={{ mt: 0.5, color }}>
{value}
</Typography>
{hint && (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
)}
</CardContent>
</Card>
);
}
export default function Overview() {
const summary = useQuery({
queryKey: ['summary', 24],
queryFn: () => apiGet<SummaryResponse>('/api/stats/summary?hours=24'),
});
const config = useQuery({
queryKey: ['config'],
queryFn: () => apiGet<ConfigResponse>('/api/config'),
});
const totalKeys = config.data ? Object.keys(config.data.items).length : 0;
const overrides = config.data
? Object.values(config.data.items).filter((i) => i.is_override).length
: 0;
const moduleCount = config.data
? new Set(Object.values(config.data.schema).map((s) => s.module)).size
: 0;
return (
<Box>
<Typography variant="h1" sx={{ mb: 1 }}>
Operations Overview
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Last 24h activity across the AI platform.
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatTile
label="Requests (24h)"
value={
summary.isLoading ? (
<CircularProgress size={20} />
) : (
(summary.data?.total_requests ?? 0).toLocaleString()
)
}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatTile
label="Error rate"
value={
summary.isLoading ? (
<CircularProgress size={20} />
) : summary.data?.error_rate !== undefined ? (
`${(summary.data.error_rate * 100).toFixed(1)}%`
) : (
'n/a'
)
}
color={
(summary.data?.error_rate ?? 0) > 0.05 ? 'error.main' : 'success.main'
}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatTile
label="Avg duration"
value={
summary.isLoading ? (
<CircularProgress size={20} />
) : summary.data?.avg_duration_ms !== undefined ? (
`${Math.round(summary.data.avg_duration_ms)} ms`
) : (
'n/a'
)
}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatTile
label="Total cost"
value={
summary.isLoading ? (
<CircularProgress size={20} />
) : (
`$${(summary.data?.total_cost_usd ?? 0).toFixed(4)}`
)
}
/>
</Grid>
</Grid>
<Typography variant="h2" sx={{ mt: 4, mb: 2 }}>
Configuration surface
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatTile
label="Live keys"
value={config.isLoading ? <CircularProgress size={20} /> : totalKeys}
hint={`${moduleCount} modules covered`}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatTile
label="Active overrides"
value={config.isLoading ? <CircularProgress size={20} /> : overrides}
hint={overrides ? 'editable from Modules' : 'all defaults'}
color={overrides ? 'warning.main' : 'text.primary'}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="caption" color="text.secondary">
Quick links
</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
<Chip
label="Live Status"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/operations/live"
/>
<Chip
label="History"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/insights/history"
/>
<Chip
label="Cost"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/insights/cost"
/>
<Chip
label="Providers"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/insights/providers"
/>
<Chip
label="Brain Atoms"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/brain/atoms"
/>
<Chip
label="Audit Log"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/system/audit"
/>
<Chip
label="Schema Overrides"
size="small"
variant="outlined"
clickable
component="a"
href="/admin-ai/system/schema"
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
Quick navigation to common operator screens.
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View file

@ -0,0 +1,28 @@
import { Box, Card, CardContent, Chip, Typography } from '@mui/material';
import ConstructionIcon from '@mui/icons-material/Construction';
export default function Stub({ title }: { title: string }) {
return (
<Box>
<Typography variant="h1" sx={{ mb: 2 }}>
{title}
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<ConstructionIcon color="warning" />
<Box>
<Typography variant="h3" sx={{ mb: 0.5 }}>
Coming soon
</Typography>
<Typography variant="body1" color="text.secondary">
This page will be wired up in a later phase.
</Typography>
</Box>
<Chip label="C.5C.7" size="small" color="primary" sx={{ ml: 'auto' }} />
</Box>
</CardContent>
</Card>
</Box>
);
}

View file

@ -0,0 +1,47 @@
import { Box, Button, Paper, Stack, Typography } from '@mui/material';
import BlockIcon from '@mui/icons-material/Block';
import { REQUIRED_ROLE, useAuth } from '@/auth/AuthContext';
export default function Unauthorized() {
const { user, logout } = useAuth();
const email = user?.email ?? user?.preferred_username ?? 'unknown';
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 3,
}}
>
<Paper sx={{ p: 6, maxWidth: 540, textAlign: 'center' }} elevation={3}>
<BlockIcon sx={{ fontSize: 72, color: 'error.main', mb: 2 }} />
<Typography variant="h2" gutterBottom>
403 Access Denied
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
Your account does not have permission to access the AI Platform admin
dashboard.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Logged in as <strong>{email}</strong>. Required realm role:{' '}
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>
{REQUIRED_ROLE}
</Box>
.
</Typography>
<Stack direction="row" spacing={2} justifyContent="center">
<Button variant="outlined" onClick={() => (window.location.href = '/')}>
Go to public site
</Button>
<Button variant="contained" color="error" onClick={() => logout()}>
Log out
</Button>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,443 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
IconButton,
MenuItem,
Paper,
Stack,
TextField,
Typography,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import DeleteIcon from '@mui/icons-material/Delete';
import StarsIcon from '@mui/icons-material/Stars';
import CloseIcon from '@mui/icons-material/Close';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiDelete, apiGet, apiPatch } from '@/api/client';
import type { AtomDetail, AtomListItem, AtomListResponse } from '@/types/brain';
const TIER_COLOR: Record<string, 'success' | 'default' | 'warning'> = {
gold: 'success',
silver: 'default',
bronze: 'warning',
};
function fmtDate(s: string | null) {
if (!s) return '—';
return new Date(s).toLocaleString();
}
function AtomDetailDrawer({
atomId,
onClose,
}: {
atomId: number | null;
onClose: () => void;
}) {
const open = atomId !== null;
const queryClient = useQueryClient();
const [forceGoldOpen, setForceGoldOpen] = useState(false);
const [correctionsJson, setCorrectionsJson] = useState('{}');
const [initialJson, setInitialJson] = useState('{}');
const [correctionsErr, setCorrectionsErr] = useState<string | null>(null);
const detail = useQuery({
queryKey: ['brain', 'atom', atomId],
queryFn: () => apiGet<AtomDetail>(`/api/brain/v1/analysis_atom/${atomId}`),
enabled: open,
});
const expireMut = useMutation({
mutationFn: () => apiDelete<{ ok: boolean }>(`/api/brain/v1/analysis_atom/${atomId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['brain', 'atoms'] });
queryClient.invalidateQueries({ queryKey: ['brain', 'stats'] });
onClose();
},
});
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
PaperProps={{ sx: { width: { xs: '100%', md: 720 } } }}
>
<Box sx={{ p: 3, overflow: 'auto' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h2">Atom #{atomId}</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
{detail.isLoading && <CircularProgress size={24} />}
{detail.error && <Alert severity="error">{String(detail.error)}</Alert>}
{detail.data && (
<Stack spacing={2}>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip label={detail.data.component} size="small" />
<Chip
label={detail.data.cache_tier}
size="small"
color={TIER_COLOR[detail.data.cache_tier] ?? 'default'}
/>
<Chip label={`tier: ${detail.data.tier}`} size="small" variant="outlined" />
<Chip
label={`hits: ${detail.data.hit_count}`}
size="small"
variant="outlined"
/>
{detail.data.human_validated && (
<Chip label="human validated" size="small" color="success" />
)}
</Stack>
<Box>
<Typography variant="caption" color="text.secondary">
Content preview
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{detail.data.content_preview ?? '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Hashes
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
content_hash: {detail.data.content_hash}
<br />
prompt_hash: {detail.data.prompt_hash}
<br />
framework_version: {detail.data.framework_version ?? '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Timestamps
</Typography>
<Typography variant="body2">
created: {fmtDate(detail.data.created_at)}
<br />
updated: {fmtDate(detail.data.updated_at)}
<br />
expires: {fmtDate(detail.data.expires_at)}
<br />
last hit: {fmtDate(detail.data.last_hit_at)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Result (processed)
</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(detail.data.result_processed, null, 2)}
</pre>
</Paper>
</Box>
{detail.data.result_raw && (
<Box>
<Typography variant="caption" color="text.secondary">
Result (raw LLM)
</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(detail.data.result_raw, null, 2)}
</pre>
</Paper>
</Box>
)}
<Stack direction="row" spacing={1} sx={{ pt: 1 }}>
<Button
variant="contained"
color="success"
startIcon={<StarsIcon />}
disabled={detail.data.cache_tier === 'gold'}
onClick={() => {
const initial = JSON.stringify(detail.data!.result_processed, null, 2);
setCorrectionsJson(initial);
setInitialJson(initial);
setCorrectionsErr(null);
setForceGoldOpen(true);
}}
>
{detail.data.cache_tier === 'gold' ? 'Already gold' : 'Force gold'}
</Button>
<Button
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
onClick={() => {
if (confirm(`Mark atom #${atomId} as expired?`)) {
expireMut.mutate();
}
}}
disabled={expireMut.isPending}
>
Mark expired
</Button>
</Stack>
</Stack>
)}
</Box>
<Dialog
open={forceGoldOpen}
onClose={() => setForceGoldOpen(false)}
fullWidth
maxWidth="md"
>
<DialogTitle>Force atom #{atomId} to gold</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Promotes this silver atom to <strong>gold</strong> (human_validated=true,
expires_at=NULL). Leave the JSON unchanged to <em>approve as-is</em> (LLM result
kept, no corrections recorded). Edit it to apply corrections the diff is
stored in <code>human_corrections</code>.
</Typography>
<TextField
multiline
minRows={12}
fullWidth
value={correctionsJson}
onChange={(e) => setCorrectionsJson(e.target.value)}
error={Boolean(correctionsErr)}
helperText={correctionsErr ?? 'Must be valid JSON'}
sx={{ '& textarea': { fontFamily: 'monospace', fontSize: 12 } }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setForceGoldOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="success"
onClick={async () => {
let parsed: unknown;
try {
parsed = JSON.parse(correctionsJson);
} catch (e) {
setCorrectionsErr(`Invalid JSON: ${e instanceof Error ? e.message : e}`);
return;
}
setCorrectionsErr(null);
const wasEdited = correctionsJson.trim() !== initialJson.trim();
try {
await apiPatch(`/api/brain/v1/analysis_atom/${atomId}`, {
human_validated: true,
human_corrections: wasEdited ? parsed : null,
result_processed: wasEdited ? parsed : null,
validator_user_id: 'admin-ui',
});
queryClient.invalidateQueries({ queryKey: ['brain'] });
setForceGoldOpen(false);
onClose();
} catch (e) {
setCorrectionsErr(e instanceof Error ? e.message : String(e));
}
}}
>
{correctionsJson.trim() !== initialJson.trim()
? 'Promote with corrections'
: 'Approve as-is'}
</Button>
</DialogActions>
</Dialog>
</Drawer>
);
}
export default function BrainAtoms() {
const queryClient = useQueryClient();
const [pagination, setPagination] = useState<GridPaginationModel>({
page: 0,
pageSize: 25,
});
const [component, setComponent] = useState('all');
const [tier, setTier] = useState('all');
const [freshness, setFreshness] = useState('all');
const [q, setQ] = useState('');
const [drawerId, setDrawerId] = useState<number | null>(null);
const { data, isLoading, error, isFetching } = useQuery({
queryKey: ['brain', 'atoms', component, tier, freshness, q, pagination.page, pagination.pageSize],
queryFn: () => {
const params = new URLSearchParams();
params.set('page', String(pagination.page + 1));
params.set('page_size', String(pagination.pageSize));
if (component !== 'all') params.set('component', component);
if (tier !== 'all') params.set('tier', tier);
if (freshness !== 'all') params.set('freshness', freshness);
if (q.trim()) params.set('q', q.trim());
return apiGet<AtomListResponse>(`/api/brain/v1/analysis_atom/list?${params}`);
},
});
const columns: GridColDef<AtomListItem>[] = useMemo(
() => [
{ field: 'atom_id', headerName: 'ID', width: 80 },
{
field: 'component',
headerName: 'Component',
width: 130,
renderCell: (p) => <Chip label={p.value} size="small" />,
},
{
field: 'cache_tier',
headerName: 'Tier',
width: 100,
renderCell: (p) => (
<Chip
label={p.value}
size="small"
color={TIER_COLOR[p.value as string] ?? 'default'}
/>
),
},
{
field: 'content_preview',
headerName: 'Preview',
flex: 1,
minWidth: 220,
renderCell: (p) => (
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{p.value ?? '—'}
</Typography>
),
},
{
field: 'llm_confidence',
headerName: 'Conf.',
width: 80,
valueFormatter: (v: number | null) => (v !== null ? v.toFixed(0) : '—'),
},
{ field: 'hit_count', headerName: 'Hits', width: 70 },
{
field: 'updated_at',
headerName: 'Updated',
width: 160,
valueFormatter: (v: string) => fmtDate(v),
},
{
field: 'expires_at',
headerName: 'Expires',
width: 160,
valueFormatter: (v: string | null) => fmtDate(v),
},
],
[],
);
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
<Typography variant="h1">Brain Atoms</Typography>
<Chip label={`${data?.total ?? '…'} total`} size="small" />
<Box sx={{ flex: 1 }} />
<IconButton
onClick={() => queryClient.invalidateQueries({ queryKey: ['brain', 'atoms'] })}
disabled={isFetching}
>
<RefreshIcon />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
<TextField
select
label="Component"
value={component}
onChange={(e) => setComponent(e.target.value)}
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="techniques">techniques</MenuItem>
<MenuItem value="ai_tampered">ai_tampered</MenuItem>
<MenuItem value="claims">claims</MenuItem>
</TextField>
<TextField
select
label="Cache tier"
value={tier}
onChange={(e) => setTier(e.target.value)}
size="small"
sx={{ minWidth: 140 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="gold">gold</MenuItem>
<MenuItem value="silver">silver</MenuItem>
<MenuItem value="bronze">bronze</MenuItem>
</TextField>
<TextField
select
label="Freshness"
value={freshness}
onChange={(e) => setFreshness(e.target.value)}
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="fresh">fresh</MenuItem>
<MenuItem value="expiring">expiring (7d)</MenuItem>
<MenuItem value="expired">expired</MenuItem>
</TextField>
<TextField
label="Search (preview/hash)"
value={q}
onChange={(e) => setQ(e.target.value)}
size="small"
sx={{ flex: 1, minWidth: 200 }}
/>
</Stack>
{error && <Alert severity="error">{String(error)}</Alert>}
<Paper sx={{ height: 640 }}>
<DataGrid
rows={data?.items ?? []}
getRowId={(row) => row.atom_id}
columns={columns}
loading={isLoading || isFetching}
rowCount={data?.total ?? 0}
paginationMode="server"
paginationModel={pagination}
onPaginationModelChange={setPagination}
pageSizeOptions={[10, 25, 50, 100]}
onRowClick={(p) => setDrawerId(p.row.atom_id as number)}
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
disableRowSelectionOnClick
/>
</Paper>
<AtomDetailDrawer atomId={drawerId} onClose={() => setDrawerId(null)} />
</Box>
);
}

View file

@ -0,0 +1,426 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Chip,
CircularProgress,
Drawer,
IconButton,
MenuItem,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import DeleteIcon from '@mui/icons-material/Delete';
import CloseIcon from '@mui/icons-material/Close';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiDelete, apiGet } from '@/api/client';
import type {
VerificationDetail,
VerificationListItem,
VerificationListResponse,
} from '@/types/brain';
const VOLATILITY_COLOR: Record<string, 'error' | 'warning' | 'success' | 'default'> = {
volatile: 'error',
evolving: 'warning',
stable: 'success',
};
const STANCE_COLOR: Record<string, 'success' | 'error' | 'default'> = {
SUPPORTS: 'success',
CONTRADICTS: 'error',
NEUTRAL: 'default',
};
function fmtDate(s: string | null) {
if (!s) return '—';
return new Date(s).toLocaleString();
}
function VerificationDetailDrawer({
rowKey,
onClose,
}: {
rowKey: { claim_hash: string; tier: string } | null;
onClose: () => void;
}) {
const open = rowKey !== null;
const detail = useQuery({
queryKey: ['brain', 'verification', 'detail', rowKey?.claim_hash, rowKey?.tier],
queryFn: () =>
apiGet<VerificationDetail>(
`/api/brain/v1/verification_cache/${encodeURIComponent(rowKey!.claim_hash)}/${rowKey!.tier}`,
),
enabled: open,
});
const vp = (detail.data?.verification_processed ?? {}) as Record<string, unknown>;
const sources = (vp.sources as Array<Record<string, unknown>> | undefined) ?? [];
const reasoning = vp.reasoning as string | undefined;
const confidence = vp.confidence as number | undefined;
const agreement = vp.agreement_score as number | undefined;
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
PaperProps={{ sx: { width: { xs: '100%', md: 760 } } }}
>
<Box sx={{ p: 3, overflow: 'auto' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h2">Verification cache</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
{detail.isLoading && <CircularProgress size={24} />}
{detail.error && <Alert severity="error">{String(detail.error)}</Alert>}
{detail.data && (
<Stack spacing={2}>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip label={detail.data.tier} size="small" color={detail.data.tier === 'premium' ? 'primary' : 'default'} />
{detail.data.status && <Chip label={`status: ${detail.data.status}`} size="small" variant="outlined" />}
{detail.data.volatility && (
<Chip
label={`volatility: ${detail.data.volatility}`}
size="small"
color={VOLATILITY_COLOR[detail.data.volatility] ?? 'default'}
/>
)}
{confidence != null && <Chip label={`conf: ${confidence}`} size="small" variant="outlined" />}
{agreement != null && <Chip label={`agreement: ${agreement}`} size="small" variant="outlined" />}
</Stack>
<Box>
<Typography variant="caption" color="text.secondary">Claim hash</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{detail.data.claim_hash}
</Typography>
</Box>
{detail.data.topic_codes.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">Topics</Typography>
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
{detail.data.topic_codes.map((t) => (
<Chip key={t} label={t} size="small" variant="outlined" />
))}
</Stack>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">Model + cache versioning</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
model: {detail.data.model ?? '—'}<br />
prompt_hash: {detail.data.prompt_hash}<br />
framework_version: {detail.data.framework_version ?? '—'}<br />
schema_name: {detail.data.schema_name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Timestamps</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
created: {fmtDate(detail.data.created_at)}<br />
updated: {fmtDate(detail.data.updated_at)}<br />
expires: {fmtDate(detail.data.expires_at)}
</Typography>
</Box>
{reasoning && (
<Box>
<Typography variant="caption" color="text.secondary">Reasoning</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{reasoning}
</Typography>
</Paper>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">
Sources ({sources.length})
</Typography>
<Stack spacing={1} sx={{ mt: 0.5 }}>
{sources.map((s, idx) => {
const url = String(s.url ?? '');
const stance = String(s.stance ?? 'NEUTRAL').toUpperCase();
const reliability = String(s.reliability ?? '');
const quote = String(s.relevant_quote ?? '');
return (
<Paper key={idx} variant="outlined" sx={{ p: 1.5 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5, flexWrap: 'wrap' }}>
<Chip
label={stance}
size="small"
color={STANCE_COLOR[stance] ?? 'default'}
/>
{reliability && <Chip label={reliability} size="small" variant="outlined" />}
</Stack>
<Typography
variant="body2"
component="a"
href={url}
target="_blank"
rel="noreferrer"
sx={{
fontSize: 12,
fontFamily: 'monospace',
wordBreak: 'break-all',
display: 'block',
mb: quote ? 0.5 : 0,
}}
>
{url}
</Typography>
{quote && (
<Typography variant="body2" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
{quote}
</Typography>
)}
</Paper>
);
})}
</Stack>
</Box>
{detail.data.evidence_urls.length > sources.length && (
<Box>
<Typography variant="caption" color="text.secondary">
Evidence URLs (raw, {detail.data.evidence_urls.length})
</Typography>
<Paper variant="outlined" sx={{ p: 1, bgcolor: 'background.default' }}>
{detail.data.evidence_urls.map((u, i) => (
<Typography key={i} variant="caption" sx={{ display: 'block', fontFamily: 'monospace' }}>
{u}
</Typography>
))}
</Paper>
</Box>
)}
</Stack>
)}
</Box>
</Drawer>
);
}
export default function BrainCache() {
const queryClient = useQueryClient();
const [pagination, setPagination] = useState<GridPaginationModel>({
page: 0,
pageSize: 25,
});
const [tier, setTier] = useState('all');
const [q, setQ] = useState('');
const [drawerKey, setDrawerKey] = useState<{ claim_hash: string; tier: string } | null>(null);
const { data, isLoading, error, isFetching } = useQuery({
queryKey: ['brain', 'verification', tier, q, pagination.page, pagination.pageSize],
queryFn: () => {
const params = new URLSearchParams();
params.set('page', String(pagination.page + 1));
params.set('page_size', String(pagination.pageSize));
if (tier !== 'all') params.set('tier', tier);
if (q.trim()) params.set('q', q.trim());
return apiGet<VerificationListResponse>(`/api/brain/v1/verification_cache/list?${params}`);
},
});
const deleteMut = useMutation({
mutationFn: ({ claim_hash, tier }: { claim_hash: string; tier: string }) =>
apiDelete<{ ok: boolean }>(
`/api/brain/v1/verification_cache/${encodeURIComponent(claim_hash)}/${tier}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['brain', 'verification'] });
},
});
const columns: GridColDef<VerificationListItem>[] = useMemo(
() => [
{
field: 'claim_hash',
headerName: 'Claim hash',
width: 200,
renderCell: (p) => (
<Tooltip title={p.value as string}>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{(p.value as string).slice(0, 16)}
</Typography>
</Tooltip>
),
},
{
field: 'tier',
headerName: 'Tier',
width: 90,
renderCell: (p) => (
<Chip label={p.value} size="small" color={p.value === 'premium' ? 'primary' : 'default'} />
),
},
{
field: 'status',
headerName: 'Status',
width: 110,
renderCell: (p) =>
p.value ? <Chip label={p.value} size="small" variant="outlined" /> : '—',
},
{
field: 'volatility',
headerName: 'Volatility',
width: 110,
renderCell: (p) =>
p.value ? (
<Chip
label={p.value}
size="small"
color={VOLATILITY_COLOR[p.value as string] ?? 'default'}
/>
) : (
'—'
),
},
{
field: 'topic_codes',
headerName: 'Topics',
width: 180,
sortable: false,
renderCell: (p) => {
const arr = (p.value as string[]) ?? [];
if (arr.length === 0) return '—';
return (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{arr.slice(0, 3).map((t) => (
<Chip key={t} label={t} size="small" variant="outlined" />
))}
{arr.length > 3 && (
<Tooltip title={arr.slice(3).join(', ')}>
<Chip label={`+${arr.length - 3}`} size="small" />
</Tooltip>
)}
</Stack>
);
},
},
{ field: 'model', headerName: 'Model', width: 220 },
{
field: 'evidence_url_count',
headerName: 'Evidence',
width: 90,
type: 'number',
},
{
field: 'updated_at',
headerName: 'Updated',
width: 160,
valueFormatter: (v: string) => fmtDate(v),
},
{
field: 'expires_at',
headerName: 'Expires',
width: 160,
valueFormatter: (v: string) => fmtDate(v),
},
{
field: 'actions',
headerName: '',
width: 70,
sortable: false,
renderCell: (p) => (
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
if (
confirm(
`Hard-delete verification entry for ${p.row.claim_hash.slice(0, 12)}… (${p.row.tier})?`,
)
) {
deleteMut.mutate({ claim_hash: p.row.claim_hash, tier: p.row.tier });
}
}}
disabled={deleteMut.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
),
},
],
[deleteMut],
);
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
<Typography variant="h1">Verification Cache</Typography>
<Chip label={`${data?.total ?? '…'} entries`} size="small" />
<Box sx={{ flex: 1 }} />
<IconButton
onClick={() => queryClient.invalidateQueries({ queryKey: ['brain', 'verification'] })}
disabled={isFetching}
>
<RefreshIcon />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
<TextField
select
label="Tier"
value={tier}
onChange={(e) => setTier(e.target.value)}
size="small"
sx={{ minWidth: 140 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="free">free</MenuItem>
<MenuItem value="premium">premium</MenuItem>
</TextField>
<TextField
label="Search (hash/model)"
value={q}
onChange={(e) => setQ(e.target.value)}
size="small"
sx={{ flex: 1, minWidth: 200 }}
/>
</Stack>
{error && <Alert severity="error">{String(error)}</Alert>}
<Paper sx={{ height: 640 }}>
<DataGrid
rows={data?.items ?? []}
getRowId={(row) => `${row.claim_hash}-${row.tier}`}
columns={columns}
loading={isLoading || isFetching}
rowCount={data?.total ?? 0}
paginationMode="server"
paginationModel={pagination}
onPaginationModelChange={setPagination}
pageSizeOptions={[10, 25, 50, 100]}
disableRowSelectionOnClick
onRowClick={(params) =>
setDrawerKey({ claim_hash: params.row.claim_hash, tier: params.row.tier })
}
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
/>
</Paper>
<VerificationDetailDrawer rowKey={drawerKey} onClose={() => setDrawerKey(null)} />
</Box>
);
}

View file

@ -0,0 +1,701 @@
/**
* Brain Fact Status Pilon 11 admin browser.
*
* Lets the operator inspect what brain "knows" about the world (subject/
* predicate/object triples extracted from claims) and override truth values
* when LLM extraction got it wrong, or lock high-stakes facts so the daily
* auditor stops auto-flipping them.
*
* UX:
* - Top row of filters: free-text entity search, predicate, truth chip,
* locked-only toggle, topic selector.
* - Main DataGrid: paginated list with quick-glance status icons.
* - Click row side drawer with: full triple, current truth, version
* timeline, moderator override form (set_truth + lock + notes).
*/
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Divider,
Drawer,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Step,
StepContent,
StepLabel,
Stepper,
Switch,
Tab,
Tabs,
TextField,
Typography,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPatch } from '@/api/client';
import type {
FactStatusItem,
FactStatusListResponse,
FactStatusPatchRequest,
FactStatusVersionsResponse,
Volatility,
} from '@/types/brain';
const VOLATILITY_COLOR: Record<
Volatility,
'error' | 'warning' | 'success'
> = {
volatile: 'error',
evolving: 'warning',
stable: 'success',
};
function fmtDate(s: string | null) {
if (!s) return '—';
return new Date(s).toLocaleString();
}
function TruthIcon({ truth }: { truth: boolean | null }) {
if (truth === true) return <CheckIcon fontSize="small" color="success" />;
if (truth === false) return <CloseIcon fontSize="small" color="error" />;
return <HelpOutlineIcon fontSize="small" color="disabled" />;
}
// ============================================================================
// Detail drawer — timeline + moderator override
// ============================================================================
function FactDetailDrawer({
factId,
onClose,
}: {
factId: number | null;
onClose: () => void;
}) {
const open = factId !== null;
const queryClient = useQueryClient();
const [moderatorId, setModeratorId] = useState('');
const [setTruth, setSetTruth] = useState<'unchanged' | 'true' | 'false'>(
'unchanged',
);
const [confidence, setConfidence] = useState<number>(95);
const [evidenceUrls, setEvidenceUrls] = useState('');
const [notes, setNotes] = useState('');
const [lockChange, setLockChange] = useState<'unchanged' | 'lock' | 'unlock'>(
'unchanged',
);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const factQuery = useQuery({
queryKey: ['brain', 'fact', factId],
queryFn: () => apiGet<FactStatusItem>(`/api/brain/v1/fact_status/${factId}`),
enabled: open,
});
const versionsQuery = useQuery({
queryKey: ['brain', 'fact', factId, 'versions'],
queryFn: () =>
apiGet<FactStatusVersionsResponse>(
`/api/brain/v1/fact_status/${factId}/versions`,
),
enabled: open,
});
const mutation = useMutation({
mutationFn: (body: FactStatusPatchRequest) =>
apiPatch<FactStatusItem>(`/api/brain/v1/fact_status/${factId}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['brain', 'fact', factId] });
queryClient.invalidateQueries({ queryKey: ['brain', 'facts'] });
setSetTruth('unchanged');
setLockChange('unchanged');
setNotes('');
setEvidenceUrls('');
setSubmitErr(null);
},
onError: (e: Error) => setSubmitErr(e.message),
});
const onSubmit = () => {
setSubmitErr(null);
if (!moderatorId.trim()) {
setSubmitErr('moderator_user_id required (your keycloak id)');
return;
}
if (setTruth === 'unchanged' && lockChange === 'unchanged' && !notes.trim()) {
setSubmitErr('Pick at least one action (truth, lock, or notes)');
return;
}
const body: FactStatusPatchRequest = {
moderator_user_id: moderatorId.trim(),
};
if (setTruth === 'true') body.set_truth = true;
if (setTruth === 'false') body.set_truth = false;
if (setTruth !== 'unchanged') {
body.confidence = confidence;
body.evidence_urls = evidenceUrls
.split('\n')
.map((u) => u.trim())
.filter(Boolean);
}
if (lockChange === 'lock') body.lock = true;
if (lockChange === 'unlock') body.lock = false;
if (notes.trim()) body.notes = notes.trim();
mutation.mutate(body);
};
const fact = factQuery.data;
const versions = versionsQuery.data?.versions ?? [];
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
PaperProps={{ sx: { width: { xs: '100%', md: 720 } } }}
>
<Box sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6">
Fact #{factId ?? '—'}
</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
<Divider sx={{ my: 2 }} />
{factQuery.isLoading && <CircularProgress size={20} />}
{factQuery.error && (
<Alert severity="error">{(factQuery.error as Error).message}</Alert>
)}
{fact && (
<>
{/* Triple summary */}
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Triple
</Typography>
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
<strong>{fact.subject}</strong> {fact.predicate}{' '}
<strong>{fact.object}</strong>
</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 1 }}>
<TruthIcon truth={fact.current_truth} />
<Typography variant="body2">
Current truth:{' '}
<strong>
{fact.current_truth === null
? 'unknown'
: fact.current_truth
? 'TRUE'
: 'FALSE'}
</strong>
{fact.current_confidence !== null &&
` (${fact.current_confidence.toFixed(0)}% confidence)`}
</Typography>
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }} flexWrap="wrap">
{fact.volatility && (
<Chip
size="small"
label={fact.volatility}
color={VOLATILITY_COLOR[fact.volatility]}
/>
)}
{fact.moderator_locked && (
<Chip
size="small"
icon={<LockIcon />}
label={`locked by ${fact.moderator_user_id ?? 'admin'}`}
color="info"
/>
)}
{fact.topic_codes.map((t) => (
<Chip key={t} size="small" label={t} variant="outlined" />
))}
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Last verified: {fmtDate(fact.last_verified_at)} Next check:{' '}
{fmtDate(fact.next_check_at)} Created: {fmtDate(fact.created_at)}
</Typography>
{fact.moderator_notes && (
<Alert severity="info" sx={{ mt: 1.5 }}>
Moderator note: {fact.moderator_notes}
</Alert>
)}
</Paper>
{/* Version timeline */}
<Typography variant="subtitle1" gutterBottom>
Truth timeline
</Typography>
{versionsQuery.isLoading && <CircularProgress size={20} />}
{versions.length === 0 && !versionsQuery.isLoading && (
<Alert severity="info" sx={{ mb: 2 }}>
No truth has been asserted yet. Use the form below to set one.
</Alert>
)}
{versions.length > 0 && (
<Stepper orientation="vertical" sx={{ mb: 2 }}>
{versions.map((v) => (
<Step key={v.version_id} active expanded>
<StepLabel
icon={<TruthIcon truth={v.truth_value} />}
optional={
<Typography variant="caption" color="text.secondary">
{v.created_by} {fmtDate(v.created_at)}
</Typography>
}
>
<strong>{v.truth_value ? 'TRUE' : 'FALSE'}</strong>
{v.confidence !== null && ` (${v.confidence.toFixed(0)}%)`}
</StepLabel>
<StepContent>
<Typography variant="caption" color="text.secondary">
valid {fmtDate(v.valid_from)} {' '}
{v.valid_to ? fmtDate(v.valid_to) : 'present'}
</Typography>
{v.llm_reasoning && (
<Typography variant="body2" sx={{ mt: 0.5 }}>
{v.llm_reasoning}
</Typography>
)}
{v.notes && (
<Alert severity="info" sx={{ mt: 0.5 }}>
{v.notes}
</Alert>
)}
{v.evidence_urls.length > 0 && (
<Box sx={{ mt: 1 }}>
{v.evidence_urls.map((u) => (
<Typography
key={u}
variant="caption"
component="a"
href={u}
target="_blank"
rel="noopener"
sx={{ display: 'block', wordBreak: 'break-all' }}
>
{u}
</Typography>
))}
</Box>
)}
</StepContent>
</Step>
))}
</Stepper>
)}
{/* Moderator override form */}
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1" gutterBottom>
Moderator override
</Typography>
<Stack spacing={2}>
<TextField
label="Your moderator id (keycloak)"
size="small"
value={moderatorId}
onChange={(e) => setModeratorId(e.target.value)}
required
helperText="Used in audit log + version provenance"
/>
<FormControl size="small">
<InputLabel>Set truth</InputLabel>
<Select
label="Set truth"
value={setTruth}
onChange={(e) =>
setSetTruth(e.target.value as 'unchanged' | 'true' | 'false')
}
>
<MenuItem value="unchanged">Don&apos;t change</MenuItem>
<MenuItem value="true">TRUE</MenuItem>
<MenuItem value="false">FALSE</MenuItem>
</Select>
</FormControl>
{setTruth !== 'unchanged' && (
<>
<TextField
label="Confidence"
type="number"
size="small"
inputProps={{ min: 0, max: 100 }}
value={confidence}
onChange={(e) => setConfidence(Number(e.target.value))}
/>
<TextField
label="Evidence URLs (one per line)"
multiline
minRows={2}
size="small"
value={evidenceUrls}
onChange={(e) => setEvidenceUrls(e.target.value)}
/>
</>
)}
<FormControl size="small">
<InputLabel>Lock state</InputLabel>
<Select
label="Lock state"
value={lockChange}
onChange={(e) =>
setLockChange(
e.target.value as 'unchanged' | 'lock' | 'unlock',
)
}
>
<MenuItem value="unchanged">Don&apos;t change</MenuItem>
<MenuItem value="lock">Lock (auditor will skip)</MenuItem>
<MenuItem value="unlock">Unlock (auditor can update)</MenuItem>
</Select>
</FormControl>
<TextField
label="Notes (audit trail)"
multiline
minRows={2}
size="small"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
{submitErr && <Alert severity="error">{submitErr}</Alert>}
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={onSubmit}
disabled={mutation.isPending}
startIcon={
mutation.isPending ? <CircularProgress size={16} /> : undefined
}
>
Apply override
</Button>
</Stack>
</Stack>
</>
)}
</Box>
</Drawer>
);
}
// ============================================================================
// List page
// ============================================================================
interface DueForRecheckResponse {
items: FactStatusItem[];
count: number;
limit: number;
volatility_filter: string | null;
}
export default function BrainFacts() {
const [tab, setTab] = useState<'browse' | 'due'>('browse');
const [dueVolatility, setDueVolatility] = useState<'' | 'volatile' | 'evolving' | 'stable'>('');
const [pagination, setPagination] = useState<GridPaginationModel>({
page: 0,
pageSize: 25,
});
const [entity, setEntity] = useState('');
const [predicate, setPredicate] = useState('');
const [truth, setTruth] = useState<'all' | 'true' | 'false' | 'unknown'>(
'all',
);
const [lockedOnly, setLockedOnly] = useState(false);
const [topic, setTopic] = useState('');
const [selectedFact, setSelectedFact] = useState<number | null>(null);
// Due for recheck — total count badge (always-on, refresh 60s)
// NOTE: must use apiGet (not raw fetch) so the SPA BASE_URL prefix
// (/admin-ai when mounted under reverse proxy) is applied.
const dueBadge = useQuery({
queryKey: ['fact_due_count'],
queryFn: () =>
apiGet<DueForRecheckResponse>(
'/api/brain/v1/fact_status/due_for_recheck?limit=1',
),
refetchInterval: 60_000,
});
// Due for recheck — full list (only fetched when on "due" tab)
const dueList = useQuery({
queryKey: ['fact_due_list', dueVolatility],
enabled: tab === 'due',
queryFn: () => {
const params = new URLSearchParams({ limit: '100' });
if (dueVolatility) params.set('volatility', dueVolatility);
return apiGet<DueForRecheckResponse>(
`/api/brain/v1/fact_status/due_for_recheck?${params.toString()}`,
);
},
});
const params = useMemo(() => {
const p = new URLSearchParams();
p.set('page', String(pagination.page + 1));
p.set('page_size', String(pagination.pageSize));
if (entity) p.set('entity', entity);
if (predicate) p.set('predicate', predicate);
if (truth === 'true') p.set('current_truth', 'true');
if (truth === 'false') p.set('current_truth', 'false');
if (lockedOnly) p.set('locked_only', 'true');
if (topic) p.set('topic', topic);
return p.toString();
}, [pagination, entity, predicate, truth, lockedOnly, topic]);
const list = useQuery({
queryKey: ['brain', 'facts', params],
queryFn: () => apiGet<FactStatusListResponse>(`/api/brain/v1/fact_status/list?${params}`),
});
const columns: GridColDef<FactStatusItem>[] = [
{
field: 'current_truth',
headerName: 'Truth',
width: 70,
sortable: false,
renderCell: (p) => <TruthIcon truth={p.row.current_truth} />,
},
{
field: 'subject',
headerName: 'Subject',
flex: 1.2,
minWidth: 160,
},
{
field: 'predicate',
headerName: 'Predicate',
width: 180,
renderCell: (p) => (
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{p.row.predicate}
</Typography>
),
},
{
field: 'object',
headerName: 'Object',
flex: 1.2,
minWidth: 160,
},
{
field: 'volatility',
headerName: 'Volatility',
width: 110,
renderCell: (p) =>
p.row.volatility ? (
<Chip
size="small"
label={p.row.volatility}
color={VOLATILITY_COLOR[p.row.volatility]}
/>
) : (
'—'
),
},
{
field: 'moderator_locked',
headerName: 'Lock',
width: 70,
sortable: false,
renderCell: (p) =>
p.row.moderator_locked ? (
<LockIcon fontSize="small" color="info" />
) : (
<LockOpenIcon fontSize="small" color="disabled" />
),
},
{
field: 'last_verified_at',
headerName: 'Verified',
width: 170,
renderCell: (p) => (
<Typography variant="caption">
{fmtDate(p.row.last_verified_at)}
</Typography>
),
},
];
return (
<Box sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<Typography variant="h5">Brain fact status</Typography>
<Box sx={{ flexGrow: 1 }} />
<IconButton onClick={() => tab === 'browse' ? list.refetch() : dueList.refetch()} aria-label="refresh">
<RefreshIcon />
</IconButton>
</Stack>
<Tabs value={tab} onChange={(_, v) => setTab(v as 'browse' | 'due')} sx={{ mb: 2 }}>
<Tab value="browse" label="Browse" />
<Tab
value="due"
label={
<Stack direction="row" alignItems="center" spacing={1}>
<span>Due for Recheck</span>
{dueBadge.data && dueBadge.data.count > 0 && (
<Chip label={dueBadge.data.count > 99 ? '99+' : dueBadge.data.count} size="small" color="warning" sx={{ height: 18, fontSize: 10 }} />
)}
</Stack>
}
/>
</Tabs>
{tab === 'due' && (
<>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">
Facts whose <code>next_check_at</code> has passed and need re-verification. Excludes moderator-locked facts.
</Typography>
<Box sx={{ flex: 1 }} />
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Volatility</InputLabel>
<Select label="Volatility" value={dueVolatility} onChange={(e) => setDueVolatility(e.target.value as 'volatile' | 'evolving' | 'stable' | '')}>
<MenuItem value="">All</MenuItem>
<MenuItem value="volatile">Volatile</MenuItem>
<MenuItem value="evolving">Evolving</MenuItem>
<MenuItem value="stable">Stable</MenuItem>
</Select>
</FormControl>
</Stack>
</Paper>
{dueList.isError && <Alert severity="error" sx={{ mb: 2 }}>{(dueList.error as Error).message}</Alert>}
<Paper variant="outlined" sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={(dueList.data?.items ?? []) as unknown as FactStatusItem[]}
getRowId={(r) => r.fact_id}
columns={columns}
loading={dueList.isLoading}
hideFooterPagination
onRowClick={(p) => setSelectedFact(p.row.fact_id)}
disableRowSelectionOnClick
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
/>
</Paper>
<FactDetailDrawer
factId={selectedFact}
onClose={() => setSelectedFact(null)}
/>
</>
)}
{tab === 'browse' && <>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} flexWrap="wrap">
<TextField
label="Entity (subject or object)"
size="small"
value={entity}
onChange={(e) => setEntity(e.target.value)}
sx={{ minWidth: 240 }}
/>
<TextField
label="Predicate"
size="small"
value={predicate}
onChange={(e) => setPredicate(e.target.value)}
sx={{ minWidth: 200 }}
/>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Truth</InputLabel>
<Select
label="Truth"
value={truth}
onChange={(e) =>
setTruth(e.target.value as 'all' | 'true' | 'false' | 'unknown')
}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">TRUE only</MenuItem>
<MenuItem value="false">FALSE only</MenuItem>
</Select>
</FormControl>
<TextField
label="Topic code"
size="small"
value={topic}
onChange={(e) => setTopic(e.target.value)}
sx={{ minWidth: 160 }}
helperText="e.g., war, climate"
/>
<FormControlLabel
control={
<Switch
checked={lockedOnly}
onChange={(e) => setLockedOnly(e.target.checked)}
/>
}
label="Locked only"
/>
</Stack>
</Paper>
{list.error && (
<Alert severity="error" sx={{ mb: 2 }}>
{(list.error as Error).message}
</Alert>
)}
<Paper variant="outlined" sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={list.data?.items ?? []}
getRowId={(r) => r.fact_id}
columns={columns}
rowCount={list.data?.total ?? 0}
loading={list.isLoading}
paginationMode="server"
paginationModel={pagination}
onPaginationModelChange={setPagination}
pageSizeOptions={[10, 25, 50, 100]}
onRowClick={(p) => setSelectedFact(p.row.fact_id)}
disableRowSelectionOnClick
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
/>
</Paper>
<FactDetailDrawer
factId={selectedFact}
onClose={() => setSelectedFact(null)}
/>
</>}
</Box>
);
}

View file

@ -0,0 +1,354 @@
/**
* Brain Cache Invalidate Pilon 8 admin UI.
*
* Form-driven mass invalidation with a 2-step UX:
* 1. Operator picks filters (topic_codes, entity_canonicals, claim_pattern,
* since) click "Preview" backend runs dry_run=true shows counts.
* 2. If counts look right, "Confirm invalidate" runs the same filter
* with dry_run=false. Audit log entry written automatically.
*
* Safety:
* - No "Confirm" button visible until a Preview has been run.
* - Filters are cleared after Confirm to prevent accidental re-execution.
* - "invalidate_gold" requires explicit checkbox (gold = moderator-validated,
* should not be flushed casually).
*/
import { useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
CircularProgress,
Divider,
FormControlLabel,
IconButton,
Paper,
Stack,
TextField,
Typography,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost } from '@/api/client';
import type {
AuditLogResponse,
CacheInvalidateRequest,
CacheInvalidateResponse,
} from '@/types/brain';
function parseList(s: string): string[] {
return s
.split(/[\n,]/)
.map((x) => x.trim())
.filter(Boolean);
}
export default function BrainInvalidate() {
const queryClient = useQueryClient();
// Filter inputs
const [topicCodes, setTopicCodes] = useState('');
const [entityCanonicals, setEntityCanonicals] = useState('');
const [claimPattern, setClaimPattern] = useState('');
const [since, setSince] = useState('');
const [invalidateGold, setInvalidateGold] = useState(false);
const [actor, setActor] = useState('');
const [reason, setReason] = useState('');
// Last preview result — only when this is non-null can we Confirm.
const [preview, setPreview] = useState<CacheInvalidateResponse | null>(null);
const [lastResult, setLastResult] = useState<CacheInvalidateResponse | null>(
null,
);
const [submitErr, setSubmitErr] = useState<string | null>(null);
// Recent invalidations panel
const recent = useQuery({
queryKey: ['brain', 'audit_log', 'invalidate'],
queryFn: () =>
apiGet<AuditLogResponse>(
'/api/brain/v1/cache/audit_log?action=invalidate&page_size=10',
),
refetchInterval: 30_000,
});
const buildBody = (dry: boolean): CacheInvalidateRequest => {
const body: CacheInvalidateRequest = {
dry_run: dry,
invalidate_gold: invalidateGold,
};
const tc = parseList(topicCodes);
const ec = parseList(entityCanonicals);
if (tc.length) body.topic_codes = tc;
if (ec.length) body.entity_canonicals = ec;
if (claimPattern.trim()) body.claim_pattern = claimPattern.trim();
if (since.trim()) body.since = new Date(since).toISOString();
if (actor.trim()) body.actor = actor.trim();
if (reason.trim()) body.reason = reason.trim();
return body;
};
const previewMutation = useMutation({
mutationFn: () =>
apiPost<CacheInvalidateResponse>(
'/api/brain/v1/cache/invalidate',
buildBody(true),
),
onSuccess: (data) => {
setPreview(data);
setSubmitErr(null);
},
onError: (e: Error) => {
setPreview(null);
setSubmitErr(e.message);
},
});
const confirmMutation = useMutation({
mutationFn: () =>
apiPost<CacheInvalidateResponse>(
'/api/brain/v1/cache/invalidate',
buildBody(false),
),
onSuccess: (data) => {
setLastResult(data);
setPreview(null);
// Reset filters so user can't accidentally re-run.
setTopicCodes('');
setEntityCanonicals('');
setClaimPattern('');
setSince('');
setReason('');
// Refresh recent invalidations.
queryClient.invalidateQueries({
queryKey: ['brain', 'audit_log', 'invalidate'],
});
},
onError: (e: Error) => setSubmitErr(e.message),
});
const hasFilter =
topicCodes.trim() ||
entityCanonicals.trim() ||
claimPattern.trim() ||
since.trim();
return (
<Box sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<Typography variant="h5">Brain cache invalidate</Typography>
</Stack>
<Stack direction={{ xs: 'column', lg: 'row' }} spacing={3}>
<Box sx={{ flex: 2 }}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
1. Build filter
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
Provide at least one of: topic codes, entity canonicals, claim
pattern, or "since" timestamp. Empty filters are rejected.
</Alert>
<Stack spacing={2}>
<TextField
label="Topic codes"
multiline
minRows={2}
size="small"
value={topicCodes}
onChange={(e) => setTopicCodes(e.target.value)}
helperText="One per line or comma-separated. Examples: war, elections, climate"
/>
<TextField
label="Entity canonicals"
multiline
minRows={2}
size="small"
value={entityCanonicals}
onChange={(e) => setEntityCanonicals(e.target.value)}
helperText='Lowercased "subject predicate object". Example: vladimir putin is_president_of russia'
/>
<TextField
label="Claim pattern (ILIKE)"
size="small"
value={claimPattern}
onChange={(e) => setClaimPattern(e.target.value)}
helperText="ILIKE pattern matched against content_preview / verification_processed"
/>
<TextField
label="Since"
type="datetime-local"
size="small"
value={since}
onChange={(e) => setSince(e.target.value)}
InputLabelProps={{ shrink: true }}
helperText="Match rows updated_at >= this timestamp"
/>
<FormControlLabel
control={
<Checkbox
checked={invalidateGold}
onChange={(e) => setInvalidateGold(e.target.checked)}
color="warning"
/>
}
label={
<Stack direction="row" spacing={1} alignItems="center">
<WarningAmberIcon fontSize="small" color="warning" />
<Typography variant="body2">
Also invalidate <strong>gold</strong> atoms (moderator-validated)
</Typography>
</Stack>
}
/>
<Divider />
<TextField
label="Actor (audit log)"
size="small"
value={actor}
onChange={(e) => setActor(e.target.value)}
helperText="Your keycloak id — defaults to 'api'"
/>
<TextField
label="Reason"
size="small"
value={reason}
onChange={(e) => setReason(e.target.value)}
helperText="Why are you doing this? Stored in audit log"
/>
</Stack>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
2. Preview & confirm
</Typography>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
onClick={() => {
setLastResult(null);
previewMutation.mutate();
}}
disabled={!hasFilter || previewMutation.isPending}
startIcon={
previewMutation.isPending ? (
<CircularProgress size={16} />
) : undefined
}
>
Preview (dry run)
</Button>
<Button
variant="contained"
color="error"
onClick={() => confirmMutation.mutate()}
disabled={!preview || confirmMutation.isPending}
startIcon={
confirmMutation.isPending ? (
<CircularProgress size={16} />
) : undefined
}
>
Confirm invalidate
</Button>
</Stack>
{submitErr && (
<Alert severity="error" sx={{ mt: 2 }}>
{submitErr}
</Alert>
)}
{preview && (
<Alert severity="warning" icon={<WarningAmberIcon />} sx={{ mt: 2 }}>
Will invalidate <strong>{preview.invalidated_atoms}</strong> atoms
+ <strong>{preview.invalidated_vcache}</strong> verification rows.
Click "Confirm invalidate" to apply.
</Alert>
)}
{lastResult && (
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mt: 2 }}>
Done invalidated <strong>{lastResult.invalidated_atoms}</strong>{' '}
atoms + <strong>{lastResult.invalidated_vcache}</strong>{' '}
verification rows at{' '}
{new Date(lastResult.executed_at).toLocaleString()}.
</Alert>
)}
</Paper>
</Box>
<Box sx={{ flex: 1, minWidth: 320 }}>
<Card variant="outlined">
<CardContent>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 1 }}
>
<Typography variant="h6">Recent invalidations</Typography>
<IconButton size="small" onClick={() => recent.refetch()}>
<RefreshIcon fontSize="small" />
</IconButton>
</Stack>
{recent.isLoading && <CircularProgress size={20} />}
{recent.data?.items.length === 0 && (
<Typography variant="body2" color="text.secondary">
No invalidations yet.
</Typography>
)}
<Stack spacing={1.5}>
{(recent.data?.items ?? []).map((entry) => {
const counts = entry.payload?.counts as
| { atoms?: number; vcache?: number }
| undefined;
const reasonText = entry.payload?.reason as string | undefined;
return (
<Box key={entry.log_id}>
<Typography variant="caption" color="text.secondary">
{new Date(entry.created_at).toLocaleString()} {' '}
{entry.actor}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 0.5 }}>
<Chip
size="small"
label={`${counts?.atoms ?? 0} atoms`}
color="default"
/>
<Chip
size="small"
label={`${counts?.vcache ?? 0} vcache`}
color="default"
/>
</Stack>
{reasonText && (
<Typography
variant="body2"
sx={{ mt: 0.5, fontStyle: 'italic' }}
>
{reasonText}
</Typography>
)}
<Divider sx={{ mt: 1 }} />
</Box>
);
})}
</Stack>
</CardContent>
</Card>
</Box>
</Stack>
</Box>
);
}

View file

@ -0,0 +1,209 @@
import {
Alert,
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
Stack,
Typography,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import {
Bar,
BarChart,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { apiGet } from '@/api/client';
import type { AtomStatsExtended } from '@/types/brain';
const TIER_COLORS = {
gold: '#FFC107',
silver: '#9E9E9E',
bronze: '#CD7F32',
} as const;
export default function BrainStats() {
const { data, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['brain', 'stats'],
queryFn: () =>
apiGet<AtomStatsExtended>('/api/brain/v1/analysis_atom/stats/extended'),
refetchInterval: 30_000,
});
if (isLoading) {
return (
<Stack alignItems="center" sx={{ py: 6 }}>
<CircularProgress />
</Stack>
);
}
if (error) {
return <Alert severity="error">Failed to load stats: {String(error)}</Alert>;
}
if (!data) return null;
const tierData = [
{ name: 'gold', value: data.by_tier.gold, fill: TIER_COLORS.gold },
{ name: 'silver', value: data.by_tier.silver, fill: TIER_COLORS.silver },
{ name: 'bronze', value: data.by_tier.bronze, fill: TIER_COLORS.bronze },
].filter((t) => t.value > 0);
const componentData = [
{ name: 'techniques', value: data.by_component.techniques },
{ name: 'ai_tampered', value: data.by_component.ai_tampered },
{ name: 'claims', value: data.by_component.claims },
];
const hitsData = [
{ name: 'gold (24h)', value: data.hits_24h_gold, fill: TIER_COLORS.gold },
{ name: 'silver (24h)', value: data.hits_24h_silver, fill: TIER_COLORS.silver },
];
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 3 }}>
<Typography variant="h1">Brain Stats</Typography>
<Chip
label={isFetching ? 'refreshing…' : 'auto-refresh 30s'}
size="small"
color={isFetching ? 'primary' : 'default'}
variant="outlined"
/>
</Stack>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Total atoms
</Typography>
<Typography variant="h2">{data.total_atoms.toLocaleString()}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Hit rate (24h)
</Typography>
<Typography variant="h2" color="success.main">
{data.hit_rate_24h !== null
? `${(data.hit_rate_24h * 100).toFixed(1)}%`
: '—'}
</Typography>
<Typography variant="caption" color="text.secondary">
{data.hits_24h_gold + data.hits_24h_silver} hits / {data.writes_24h} writes
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Writes (24h)
</Typography>
<Typography variant="h2">{data.writes_24h}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Gold promotions (24h)
</Typography>
<Typography variant="h2" sx={{ color: TIER_COLORS.gold }}>
{data.gold_promotions_24h}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Atoms by tier
</Typography>
{tierData.length === 0 ? (
<Alert severity="info">No atoms yet</Alert>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={tierData} dataKey="value" nameKey="name" outerRadius={90} label>
{tierData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Atoms by component
</Typography>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={componentData}>
<XAxis dataKey="name" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="value" fill="#0052CC" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Hits by tier (24h)
</Typography>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={hitsData}>
<XAxis dataKey="name" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="value">
{hitsData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<Typography variant="caption" color="text.secondary">
Refreshed live every 30s. Click anywhere on the page to force a re-fetch via{' '}
<code onClick={() => refetch()} style={{ cursor: 'pointer' }}>
refetch()
</code>
.
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View file

@ -0,0 +1,129 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
List,
ListItem,
ListItemText,
Stack,
Typography,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost } from '@/api/client';
import type { TaxonomyInfo } from '@/types/brain';
interface ReloadResponse {
ok: boolean;
before: number | null;
after: number | null;
fetched: number | null;
error: string | null;
}
export default function BrainTaxonomy() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{ kind: 'ok' | 'err'; msg: string } | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['brain', 'taxonomy'],
queryFn: () => apiGet<TaxonomyInfo>('/api/brain/v1/taxonomy'),
});
const reloadMut = useMutation({
mutationFn: () => apiPost<ReloadResponse>('/api/brain/v1/taxonomy/reload'),
onSuccess: (data) => {
if (data.ok) {
setFeedback({
kind: 'ok',
msg: `Reloaded — ${data.fetched} tags from atomic. Resolver was ${data.before} → now ${data.after}.`,
});
} else {
setFeedback({ kind: 'err', msg: data.error ?? 'Reload failed' });
}
queryClient.invalidateQueries({ queryKey: ['brain', 'taxonomy'] });
},
onError: (e: unknown) => {
setFeedback({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
},
});
if (isLoading) {
return (
<Stack alignItems="center" sx={{ py: 6 }}>
<CircularProgress />
</Stack>
);
}
if (error) return <Alert severity="error">{String(error)}</Alert>;
if (!data) return null;
const sortedNamespaces = [...data.namespaces].sort();
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 3 }}>
<Typography variant="h1">Taxonomy</Typography>
<Chip label={`${data.total_tags} tags`} size="small" />
<Box sx={{ flex: 1 }} />
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => reloadMut.mutate()}
disabled={reloadMut.isPending}
>
{reloadMut.isPending ? 'Reloading…' : 'Reload from atomic'}
</Button>
</Stack>
{feedback && (
<Alert
severity={feedback.kind === 'ok' ? 'success' : 'error'}
onClose={() => setFeedback(null)}
sx={{ mb: 2 }}
>
{feedback.msg}
</Alert>
)}
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Namespaces
</Typography>
{sortedNamespaces.length === 0 ? (
<Alert severity="info">
No taxonomy loaded. Brain may have failed to fetch from atomic at startup.
</Alert>
) : (
<List dense>
{sortedNamespaces.map((ns) => (
<ListItem
key={ns}
secondaryAction={
<Chip
label={`${data.by_namespace[ns] ?? 0} tags`}
size="small"
variant="outlined"
/>
}
>
<ListItemText
primary={ns}
primaryTypographyProps={{ sx: { fontFamily: 'monospace' } }}
/>
</ListItem>
))}
</List>
)}
</CardContent>
</Card>
</Box>
);
}

View file

@ -0,0 +1,116 @@
import { useState } from 'react';
import {
Alert,
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
Stack,
TextField,
Typography,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '@/api/client';
interface ArchiveResponse {
claims: Array<{
id?: number;
claim_id?: string | number;
claim?: string;
summary?: string | null;
verdict?: string | null;
confidence?: number | null;
created_at?: string;
[key: string]: unknown;
}>;
total?: number;
}
function fmt(s: string | undefined | null) {
if (!s) return '—';
return new Date(s).toLocaleString();
}
export default function Archive() {
const [q, setQ] = useState('');
const { data, isLoading, error } = useQuery({
queryKey: ['archive', q],
queryFn: () => {
const params = new URLSearchParams();
params.set('limit', '50');
if (q.trim()) params.set('q', q.trim());
return apiGet<ArchiveResponse>(`/api/archive/claims?${params}`);
},
});
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
<Typography variant="h1">Claims Archive</Typography>
<Chip label={`${data?.total ?? data?.claims?.length ?? '…'} claims`} size="small" />
</Stack>
<TextField
label="Search claim text (ILIKE)"
value={q}
onChange={(e) => setQ(e.target.value)}
size="small"
fullWidth
sx={{ mb: 2 }}
/>
{isLoading && (
<Stack alignItems="center" sx={{ py: 4 }}>
<CircularProgress />
</Stack>
)}
{error && <Alert severity="error">{String(error)}</Alert>}
{data?.claims && data.claims.length === 0 && (
<Alert severity="info">No claims yet promote a request from /history first.</Alert>
)}
<Grid container spacing={2}>
{(data?.claims ?? []).map((c, i) => (
<Grid size={{ xs: 12, md: 6 }} key={c.id ?? c.claim_id ?? i}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" spacing={1} sx={{ mb: 1 }} alignItems="center">
{c.verdict && (
<Chip
label={c.verdict}
size="small"
color={c.verdict === 'true' ? 'success' : c.verdict === 'false' ? 'error' : 'default'}
/>
)}
{c.confidence !== null && c.confidence !== undefined && (
<Chip
label={`${(Number(c.confidence) * 100).toFixed(0)}%`}
size="small"
variant="outlined"
/>
)}
<Box sx={{ flex: 1 }} />
<Typography variant="caption" color="text.secondary">
{fmt(c.created_at as string | undefined)}
</Typography>
</Stack>
<Typography variant="body1" sx={{ fontWeight: 500, mb: 1 }}>
{c.claim ?? '(no claim text)'}
</Typography>
{c.summary && (
<Typography variant="body2" color="text.secondary">
{c.summary}
</Typography>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
}

View file

@ -0,0 +1,296 @@
import {
Alert,
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
LinearProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { apiGet } from '@/api/client';
import type { CostStats } from '@/types/insights';
const TIER_COLORS = { free: '#0052CC', premium: '#FF8C42', null: '#A0A0B0' } as const;
function fmt(s: string | null) {
if (!s) return '—';
return new Date(s).toLocaleString();
}
function tierColor(t: string | null | undefined): string {
if (!t) return TIER_COLORS.null;
return (TIER_COLORS as Record<string, string>)[t] ?? '#A0A0B0';
}
export default function Cost() {
const { data, isLoading, error } = useQuery({
queryKey: ['cost'],
queryFn: () => apiGet<CostStats>('/api/stats/cost'),
refetchInterval: 60_000,
});
if (isLoading) {
return (
<Stack alignItems="center" sx={{ py: 6 }}>
<CircularProgress />
</Stack>
);
}
if (error) return <Alert severity="error">{String(error)}</Alert>;
if (!data) return null;
const tierPie = data.by_tier.map((r) => ({
name: r.tier ?? 'unknown',
value: r.cost,
fill: tierColor(r.tier),
}));
const providerBars = data.by_provider.map((r) => ({
name: r.provider ?? 'unknown',
cost: r.cost,
count: r.count,
}));
return (
<Box>
<Typography variant="h1" sx={{ mb: 1 }}>
Cost
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Spend rollups (24h / 7d / 30d) + provider/tier breakdown. Refreshes every 60s.
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Last 24h
</Typography>
<Typography variant="h2">${data.cost_24h.toFixed(4)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Last 7d
</Typography>
<Typography variant="h2">${data.cost_7d.toFixed(4)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Typography variant="caption" color="text.secondary">
Last 30d
</Typography>
<Typography variant="h2">${data.cost_30d.toFixed(4)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="baseline">
<Typography variant="caption" color="text.secondary">
Projected monthly
</Typography>
{(data as { projection_confidence?: string }).projection_confidence && (
<Chip
label={(data as { projection_confidence?: string }).projection_confidence}
size="small"
variant="outlined"
color={
(data as { projection_confidence?: string }).projection_confidence === 'stable' ? 'success' :
(data as { projection_confidence?: string }).projection_confidence === 'moderate' ? 'default' : 'warning'
}
/>
)}
</Stack>
<Typography variant="h2" color="warning.main">
${data.projected_monthly.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{(data as { projection_basis?: string }).projection_basis ?? 'estimate'}
</Typography>
{(data as { trend?: string }).trend && (data as { trend?: string }).trend !== 'unknown' && (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{
color: (data as { trend?: string }).trend === 'increasing' ? 'error.main' :
(data as { trend?: string }).trend === 'decreasing' ? 'success.main' : 'text.secondary',
fontWeight: 500,
}}>
{(data as { trend?: string }).trend === 'increasing' ? '↗' : (data as { trend?: string }).trend === 'decreasing' ? '↘' : '→'} 7d trend: {(data as { trend?: string }).trend}
</Typography>
{(data as { trend_pct?: number | null }).trend_pct !== undefined && (data as { trend_pct?: number | null }).trend_pct !== null && (
<Typography variant="caption" color="text.secondary">
({(data as { trend_pct?: number }).trend_pct! >= 0 ? '+' : ''}{(data as { trend_pct?: number }).trend_pct}%)
</Typography>
)}
</Stack>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Cost by tier (30d)
</Typography>
{tierPie.length === 0 ? (
<Alert severity="info">No spend recorded</Alert>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={tierPie} dataKey="value" nameKey="name" outerRadius={90} label>
{tierPie.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Cost by provider (30d)
</Typography>
{providerBars.length === 0 ? (
<Alert severity="info">No provider cost recorded</Alert>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={providerBars}>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="cost" fill="#0052CC" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Provider quota usage
</Typography>
{data.budgets.length === 0 ? (
<Alert severity="info">No provider quotas configured</Alert>
) : (
<Stack spacing={2}>
{data.budgets.map((b) => {
// USD quotas should be formatted as $X.XX (Pay-as-you-go billing
// shows fractional dollars; toLocaleString() with no options
// strips decimals AND inserts thousand separators, so 43.11
// ends up rendered as "43,112" on locales that group thousands).
const isUsd = (b.unit ?? '').toLowerCase() === 'usd';
const fmt = (n: number) =>
isUsd
? `$${n.toFixed(2)}`
: n.toLocaleString(undefined, { maximumFractionDigits: 0 });
return (
<Box key={b.name}>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{b.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{fmt(b.used)} / {fmt(b.limit)}
{!isUsd && b.unit ? ` ${b.unit}` : ''} (
{b.percent.toFixed(1)}%)
{b.plan_price && `$${b.plan_price}/mo`}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={Math.min(100, b.percent)}
sx={{
height: 8,
borderRadius: 1,
'& .MuiLinearProgress-bar': {
bgcolor:
b.percent > 90 ? 'error.main' : b.percent > 75 ? 'warning.main' : 'success.main',
},
}}
/>
</Box>
);
})}
</Stack>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12 }}>
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 2 }}>
Top 10 most expensive (30d)
</Typography>
{data.top.length === 0 ? (
<Alert severity="info">No expensive requests</Alert>
) : (
<Paper variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Tier</TableCell>
<TableCell>Provider</TableCell>
<TableCell>Endpoint</TableCell>
<TableCell align="right">Duration</TableCell>
<TableCell align="right">Cost</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.top.map((r) => (
<TableRow key={r.id}>
<TableCell>{fmt(r.created_at)}</TableCell>
<TableCell>
{r.tier && <Chip label={r.tier} size="small" />}
</TableCell>
<TableCell>{r.provider ?? '—'}</TableCell>
<TableCell>{r.endpoint ?? '—'}</TableCell>
<TableCell align="right">
{r.duration_ms !== null ? `${r.duration_ms} ms` : '—'}
</TableCell>
<TableCell align="right">${r.cost.toFixed(4)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View file

@ -0,0 +1,415 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Drawer,
IconButton,
MenuItem,
Paper,
Stack,
TextField,
Typography,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import ArchiveIcon from '@mui/icons-material/Archive';
import CloseIcon from '@mui/icons-material/Close';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link as RouterLink } from 'react-router-dom';
import { apiGet, apiPost } from '@/api/client';
import type { HistoryDetail, HistoryItem, HistoryListResponse } from '@/types/insights';
function fmt(s: string | null) {
if (!s) return '—';
return new Date(s).toLocaleString();
}
function statusColor(code: number | null): 'success' | 'warning' | 'error' | 'default' {
if (code === null) return 'default';
if (code < 300) return 'success';
if (code < 500) return 'warning';
return 'error';
}
function HistoryDetailDrawer({ id, onClose }: { id: number | null; onClose: () => void }) {
const open = id !== null;
const queryClient = useQueryClient();
const detail = useQuery({
queryKey: ['history', id],
queryFn: () => apiGet<HistoryDetail>(`/api/history/${id}`),
enabled: open,
});
// Promote-to-archive mutation. Backend rejects everything except /v1/gather
// rows that have a stored raw_response, so we hide the button otherwise.
const promoteMut = useMutation({
mutationFn: () => {
if (!detail.data) throw new Error('No row loaded');
const reqId = detail.data.request_id;
return apiPost<{ ok: boolean; archived_id?: number; created?: boolean }>(
`/api/archive/promote/${encodeURIComponent(reqId)}`,
{},
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archive', 'claims'] });
},
});
const canPromote = Boolean(
detail.data?.endpoint === '/v1/gather' && detail.data?.raw_response,
);
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
PaperProps={{ sx: { width: { xs: '100%', md: 720 } } }}
>
<Box sx={{ p: 3, overflow: 'auto' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h2">Request #{id}</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
{detail.isLoading && <CircularProgress size={24} />}
{detail.error && <Alert severity="error">{String(detail.error)}</Alert>}
{detail.data && (
<Stack spacing={2}>
<Stack direction="row" spacing={1} flexWrap="wrap">
{detail.data.module && (
<Chip
label={`module: ${detail.data.module}`}
size="small"
color={
detail.data.module === 'brain'
? 'secondary'
: detail.data.module === 'agent_v3'
? 'success'
: 'primary'
}
/>
)}
{detail.data.tier && detail.data.tier !== 'n/a' && <Chip label={`tier: ${detail.data.tier}`} size="small" />}
{detail.data.endpoint && (
<Chip label={detail.data.endpoint} size="small" variant="outlined" />
)}
{detail.data.provider && (
<Chip label={`provider: ${detail.data.provider}`} size="small" variant="outlined" />
)}
<Chip
label={`HTTP ${detail.data.status_code ?? '?'}` as string}
size="small"
color={statusColor(detail.data.status_code)}
/>
{detail.data.duration_ms !== null && (
<Chip
label={`${detail.data.duration_ms} ms`}
size="small"
variant="outlined"
/>
)}
{detail.data.cost_usd !== null && detail.data.cost_usd !== undefined && (
<Chip
label={`$${Number(detail.data.cost_usd).toFixed(4)}`}
size="small"
variant="outlined"
/>
)}
</Stack>
<Box>
<Typography variant="caption" color="text.secondary">
Request ID
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{detail.data.request_id}
</Typography>
</Box>
{detail.data.error && (
<Alert severity="error">
<Typography variant="body2">{detail.data.error}</Typography>
</Alert>
)}
{/* Promote-to-archive: only meaningful for /v1/gather rows that
actually have a raw_response payload to extract. Backend
rejects everything else with 400/404. */}
{canPromote && (
<Box>
<Stack direction="row" spacing={1} alignItems="center">
<Button
variant="contained"
color="success"
size="small"
startIcon={<ArchiveIcon />}
onClick={() => promoteMut.mutate()}
disabled={promoteMut.isPending || promoteMut.isSuccess}
>
{promoteMut.isPending
? 'Promoting…'
: promoteMut.isSuccess
? 'Promoted'
: 'Promote to Archive'}
</Button>
{promoteMut.isSuccess && (
<Button size="small" component={RouterLink} to="/insights/archive">
View in Archive
</Button>
)}
</Stack>
{promoteMut.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{(promoteMut.error as Error).message}
</Alert>
)}
{promoteMut.isSuccess && promoteMut.data && (
<Alert severity="success" sx={{ mt: 1 }}>
Archived as claim #{promoteMut.data.archived_id}
{promoteMut.data.created === false ? ' (already existed, refreshed)' : ''}.
</Alert>
)}
</Box>
)}
{Boolean(detail.data.stages) && (
<Box>
<Typography variant="caption" color="text.secondary">
Stages
</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(detail.data.stages, null, 2)}
</pre>
</Paper>
</Box>
)}
{Boolean(detail.data.raw_request) && (
<Box>
<Typography variant="caption" color="text.secondary">
Raw request
</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(detail.data.raw_request, null, 2)}
</pre>
</Paper>
</Box>
)}
{Boolean(detail.data.raw_response) && (
<Box>
<Typography variant="caption" color="text.secondary">
Raw response
</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'background.default' }}>
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(detail.data.raw_response, null, 2)}
</pre>
</Paper>
</Box>
)}
</Stack>
)}
</Box>
</Drawer>
);
}
const MODULE_COLOR: Record<string, 'primary' | 'secondary' | 'success' | 'default'> = {
web: 'primary',
brain: 'secondary',
agent_v3: 'success',
};
export default function History() {
const queryClient = useQueryClient();
const [pagination, setPagination] = useState<GridPaginationModel>({ page: 0, pageSize: 25 });
const [module_, setModule] = useState('all');
const [tier, setTier] = useState('all');
const [provider, setProvider] = useState('');
const [endpoint, setEndpoint] = useState('');
const [hours, setHours] = useState(24);
const [drawerId, setDrawerId] = useState<number | null>(null);
const { data, isLoading, error, isFetching } = useQuery({
queryKey: ['history', module_, tier, provider, endpoint, hours, pagination.page, pagination.pageSize],
queryFn: () => {
const params = new URLSearchParams();
params.set('limit', String(pagination.pageSize));
params.set('offset', String(pagination.page * pagination.pageSize));
params.set('hours', String(hours));
if (module_ !== 'all') params.set('module', module_);
if (tier !== 'all') params.set('tier', tier);
if (provider.trim()) params.set('provider', provider.trim());
if (endpoint.trim()) params.set('endpoint', endpoint.trim());
return apiGet<HistoryListResponse>(`/api/history?${params}`);
},
});
const columns: GridColDef<HistoryItem>[] = useMemo(
() => [
{ field: 'id', headerName: 'ID', width: 80 },
{
field: 'created_at',
headerName: 'When',
width: 170,
valueFormatter: (v: string) => fmt(v),
},
{
field: 'module',
headerName: 'Module',
width: 100,
renderCell: (p) => (
<Chip
label={p.value ?? 'web'}
size="small"
color={MODULE_COLOR[(p.value as string) ?? 'web'] ?? 'default'}
/>
),
},
{
field: 'tier',
headerName: 'Tier',
width: 90,
renderCell: (p) =>
p.value && p.value !== 'n/a' ? (
<Chip label={p.value} size="small" color={p.value === 'premium' ? 'primary' : 'default'} />
) : (
'—'
),
},
{ field: 'endpoint', headerName: 'Endpoint', width: 140 },
{ field: 'provider', headerName: 'Provider', width: 130 },
{
field: 'status_code',
headerName: 'Status',
width: 90,
renderCell: (p) => (
<Chip
label={p.value ?? '?'}
size="small"
color={statusColor(p.value as number | null)}
/>
),
},
{
field: 'duration_ms',
headerName: 'Duration',
width: 110,
valueFormatter: (v: number | null) => (v !== null ? `${v} ms` : '—'),
},
{
field: 'cost_usd',
headerName: 'Cost',
width: 100,
valueFormatter: (v: number | null) =>
v !== null && v !== undefined ? `$${Number(v).toFixed(4)}` : '—',
},
],
[],
);
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
<Typography variant="h1">Request History</Typography>
<Chip label={`${data?.items.length ?? '…'} loaded`} size="small" />
<Box sx={{ flex: 1 }} />
<IconButton
onClick={() => queryClient.invalidateQueries({ queryKey: ['history'] })}
disabled={isFetching}
>
<RefreshIcon />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
<TextField
select
label="Module"
value={module_}
onChange={(e) => setModule(e.target.value)}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="web">web</MenuItem>
<MenuItem value="brain">brain</MenuItem>
<MenuItem value="agent_v3">agent_v3</MenuItem>
</TextField>
<TextField
select
label="Tier"
value={tier}
onChange={(e) => setTier(e.target.value)}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="free">free</MenuItem>
<MenuItem value="premium">premium</MenuItem>
</TextField>
<TextField
select
label="Window"
value={hours}
onChange={(e) => setHours(Number(e.target.value))}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value={1}>Last hour</MenuItem>
<MenuItem value={24}>Last 24h</MenuItem>
<MenuItem value={168}>Last 7d</MenuItem>
<MenuItem value={720}>Last 30d</MenuItem>
</TextField>
<TextField
label="Provider"
value={provider}
onChange={(e) => setProvider(e.target.value)}
size="small"
sx={{ minWidth: 160 }}
/>
<TextField
label="Endpoint"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
size="small"
sx={{ minWidth: 160 }}
/>
</Stack>
{error && <Alert severity="error">{String(error)}</Alert>}
<Paper sx={{ height: 640 }}>
<DataGrid
rows={data?.items ?? []}
getRowId={(row) => row.id}
columns={columns}
loading={isLoading || isFetching}
paginationMode="server"
rowCount={data?.total ?? data?.items?.length ?? 0}
paginationModel={pagination}
onPaginationModelChange={setPagination}
pageSizeOptions={[10, 25, 50, 100]}
onRowClick={(p) => setDrawerId(p.row.id as number)}
sx={{ '& .MuiDataGrid-row': { cursor: 'pointer' } }}
disableRowSelectionOnClick
/>
</Paper>
<HistoryDetailDrawer id={drawerId} onClose={() => setDrawerId(null)} />
</Box>
);
}

View file

@ -0,0 +1,134 @@
import {
Alert,
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
LinearProgress,
Stack,
Typography,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { apiGet } from '@/api/client';
import type { ProvidersStatsResponse } from '@/types/insights';
export default function Providers() {
const { data, isLoading, error } = useQuery({
queryKey: ['providers'],
queryFn: () => apiGet<ProvidersStatsResponse>('/api/stats/providers'),
refetchInterval: 30_000,
});
if (isLoading) {
return (
<Stack alignItems="center" sx={{ py: 6 }}>
<CircularProgress />
</Stack>
);
}
if (error) return <Alert severity="error">{String(error)}</Alert>;
if (!data) return null;
const ageSec = (data as unknown as { age_seconds?: number | null })?.age_seconds ?? null;
const ageLabel = ageSec !== null ? `cache ${Math.round(ageSec)}s old` : null;
const ageColor: 'default' | 'warning' | 'error' = ageSec === null ? 'default' : ageSec > 120 ? 'warning' : 'default';
return (
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="h1">Providers</Typography>
{ageLabel && <Chip label={ageLabel} size="small" variant="outlined" color={ageColor} />}
</Stack>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Live quota + health for all configured upstream providers. Auto-refreshes every 30s.
</Typography>
<Grid container spacing={2}>
{data.providers.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.name}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
{p.healthy === true ? (
<HealthAndSafetyIcon fontSize="small" color="success" />
) : p.healthy === false ? (
<ErrorOutlineIcon fontSize="small" color="error" />
) : null}
<Typography variant="h3">{p.display_name}</Typography>
<Box sx={{ flex: 1 }} />
<Chip label={p.kind} size="small" variant="outlined" />
</Stack>
{p.message && (
<Typography
variant="caption"
color={p.healthy === false ? 'error.main' : 'text.secondary'}
sx={{ display: 'block', mb: 1 }}
>
{p.message}
</Typography>
)}
{p.quota_limit ? (
<Box sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption">
{(() => {
const isUsd = (p.quota_unit ?? '').toLowerCase() === 'usd';
const fmt = (n: number) =>
isUsd
? `$${n.toFixed(2)}`
: n.toLocaleString(undefined, { maximumFractionDigits: 0 });
return (
<>
{fmt(p.quota_used ?? 0)} / {fmt(p.quota_limit)}
{!isUsd && p.quota_unit ? ` ${p.quota_unit}` : ''}
</>
);
})()}
</Typography>
<Typography variant="caption" color="text.secondary">
{(p.quota_percent_used ?? 0).toFixed(1)}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={Math.min(100, p.quota_percent_used ?? 0)}
sx={{
height: 8,
borderRadius: 1,
'& .MuiLinearProgress-bar': {
bgcolor:
(p.quota_percent_used ?? 0) > 90
? 'error.main'
: (p.quota_percent_used ?? 0) > 75
? 'warning.main'
: 'success.main',
},
}}
/>
</Box>
) : (
<Typography variant="caption" color="text.secondary">
No quota tracking
</Typography>
)}
{p.plan_price_monthly !== null && p.plan_price_monthly !== undefined && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Plan: ${p.plan_price_monthly}/month
</Typography>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
}

View file

@ -0,0 +1,396 @@
import { useState } from 'react';
import { Alert, Box, Button, Card, CardContent, Chip, CircularProgress, IconButton, Stack, Tab, Table, TableBody, TableCell, TableHead, TableRow, Tabs, Tooltip, Typography } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import RefreshIcon from '@mui/icons-material/Refresh';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import WarningIcon from '@mui/icons-material/Warning';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ModuleConfigForm from '@/components/ModuleConfigForm';
import { apiGet } from '@/api/client';
type Tier = 'live' | 'restart' | 'mixed';
interface ModuleMeta {
title: string;
subtitle: string;
/**
* Whether overrides land instantly (live) or require restart, or mixed.
* Influences the banner shown at the top of the Config tab.
*/
tier: Tier;
categoryOrder?: string[];
}
const MODULES: Record<string, ModuleMeta> = {
web: {
title: 'Web Search',
subtitle:
'Free (SearXNG) + premium (Brave/Tavily/SerpAPI/LinkUp/Exa) routing, evidence packing, brain integration.',
tier: 'mixed',
categoryOrder: [
'providers',
'routing',
'tiers',
'llm',
'search',
'fetch',
'browse',
'vision',
'evidence',
'gather',
'context',
'brain',
'rate_limit',
'concurrency',
'log',
],
},
llm: {
title: 'LLM Inference',
subtitle: 'Router for LiteLLM / vLLM / llama.cpp. Backend selection + retry config.',
tier: 'mixed',
categoryOrder: ['backend', 'timeouts', 'retries', 'rate_limit', 'concurrency', 'llamacpp', 'log'],
},
embeddings: {
title: 'Embeddings',
subtitle: 'BGE-M3 1024-dim via vLLM/llama.cpp. Used by didi-brain.',
tier: 'mixed',
categoryOrder: ['backend', 'timeouts', 'rate_limit', 'concurrency', 'log'],
},
rerank: {
title: 'Rerank',
subtitle: 'BGE-reranker-v2-m3 cross-encoder for two-stage retrieval.',
tier: 'mixed',
categoryOrder: ['backend', 'timeouts', 'rate_limit', 'concurrency', 'log'],
},
audio: {
title: 'Audio (Whisper)',
subtitle: 'faster-whisper transcription. Model load is restart-only.',
tier: 'mixed',
categoryOrder: ['whisper', 'upload', 'log'],
},
video: {
title: 'Video Analysis',
subtitle: 'BusterX deepfake + Qwen3-VL semantic. Prompts editable live.',
tier: 'live',
categoryOrder: ['frames', 'inference', 'semantic', 'prompts', 'log'],
},
catalog: {
title: 'Catalog API',
subtitle: 'Service registry: aggregates /v1/info + OpenAPI from all modules.',
tier: 'mixed',
categoryOrder: ['discovery', 'log'],
},
brain: {
title: 'Brain (Cache)',
subtitle: 'Verification cache TTL + atom tier policy.',
// Mixed: atom.* and log.level apply live (RuntimeConfigClient polls every 30s),
// verification_cache.* are env-only (restart required to pick up the change).
tier: 'mixed',
categoryOrder: ['verification_cache', 'atom', 'log'],
},
gateway: {
title: 'Gateway',
subtitle: 'Read-only — nginx token + route table. Static config; rotate via deploy.',
tier: 'restart',
},
};
interface Props {
moduleId: string;
}
function tierBanner(tier: Tier): { kind: 'success' | 'info' | 'warning'; text: string } {
if (tier === 'live')
return {
kind: 'success',
text: 'All keys here apply live within 30s of save (no restart).',
};
if (tier === 'restart')
return {
kind: 'warning',
text: 'Most keys here require a service restart to take effect — flagged inline.',
};
return {
kind: 'info',
text: 'Mix of live + restart-required keys. Restart-required ones are flagged inline.',
};
}
export default function ModulePage({ moduleId }: Props) {
const meta = MODULES[moduleId];
const [tab, setTab] = useState(0);
if (!meta) {
return <Alert severity="error">Unknown module: {moduleId}</Alert>;
}
const banner = tierBanner(meta.tier);
return (
<Box>
<Box sx={{ mb: 2 }}>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
<Typography variant="h1">{meta.title}</Typography>
<Chip label={moduleId} size="small" sx={{ fontFamily: 'monospace' }} />
</Stack>
<Typography variant="body1" color="text.secondary">
{meta.subtitle}
</Typography>
</Box>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Config" />
<Tab label="Live State" />
<Tab label="Actions" />
</Tabs>
{tab === 0 && (
<Box>
<Alert severity={banner.kind} sx={{ mb: 2 }}>
{banner.text}
</Alert>
{moduleId === 'gateway' ? (
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 1 }}>
Gateway is static config
</Typography>
<Typography variant="body1" color="text.secondary">
The nginx config + bearer token live in deploy/.env and are baked at container
start. No live-editable knobs are exposed in the dashboard schema. Token rotation
+ route changes require a deploy.
</Typography>
</CardContent>
</Card>
) : (
<ModuleConfigForm moduleId={moduleId} categoryOrder={meta.categoryOrder} />
)}
</Box>
)}
{tab === 1 && <LiveStateTab moduleId={moduleId} />}
{tab === 2 && <ActionsTab moduleId={moduleId} />}
</Box>
);
}
interface ModuleHealth {
module: string;
url: string;
status: 'healthy' | 'degraded' | 'down';
http_status?: number;
latency_ms: number;
error?: string | null;
info?: Record<string, unknown> | null;
}
function statusColor(s: string): 'success' | 'warning' | 'error' | 'default' {
if (s === 'healthy') return 'success';
if (s === 'degraded') return 'warning';
if (s === 'down') return 'error';
return 'default';
}
function StatusIcon({ status }: { status: string }) {
if (status === 'healthy') return <CheckCircleIcon color="success" />;
if (status === 'degraded') return <WarningIcon color="warning" />;
return <ErrorIcon color="error" />;
}
function LiveStateTab({ moduleId }: { moduleId: string }) {
const health = useQuery({
queryKey: ['module_health', moduleId],
queryFn: () => apiGet<ModuleHealth>(`/api/proxy/${moduleId}/health`),
refetchInterval: 10_000,
});
return (
<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h3">Live State</Typography>
<Tooltip title="Refresh">
<IconButton size="small" onClick={() => health.refetch()} disabled={health.isFetching}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
{health.isLoading && <CircularProgress size={20} />}
{health.isError && <Alert severity="error">{String(health.error)}</Alert>}
{health.data && (
<>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
<StatusIcon status={health.data.status} />
<Box>
<Typography variant="h2" sx={{ textTransform: 'capitalize' }}>{health.data.status}</Typography>
<Typography variant="caption" color="text.secondary">
{health.data.latency_ms}ms
{health.data.http_status ? ` · HTTP ${health.data.http_status}` : ''}
{' · auto-refresh 10s'}
</Typography>
</Box>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Endpoint: <code>{health.data.url}</code>
</Typography>
{health.data.error && (
<Alert severity={statusColor(health.data.status) === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
<Typography variant="body2">{health.data.error}</Typography>
{(health.data.error.includes('name resolution') || health.data.error.includes('Connection refused') || health.data.error.includes('timeout')) && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Service not reachable. To override the default URL, set environment variable
<code style={{ marginLeft: 4 }}>DASHBOARD_{moduleId.toUpperCase()}_HEALTH_URL=http://&lt;host&gt;:&lt;port&gt;/v1/info</code>
in dashboard's deploy/.env and redeploy.
</Typography>
)}
</Alert>
)}
{health.data.info && Object.keys(health.data.info).length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Module info from <code>/v1/info</code>
</Typography>
<Card variant="outlined">
<CardContent sx={{ '&:last-child': { pb: 2 } }}>
<Box component="pre" sx={{ fontSize: 11, fontFamily: 'monospace', whiteSpace: 'pre-wrap', m: 0, maxHeight: 300, overflow: 'auto' }}>
{JSON.stringify(health.data.info, null, 2)}
</Box>
</CardContent>
</Card>
</Box>
)}
</>
)}
</CardContent>
</Card>
);
}
interface ConfigItem {
key: string;
value: unknown;
default: unknown;
is_override: boolean;
updated_at?: string | null;
updated_by?: string | null;
}
interface ConfigResponse {
items: Record<string, ConfigItem>;
schema: Record<string, { module: string; restart_required?: boolean; label?: string }>;
}
function ActionsTab({ moduleId }: { moduleId: string }) {
const config = useQuery({
queryKey: ['config'],
queryFn: () => apiGet<ConfigResponse>('/api/config'),
});
// Find keys for THIS module that are overridden AND require restart
const pendingRestarts = config.data
? Object.entries(config.data.items)
.filter(([k, v]) => {
const sch = config.data!.schema[k];
return sch && sch.module === moduleId && v.is_override && sch.restart_required === true;
})
.map(([k, v]) => ({
key: k,
label: config.data!.schema[k].label ?? k,
value: v.value,
default: v.default,
updated_at: v.updated_at,
updated_by: v.updated_by,
}))
: [];
const swarmCmd = `sudo docker service update --force <stack>_${moduleId}`;
const copyCmd = () => {
navigator.clipboard.writeText(swarmCmd).catch(() => {});
};
return (
<Card>
<CardContent>
<Typography variant="h3" sx={{ mb: 1 }}>Pending Restarts & Actions</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Keys flagged <code>restart_required=true</code> need a container restart for the change to take effect. Live keys take effect immediately nothing to do here for those.
</Typography>
{config.isLoading && <CircularProgress size={20} />}
{config.data && pendingRestarts.length === 0 && (
<Alert severity="success" icon={<CheckCircleIcon />}>
No pending restart-required overrides for this module.
</Alert>
)}
{pendingRestarts.length > 0 && (
<>
<Alert severity="warning" sx={{ mb: 2 }}>
{pendingRestarts.length} override{pendingRestarts.length > 1 ? 's' : ''} waiting for container restart.
</Alert>
<Table size="small" sx={{ mb: 2 }}>
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>New value</TableCell>
<TableCell>Default</TableCell>
<TableCell>Set by</TableCell>
<TableCell>Set at</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pendingRestarts.map((p) => (
<TableRow key={p.key}>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{p.key}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{String(p.value).slice(0, 50)}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>{String(p.default).slice(0, 50)}</Typography>
</TableCell>
<TableCell><Typography variant="caption">{p.updated_by ?? '—'}</Typography></TableCell>
<TableCell><Typography variant="caption">{p.updated_at ? new Date(p.updated_at).toLocaleString() : '—'}</Typography></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
<Box sx={{ mt: 3, p: 2, bgcolor: 'background.default', borderRadius: 1, border: 1, borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Manual restart command (run on Swarm manager replace <code>&lt;stack&gt;</code> with your Docker stack name)
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Box component="code" sx={{ fontFamily: 'monospace', fontSize: 13, flex: 1, p: 1, bgcolor: 'background.paper', borderRadius: 0.5 }}>
{swarmCmd}
</Box>
<Tooltip title="Copy">
<IconButton size="small" onClick={copyCmd}><ContentCopyIcon fontSize="small" /></IconButton>
</Tooltip>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Restart triggers a rolling update typically &lt;30s downtime per replica. After completion, run a config check by visiting the Live State tab.
</Typography>
</Box>
{config.data && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button size="small" startIcon={<RefreshIcon />} onClick={() => config.refetch()}>
Refresh pending list
</Button>
</Box>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,322 @@
import { Box, Card, CardContent, Chip, CircularProgress, Grid, LinearProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '@/api/client';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
const REFRESH_MS = 5_000;
interface SummaryResponse {
window_hours: number;
total_requests: number;
total_errors: number;
error_rate: number;
avg_duration_ms: number;
total_cost_usd: number;
}
interface HistoryItem {
id: string;
created_at: string;
endpoint: string;
provider: string;
tier: string;
status_code: number;
duration_ms: number;
cost_usd: number | null;
}
interface HistoryResponse {
items: HistoryItem[];
total: number;
}
interface ProviderStatItem {
name: string;
display_name?: string;
kind?: string;
healthy: boolean | null;
quota_percent_used?: number | null;
quota_used?: number | null;
quota_limit?: number | null;
quota_unit?: string | null;
plan_name?: string | null;
plan_price_monthly?: number | null;
message?: string | null;
last_error?: string | null;
}
interface ProvidersResponse {
providers: ProviderStatItem[];
last_refresh?: string | null;
age_seconds?: number | null;
cache_ttl_seconds?: number;
}
interface TimelinePoint { hour: string; tier: string; count: number; avg_ms: number; }
interface TimelineResponse { timeline: TimelinePoint[]; window_hours: number; }
function statusColor(code: number): 'success' | 'warning' | 'error' | 'default' {
if (!code) return 'default';
if (code >= 500) return 'error';
if (code >= 400) return 'warning';
return 'success';
}
function ageSec(iso: string): string {
const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
return `${Math.floor(s / 3600)}h ago`;
}
function KpiTile({ label, value, color, hint }: { label: string; value: React.ReactNode; color?: string; hint?: string }) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="h2" sx={{ mt: 0.5, color: color ?? 'primary.main' }}>{value}</Typography>
{hint && <Typography variant="caption" color="text.secondary">{hint}</Typography>}
</CardContent>
</Card>
);
}
export default function LiveStatus() {
const summary = useQuery({
queryKey: ['live', 'summary', 1],
queryFn: () => apiGet<SummaryResponse>('/api/stats/summary?hours=1'),
refetchInterval: REFRESH_MS,
});
const recent = useQuery({
queryKey: ['live', 'history', 20],
queryFn: () => apiGet<HistoryResponse>('/api/history?limit=20&offset=0'),
refetchInterval: REFRESH_MS,
});
const providers = useQuery({
queryKey: ['live', 'providers'],
queryFn: () => apiGet<ProvidersResponse>('/api/stats/providers'),
refetchInterval: 30_000,
});
const timeline = useQuery({
queryKey: ['live', 'timeline', 6],
queryFn: () => apiGet<TimelineResponse>('/api/stats/timeline?hours=6'),
refetchInterval: 30_000,
});
const errorRatePct = summary.data?.error_rate !== undefined ? (summary.data.error_rate * 100).toFixed(1) : '—';
const errorColor = (summary.data?.error_rate ?? 0) > 0.05 ? 'error.main' : 'success.main';
// Aggregate timeline (sum per hour across tiers)
const timelineByHour: Record<string, number> = {};
timeline.data?.timeline.forEach((p) => {
const h = p.hour ?? 'unknown';
timelineByHour[h] = (timelineByHour[h] ?? 0) + p.count;
});
const timelineEntries = Object.entries(timelineByHour).sort(([a], [b]) => a.localeCompare(b)).slice(-12);
const maxCount = Math.max(1, ...timelineEntries.map(([, c]) => c));
return (
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<MonitorHeartIcon color="primary" />
<Typography variant="h1">Live Status</Typography>
<Chip label={`refresh ${REFRESH_MS / 1000}s`} size="small" variant="outlined" sx={{ ml: 1 }} />
</Stack>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Real-time view of platform activity (last 1 hour). Refreshes every {REFRESH_MS / 1000}s.
</Typography>
{/* KPI tiles — last 1h */}
<Grid container spacing={2} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<KpiTile
label="Requests (1h)"
value={summary.isLoading ? <CircularProgress size={20} /> : (summary.data?.total_requests ?? 0).toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<KpiTile
label="Error rate (1h)"
value={summary.isLoading ? <CircularProgress size={20} /> : `${errorRatePct}%`}
color={errorColor}
hint={`${summary.data?.total_errors ?? 0} errors`}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<KpiTile
label="Avg duration (1h)"
value={summary.isLoading ? <CircularProgress size={20} /> : `${Math.round(summary.data?.avg_duration_ms ?? 0)} ms`}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<KpiTile
label="Cost (1h)"
value={summary.isLoading ? <CircularProgress size={20} /> : `$${(summary.data?.total_cost_usd ?? 0).toFixed(4)}`}
/>
</Grid>
</Grid>
<Grid container spacing={3}>
{/* Provider health column */}
<Grid size={{ xs: 12, md: 5 }}>
<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 2 }}>
<Typography variant="h2">Provider Health</Typography>
{providers.data?.age_seconds !== undefined && providers.data?.age_seconds !== null && (
<Chip
label={`refreshed ${Math.round(providers.data.age_seconds)}s ago`}
size="small"
variant="outlined"
color={providers.data.age_seconds > 120 ? 'warning' : 'default'}
/>
)}
</Stack>
{providers.isLoading ? (
<CircularProgress size={20} />
) : (
<Stack spacing={1.5}>
{(providers.data?.providers ?? []).map((p) => {
const statusLabel = p.healthy === true ? 'healthy' : p.healthy === false ? 'down' : 'unknown';
const statusColor: 'success' | 'error' | 'default' = p.healthy === true ? 'success' : p.healthy === false ? 'error' : 'default';
const quotaPct = p.quota_percent_used;
const quotaColor: 'error' | 'warning' | 'primary' = (quotaPct ?? 0) >= 90 ? 'error' : (quotaPct ?? 0) >= 75 ? 'warning' : 'primary';
return (
<Box key={p.name} sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" sx={{ fontWeight: 500 }}>{p.display_name ?? p.name}</Typography>
<Chip label={statusLabel} color={statusColor} size="small" />
{p.plan_name && <Typography variant="caption" color="text.secondary">{p.plan_name}</Typography>}
</Stack>
{quotaPct !== null && quotaPct !== undefined && (
<Typography variant="caption" color="text.secondary">{quotaPct.toFixed(0)}%</Typography>
)}
</Box>
{quotaPct !== null && quotaPct !== undefined && (
<LinearProgress variant="determinate" value={Math.min(100, quotaPct)} color={quotaColor} sx={{ height: 6, borderRadius: 3 }} />
)}
{(p.last_error || p.message) && (
<Typography variant="caption" color={p.healthy === false ? 'error.main' : 'text.secondary'}>
{p.last_error ?? p.message}
</Typography>
)}
</Box>
);
})}
{(providers.data?.providers ?? []).length === 0 && (
<Typography variant="body2" color="text.secondary">No provider data.</Typography>
)}
</Stack>
)}
</CardContent>
</Card>
{/* Throughput sparkline (last 6h) */}
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>Throughput (last 6h)</Typography>
{timeline.isLoading ? (
<CircularProgress size={20} />
) : timelineEntries.length === 0 ? (
<Typography variant="body2" color="text.secondary">No traffic.</Typography>
) : (
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 0.5, height: 80 }}>
{timelineEntries.map(([hour, count]) => {
const heightPct = (count / maxCount) * 100;
const label = new Date(hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<Box key={hour} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Box
title={`${label}: ${count} req`}
sx={{
width: '100%',
height: `${Math.max(2, heightPct)}%`,
bgcolor: 'primary.main',
borderRadius: '2px 2px 0 0',
opacity: 0.85,
}}
/>
<Typography variant="caption" sx={{ fontSize: 10, mt: 0.5, color: 'text.secondary' }}>
{label.slice(0, 2)}
</Typography>
</Box>
);
})}
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Recent activity feed */}
<Grid size={{ xs: 12, md: 7 }}>
<Card>
<CardContent>
{(() => {
const items = recent.data?.items ?? [];
const newestAge = items.length > 0 ? (Date.now() - new Date(items[0].created_at).getTime()) / 1000 : null;
const allStale = items.length > 0 && newestAge !== null && newestAge > 3600;
return (
<>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h2">Latest 20 requests</Typography>
{allStale && (
<Chip label="all >1h old — no recent traffic" size="small" color="warning" variant="outlined" />
)}
</Stack>
</>
);
})()}
{recent.isLoading ? (
<CircularProgress size={20} />
) : (recent.data?.items ?? []).length === 0 ? (
<Typography variant="body2" color="text.secondary">No requests logged yet modules may not be ingesting events to /api/ingest/event.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Endpoint</TableCell>
<TableCell>Provider</TableCell>
<TableCell>Tier</TableCell>
<TableCell align="right">Status</TableCell>
<TableCell align="right">Duration</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(recent.data?.items ?? []).map((it) => (
<TableRow key={it.id} hover>
<TableCell>
<Typography variant="caption">{ageSec(it.created_at)}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{it.endpoint}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption">{it.provider || '—'}</Typography>
</TableCell>
<TableCell>
<Chip label={it.tier} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">
<Chip label={it.status_code || '—'} size="small" color={statusColor(it.status_code)} />
</TableCell>
<TableCell align="right">
<Typography variant="caption">{it.duration_ms}ms</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View file

@ -0,0 +1,250 @@
import {
Alert,
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import { apiGet } from '@/api/client';
import KpiTile from '@/components/KpiTile';
import StatusChip from '@/components/StatusChip';
const REFRESH_MS = 5_000;
const LATENCY_REFRESH_MS = 15_000;
interface ServiceHealth {
module: string;
url: string;
status: 'healthy' | 'degraded' | 'down' | string;
http_status?: number;
latency_ms?: number;
info?: { name?: string; models?: unknown; status?: string } | null;
error?: string;
}
interface ServicesResponse {
services: ServiceHealth[];
summary: { healthy: number; degraded: number; down: number; total: number };
}
interface QueueItem {
name: string;
vhost: string;
messages: number;
ready: number;
unacked: number;
consumers: number;
state?: string;
}
interface QueuesResponse {
enabled: boolean;
queues: QueueItem[];
reason?: string;
error?: string;
}
interface LatencyItem {
job: string;
p50_ms: number | null;
p90_ms: number | null;
p99_ms: number | null;
}
interface LatencyResponse {
enabled: boolean;
services: LatencyItem[];
reason?: string;
error?: string;
}
function modelHint(info: ServiceHealth['info']): string | undefined {
if (!info) return undefined;
if (typeof info.name === 'string') return info.name;
return undefined;
}
function fmtMs(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${Math.round(v)} ms`;
}
export default function Monitoring() {
const services = useQuery({
queryKey: ['monitoring', 'services'],
queryFn: () => apiGet<ServicesResponse>('/api/monitoring/services'),
refetchInterval: REFRESH_MS,
});
const queues = useQuery({
queryKey: ['monitoring', 'queues'],
queryFn: () => apiGet<QueuesResponse>('/api/monitoring/queues'),
refetchInterval: REFRESH_MS,
});
const latency = useQuery({
queryKey: ['monitoring', 'latency'],
queryFn: () => apiGet<LatencyResponse>('/api/monitoring/latency'),
refetchInterval: LATENCY_REFRESH_MS,
});
const sum = services.data?.summary;
return (
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<MonitorHeartIcon color="primary" />
<Typography variant="h1">AI Monitoring</Typography>
<Chip label={`refresh ${REFRESH_MS / 1000}s`} size="small" variant="outlined" sx={{ ml: 1 }} />
</Stack>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Central health of all AI services, message-queue depth and request latency percentiles.
</Typography>
{/* Roll-up KPI tiles */}
<Grid container spacing={2} sx={{ mb: 4 }}>
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
<KpiTile label="Services healthy" color="success.main"
value={services.isLoading ? <CircularProgress size={20} /> : `${sum?.healthy ?? 0}/${sum?.total ?? 0}`} />
</Grid>
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
<KpiTile label="Degraded" color={(sum?.degraded ?? 0) > 0 ? 'warning.main' : undefined}
value={services.isLoading ? <CircularProgress size={20} /> : (sum?.degraded ?? 0)} />
</Grid>
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
<KpiTile label="Down" color={(sum?.down ?? 0) > 0 ? 'error.main' : undefined}
value={services.isLoading ? <CircularProgress size={20} /> : (sum?.down ?? 0)} />
</Grid>
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
<KpiTile label="Total services"
value={services.isLoading ? <CircularProgress size={20} /> : (sum?.total ?? 0)} />
</Grid>
</Grid>
{/* Service health grid */}
<Typography variant="h2" sx={{ mb: 2 }}>Services</Typography>
{services.isError && <Alert severity="error" sx={{ mb: 2 }}>Failed to load service health.</Alert>}
<Grid container spacing={2} sx={{ mb: 4 }}>
{(services.data?.services ?? []).map((s) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={s.module}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 600 }}>{s.module}</Typography>
<StatusChip status={s.status} />
</Stack>
<Typography variant="caption" color="text.secondary" display="block">
{fmtMs(s.latency_ms)}{s.http_status ? ` · HTTP ${s.http_status}` : ''}
</Typography>
{modelHint(s.info) && (
<Typography variant="caption" color="text.secondary" display="block" sx={{ fontFamily: 'monospace' }}>
{modelHint(s.info)}
</Typography>
)}
{s.error && (
<Typography variant="caption" color="error.main" display="block">{s.error}</Typography>
)}
</CardContent>
</Card>
</Grid>
))}
{!services.isLoading && (services.data?.services ?? []).length === 0 && (
<Grid size={{ xs: 12 }}>
<Typography variant="body2" color="text.secondary">No services configured.</Typography>
</Grid>
)}
</Grid>
<Grid container spacing={3}>
{/* Message queues */}
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>Message Queues (RabbitMQ)</Typography>
{queues.data && !queues.data.enabled ? (
<Alert severity="info">Queue panel disabled set <code>rabbitmq_mgmt_url</code>.</Alert>
) : queues.isLoading ? (
<CircularProgress size={20} />
) : queues.data?.error ? (
<Alert severity="warning">RabbitMQ unreachable: {queues.data.error}</Alert>
) : (queues.data?.queues ?? []).length === 0 ? (
<Typography variant="body2" color="text.secondary">No queues.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Queue</TableCell>
<TableCell align="right">Ready</TableCell>
<TableCell align="right">Unacked</TableCell>
<TableCell align="right">Consumers</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(queues.data?.queues ?? []).map((q) => {
const backlog = q.ready > 0 && q.consumers === 0;
return (
<TableRow key={`${q.vhost}/${q.name}`} hover>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{q.name}</Typography>
{backlog && <Chip label="no consumers" size="small" color="warning" sx={{ ml: 1 }} />}
</TableCell>
<TableCell align="right">{q.ready}</TableCell>
<TableCell align="right">{q.unacked}</TableCell>
<TableCell align="right">{q.consumers}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</Grid>
{/* Latency percentiles */}
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>Request Latency (p50/p90/p99)</Typography>
{latency.data && !latency.data.enabled ? (
<Alert severity="info">Latency panel disabled set <code>prometheus_url</code>.</Alert>
) : latency.isLoading ? (
<CircularProgress size={20} />
) : latency.data?.error ? (
<Alert severity="warning">Prometheus unreachable: {latency.data.error}</Alert>
) : (latency.data?.services ?? []).length === 0 ? (
<Typography variant="body2" color="text.secondary">No latency data.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell align="right">p50</TableCell>
<TableCell align="right">p90</TableCell>
<TableCell align="right">p99</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(latency.data?.services ?? []).map((l) => (
<TableRow key={l.job} hover>
<TableCell><Typography variant="caption">{l.job}</Typography></TableCell>
<TableCell align="right">{fmtMs(l.p50_ms)}</TableCell>
<TableCell align="right">{fmtMs(l.p90_ms)}</TableCell>
<TableCell align="right">{fmtMs(l.p99_ms)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View file

@ -0,0 +1,41 @@
/**
* Audit log tabs wrapper.
*
* - Dashboard tab: dashboard's own audit_log (config changes, archive
* promotions, user CRUD). Sourced from /api/audit.
* - Brain tab: brain_audit_log (cache mutations, fact_status changes,
* judge decisions). Sourced from /api/brain/v1/cache/audit_log (Phase D2).
*
* Two distinct sources kept side-by-side so an admin can see the full
* picture without context-switching pages.
*/
import { useState } from 'react';
import { Box, Stack, Tab, Tabs, Typography } from '@mui/material';
import AuditLogDashboard from './AuditLogDashboard';
import AuditLogBrain from './AuditLogBrain';
export default function AuditLog() {
const [tab, setTab] = useState<'dashboard' | 'brain'>('dashboard');
return (
<Box sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<Typography variant="h5">Audit log</Typography>
</Stack>
<Tabs
value={tab}
onChange={(_, v) => setTab(v as 'dashboard' | 'brain')}
sx={{ mb: 2 }}
>
<Tab label="Dashboard" value="dashboard" />
<Tab label="Brain" value="brain" />
</Tabs>
{tab === 'dashboard' && <AuditLogDashboard />}
{tab === 'brain' && <AuditLogBrain />}
</Box>
);
}

View file

@ -0,0 +1,244 @@
/**
* Brain audit log viewer Phase D2.
*
* Reads brain_audit_log via /v1/cache/audit_log. Shows every cache mutation:
* judge decisions (KEEP/INVALIDATE/RECHECK), fact_status changes, mass
* invalidations, gold promotions. Filters: action prefix, target table,
* actor, since.
*
* Sibling of AuditLogDashboard.tsx (which reads dashboard's own audit
* table) both rendered via the AuditLog tabs wrapper.
*/
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Chip,
IconButton,
MenuItem,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '@/api/client';
import type { AuditLogItem, AuditLogResponse } from '@/types/brain';
function fmt(s: string) {
return new Date(s).toLocaleString();
}
// Color hint by action prefix — keeps the grid scannable.
type ChipColor = 'primary' | 'warning' | 'success' | 'error' | 'default';
const ACTION_COLORS: Record<string, ChipColor> = {
invalidate: 'warning',
judge_invalidate: 'error',
judge_keep_cache: 'success',
judge_needs_full_recheck: 'primary',
fact_truth_changed: 'warning',
fact_truth_set: 'success',
promote_gold: 'success',
audit_demote: 'warning',
};
function actionColor(action: string): ChipColor {
if (action in ACTION_COLORS) return ACTION_COLORS[action];
for (const [k, v] of Object.entries(ACTION_COLORS)) {
if (action.startsWith(k)) return v;
}
return 'default';
}
function jsonPreview(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'string') return v.length > 80 ? v.slice(0, 77) + '…' : v;
const s = JSON.stringify(v);
return s.length > 80 ? s.slice(0, 77) + '…' : s;
}
const ACTION_PRESETS = [
{ value: 'all', label: 'All actions' },
{ value: 'invalidate', label: 'Mass invalidations' },
{ value: 'judge_', label: 'Judge decisions (any)' },
{ value: 'judge_invalidate', label: 'Judge → INVALIDATE' },
{ value: 'judge_keep_cache', label: 'Judge → KEEP' },
{ value: 'fact_truth_', label: 'Fact truth changes' },
{ value: 'promote_gold', label: 'Gold promotions' },
];
const TARGET_PRESETS = [
{ value: 'all', label: 'All targets' },
{ value: 'brain_analysis_atom', label: 'Atoms' },
{ value: 'brain_verification_cache', label: 'Verification cache' },
{ value: 'brain_fact_status', label: 'Fact status' },
{ value: 'multi', label: 'Multi (mass invalidate)' },
];
export default function AuditLogBrain() {
const [pagination, setPagination] = useState<GridPaginationModel>({
page: 0,
pageSize: 50,
});
const [actionFilter, setActionFilter] = useState('all');
const [targetFilter, setTargetFilter] = useState('all');
const [actorFilter, setActorFilter] = useState('');
const params = useMemo(() => {
const p = new URLSearchParams();
p.set('page', String(pagination.page + 1));
p.set('page_size', String(pagination.pageSize));
if (actionFilter !== 'all') p.set('action', actionFilter);
if (targetFilter !== 'all') p.set('target_table', targetFilter);
if (actorFilter.trim()) p.set('actor', actorFilter.trim());
return p.toString();
}, [pagination, actionFilter, targetFilter, actorFilter]);
const { data, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ['brain', 'audit_log', params],
queryFn: () => apiGet<AuditLogResponse>(`/api/brain/v1/cache/audit_log?${params}`),
});
const columns: GridColDef<AuditLogItem>[] = useMemo(
() => [
{
field: 'created_at',
headerName: 'When',
width: 170,
renderCell: (p) => (
<Typography variant="caption">{fmt(p.row.created_at)}</Typography>
),
},
{
field: 'action',
headerName: 'Action',
width: 200,
renderCell: (p) => (
<Chip
size="small"
label={p.row.action}
color={actionColor(p.row.action)}
sx={{ fontFamily: 'monospace' }}
/>
),
},
{
field: 'target_table',
headerName: 'Target',
width: 200,
renderCell: (p) => (
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{p.row.target_table}
{p.row.target_id !== 'mass' && (
<span style={{ opacity: 0.6 }}>:{p.row.target_id}</span>
)}
</Typography>
),
},
{
field: 'actor',
headerName: 'Actor',
width: 160,
renderCell: (p) => p.row.actor ?? '—',
},
{
field: 'payload',
headerName: 'Payload',
flex: 1,
minWidth: 280,
sortable: false,
renderCell: (p) => (
<Tooltip title={<pre>{JSON.stringify(p.row.payload, null, 2)}</pre>}>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', cursor: 'help' }}
>
{jsonPreview(p.row.payload)}
</Typography>
</Tooltip>
),
},
],
[],
);
return (
<Box>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<Typography variant="subtitle1">Brain cache + fact_status mutations</Typography>
<Box sx={{ flexGrow: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Stack>
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
<TextField
select
label="Action"
size="small"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
sx={{ minWidth: 220 }}
>
{ACTION_PRESETS.map((p) => (
<MenuItem key={p.value} value={p.value}>
{p.label}
</MenuItem>
))}
</TextField>
<TextField
select
label="Target table"
size="small"
value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)}
sx={{ minWidth: 200 }}
>
{TARGET_PRESETS.map((p) => (
<MenuItem key={p.value} value={p.value}>
{p.label}
</MenuItem>
))}
</TextField>
<TextField
label="Actor (ILIKE)"
size="small"
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
sx={{ minWidth: 200 }}
/>
</Stack>
</Paper>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{(error as Error).message}
</Alert>
)}
<Paper variant="outlined" sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={data?.items ?? []}
getRowId={(r) => r.log_id}
columns={columns}
rowCount={data?.total ?? 0}
loading={isLoading}
paginationMode="server"
paginationModel={pagination}
onPaginationModelChange={setPagination}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick
/>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,174 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Chip,
IconButton,
MenuItem,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiGet } from '@/api/client';
import type { AuditEntry, AuditListResponse } from '@/types/insights';
function fmt(s: string) {
return new Date(s).toLocaleString();
}
const ACTION_COLORS: Record<string, 'primary' | 'warning' | 'success' | 'error'> = {
'config.set': 'primary',
'config.delete': 'warning',
'archive.promote': 'success',
};
function jsonPreview(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'string') return v.length > 60 ? v.slice(0, 57) + '…' : v;
const s = JSON.stringify(v);
return s.length > 60 ? s.slice(0, 57) + '…' : s;
}
export default function AuditLogDashboard() {
const queryClient = useQueryClient();
const [pagination, setPagination] = useState<GridPaginationModel>({ page: 0, pageSize: 25 });
const [usernameFilter, setUsernameFilter] = useState('');
const [actionFilter, setActionFilter] = useState('all');
const { data, isLoading, error, isFetching } = useQuery({
queryKey: ['audit', usernameFilter, actionFilter, pagination.page, pagination.pageSize],
queryFn: () => {
const params = new URLSearchParams();
params.set('limit', String(pagination.pageSize));
params.set('offset', String(pagination.page * pagination.pageSize));
if (usernameFilter.trim()) params.set('username', usernameFilter.trim());
if (actionFilter !== 'all') params.set('action', actionFilter);
return apiGet<AuditListResponse>(`/api/audit?${params}`);
},
});
const columns: GridColDef<AuditEntry>[] = useMemo(
() => [
{ field: 'id', headerName: 'ID', width: 70 },
{
field: 'timestamp',
headerName: 'When',
width: 170,
valueFormatter: (v: string) => fmt(v),
},
{ field: 'username', headerName: 'User', width: 130 },
{
field: 'action',
headerName: 'Action',
width: 160,
renderCell: (p) => (
<Chip
label={p.value}
size="small"
color={ACTION_COLORS[p.value as string] ?? 'default'}
/>
),
},
{
field: 'target',
headerName: 'Target',
flex: 1,
minWidth: 200,
renderCell: (p) => (
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{p.value}
</Typography>
),
},
{
field: 'old_value',
headerName: 'Old',
width: 200,
renderCell: (p) => (
<Tooltip title={JSON.stringify(p.value, null, 2)}>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{jsonPreview(p.value)}
</Typography>
</Tooltip>
),
},
{
field: 'new_value',
headerName: 'New',
width: 200,
renderCell: (p) => (
<Tooltip title={JSON.stringify(p.value, null, 2)}>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{jsonPreview(p.value)}
</Typography>
</Tooltip>
),
},
],
[],
);
return (
<Box>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 2 }}>
<Typography variant="h1">Audit Log</Typography>
<Chip label={`${data?.total ?? '…'} entries`} size="small" />
<Box sx={{ flex: 1 }} />
<IconButton
onClick={() => queryClient.invalidateQueries({ queryKey: ['audit'] })}
disabled={isFetching}
>
<RefreshIcon />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap' }}>
<TextField
select
label="Action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
size="small"
sx={{ minWidth: 180 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="config.set">config.set</MenuItem>
<MenuItem value="config.delete">config.delete</MenuItem>
<MenuItem value="config.reset">config.reset</MenuItem>
<MenuItem value="archive.promote">archive.promote</MenuItem>
</TextField>
<TextField
label="Username (exact)"
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
size="small"
sx={{ minWidth: 180 }}
/>
</Stack>
{error && <Alert severity="error">{String(error)}</Alert>}
<Paper sx={{ height: 640 }}>
<DataGrid
rows={data?.items ?? []}
getRowId={(row) => row.id}
columns={columns}
loading={isLoading || isFetching}
paginationMode="server"
rowCount={data?.total ?? 0}
paginationModel={pagination}
onPaginationModelChange={setPagination}
pageSizeOptions={[10, 25, 50, 100]}
disableRowSelectionOnClick
/>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,200 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import HubIcon from '@mui/icons-material/Hub';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiDelete, apiGet, apiPost } from '@/api/client';
interface CatalogEntry {
id: number;
kind: string;
name: string;
display_name: string | null;
service: string;
context_length: number | null;
supports_cpu: boolean;
supports_gpu: boolean;
quantization: string | null;
endpoint: string | null;
enabled: boolean;
}
interface CatalogResponse { items: CatalogEntry[]; total: number; }
const SERVICES = ['llm', 'embeddings', 'rerank', 'audio', 'video', 'extractors', 'web'];
const KINDS = ['model', 'extractor'];
const EMPTY = {
kind: 'model', name: '', display_name: '', service: 'llm',
context_length: '', quantization: '', endpoint: '',
supports_cpu: false, supports_gpu: true,
};
export default function Catalog() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ ...EMPTY });
const [error, setError] = useState<string | null>(null);
const list = useQuery({
queryKey: ['catalog'],
queryFn: () => apiGet<CatalogResponse>('/api/catalog'),
});
const create = useMutation({
mutationFn: (body: unknown) => apiPost('/api/catalog', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['catalog'] }); setOpen(false); setForm({ ...EMPTY }); setError(null); },
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: (id: number) => apiDelete(`/api/catalog/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }),
});
function submit() {
setError(null);
create.mutate({
kind: form.kind,
name: form.name.trim(),
display_name: form.display_name.trim() || null,
service: form.service,
context_length: form.context_length ? Number(form.context_length) : null,
quantization: form.quantization.trim() || null,
endpoint: form.endpoint.trim() || null,
supports_cpu: form.supports_cpu,
supports_gpu: form.supports_gpu,
});
}
return (
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<HubIcon color="primary" />
<Typography variant="h1">Model & Extractor Catalog</Typography>
</Stack>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
DB-backed catalog of AI models and extractors. Create, edit and disable entries.
</Typography>
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setForm({ ...EMPTY }); setError(null); setOpen(true); }}>
Add entry
</Button>
</Stack>
<Card>
<CardContent>
{list.isError && <Alert severity="error">Failed to load catalog.</Alert>}
{list.isLoading ? (
<CircularProgress size={20} />
) : (list.data?.items ?? []).length === 0 ? (
<Typography variant="body2" color="text.secondary">No entries yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Service</TableCell>
<TableCell align="right">Context</TableCell>
<TableCell>Device</TableCell>
<TableCell>Quant</TableCell>
<TableCell>Enabled</TableCell>
<TableCell align="right" />
</TableRow>
</TableHead>
<TableBody>
{(list.data?.items ?? []).map((e) => (
<TableRow key={e.id} hover>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{e.display_name || e.name}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>{e.name}</Typography>
</TableCell>
<TableCell><Chip label={e.kind} size="small" variant="outlined" /></TableCell>
<TableCell>{e.service}</TableCell>
<TableCell align="right">{e.context_length ?? '—'}</TableCell>
<TableCell>
{e.supports_gpu && <Chip label="GPU" size="small" sx={{ mr: 0.5 }} />}
{e.supports_cpu && <Chip label="CPU" size="small" variant="outlined" />}
</TableCell>
<TableCell>{e.quantization ?? '—'}</TableCell>
<TableCell>
<Chip label={e.enabled ? 'yes' : 'no'} size="small" color={e.enabled ? 'success' : 'default'} />
</TableCell>
<TableCell align="right">
<Tooltip title="Delete">
<IconButton size="small" onClick={() => remove.mutate(e.id)} disabled={remove.isPending}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Add catalog entry</DialogTitle>
<DialogContent>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Stack spacing={2} sx={{ mt: 1 }}>
<Stack direction="row" spacing={2}>
<TextField select label="Kind" value={form.kind} onChange={(ev) => setForm({ ...form, kind: ev.target.value })} fullWidth>
{KINDS.map((k) => <MenuItem key={k} value={k}>{k}</MenuItem>)}
</TextField>
<TextField select label="Service" value={form.service} onChange={(ev) => setForm({ ...form, service: ev.target.value })} fullWidth>
{SERVICES.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</TextField>
</Stack>
<TextField label="Name (id)" value={form.name} onChange={(ev) => setForm({ ...form, name: ev.target.value })} required fullWidth />
<TextField label="Display name" value={form.display_name} onChange={(ev) => setForm({ ...form, display_name: ev.target.value })} fullWidth />
<Stack direction="row" spacing={2}>
<TextField label="Context length" type="number" value={form.context_length} onChange={(ev) => setForm({ ...form, context_length: ev.target.value })} fullWidth />
<TextField label="Quantization" value={form.quantization} onChange={(ev) => setForm({ ...form, quantization: ev.target.value })} fullWidth />
</Stack>
<TextField label="Endpoint" value={form.endpoint} onChange={(ev) => setForm({ ...form, endpoint: ev.target.value })} fullWidth />
<Stack direction="row" spacing={2}>
<FormControlLabel control={<Checkbox checked={form.supports_gpu} onChange={(ev) => setForm({ ...form, supports_gpu: ev.target.checked })} />} label="GPU" />
<FormControlLabel control={<Checkbox checked={form.supports_cpu} onChange={(ev) => setForm({ ...form, supports_cpu: ev.target.checked })} />} label="CPU" />
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={submit} disabled={!form.name.trim() || create.isPending}>
{create.isPending ? 'Saving…' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,381 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import SchemaIcon from '@mui/icons-material/Schema';
import { apiGet, apiPut, apiDelete } from '@/api/client';
interface SchemaMetadata {
type: 'bool' | 'int' | 'float' | 'string' | 'enum' | 'csv' | 'text';
default: unknown;
module: string;
category?: string;
label?: string;
description?: string;
restart_required?: boolean;
min?: number;
max?: number;
options?: string[];
}
interface SchemaOverrideRow {
key: string;
metadata: SchemaMetadata;
created_at: string | null;
updated_at: string | null;
created_by: string | null;
}
interface ListResponse { overrides: SchemaOverrideRow[]; }
const TYPES: SchemaMetadata['type'][] = ['bool', 'int', 'float', 'string', 'enum', 'csv', 'text'];
const MODULES = ['web', 'llm', 'embeddings', 'rerank', 'audio', 'video', 'catalog', 'brain', 'system'];
interface FormState {
key: string;
type: SchemaMetadata['type'];
defaultStr: string;
module: string;
category: string;
label: string;
description: string;
restart_required: boolean;
min: string;
max: string;
options: string;
}
const EMPTY_FORM: FormState = {
key: '',
type: 'string',
defaultStr: '',
module: 'web',
category: '',
label: '',
description: '',
restart_required: false,
min: '',
max: '',
options: '',
};
function formToMetadata(f: FormState): { metadata: SchemaMetadata; error?: string } {
if (!f.key.trim()) return { metadata: {} as SchemaMetadata, error: 'Key is required' };
if (!/^[a-z][a-z0-9._-]*$/.test(f.key)) return { metadata: {} as SchemaMetadata, error: 'Key must be lowercase, dot/underscore/hyphen separated' };
let parsedDefault: unknown = f.defaultStr;
if (f.type === 'bool') parsedDefault = f.defaultStr.toLowerCase() === 'true';
else if (f.type === 'int') {
const n = parseInt(f.defaultStr, 10);
if (isNaN(n)) return { metadata: {} as SchemaMetadata, error: 'Default must be an integer' };
parsedDefault = n;
} else if (f.type === 'float') {
const n = parseFloat(f.defaultStr);
if (isNaN(n)) return { metadata: {} as SchemaMetadata, error: 'Default must be a number' };
parsedDefault = n;
} else if (f.type === 'csv') {
parsedDefault = f.defaultStr.split(',').map((s) => s.trim()).filter(Boolean);
}
const meta: SchemaMetadata = {
type: f.type,
default: parsedDefault,
module: f.module,
restart_required: f.restart_required,
};
if (f.category) meta.category = f.category;
if (f.label) meta.label = f.label;
if (f.description) meta.description = f.description;
if ((f.type === 'int' || f.type === 'float') && f.min) meta.min = parseFloat(f.min);
if ((f.type === 'int' || f.type === 'float') && f.max) meta.max = parseFloat(f.max);
if (f.type === 'enum') {
const opts = f.options.split(',').map((s) => s.trim()).filter(Boolean);
if (opts.length === 0) return { metadata: meta, error: 'Enum type requires at least one option' };
meta.options = opts;
}
return { metadata: meta };
}
function metadataToForm(key: string, m: SchemaMetadata): FormState {
let defaultStr = '';
if (m.default !== undefined && m.default !== null) {
if (Array.isArray(m.default)) defaultStr = (m.default as unknown[]).join(', ');
else defaultStr = String(m.default);
}
return {
key,
type: m.type,
defaultStr,
module: m.module,
category: m.category ?? '',
label: m.label ?? '',
description: m.description ?? '',
restart_required: m.restart_required ?? false,
min: m.min !== undefined ? String(m.min) : '',
max: m.max !== undefined ? String(m.max) : '',
options: (m.options ?? []).join(', '),
};
}
export default function SchemaOverrides() {
const qc = useQueryClient();
const list = useQuery({
queryKey: ['schema_overrides'],
queryFn: () => apiGet<ListResponse>('/api/config/schema/_overrides'),
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const upsert = useMutation({
mutationFn: async ({ key, metadata }: { key: string; metadata: SchemaMetadata }) => {
return apiPut(`/api/config/schema/${encodeURIComponent(key)}`, metadata);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema_overrides'] });
qc.invalidateQueries({ queryKey: ['config'] });
qc.invalidateQueries({ queryKey: ['settings', 'config'] });
setDialogOpen(false);
},
});
const remove = useMutation({
mutationFn: async (key: string) => apiDelete(`/api/config/schema/${encodeURIComponent(key)}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema_overrides'] });
qc.invalidateQueries({ queryKey: ['config'] });
},
});
const openCreate = () => {
setEditingKey(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
};
const openEdit = (row: SchemaOverrideRow) => {
setEditingKey(row.key);
setForm(metadataToForm(row.key, row.metadata));
setFormError(null);
setDialogOpen(true);
};
const handleSave = () => {
const { metadata, error } = formToMetadata(form);
if (error) {
setFormError(error);
return;
}
upsert.mutate({ key: form.key, metadata });
};
const handleDelete = (key: string) => {
if (window.confirm(`Delete runtime schema for "${key}"? Hardcoded schema (if any) will reappear.`)) {
remove.mutate(key);
}
};
return (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<SchemaIcon color="primary" />
<Typography variant="h1">Schema Overrides</Typography>
</Stack>
<Button startIcon={<AddIcon />} variant="contained" onClick={openCreate}>
Register key
</Button>
</Stack>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Register or override config schema entries at runtime. Hardcoded keys still work entries here augment or override them without redeploy.
</Typography>
{list.isLoading && <CircularProgress />}
{list.isError && <Alert severity="error">{String(list.error)}</Alert>}
{list.data && (
<Paper>
{list.data.overrides.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography color="text.secondary">No runtime schema overrides yet. Click "Register key" to add one.</Typography>
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Module</TableCell>
<TableCell>Type</TableCell>
<TableCell>Default</TableCell>
<TableCell>Restart</TableCell>
<TableCell>Updated</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{list.data.overrides.map((row) => (
<TableRow key={row.key} hover>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{row.key}</Typography>
{row.metadata.label && <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{row.metadata.label}</Typography>}
</TableCell>
<TableCell>
<Chip label={row.metadata.module} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Chip label={row.metadata.type} size="small" />
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{row.metadata.default === null ? 'null' : Array.isArray(row.metadata.default) ? `[${(row.metadata.default as unknown[]).length}]` : String(row.metadata.default).slice(0, 40)}
</Typography>
</TableCell>
<TableCell>
{row.metadata.restart_required ? <Chip label="yes" size="small" color="warning" /> : '—'}
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary">
{row.updated_at ? new Date(row.updated_at).toLocaleDateString() : '—'}
</Typography>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(row)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => handleDelete(row.key)} color="error"><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editingKey ? `Edit schema: ${editingKey}` : 'Register new schema key'}</DialogTitle>
<DialogContent>
{formError && <Alert severity="error" sx={{ mb: 2 }}>{formError}</Alert>}
{upsert.isError && <Alert severity="error" sx={{ mb: 2 }}>{String(upsert.error)}</Alert>}
<Grid container spacing={2} sx={{ mt: 0 }}>
<Grid size={12}>
<TextField
fullWidth
size="small"
label="Key"
value={form.key}
disabled={editingKey !== null}
onChange={(e) => setForm({ ...form, key: e.target.value })}
helperText="lowercase, dot/underscore separated (e.g. web.providers.brave.enabled)"
/>
</Grid>
<Grid size={6}>
<FormControl fullWidth size="small">
<InputLabel>Type</InputLabel>
<Select label="Type" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as SchemaMetadata['type'] })}>
{TYPES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
</Select>
</FormControl>
</Grid>
<Grid size={6}>
<FormControl fullWidth size="small">
<InputLabel>Module</InputLabel>
<Select label="Module" value={form.module} onChange={(e) => setForm({ ...form, module: e.target.value })}>
{MODULES.map((m) => <MenuItem key={m} value={m}>{m}</MenuItem>)}
</Select>
</FormControl>
</Grid>
<Grid size={12}>
<TextField
fullWidth
size="small"
label="Default value"
value={form.defaultStr}
onChange={(e) => setForm({ ...form, defaultStr: e.target.value })}
helperText={
form.type === 'bool' ? 'true or false' :
form.type === 'csv' ? 'comma-separated list' :
form.type === 'enum' ? 'must match one of the options below' :
'value used when no override is set'
}
/>
</Grid>
{form.type === 'enum' && (
<Grid size={12}>
<TextField
fullWidth
size="small"
label="Options (comma-separated)"
value={form.options}
onChange={(e) => setForm({ ...form, options: e.target.value })}
helperText="e.g. round-robin, parallel, priority"
/>
</Grid>
)}
{(form.type === 'int' || form.type === 'float') && (
<>
<Grid size={6}>
<TextField fullWidth size="small" label="Min" value={form.min} onChange={(e) => setForm({ ...form, min: e.target.value })} />
</Grid>
<Grid size={6}>
<TextField fullWidth size="small" label="Max" value={form.max} onChange={(e) => setForm({ ...form, max: e.target.value })} />
</Grid>
</>
)}
<Grid size={6}>
<TextField fullWidth size="small" label="Category" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} helperText="logical group inside module" />
</Grid>
<Grid size={6}>
<TextField fullWidth size="small" label="Label" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} helperText="short human-friendly title" />
</Grid>
<Grid size={12}>
<TextField fullWidth size="small" label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} helperText="one-line help text shown under the field" />
</Grid>
<Grid size={12}>
<FormControlLabel
control={<Switch checked={form.restart_required} onChange={(e) => setForm({ ...form, restart_required: e.target.checked })} />}
label="Restart required"
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSave} disabled={upsert.isPending}>
{upsert.isPending ? <CircularProgress size={16} /> : editingKey ? 'Update' : 'Register'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,297 @@
import { Box, Card, CardContent, Chip, CircularProgress, Grid, Link, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { Link as RouterLink } from 'react-router-dom';
import { apiGet } from '@/api/client';
import SettingsIcon from '@mui/icons-material/Settings';
import LaunchIcon from '@mui/icons-material/Launch';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import { useAuth } from '@/auth/AuthContext';
interface ConfigResponse {
items: Record<string, { value: unknown; default: unknown; is_override: boolean }>;
schema: Record<string, { module: string; restart_required: boolean; description?: string; tier?: string }>;
}
interface HealthResponse {
status: string;
// Dashboard /health returns `db: boolean` (true = PG reachable). Older
// sketches expected `database: string`; we accept both for forward-compat.
db?: boolean;
database?: string;
version?: string;
uptime_seconds?: number;
build_time?: string;
}
function formatUptime(sec?: number): string {
if (!sec) return '—';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// Module → route mapping. Routes are stable; modules detected dynamically from /api/config schema.
const MODULE_ROUTES: Record<string, string> = {
web: '/modules/web',
llm: '/modules/llm',
embeddings: '/modules/embeddings',
rerank: '/modules/rerank',
audio: '/modules/audio',
video: '/modules/video',
catalog: '/modules/catalog',
gateway: '/modules/gateway',
brain: '/brain/settings',
};
// Static system links (always shown)
const SYSTEM_LINKS = [
{ label: 'Audit Log', to: '/system/audit' },
{ label: 'Live Status', to: '/operations/live' },
];
function moduleLabel(id: string): string {
const map: Record<string, string> = {
web: 'Web Search',
llm: 'LLM Inference',
embeddings: 'Embeddings',
rerank: 'Rerank',
audio: 'Audio',
video: 'Video Analysis',
catalog: 'Catalog',
gateway: 'Gateway',
brain: 'Brain',
};
return (map[id] ?? id.charAt(0).toUpperCase() + id.slice(1)) + ' config';
}
export default function Settings() {
const { user } = useAuth();
const config = useQuery({
queryKey: ['settings', 'config'],
queryFn: () => apiGet<ConfigResponse>('/api/config'),
});
const health = useQuery({
queryKey: ['settings', 'health'],
// Dashboard liveness probe lives at root /health (next to /ready), not
// /api/health — the latter returns 404 and shows "unknown" in the UI.
queryFn: () => apiGet<HealthResponse>('/health'),
refetchInterval: 30_000,
});
const items = config.data?.items ?? {};
const schema = config.data?.schema ?? {};
const totalKeys = Object.keys(items).length;
const overrides = Object.entries(items).filter(([, v]) => v.is_override);
const restartRequired = overrides.filter(([k]) => schema[k]?.restart_required);
const liveOverrides = overrides.length - restartRequired.length;
// Group by module
const moduleStats = new Map<string, { total: number; overrides: number }>();
Object.entries(schema).forEach(([key, s]) => {
const m = s.module || 'other';
const cur = moduleStats.get(m) ?? { total: 0, overrides: 0 };
cur.total += 1;
if (items[key]?.is_override) cur.overrides += 1;
moduleStats.set(m, cur);
});
const sortedModules = Array.from(moduleStats.entries()).sort((a, b) => b[1].total - a[1].total);
return (
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<SettingsIcon color="primary" />
<Typography variant="h1">System Settings</Typography>
</Stack>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Platform-wide overview, system health, and quick access to module configuration.
</Typography>
<Grid container spacing={3}>
{/* System health card */}
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>System Status</Typography>
{health.isLoading ? (
<CircularProgress size={20} />
) : (
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="body2">API Status</Typography>
<Stack direction="row" spacing={0.5} alignItems="center">
{health.data?.status === 'ok' || health.data?.status === 'healthy' ? (
<CheckCircleIcon fontSize="small" color="success" />
) : (
<CancelIcon fontSize="small" color="error" />
)}
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{health.data?.status ?? 'unknown'}
</Typography>
</Stack>
</Stack>
{health.data?.version && (
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2">Version</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{health.data.version}</Typography>
</Stack>
)}
{health.data?.uptime_seconds !== undefined && (
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2">Uptime</Typography>
<Typography variant="body2">{formatUptime(health.data.uptime_seconds)}</Typography>
</Stack>
)}
{(health.data?.db !== undefined || health.data?.database) && (
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2">Database</Typography>
<Chip
label={
typeof health.data?.db === 'boolean'
? health.data.db
? 'connected'
: 'down'
: (health.data?.database ?? 'unknown')
}
size="small"
color={
health.data?.db === false ? 'error' : 'success'
}
variant="outlined"
/>
</Stack>
)}
{health.data?.build_time && (
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2">Build time</Typography>
<Typography variant="caption" color="text.secondary">{health.data.build_time}</Typography>
</Stack>
)}
</Stack>
)}
</CardContent>
</Card>
{/* Identity card */}
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>Your Session</Typography>
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2">User</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{user?.email ?? user?.preferred_username ?? '—'}</Typography>
</Stack>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Roles</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap">
{(((user?.realm_access as { roles?: string[] } | undefined)?.roles ?? [])
.filter((r) => !['default-roles-didi-admins', 'offline_access', 'uma_authorization'].includes(r))
.map((r) => <Chip key={r} label={r} size="small" />))}
</Stack>
</Stack>
</Stack>
</CardContent>
</Card>
</Grid>
{/* Configuration overview */}
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>Configuration Surface</Typography>
{config.isLoading ? (
<CircularProgress size={20} />
) : (
<>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={4}>
<Box>
<Typography variant="caption" color="text.secondary">Total keys</Typography>
<Typography variant="h2">{totalKeys}</Typography>
</Box>
</Grid>
<Grid size={4}>
<Box>
<Typography variant="caption" color="text.secondary">Live overrides</Typography>
<Typography variant="h2" sx={{ color: liveOverrides ? 'warning.main' : 'text.primary' }}>{liveOverrides}</Typography>
</Box>
</Grid>
<Grid size={4}>
<Box>
<Typography variant="caption" color="text.secondary">Need restart</Typography>
<Typography variant="h2" sx={{ color: restartRequired.length ? 'error.main' : 'text.primary' }}>{restartRequired.length}</Typography>
</Box>
</Grid>
</Grid>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Per module
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Module</TableCell>
<TableCell align="right">Keys</TableCell>
<TableCell align="right">Overrides</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedModules.map(([m, s]) => (
<TableRow key={m}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, textTransform: 'capitalize' }}>{m}</Typography>
</TableCell>
<TableCell align="right">{s.total}</TableCell>
<TableCell align="right">
{s.overrides > 0 ? (
<Chip label={s.overrides} size="small" color="warning" />
) : (
<Typography variant="caption" color="text.secondary"></Typography>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</CardContent>
</Card>
</Grid>
{/* Quick links — modules generated dynamically from config schema */}
<Grid size={12}>
<Card>
<CardContent>
<Typography variant="h2" sx={{ mb: 2 }}>Quick Access</Typography>
<Grid container spacing={1}>
{sortedModules
.filter(([m]) => MODULE_ROUTES[m])
.map(([m, s]) => (
<Grid key={m} size={{ xs: 12, sm: 6, md: 3 }}>
<Link component={RouterLink} to={MODULE_ROUTES[m]!} underline="hover" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.5 }}>
<LaunchIcon sx={{ fontSize: 14 }} />
<Typography variant="body2">{moduleLabel(m)}</Typography>
{s.overrides > 0 && <Chip label={s.overrides} size="small" color="warning" sx={{ height: 16, fontSize: 10 }} />}
</Link>
</Grid>
))}
{SYSTEM_LINKS.map((q) => (
<Grid key={q.to} size={{ xs: 12, sm: 6, md: 3 }}>
<Link component={RouterLink} to={q.to} underline="hover" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.5 }}>
<LaunchIcon sx={{ fontSize: 14 }} />
<Typography variant="body2">{q.label}</Typography>
</Link>
</Grid>
))}
</Grid>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}

View file

@ -0,0 +1,66 @@
import { createTheme } from '@mui/material/styles';
// Matches DIDI admin-dashboard palette (deep blue + honest teal accent)
// Dark by default — AI platform admins live in this UI all day.
export const theme = createTheme({
palette: {
mode: 'dark',
primary: { main: '#0052CC' },
secondary: { main: '#00BFA6' },
success: { main: '#28A745' },
error: { main: '#E63946' },
warning: { main: '#FF8C42' },
background: {
default: '#050510',
paper: '#0E0E1A',
},
text: {
primary: '#E8E8E8',
secondary: '#A0A0B0',
},
divider: '#1f1f2f',
},
typography: {
fontFamily: 'Inter, Roboto, Helvetica, Arial, sans-serif',
h1: { fontSize: 32, fontWeight: 700 },
h2: { fontSize: 24, fontWeight: 600 },
h3: { fontSize: 20, fontWeight: 500 },
h4: { fontSize: 16, fontWeight: 500 },
body1: { fontSize: 14 },
button: { textTransform: 'none', fontWeight: 500 },
},
shape: { borderRadius: 8 },
components: {
MuiButton: {
styleOverrides: {
root: { borderRadius: 8 },
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
backgroundColor: '#0E0E1A',
border: '1px solid #1f1f2f',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#0A0A14',
backgroundImage: 'none',
borderBottom: '1px solid #1f1f2f',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: '#0A0A14',
borderRight: '1px solid #1f1f2f',
},
},
},
},
});

View file

@ -0,0 +1,184 @@
// Mirror of brain admin endpoints from didi_brain/brain_api/schemas.py
export interface AtomListItem {
atom_id: number;
content_hash: string;
content_preview: string | null;
component: 'techniques' | 'ai_tampered' | 'claims';
tier: 'free' | 'premium';
cache_tier: 'gold' | 'silver' | 'bronze';
prompt_hash: string;
framework_version: string | null;
model_used: string | null;
llm_confidence: number | null;
human_validated: boolean;
hit_count: number;
last_hit_at: string | null;
created_at: string;
updated_at: string;
expires_at: string | null;
}
export interface AtomListResponse {
items: AtomListItem[];
total: number;
page: number;
page_size: number;
}
export interface AtomDetail extends AtomListItem {
result_processed: Record<string, unknown>;
result_raw: Record<string, unknown> | null;
human_corrections: Record<string, unknown> | null;
validator_user_id: string | null;
validated_at: string | null;
}
export interface VerificationListItem {
claim_hash: string;
tier: 'free' | 'premium';
model: string | null;
prompt_hash: string;
framework_version: string | null;
schema_name: string;
evidence_url_count: number;
status: string | null;
volatility: 'volatile' | 'evolving' | 'stable' | null;
topic_codes: string[];
created_at: string;
updated_at: string;
expires_at: string;
}
export interface VerificationListResponse {
items: VerificationListItem[];
total: number;
page: number;
page_size: number;
}
export interface VerificationDetail extends VerificationListItem {
evidence_urls: string[];
verification_processed: Record<string, unknown>;
verification_raw: Record<string, unknown> | null;
}
export interface TaxonomyInfo {
total_tags: number;
namespaces: string[];
by_namespace: Record<string, number>;
}
export interface AtomStatsExtended {
total_atoms: number;
by_tier: { gold: number; silver: number; bronze: number };
by_component: { techniques: number; ai_tampered: number; claims: number };
hit_rate_24h: number | null;
hits_24h_gold: number;
hits_24h_silver: number;
writes_24h: number;
gold_promotions_24h: number;
}
// ============================================================================
// Phase D2 — fact_status, audit log, cache invalidate
// ============================================================================
export type Volatility = 'volatile' | 'evolving' | 'stable';
export interface FactStatusItem {
fact_id: number;
subject: string;
predicate: string;
object: string;
canonical_form: string;
canonical_form_hash: string;
current_truth: boolean | null;
current_version_id: number | null;
current_confidence: number | null;
last_verified_at: string | null;
last_evidence_urls: string[];
volatility: Volatility | null;
topic_codes: string[];
next_check_at: string;
check_interval_hours: number;
moderator_locked: boolean;
moderator_user_id: string | null;
moderator_notes: string | null;
created_at: string;
updated_at: string;
}
export interface FactStatusListResponse {
items: FactStatusItem[];
total: number;
page: number;
page_size: number;
}
export interface FactStatusVersionItem {
version_id: number;
fact_id: number;
truth_value: boolean;
confidence: number | null;
valid_from: string;
valid_to: string | null;
source_atom_ids: string[];
evidence_urls: string[];
llm_reasoning: string | null;
created_by: string;
moderator_user_id: string | null;
notes: string | null;
created_at: string;
}
export interface FactStatusVersionsResponse {
versions: FactStatusVersionItem[];
fact_id: number;
total: number;
}
export interface FactStatusPatchRequest {
set_truth?: boolean | null;
confidence?: number;
evidence_urls?: string[];
notes?: string | null;
lock?: boolean | null;
moderator_user_id: string;
}
export interface AuditLogItem {
log_id: number;
action: string;
target_table: string;
target_id: string;
actor: string | null;
payload: Record<string, unknown>;
created_at: string;
}
export interface AuditLogResponse {
items: AuditLogItem[];
total: number;
page: number;
page_size: number;
}
export interface CacheInvalidateRequest {
topic_codes?: string[];
entity_canonicals?: string[];
claim_pattern?: string | null;
since?: string | null;
invalidate_gold?: boolean;
dry_run?: boolean;
actor?: string;
reason?: string;
}
export interface CacheInvalidateResponse {
invalidated_atoms: number;
invalidated_vcache: number;
dry_run: boolean;
filters_applied: Record<string, unknown>;
executed_at: string;
}

View file

@ -0,0 +1,30 @@
// Mirror of dashboard FastAPI /api/config response shape.
export type ConfigType = 'bool' | 'int' | 'float' | 'string' | 'enum' | 'csv' | 'text';
export interface ConfigSchema {
type: ConfigType;
default: unknown;
module: string;
category: string;
label: string;
description: string;
restart_required: boolean;
min?: number;
max?: number;
options?: string[];
}
export interface ConfigItem {
key: string;
value: unknown;
default: unknown;
is_override: boolean;
updated_at: string | null;
updated_by: string | null;
}
export interface ConfigResponse {
items: Record<string, ConfigItem>;
schema: Record<string, ConfigSchema>;
}

View file

@ -0,0 +1,113 @@
// Mirror of dashboard insights/audit endpoints.
export interface HistoryItem {
id: number;
request_id: string;
created_at: string;
module: string;
tier: string | null;
endpoint: string | null;
provider: string | null;
status_code: number | null;
duration_ms: number | null;
results_count: number | null;
cost_usd: number | null;
error: string | null;
}
export interface HistoryListResponse {
items: HistoryItem[];
total?: number;
limit?: number;
offset?: number;
}
export interface HistoryDetail extends HistoryItem {
stages?: unknown;
raw_request?: unknown;
raw_response?: unknown;
}
export interface CostBreakdownRow {
provider?: string | null;
tier?: string | null;
count: number;
cost: number;
}
export interface TopExpensiveRow {
id: number;
created_at: string | null;
tier: string | null;
provider: string | null;
endpoint: string | null;
cost: number;
duration_ms: number | null;
}
export interface BudgetBar {
name: string;
kind: string;
used: number;
limit: number;
percent: number;
unit: string | null;
plan_price: number | null;
}
export interface CostStats {
cost_24h: number;
cost_7d: number;
cost_30d: number;
projected_monthly: number;
by_provider: CostBreakdownRow[];
by_tier: CostBreakdownRow[];
top: TopExpensiveRow[];
budgets: BudgetBar[];
}
export interface ProviderStat {
name: string;
display_name: string;
kind: string;
healthy: boolean | null;
message?: string | null;
quota_used?: number | null;
quota_limit?: number | null;
quota_percent_used?: number | null;
quota_unit?: string | null;
plan_price_monthly?: number | null;
// Allow extra fields
[key: string]: unknown;
}
export interface ProvidersStatsResponse {
providers: ProviderStat[];
}
export interface ArchiveClaim {
id?: number;
claim_id?: string;
claim: string;
verdict?: string | null;
confidence?: number | null;
summary?: string | null;
created_at: string;
}
export interface AuditEntry {
id: number;
timestamp: string;
username: string;
action: string;
target: string;
old_value: unknown;
new_value: unknown;
}
export interface AuditListResponse {
items: AuditEntry[];
total: number;
limit: number;
offset: number;
}

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": []
}

View file

@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/AuthContext.tsx","./src/auth/ProtectedRoute.tsx","./src/auth/keycloak.ts","./src/components/ConfigField.tsx","./src/components/KpiTile.tsx","./src/components/ModuleConfigForm.tsx","./src/components/StatusChip.tsx","./src/layout/AppShell.tsx","./src/pages/Overview.tsx","./src/pages/Stub.tsx","./src/pages/Unauthorized.tsx","./src/pages/brain/Atoms.tsx","./src/pages/brain/Cache.tsx","./src/pages/brain/Facts.tsx","./src/pages/brain/Invalidate.tsx","./src/pages/brain/Stats.tsx","./src/pages/brain/Taxonomy.tsx","./src/pages/insights/Archive.tsx","./src/pages/insights/Cost.tsx","./src/pages/insights/History.tsx","./src/pages/insights/Providers.tsx","./src/pages/modules/ModulePage.tsx","./src/pages/operations/Live.tsx","./src/pages/operations/Monitoring.tsx","./src/pages/system/AuditLog.tsx","./src/pages/system/AuditLogBrain.tsx","./src/pages/system/AuditLogDashboard.tsx","./src/pages/system/Catalog.tsx","./src/pages/system/SchemaOverrides.tsx","./src/pages/system/Settings.tsx","./src/types/brain.ts","./src/types/config.ts","./src/types/insights.ts"],"version":"5.9.3"}

View file

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
// React SPA mounted at /admin-ai/ in production (FastAPI StaticFiles).
// In dev, runs at :5173 with proxy to FastAPI for /api/* + /health.
export default defineConfig({
base: '/admin-ai/',
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
build: {
outDir: 'dist',
sourcemap: true,
chunkSizeWarningLimit: 700,
},
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': 'http://localhost:51300',
'/health': 'http://localhost:51300',
},
},
});