Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
13
ai_platform/modules/dashboard/web/index.html
Normal file
13
ai_platform/modules/dashboard/web/index.html
Normal 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>
|
||||
3215
ai_platform/modules/dashboard/web/package-lock.json
generated
Normal file
3215
ai_platform/modules/dashboard/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
ai_platform/modules/dashboard/web/package.json
Normal file
33
ai_platform/modules/dashboard/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
79
ai_platform/modules/dashboard/web/src/App.tsx
Normal file
79
ai_platform/modules/dashboard/web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
ai_platform/modules/dashboard/web/src/api/client.ts
Normal file
100
ai_platform/modules/dashboard/web/src/api/client.ts
Normal 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'
|
||||
);
|
||||
})();
|
||||
168
ai_platform/modules/dashboard/web/src/auth/AuthContext.tsx
Normal file
168
ai_platform/modules/dashboard/web/src/auth/AuthContext.tsx
Normal 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 };
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
17
ai_platform/modules/dashboard/web/src/auth/keycloak.ts
Normal file
17
ai_platform/modules/dashboard/web/src/auth/keycloak.ts
Normal 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 });
|
||||
243
ai_platform/modules/dashboard/web/src/components/ConfigField.tsx
Normal file
243
ai_platform/modules/dashboard/web/src/components/ConfigField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
ai_platform/modules/dashboard/web/src/components/KpiTile.tsx
Normal file
36
ai_platform/modules/dashboard/web/src/components/KpiTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "{moduleId}". (Either the module has no
|
||||
editable knobs, or schema isn'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
307
ai_platform/modules/dashboard/web/src/layout/AppShell.tsx
Normal file
307
ai_platform/modules/dashboard/web/src/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
ai_platform/modules/dashboard/web/src/main.tsx
Normal file
35
ai_platform/modules/dashboard/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
230
ai_platform/modules/dashboard/web/src/pages/Overview.tsx
Normal file
230
ai_platform/modules/dashboard/web/src/pages/Overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
ai_platform/modules/dashboard/web/src/pages/Stub.tsx
Normal file
28
ai_platform/modules/dashboard/web/src/pages/Stub.tsx
Normal 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.5–C.7" size="small" color="primary" sx={{ ml: 'auto' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
47
ai_platform/modules/dashboard/web/src/pages/Unauthorized.tsx
Normal file
47
ai_platform/modules/dashboard/web/src/pages/Unauthorized.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
443
ai_platform/modules/dashboard/web/src/pages/brain/Atoms.tsx
Normal file
443
ai_platform/modules/dashboard/web/src/pages/brain/Atoms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
426
ai_platform/modules/dashboard/web/src/pages/brain/Cache.tsx
Normal file
426
ai_platform/modules/dashboard/web/src/pages/brain/Cache.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
701
ai_platform/modules/dashboard/web/src/pages/brain/Facts.tsx
Normal file
701
ai_platform/modules/dashboard/web/src/pages/brain/Facts.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
354
ai_platform/modules/dashboard/web/src/pages/brain/Invalidate.tsx
Normal file
354
ai_platform/modules/dashboard/web/src/pages/brain/Invalidate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
ai_platform/modules/dashboard/web/src/pages/brain/Stats.tsx
Normal file
209
ai_platform/modules/dashboard/web/src/pages/brain/Stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
ai_platform/modules/dashboard/web/src/pages/brain/Taxonomy.tsx
Normal file
129
ai_platform/modules/dashboard/web/src/pages/brain/Taxonomy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
ai_platform/modules/dashboard/web/src/pages/insights/Archive.tsx
Normal file
116
ai_platform/modules/dashboard/web/src/pages/insights/Archive.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
ai_platform/modules/dashboard/web/src/pages/insights/Cost.tsx
Normal file
296
ai_platform/modules/dashboard/web/src/pages/insights/Cost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
415
ai_platform/modules/dashboard/web/src/pages/insights/History.tsx
Normal file
415
ai_platform/modules/dashboard/web/src/pages/insights/History.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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://<host>:<port>/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><stack></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 <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>
|
||||
);
|
||||
}
|
||||
322
ai_platform/modules/dashboard/web/src/pages/operations/Live.tsx
Normal file
322
ai_platform/modules/dashboard/web/src/pages/operations/Live.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
200
ai_platform/modules/dashboard/web/src/pages/system/Catalog.tsx
Normal file
200
ai_platform/modules/dashboard/web/src/pages/system/Catalog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
297
ai_platform/modules/dashboard/web/src/pages/system/Settings.tsx
Normal file
297
ai_platform/modules/dashboard/web/src/pages/system/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
ai_platform/modules/dashboard/web/src/theme.ts
Normal file
66
ai_platform/modules/dashboard/web/src/theme.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
184
ai_platform/modules/dashboard/web/src/types/brain.ts
Normal file
184
ai_platform/modules/dashboard/web/src/types/brain.ts
Normal 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;
|
||||
}
|
||||
30
ai_platform/modules/dashboard/web/src/types/config.ts
Normal file
30
ai_platform/modules/dashboard/web/src/types/config.ts
Normal 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>;
|
||||
}
|
||||
113
ai_platform/modules/dashboard/web/src/types/insights.ts
Normal file
113
ai_platform/modules/dashboard/web/src/types/insights.ts
Normal 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;
|
||||
}
|
||||
1
ai_platform/modules/dashboard/web/src/vite-env.d.ts
vendored
Normal file
1
ai_platform/modules/dashboard/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
27
ai_platform/modules/dashboard/web/tsconfig.json
Normal file
27
ai_platform/modules/dashboard/web/tsconfig.json
Normal 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": []
|
||||
}
|
||||
1
ai_platform/modules/dashboard/web/tsconfig.tsbuildinfo
Normal file
1
ai_platform/modules/dashboard/web/tsconfig.tsbuildinfo
Normal 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"}
|
||||
26
ai_platform/modules/dashboard/web/vite.config.ts
Normal file
26
ai_platform/modules/dashboard/web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue