livrare lot 2
This commit is contained in:
commit
8ecc78e729
763 changed files with 164593 additions and 0 deletions
38
backend/admin-dashboard/src/App.css
Normal file
38
backend/admin-dashboard/src/App.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
101
backend/admin-dashboard/src/App.tsx
Normal file
101
backend/admin-dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||
import { Unauthorized } from './components/auth/Unauthorized';
|
||||
import { LoginRedirect } from './components/auth/LoginRedirect';
|
||||
import { AdminLayout } from './components/layout/AdminLayout';
|
||||
import { ServicesDashboard } from './components/dashboard/ServicesDashboard';
|
||||
import { FrameworkDashboard } from './pages/FrameworkDashboard';
|
||||
import { ProgressiveAnalysisTree } from './components/framework/ProgressiveAnalysisTree';
|
||||
import { UserManagement } from './components/UserManagement';
|
||||
import { AnalysisHistory } from './components/AnalysisHistory';
|
||||
import { ProvidersManagement } from './components/ProvidersManagement';
|
||||
import { LLMComponentsConfig } from './components/LLMComponentsConfig';
|
||||
import { PipelinesPage } from './components/Pipelines/PipelinesPage';
|
||||
import { ModerationQueue } from './components/Moderation/ModerationQueue';
|
||||
import { ModerationDetail } from './components/Moderation/ModerationDetail';
|
||||
import { ModerationStats } from './components/Moderation/ModerationStats';
|
||||
|
||||
const DefaultLanding: React.FC = () => {
|
||||
const { hasRole } = useAuth();
|
||||
if (hasRole('admin')) return <ServicesDashboard />;
|
||||
return <Navigate to="/moderation" replace />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router basename="/admin">
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRedirect />} />
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
|
||||
{/* AdminLayout requires staff role (admin / moderator / senior_moderator).
|
||||
End users (viewer / free_tier / paid_tier / etc.) get 403. */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DefaultLanding />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute requiredRole="admin"><ServicesDashboard /></ProtectedRoute>} />
|
||||
|
||||
{/* Admin-only pages — require 'admin' role */}
|
||||
<Route path="/framework" element={<ProtectedRoute requiredRole="admin"><FrameworkDashboard /></ProtectedRoute>} />
|
||||
<Route path="/framework/progressive" element={<ProtectedRoute requiredRole="admin"><ProgressiveAnalysisTree /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute requiredRole="admin"><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="/providers" element={<ProtectedRoute requiredRole="admin"><ProvidersManagement /></ProtectedRoute>} />
|
||||
<Route path="/llm-components" element={<ProtectedRoute requiredRole="admin"><LLMComponentsConfig /></ProtectedRoute>} />
|
||||
<Route path="/pipelines" element={<ProtectedRoute requiredRole="admin"><PipelinesPage /></ProtectedRoute>} />
|
||||
|
||||
{/* History — visible to admin + moderators (audit trail) */}
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<AnalysisHistory />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Moderation — visible to all staff (admin/moderator/senior_moderator) */}
|
||||
<Route
|
||||
path="/moderation"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<ModerationQueue />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation/stats"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<ModerationStats />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/moderation/:queueId"
|
||||
element={
|
||||
<ProtectedRoute requiredAnyRole={['admin', 'moderator', 'senior_moderator']}>
|
||||
<ModerationDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* AnalysisDetailModal — color maps, cost calc, layout primitives
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import type { ModelPricing } from './types';
|
||||
|
||||
export const REDIS_COLOR_MAP: Record<string, string> = {
|
||||
green: '#22c55e',
|
||||
lightgreen: '#84cc16',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
darkred: '#7f1d1d',
|
||||
};
|
||||
|
||||
export const getRiskColor = (verdict: any): string => {
|
||||
if (verdict?.risk_level_color && REDIS_COLOR_MAP[verdict.risk_level_color]) {
|
||||
return REDIS_COLOR_MAP[verdict.risk_level_color];
|
||||
}
|
||||
switch (verdict?.risk_level) {
|
||||
case 'VERY_LOW':
|
||||
case 'LOW': return '#22c55e';
|
||||
case 'MEDIUM': return '#eab308';
|
||||
case 'HIGH': return '#f97316';
|
||||
case 'VERY_HIGH': return '#ef4444';
|
||||
case 'CRITICAL': return '#7f1d1d';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryColor = (verdict: any): string => {
|
||||
if (verdict?.risk_category_color && REDIS_COLOR_MAP[verdict.risk_category_color]) {
|
||||
return REDIS_COLOR_MAP[verdict.risk_category_color];
|
||||
}
|
||||
switch (verdict?.risk_category) {
|
||||
case 'RELIABLE': return '#22c55e';
|
||||
case 'MOSTLY_RELIABLE': return '#84cc16';
|
||||
case 'MIXED': return '#eab308';
|
||||
case 'QUESTIONABLE': return '#f97316';
|
||||
case 'UNRELIABLE': return '#ef4444';
|
||||
case 'DISINFORMATION': return '#7f1d1d';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
};
|
||||
|
||||
/** Normalize model_key to lookup key used for pricing map. Strips provider prefix. */
|
||||
export function normalizeModelKey(key: string): string {
|
||||
if (!key) return '';
|
||||
// "openrouter:google/gemini-3-flash-preview" → "google/gemini-3-flash-preview"
|
||||
const colonIdx = key.indexOf(':');
|
||||
return colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
|
||||
}
|
||||
|
||||
export function computeTokenCost(
|
||||
promptTokens: number,
|
||||
completionTokens: number,
|
||||
pricing: ModelPricing | undefined,
|
||||
): number {
|
||||
if (!pricing) return 0;
|
||||
return (promptTokens / 1_000_000) * pricing.input_per_1m + (completionTokens / 1_000_000) * pricing.output_per_1m;
|
||||
}
|
||||
|
||||
export function formatUsd(amount: number): string {
|
||||
if (amount === 0) return '$0.00';
|
||||
if (amount < 0.0001) return `<$0.0001`;
|
||||
if (amount < 0.01) return `$${amount.toFixed(5)}`;
|
||||
return `$${amount.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export const formatDate = (dateStr: string | null) =>
|
||||
dateStr ? new Date(dateStr).toLocaleString() : '-';
|
||||
|
||||
export const formatDuration = (ms: number | null | undefined) => {
|
||||
if (!ms) return '-';
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
export const GridRow: React.FC<{ children: React.ReactNode; columns?: number; sx?: any }> = ({ children, columns = 4, sx = {} }) => (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 2, ...sx }}>{children}</Box>
|
||||
);
|
||||
|
||||
export const GridCell: React.FC<{ children: React.ReactNode }> = ({ children }) => <Box>{children}</Box>;
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
Box, Typography, Chip, CircularProgress, Alert,
|
||||
} from '@mui/material';
|
||||
import type { AnalysisDetailModalProps, AnalysisDetail, PricingMap } from './types';
|
||||
import { getRiskColor } from './helpers';
|
||||
import { SessionInfoSection, InputContentSection } from './sections/meta';
|
||||
import { VerdictSection } from './sections/verdict';
|
||||
import { TechniquesSection, AISection } from './sections/components';
|
||||
import { ClaimsSection } from './sections/claims';
|
||||
import { SourceAssessmentSection, LegacyDomainSection } from './sections/source';
|
||||
import { LLMUsageSection } from './sections/llm-usage';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export const AnalysisDetailModal: React.FC<AnalysisDetailModalProps> = ({ open, sessionId, onClose }) => {
|
||||
const [data, setData] = useState<AnalysisDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pricingMap, setPricingMap] = useState<PricingMap>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
|
||||
const fetchDetails = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const [detailRes, modelsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' },
|
||||
}),
|
||||
fetch(`${API_BASE}/framework/api/providers/models`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' },
|
||||
}),
|
||||
]);
|
||||
if (!detailRes.ok) throw new Error('Failed to fetch analysis details');
|
||||
const result = await detailRes.json();
|
||||
setData(result.data);
|
||||
|
||||
// Build pricing map from model_code → {input_per_1m, output_per_1m}
|
||||
if (modelsRes.ok) {
|
||||
const modelsData = await modelsRes.json();
|
||||
const map: PricingMap = {};
|
||||
for (const m of modelsData.data || []) {
|
||||
map[m.model_code] = {
|
||||
input_per_1m: parseFloat(m.input_cost_per_1m) || 0,
|
||||
output_per_1m: parseFloat(m.output_cost_per_1m) || 0,
|
||||
};
|
||||
}
|
||||
setPricingMap(map);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDetails();
|
||||
}, [open, sessionId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">Analysis Details</Typography>
|
||||
{data?.verdict && (
|
||||
<Chip
|
||||
label={`Risk: ${data.verdict.risk_score} - ${data.verdict.risk_category}`}
|
||||
sx={{ bgcolor: getRiskColor(data.verdict), color: 'white', fontWeight: 'bold' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{data && !loading && (
|
||||
<Box>
|
||||
<SessionInfoSection data={data} />
|
||||
<InputContentSection data={data} />
|
||||
<VerdictSection data={data} />
|
||||
<TechniquesSection data={data} />
|
||||
<AISection data={data} />
|
||||
<ClaimsSection data={data} />
|
||||
<SourceAssessmentSection data={data} />
|
||||
<LegacyDomainSection data={data} />
|
||||
<LLMUsageSection data={data} pricingMap={pricingMap} />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Cancel as CancelIcon,
|
||||
Help as HelpIcon,
|
||||
FactCheck as FactCheckIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, REDIS_COLOR_MAP } from '../helpers';
|
||||
|
||||
export const ClaimsSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.claims) return null;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<FactCheckIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Claims Verification ({data.claims.total_claims} claims)</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={4}>
|
||||
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'success.light' }}>
|
||||
<CheckCircleIcon color="success" />
|
||||
<Typography variant="h6">{data.claims.verified_true}</Typography>
|
||||
<Typography variant="caption">Verified True</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'error.light' }}>
|
||||
<CancelIcon color="error" />
|
||||
<Typography variant="h6">{data.claims.verified_false}</Typography>
|
||||
<Typography variant="caption">Verified False</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1, textAlign: 'center', bgcolor: 'grey.200' }}>
|
||||
<HelpIcon />
|
||||
<Typography variant="h6">{data.claims.unverified}</Typography>
|
||||
<Typography variant="caption">Unverified</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1, textAlign: 'center' }}>
|
||||
<Typography variant="h6">{Math.round(Number(data.claims.credibility_score))}%</Typography>
|
||||
<Typography variant="caption">Credibility</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{/* Meta info */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{data.claims.interpretation && (
|
||||
<Chip label={data.claims.interpretation} size="small" variant="outlined" />
|
||||
)}
|
||||
{data.claims.web_searches_made != null && (
|
||||
<Chip icon={<SearchIcon />} label={`${data.claims.web_searches_made} web searches`} size="small" variant="outlined" />
|
||||
)}
|
||||
{data.claims.llm_extraction && <Chip label={`Extraction: ${data.claims.llm_extraction}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.claims.llm_verification && <Chip label={`Verification: ${data.claims.llm_verification}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.claims.claims_by_status && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{Object.entries(data.claims.claims_by_status).map(([status, count]) => (
|
||||
<Chip key={status} label={`${status}: ${count}`} size="small"
|
||||
color={status === 'VT' || status === 'LT' ? 'success' : status === 'VF' || status === 'LF' ? 'error' : 'default'} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Individual claims */}
|
||||
{data.claims.claims_verified?.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{data.claims.claims_verified.map((claim: any, idx: number) => (
|
||||
<Paper key={idx} sx={{
|
||||
p: 2, mb: 1.5, borderLeft: 4,
|
||||
borderColor: claim.status === 'VT' || claim.status === 'LT' ? 'success.main'
|
||||
: claim.status === 'VF' || claim.status === 'LF' ? 'error.main'
|
||||
: 'grey.400',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="body1" fontWeight="bold" sx={{ flex: 1, mr: 1 }}>
|
||||
{claim.text || claim.claim}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<Chip
|
||||
label={claim.status_name || claim.status}
|
||||
size="small"
|
||||
color={claim.status === 'VT' || claim.status === 'LT' ? 'success' : claim.status === 'VF' || claim.status === 'LF' ? 'error' : 'default'}
|
||||
sx={claim.status_color ? { bgcolor: REDIS_COLOR_MAP[claim.status_color] || undefined, color: '#fff' } : {}}
|
||||
/>
|
||||
{claim.type_name && <Chip label={claim.type_name} size="small" variant="outlined" />}
|
||||
{claim.priority && <Chip label={claim.priority} size="small" variant="outlined"
|
||||
color={claim.priority === 'high' ? 'error' : claim.priority === 'medium' ? 'warning' : 'default'} />}
|
||||
</Box>
|
||||
</Box>
|
||||
{claim.context && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, fontStyle: 'italic' }}>
|
||||
{claim.context}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Confidence & agreement */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
|
||||
{claim.confidence != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Confidence: <strong>{claim.confidence}%</strong>
|
||||
</Typography>
|
||||
)}
|
||||
{claim.agreement_score != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Agreement: <strong>{claim.agreement_score}%</strong>
|
||||
</Typography>
|
||||
)}
|
||||
{claim.verification_method && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Method: <strong>{claim.verification_method}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{/* Reasoning */}
|
||||
{claim.reasoning && (
|
||||
<Paper sx={{ p: 1.5, bgcolor: 'grey.50', mb: 1 }}>
|
||||
<Typography variant="body2">{claim.reasoning}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{/* Sources */}
|
||||
{claim.sources?.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
Sources ({claim.sources.length}):
|
||||
</Typography>
|
||||
{claim.sources.map((src: any, si: number) => (
|
||||
<Paper key={si} sx={{ p: 1, mt: 0.5, bgcolor: 'grey.50', display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<Chip
|
||||
label={src.stance}
|
||||
size="small"
|
||||
color={src.stance === 'SUPPORTS' ? 'success' : src.stance === 'CONTRADICTS' ? 'error' : 'default'}
|
||||
sx={{ minWidth: 90, mt: 0.3 }}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'primary.main', wordBreak: 'break-all', cursor: 'pointer' }}
|
||||
component="a" href={src.url} target="_blank" rel="noopener noreferrer">
|
||||
{src.url}
|
||||
</Typography>
|
||||
<OpenInNewIcon sx={{ fontSize: 12, color: 'text.secondary' }} />
|
||||
{src.reliability && <Chip label={src.reliability} size="small" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
</Box>
|
||||
{src.relevant_quote && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontStyle: 'italic' }}>
|
||||
"{src.relevant_quote}"
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Chip, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
List, ListItem, ListItemText,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, GridCell, formatDuration } from '../helpers';
|
||||
|
||||
export const TechniquesSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.techniques) return null;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<PsychologyIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Manipulation Techniques ({data.techniques.techniques_count} detected)
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={3}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Manipulation Score</Typography>
|
||||
<Typography variant="h6">{Math.round(Number(data.techniques.manipulation_score))}%</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Total Severity</Typography>
|
||||
<Typography variant="h6">{data.techniques.total_severity}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Duration</Typography>
|
||||
<Typography variant="h6">{formatDuration(data.techniques.total_duration_ms || data.techniques.duration_ms?.total)}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
{(data.techniques.llm_screening || data.techniques.llm_deep) && (
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{data.techniques.llm_screening && <Chip label={`Screening: ${data.techniques.llm_screening}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.techniques.llm_deep && <Chip label={`Deep: ${data.techniques.llm_deep}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
</Box>
|
||||
)}
|
||||
{data.techniques.dimensions_affected?.length > 0 && (
|
||||
<Box sx={{ my: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">Dimensions Affected</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
{data.techniques.dimensions_affected.map((dim: string) => <Chip key={dim} label={dim} size="small" />)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{data.techniques.techniques_detected?.length > 0 && (
|
||||
<List dense>
|
||||
{data.techniques.techniques_detected.map((tech: any, idx: number) => (
|
||||
<ListItem key={idx} sx={{ bgcolor: 'grey.50', mb: 0.5, borderRadius: 1 }}>
|
||||
<ListItemText primary={tech.name || `Technique ${idx + 1}`} secondary={tech.description || tech.evidence} />
|
||||
{tech.severity && <Chip label={`Severity: ${tech.severity}`} size="small" color={tech.severity > 3 ? 'error' : 'warning'} />}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const AISection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.ai_tampered) return null;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<PsychologyIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">AI Content Detection - {data.ai_tampered.verdict}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={3}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">AI Probability</Typography>
|
||||
<Typography variant="h6">{Math.round(Number(data.ai_tampered.ai_probability))}%</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Indicators Found</Typography>
|
||||
<Typography variant="h6">{data.ai_tampered.indicators_count}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Duration</Typography>
|
||||
<Typography variant="h6">{formatDuration(data.ai_tampered.total_duration_ms || data.ai_tampered.duration_ms?.total)}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
{(data.ai_tampered.llm_screening || data.ai_tampered.llm_deep) && (
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{data.ai_tampered.llm_screening && <Chip label={`Screening: ${data.ai_tampered.llm_screening}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
{data.ai_tampered.llm_deep && <Chip label={`Deep: ${data.ai_tampered.llm_deep}`} size="small" variant="outlined" sx={{ fontSize: 11 }} />}
|
||||
</Box>
|
||||
)}
|
||||
{data.ai_tampered.disclosure_detected && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>AI Disclosure detected: {data.ai_tampered.disclosure_text}</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
GeneratingTokens as TokenIcon,
|
||||
AttachMoney as MoneyIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail, PricingMap } from '../types';
|
||||
import { GridRow, normalizeModelKey, computeTokenCost, formatUsd } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
data: AnalysisDetail;
|
||||
pricingMap: PricingMap;
|
||||
}
|
||||
|
||||
export const LLMUsageSection: React.FC<Props> = ({ data, pricingMap }) => {
|
||||
if (!data.llm_usage) return null;
|
||||
|
||||
// Compute cost per component + grand total. We don't have per-model token split
|
||||
// in by_component, so we charge all tokens at the FIRST model used by that
|
||||
// component (good enough for single-model primary path).
|
||||
const byComponent = data.llm_usage.by_component || {};
|
||||
const componentCosts: Record<string, { cost: number; model: string | null }> = {};
|
||||
let totalCost = 0;
|
||||
for (const [comp, usage] of Object.entries(byComponent) as [string, any][]) {
|
||||
const firstModel = usage.models_used?.[0];
|
||||
const pricingKey = firstModel ? normalizeModelKey(firstModel) : '';
|
||||
const pricing = pricingKey ? pricingMap[pricingKey] : undefined;
|
||||
const cost = computeTokenCost(
|
||||
usage.prompt_tokens || 0,
|
||||
usage.completion_tokens || 0,
|
||||
pricing,
|
||||
);
|
||||
componentCosts[comp] = { cost, model: firstModel || null };
|
||||
totalCost += cost;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<TokenIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold" sx={{ flex: 1 }}>
|
||||
LLM Usage — {data.llm_usage.total?.total_tokens?.toLocaleString() || 0} tokens ({data.llm_usage.total?.calls || 0} calls)
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<MoneyIcon />}
|
||||
label={formatUsd(totalCost)}
|
||||
size="small"
|
||||
color={totalCost === 0 ? 'success' : 'warning'}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={5}>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{data.llm_usage.total?.calls || 0}</Typography>
|
||||
<Typography variant="caption">Total Calls</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{(data.llm_usage.total?.prompt_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption">Prompt Tokens</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{(data.llm_usage.total?.completion_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption">Completion Tokens</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{(data.llm_usage.total?.total_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption">Total Tokens</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center', bgcolor: totalCost === 0 ? 'success.50' : 'warning.50' }}>
|
||||
<Typography variant="h5" color={totalCost === 0 ? 'success.main' : 'warning.main'}>
|
||||
{formatUsd(totalCost)}
|
||||
</Typography>
|
||||
<Typography variant="caption">Est. Cost</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{Object.keys(byComponent).length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Per Component</Typography>
|
||||
<GridRow columns={Object.keys(byComponent).length}>
|
||||
{Object.entries(byComponent).map(([comp, usage]: [string, any]) => {
|
||||
const cc = componentCosts[comp];
|
||||
return (
|
||||
<Paper key={comp} sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="capitalize">{comp.replace('_', ' ')}</Typography>
|
||||
<Typography variant="h6">{(usage.total_tokens || 0).toLocaleString()}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{usage.calls} calls • {(usage.prompt_tokens || 0).toLocaleString()} in / {(usage.completion_tokens || 0).toLocaleString()} out
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip
|
||||
icon={<MoneyIcon />}
|
||||
label={formatUsd(cc.cost)}
|
||||
size="small"
|
||||
color={cc.cost === 0 ? 'success' : 'warning'}
|
||||
variant="outlined"
|
||||
sx={{ height: 22, fontSize: 11 }}
|
||||
/>
|
||||
</Box>
|
||||
{usage.models_used?.length > 0 && (
|
||||
<Box sx={{ mt: 0.5, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{usage.models_used.map((m: string) => (
|
||||
<Chip key={m} label={m} size="small" variant="outlined" sx={{ height: 20, fontSize: 10 }} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</GridRow>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Typography, Paper, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
|
||||
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, GridCell, formatDate, formatDuration } from '../helpers';
|
||||
|
||||
export const SessionInfoSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<GridRow columns={2}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Session ID</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{data.session_id}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">User</Typography>
|
||||
<Typography variant="body2">{data.user_email || data.user_id}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
<GridRow columns={4} sx={{ mt: 2 }}>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Started</Typography>
|
||||
<Typography variant="body2">{formatDate(data.started_at)}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Duration</Typography>
|
||||
<Typography variant="body2">{formatDuration(data.total_duration_ms)}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Type</Typography>
|
||||
<Typography variant="body2">{data.input_type}</Typography>
|
||||
</GridCell>
|
||||
<GridCell>
|
||||
<Typography variant="caption" color="text.secondary">Source</Typography>
|
||||
<Typography variant="body2">{data.source_app}</Typography>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export const InputContentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => (
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Input Content</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{data.input_text && (
|
||||
<Paper sx={{ p: 2, bgcolor: 'grey.100', maxHeight: 200, overflow: 'auto' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
|
||||
{data.input_text}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{data.input_url && <Typography variant="body2" sx={{ mt: 1 }}><strong>URL:</strong> {data.input_url}</Typography>}
|
||||
{data.input_media_url && <Typography variant="body2" sx={{ mt: 1 }}><strong>Media:</strong> {data.input_media_url}</Typography>}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Language as LanguageIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, formatDuration } from '../helpers';
|
||||
|
||||
export const SourceAssessmentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.source_assessment) return null;
|
||||
const sa = data.source_assessment;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<LanguageIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Source Assessment</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={3}>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: sa.trust_score >= 60 ? '#22c55e' : sa.trust_score >= 30 ? '#f97316' : '#ef4444' }}>
|
||||
{sa.trust_score}
|
||||
</Typography>
|
||||
<Typography variant="caption">Trust Score</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{
|
||||
color: sa.verdict === 'TRUSTED' ? '#22c55e' : sa.verdict === 'NEUTRAL' ? '#eab308'
|
||||
: sa.verdict === 'SUSPICIOUS' ? '#f97316' : '#ef4444',
|
||||
}}>{sa.verdict}</Typography>
|
||||
<Typography variant="caption">Verdict</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="body1">{sa.risk_level}</Typography>
|
||||
<Typography variant="caption">Risk Level</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{/* 4 Axes */}
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>Assessment Axes</Typography>
|
||||
<GridRow columns={4}>
|
||||
{sa.publication && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Publication ({sa.formula?.publication_weight ? `${Math.round(sa.formula.publication_weight * 100)}%` : '35%'})</Typography>
|
||||
<Typography variant="h6">{sa.publication.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.publication.name || '-'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{sa.publication.source_type}</Typography>
|
||||
{sa.publication.confirmed && <Chip label="Confirmed" size="small" color="success" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
|
||||
</Paper>
|
||||
)}
|
||||
{sa.domain && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Domain ({sa.formula?.domain_weight ? `${Math.round(sa.formula.domain_weight * 100)}%` : '25%'})</Typography>
|
||||
<Typography variant="h6">{sa.domain.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.domain.name || '-'}</Typography>
|
||||
{sa.domain.is_blacklisted && <Chip label="BLACKLISTED" size="small" color="error" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
|
||||
{sa.domain.has_ssl != null && (
|
||||
<Chip label={sa.domain.has_ssl ? 'SSL' : 'No SSL'} size="small" variant="outlined" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
{sa.author && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Author ({sa.formula?.author_weight ? `${Math.round(sa.formula.author_weight * 100)}%` : '25%'})</Typography>
|
||||
<Typography variant="h6">{sa.author.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.author.name || '-'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{sa.author.classification}</Typography>
|
||||
{sa.author.confirmed && <Chip label="Confirmed" size="small" color="success" sx={{ mt: 0.5, height: 20, fontSize: 10 }} />}
|
||||
</Paper>
|
||||
)}
|
||||
{sa.platform && (
|
||||
<Paper sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Platform ({sa.formula?.platform_weight ? `${Math.round(sa.formula.platform_weight * 100)}%` : '15%'})</Typography>
|
||||
<Typography variant="h6">{sa.platform.score}</Typography>
|
||||
<Typography variant="body2" noWrap>{sa.platform.name || '-'}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</GridRow>
|
||||
{sa.formula?.breakdown && (
|
||||
<Paper sx={{ p: 1, mt: 1, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">Formula: </Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{sa.formula.breakdown}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{/* Meta info */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
|
||||
{sa.llm_model_used && <Chip label={`LLM: ${sa.llm_model_used}`} size="small" variant="outlined" />}
|
||||
{sa.search_results_count != null && (
|
||||
<Chip icon={<SearchIcon />} label={`${sa.search_results_count} search results`} size="small" variant="outlined" />
|
||||
)}
|
||||
{sa.duration_ms != null && <Chip label={formatDuration(sa.duration_ms)} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
{sa.red_flags?.length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<strong>Red Flags:</strong>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{sa.red_flags.map((flag: string, idx: number) => <li key={idx}>{flag}</li>)}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
{sa.warnings?.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<strong>Warnings:</strong>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{sa.warnings.map((w: string, idx: number) => <li key={idx}>{w}</li>)}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const LegacyDomainSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (data.source_assessment || !data.domain) return null;
|
||||
const d = data.domain;
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<LanguageIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Domain Analysis - {d.domain}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={4}>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{ color: d.trust_score >= 60 ? '#22c55e' : d.trust_score >= 30 ? '#f97316' : '#ef4444' }}>
|
||||
{d.trust_score}
|
||||
</Typography>
|
||||
<Typography variant="caption">Trust Score</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{
|
||||
color: d.verdict === 'TRUSTED' ? '#22c55e' : d.verdict === 'NEUTRAL' ? '#eab308'
|
||||
: d.verdict === 'SUSPICIOUS' ? '#f97316' : '#ef4444',
|
||||
}}>{d.verdict}</Typography>
|
||||
<Typography variant="caption">Verdict</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6">{d.age_days != null ? `${d.age_days} days` : '-'}</Typography>
|
||||
<Typography variant="caption">Age</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: d.has_ssl ? (d.ssl_valid ? '#22c55e' : '#ef4444') : '#9e9e9e' }}>
|
||||
{d.has_ssl ? (d.ssl_valid ? 'Valid' : 'Invalid') : 'None'}
|
||||
</Typography>
|
||||
<Typography variant="caption">SSL</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
|
||||
{d.risk_level && <Chip label={`Risk: ${d.risk_level}`} size="small" variant="outlined" />}
|
||||
{d.is_blacklisted && <Chip label="BLACKLISTED" size="small" color="error" />}
|
||||
{d.registrar && <Chip label={`Registrar: ${d.registrar}`} size="small" variant="outlined" />}
|
||||
{d.country && <Chip label={`Country: ${d.country}`} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisDetail } from '../types';
|
||||
import { GridRow, getRiskColor, getCategoryColor } from '../helpers';
|
||||
|
||||
export const VerdictSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => {
|
||||
if (!data.verdict) return null;
|
||||
return (
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<AssessmentIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1" fontWeight="bold">Final Verdict</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<GridRow columns={4}>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h3" sx={{ color: getRiskColor(data.verdict) }}>{data.verdict.risk_score}</Typography>
|
||||
<Typography variant="caption">Risk Score</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{ color: getCategoryColor(data.verdict) }}>{data.verdict.risk_category}</Typography>
|
||||
<Typography variant="caption">Category</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h5">{data.verdict.confidence}%</Typography>
|
||||
<Typography variant="caption">Confidence ({data.verdict.confidence_level})</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body1">{data.verdict.recommended_action}</Typography>
|
||||
<Typography variant="caption">Action</Typography>
|
||||
</Paper>
|
||||
</GridRow>
|
||||
{(data.verdict.score_manipulation != null || data.verdict.score_claims != null || data.verdict.score_ai != null || data.verdict.score_source != null || data.verdict.score_context != null || data.verdict.component_scores) && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Component Scores</Typography>
|
||||
<GridRow columns={5}>
|
||||
{data.verdict.component_scores
|
||||
? Object.entries(data.verdict.component_scores).map(([key, value]) => (
|
||||
<Paper key={key} sx={{ p: 1, textAlign: 'center' }}>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" textTransform="capitalize">{key}</Typography>
|
||||
</Paper>
|
||||
))
|
||||
: [
|
||||
{ key: 'manipulation', value: data.verdict.score_manipulation },
|
||||
{ key: 'claims', value: data.verdict.score_claims },
|
||||
{ key: 'ai', value: data.verdict.score_ai },
|
||||
{ key: 'source', value: data.verdict.score_source },
|
||||
{ key: 'context', value: data.verdict.score_context },
|
||||
].map(({ key, value }) => (
|
||||
<Paper key={key} sx={{ p: 1, textAlign: 'center' }}>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" textTransform="capitalize">{key}</Typography>
|
||||
</Paper>
|
||||
))
|
||||
}
|
||||
</GridRow>
|
||||
</Box>
|
||||
)}
|
||||
{(data.verdict.explanation_ro || data.verdict.explanation_en) && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Explanation</Typography>
|
||||
{data.verdict.explanation_ro && (
|
||||
<Paper sx={{ p: 2, mb: 1, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">RO</Typography>
|
||||
<Typography variant="body2">{data.verdict.explanation_ro}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{data.verdict.explanation_en && (
|
||||
<Paper sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="caption" color="text.secondary">EN</Typography>
|
||||
<Typography variant="body2">{data.verdict.explanation_en}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{data.verdict.override_applied && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<strong>Override Applied:</strong> {data.verdict.override_type}
|
||||
{data.verdict.override_reason && <> — {data.verdict.override_reason}</>}
|
||||
{data.verdict.override_adjustment != null && <> (adjustment: {data.verdict.override_adjustment})</>}
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* AnalysisDetailModal — types
|
||||
*/
|
||||
|
||||
export interface AnalysisDetailModalProps {
|
||||
open: boolean;
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface AnalysisDetail {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
input_type: string;
|
||||
input_text: string | null;
|
||||
input_url: string | null;
|
||||
input_media_url: string | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
total_duration_ms: number | null;
|
||||
source_app: string;
|
||||
components_run: string[] | null;
|
||||
components_skipped: string[] | null;
|
||||
verdict: any | null;
|
||||
techniques: any | null;
|
||||
ai_tampered: any | null;
|
||||
claims: any | null;
|
||||
domain: any | null;
|
||||
source_assessment: any | null;
|
||||
llm_usage: any | null;
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
input_per_1m: number;
|
||||
output_per_1m: number;
|
||||
}
|
||||
|
||||
export type PricingMap = Record<string, ModelPricing>;
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Container, Typography, Button, Alert,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
} from '@mui/material';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
import { AnalysisDetailModal } from './AnalysisDetailModal';
|
||||
import type { AnalysisSession, PaginationInfo } from './history-types';
|
||||
import { HistoryFilters } from './HistoryFilters';
|
||||
import { HistoryTable } from './HistoryTable';
|
||||
import { SocialPostModal } from './SocialPostModal';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export const AnalysisHistory: React.FC = () => {
|
||||
const [analyses, setAnalyses] = useState<AnalysisSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
|
||||
// Filters
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [riskLevelFilter, setRiskLevelFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [fromDate, setFromDate] = useState<string>('');
|
||||
const [toDate, setToDate] = useState<string>('');
|
||||
|
||||
// UI State
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<AnalysisSession | null>(null);
|
||||
const [socialPostOpen, setSocialPostOpen] = useState(false);
|
||||
const [socialPostSession, setSocialPostSession] = useState<AnalysisSession | null>(null);
|
||||
|
||||
const handleCopyId = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(id).then(() => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); });
|
||||
};
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const fetchAnalyses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
});
|
||||
|
||||
if (searchFilter) params.append('search', searchFilter);
|
||||
if (riskLevelFilter) params.append('risk_level', riskLevelFilter);
|
||||
if (statusFilter) params.append('status', statusFilter);
|
||||
if (fromDate) params.append('from_date', fromDate);
|
||||
if (toDate) params.append('to_date', toDate);
|
||||
|
||||
const response = await fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin?${params}`, {
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch analyses');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAnalyses(data.data?.items || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.data?.pagination?.total || 0,
|
||||
total_pages: data.data?.pagination?.total_pages || 0,
|
||||
has_next: data.data?.pagination?.has_next || false,
|
||||
has_prev: data.data?.pagination?.has_prev || false,
|
||||
}));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, searchFilter, riskLevelFilter, statusFilter, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalyses();
|
||||
}, [fetchAnalyses]);
|
||||
|
||||
const handlePageChange = (_event: unknown, newPage: number) => {
|
||||
setPagination(prev => ({ ...prev, page: newPage + 1 }));
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPagination(prev => ({ ...prev, limit: parseInt(event.target.value, 10), page: 1 }));
|
||||
};
|
||||
|
||||
const handleViewDetails = (sessionId: string) => {
|
||||
setSelectedSession(sessionId);
|
||||
setDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (session: AnalysisSession) => {
|
||||
setSessionToDelete(session);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!sessionToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionToDelete.session_id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete analysis');
|
||||
}
|
||||
|
||||
fetchAnalyses();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setSessionToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (session: AnalysisSession) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// user_id sent for staging fallback; JWT identity used when present.
|
||||
body: JSON.stringify({ user_id: session.user_id }),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || 'Failed to cancel analysis');
|
||||
}
|
||||
fetchAnalyses();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async (session: AnalysisSession) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/resume`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: session.user_id }),
|
||||
}
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
// 409 = expired payload / already complete — surface the reason.
|
||||
throw new Error(body.error || 'Failed to resume analysis');
|
||||
}
|
||||
fetchAnalyses();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchFilter('');
|
||||
setRiskLevelFilter('');
|
||||
setStatusFilter('');
|
||||
setFromDate('');
|
||||
setToDate('');
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchFilter || riskLevelFilter || statusFilter || fromDate || toDate;
|
||||
|
||||
// Filter changes reset page to 1
|
||||
const onSearchChange = (v: string) => { setSearchFilter(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onRiskLevelChange = (v: string) => { setRiskLevelFilter(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onStatusChange = (v: string) => { setStatusFilter(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onFromDateChange = (v: string) => { setFromDate(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
const onToDateChange = (v: string) => { setToDate(v); setPagination(p => ({ ...p, page: 1 })); };
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>Analysis History</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Browse and manage analysis sessions
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<HistoryFilters
|
||||
searchFilter={searchFilter}
|
||||
riskLevelFilter={riskLevelFilter}
|
||||
statusFilter={statusFilter}
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onSearchChange={onSearchChange}
|
||||
onRiskLevelChange={onRiskLevelChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onFromDateChange={onFromDateChange}
|
||||
onToDateChange={onToDateChange}
|
||||
onRefresh={fetchAnalyses}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<HistoryTable
|
||||
loading={loading}
|
||||
analyses={analyses}
|
||||
pagination={pagination}
|
||||
copiedId={copiedId}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
onViewDetails={handleViewDetails}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onCancelClick={handleCancel}
|
||||
onResumeClick={handleResume}
|
||||
onSocialPostClick={(s) => { setSocialPostSession(s); setSocialPostOpen(true); }}
|
||||
onCopyId={handleCopyId}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedSession && (
|
||||
<AnalysisDetailModal
|
||||
open={detailModalOpen}
|
||||
sessionId={selectedSession}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedSession(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Social Post Modal */}
|
||||
<SocialPostModal
|
||||
open={socialPostOpen}
|
||||
session={socialPostSession}
|
||||
onClose={() => {
|
||||
setSocialPostOpen(false);
|
||||
setSocialPostSession(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete Analysis</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Are you sure you want to delete this analysis?</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Session: {sessionToDelete?.session_id}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, Button, TextField, InputAdornment,
|
||||
FormControl, InputLabel, Select, MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Refresh as RefreshIcon,
|
||||
FilterAltOff as ClearIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Props {
|
||||
searchFilter: string;
|
||||
riskLevelFilter: string;
|
||||
statusFilter: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
hasActiveFilters: string | boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRiskLevelChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onFromDateChange: (value: string) => void;
|
||||
onToDateChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export const HistoryFilters: React.FC<Props> = ({
|
||||
searchFilter,
|
||||
riskLevelFilter,
|
||||
statusFilter,
|
||||
fromDate,
|
||||
toDate,
|
||||
hasActiveFilters,
|
||||
onSearchChange,
|
||||
onRiskLevelChange,
|
||||
onStatusChange,
|
||||
onFromDateChange,
|
||||
onToDateChange,
|
||||
onRefresh,
|
||||
onClearFilters,
|
||||
}) => (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
placeholder="Search by email or user..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 220 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<InputLabel>Risk Level</InputLabel>
|
||||
<Select
|
||||
value={riskLevelFilter}
|
||||
onChange={(e) => onRiskLevelChange(e.target.value)}
|
||||
label="Risk Level"
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="VERY_LOW">Very Low</MenuItem>
|
||||
<MenuItem value="LOW">Low</MenuItem>
|
||||
<MenuItem value="MEDIUM">Medium</MenuItem>
|
||||
<MenuItem value="HIGH">High</MenuItem>
|
||||
<MenuItem value="VERY_HIGH">Very High</MenuItem>
|
||||
<MenuItem value="CRITICAL">Critical</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
<MenuItem value="running">Running</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="From"
|
||||
type="date"
|
||||
size="small"
|
||||
value={fromDate}
|
||||
onChange={(e) => onFromDateChange(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="To"
|
||||
type="date"
|
||||
size="small"
|
||||
value={toDate}
|
||||
onChange={(e) => onToDateChange(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onRefresh}
|
||||
size="medium"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
startIcon={<ClearIcon />}
|
||||
onClick={onClearFilters}
|
||||
size="medium"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Chip, IconButton, Tooltip, LinearProgress,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
History as HistoryIcon,
|
||||
Delete as DeleteIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Cancel as CancelIcon,
|
||||
Replay as ResumeIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AnalysisSession, PaginationInfo } from './history-types';
|
||||
import { getRiskIcon, getRiskCategoryColor, formatDuration, formatDate } from './history-helpers';
|
||||
|
||||
// Sessions still in flight can be canceled (agent-v3 /:id/cancel).
|
||||
const CANCELABLE = new Set(['pending', 'running', 'processing']);
|
||||
// Failed/canceled sessions can be resumed from checkpoint (/:id/resume).
|
||||
const RESUMABLE = new Set(['failed', 'canceled']);
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
analyses: AnalysisSession[];
|
||||
pagination: PaginationInfo;
|
||||
copiedId: string | null;
|
||||
onPageChange: (event: unknown, newPage: number) => void;
|
||||
onRowsPerPageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onViewDetails: (sessionId: string) => void;
|
||||
onDeleteClick: (session: AnalysisSession) => void;
|
||||
onCopyId: (e: React.MouseEvent, id: string) => void;
|
||||
onSocialPostClick?: (session: AnalysisSession) => void;
|
||||
onCancelClick?: (session: AnalysisSession) => void;
|
||||
onResumeClick?: (session: AnalysisSession) => void;
|
||||
}
|
||||
|
||||
export const HistoryTable: React.FC<Props> = ({
|
||||
loading,
|
||||
analyses,
|
||||
pagination,
|
||||
copiedId,
|
||||
onPageChange,
|
||||
onRowsPerPageChange,
|
||||
onViewDetails,
|
||||
onDeleteClick,
|
||||
onCopyId,
|
||||
onSocialPostClick,
|
||||
onCancelClick,
|
||||
onResumeClick,
|
||||
}) => (
|
||||
<Paper>
|
||||
{loading && <LinearProgress />}
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Session ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Date</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Email</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Source</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Risk</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Techniques</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>AI %</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Claims</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Duration</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!loading && analyses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
|
||||
<HistoryIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography color="text.secondary">No analyses found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
analyses.map((analysis) => (
|
||||
<TableRow
|
||||
key={analysis.session_id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => onViewDetails(analysis.session_id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Tooltip title={analysis.session_id}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{analysis.session_id.substring(0, 8)}...
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Tooltip title={copiedId === analysis.session_id ? 'Copied!' : 'Copy'}>
|
||||
<IconButton size="small" onClick={(e) => onCopyId(e, analysis.session_id)}>
|
||||
<CopyIcon sx={{ fontSize: 14, color: copiedId === analysis.session_id ? 'success.main' : 'action.active' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(analysis.started_at)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{analysis.input_type}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={`User ID: ${analysis.user_id}`}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 180,
|
||||
}}
|
||||
>
|
||||
{analysis.user_email || <span style={{ color: '#999' }}>No email</span>}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
label={analysis.input_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={analysis.input_type === 'url' ? 'primary' : analysis.input_type === 'text' ? 'default' : 'secondary'}
|
||||
sx={{ textTransform: 'uppercase', fontSize: 11, height: 22 }}
|
||||
/>
|
||||
<Chip label={analysis.source_app} size="small" variant="outlined" sx={{ fontSize: 11, height: 22 }} />
|
||||
</Box>
|
||||
{(analysis.source_verdict || analysis.domain) && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.3 }}>
|
||||
{analysis.source_publication || analysis.domain || ''}
|
||||
{(analysis.source_verdict || analysis.domain_verdict) && (
|
||||
<Chip
|
||||
label={analysis.source_verdict || analysis.domain_verdict}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 0.5, height: 18, fontSize: 10,
|
||||
bgcolor: (analysis.source_verdict || analysis.domain_verdict) === 'TRUSTED' ? '#22c55e'
|
||||
: (analysis.source_verdict || analysis.domain_verdict) === 'SUSPICIOUS' ? '#f97316'
|
||||
: (analysis.source_verdict || analysis.domain_verdict) === 'UNTRUSTED' ? '#ef4444' : undefined,
|
||||
color: (analysis.source_verdict || analysis.domain_verdict) !== 'NEUTRAL' ? '#fff' : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.risk_score !== null ? (
|
||||
<Tooltip title={`${analysis.risk_category} - ${analysis.risk_level}`}>
|
||||
<Chip
|
||||
icon={getRiskIcon(analysis.risk_level)}
|
||||
label={`${analysis.risk_score} ${analysis.risk_category || ''}`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getRiskCategoryColor(analysis.risk_category),
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': { color: '#fff' },
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Chip label="-" size="small" variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.techniques_count !== null ? (
|
||||
<Chip
|
||||
label={analysis.techniques_count}
|
||||
size="small"
|
||||
color={Number(analysis.techniques_count) > 0 ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.ai_probability !== null ? (
|
||||
<Tooltip title={analysis.ai_verdict || ''}>
|
||||
<Chip
|
||||
label={`${Math.round(Number(analysis.ai_probability))}%`}
|
||||
size="small"
|
||||
color={Number(analysis.ai_probability) > 50 ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{analysis.total_claims !== null ? (
|
||||
<Tooltip title={`True: ${analysis.verified_true}, False: ${analysis.verified_false}`}>
|
||||
<Typography variant="body2">
|
||||
{analysis.total_claims}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDuration(analysis.total_duration_ms)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center" onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onViewDetails(analysis.session_id)}
|
||||
title="View details"
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{onSocialPostClick && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSocialPostClick(analysis)}
|
||||
title="Post to Facebook"
|
||||
sx={{ color: '#1877F2' }}
|
||||
>
|
||||
<FacebookIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{onCancelClick && CANCELABLE.has(analysis.status) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCancelClick(analysis)}
|
||||
title="Cancel running analysis"
|
||||
color="warning"
|
||||
>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{onResumeClick && RESUMABLE.has(analysis.status) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onResumeClick(analysis)}
|
||||
title="Resume from checkpoint (re-runs only unfinished components)"
|
||||
color="primary"
|
||||
>
|
||||
<ResumeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDeleteClick(analysis)}
|
||||
title="Delete"
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={pagination.total}
|
||||
page={pagination.page - 1}
|
||||
onPageChange={onPageChange}
|
||||
rowsPerPage={pagination.limit}
|
||||
onRowsPerPageChange={onRowsPerPageChange}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Box, TextField, Button, Alert, Chip, CircularProgress,
|
||||
Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Facebook as FacebookIcon, Close as CloseIcon,
|
||||
Send as SendIcon, Schedule as ScheduleIcon, OpenInNew as OpenInNewIcon,
|
||||
Visibility as PreviewIcon,
|
||||
} from '@mui/icons-material';
|
||||
import axios from 'axios';
|
||||
import type { AnalysisSession } from './history-types';
|
||||
|
||||
const FRAMEWORK_BASE = process.env.REACT_APP_FRAMEWORK_URL || '/framework';
|
||||
const AGENT_BASE = process.env.REACT_APP_AGENT_V3_URL || '/agent-v3';
|
||||
const SHARE_BASE = 'https://didi365.eu/share';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'editing' | 'publishing' | 'published' | 'error';
|
||||
|
||||
type Block = {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
content: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
session: AnalysisSession | null;
|
||||
onSuccess?: (postId: string, externalUrl: string) => void;
|
||||
}
|
||||
|
||||
const buildBlocks = (detail: any): Block[] => {
|
||||
const risk = detail.risk_category || 'NEUTRAL';
|
||||
const score = detail.risk_score ?? 0;
|
||||
const emoji =
|
||||
score >= 80 ? '🚨' : score >= 60 ? '⚠️' : score >= 40 ? 'ℹ️' : '✅';
|
||||
|
||||
const tech = detail.techniques || {};
|
||||
const claims = detail.claims || {};
|
||||
const ai = detail.ai_tampered || {};
|
||||
const domain = detail.domain || {};
|
||||
const verdict = detail.verdict || {};
|
||||
|
||||
const techNames: string = (tech.techniques_detected || [])
|
||||
.map((t: any) => t.name_ro || t.name_en || t.name)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(', ');
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: 'header',
|
||||
label: 'Antet (verdict + scor)',
|
||||
enabled: true,
|
||||
content: `${emoji} DiDi · ${risk} · Scor risc ${score}/100`,
|
||||
},
|
||||
{
|
||||
id: 'verdict',
|
||||
label: 'Explicație verdict',
|
||||
enabled: !!verdict.explanation_ro,
|
||||
content: (verdict.explanation_ro || verdict.explanation_en || '').trim(),
|
||||
},
|
||||
{
|
||||
id: 'techniques',
|
||||
label: 'Tehnici de manipulare detectate',
|
||||
enabled: (tech.techniques_count || 0) > 0,
|
||||
content: techNames
|
||||
? `Tehnici de manipulare detectate (${tech.techniques_count}): ${techNames}\nScor manipulare: ${tech.manipulation_score ?? 0}/100`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'claims',
|
||||
label: 'Afirmații verificate',
|
||||
enabled: (claims.total_claims || 0) > 0,
|
||||
content: claims.total_claims
|
||||
? `Afirmații verificate: ${claims.verified_true || 0} confirmate, ${claims.verified_false || 0} false (credibilitate ${claims.credibility_score ?? 0}/100)`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'Detectare conținut AI',
|
||||
enabled: (ai.ai_probability || 0) >= 50,
|
||||
content:
|
||||
typeof ai.ai_probability === 'number'
|
||||
? `Probabilitate generat AI: ${ai.ai_probability}% (${ai.verdict || 'N/A'})`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
label: 'Sursă / Domeniu',
|
||||
enabled: !!(domain.domain && domain.domain_verdict),
|
||||
content: domain.domain
|
||||
? `Sursă: ${domain.domain} · ${domain.domain_verdict || ''} (trust ${domain.domain_trust_score ?? 0}/100)`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
id: 'hashtags',
|
||||
label: 'Hashtags',
|
||||
enabled: true,
|
||||
content: '#DiDi #Antifake #VerificatDeDiDi #DESI',
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
label: 'Link analiză publică',
|
||||
enabled: false,
|
||||
content: detail.session_id
|
||||
? `Vezi analiza completă: ${SHARE_BASE}/${detail.session_id}`
|
||||
: '',
|
||||
},
|
||||
];
|
||||
return blocks.filter((b) => b.content || b.id === 'header' || b.id === 'link');
|
||||
};
|
||||
|
||||
const joinBlocks = (blocks: Block[]): string =>
|
||||
blocks
|
||||
.filter((b) => b.enabled && b.content.trim())
|
||||
.map((b) => b.content.trim())
|
||||
.join('\n\n');
|
||||
|
||||
export const SocialPostModal: React.FC<Props> = ({ open, onClose, session, onSuccess }) => {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [postId, setPostId] = useState<string | null>(null);
|
||||
const [externalUrl, setExternalUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !session) return;
|
||||
setStatus('loading');
|
||||
setBlocks([]);
|
||||
setImageUrl('');
|
||||
setLinkUrl(session.session_id ? `${SHARE_BASE}/${session.session_id}` : '');
|
||||
setScheduledAt('');
|
||||
setError(null);
|
||||
setPostId(null);
|
||||
setExternalUrl(null);
|
||||
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
axios
|
||||
.get(`${AGENT_BASE}/api/v3/pipeline/history/admin/${session.session_id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => {
|
||||
const detail = res.data?.data || res.data;
|
||||
if (!detail) throw new Error('Răspuns gol de la API');
|
||||
setBlocks(buildBlocks(detail));
|
||||
setStatus('editing');
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e?.response?.data?.error || e.message);
|
||||
setStatus('error');
|
||||
});
|
||||
}, [open, session]);
|
||||
|
||||
const previewText = useMemo(() => joinBlocks(blocks), [blocks]);
|
||||
const charCount = previewText.length;
|
||||
|
||||
const updateBlock = (id: string, patch: Partial<Block>) => {
|
||||
setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b)));
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!previewText.trim()) {
|
||||
setError('Niciun bloc bifat sau toate sunt goale');
|
||||
return;
|
||||
}
|
||||
setStatus('publishing');
|
||||
setError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const draftRes = await axios.post(
|
||||
`${FRAMEWORK_BASE}/api/admin/social/draft`,
|
||||
{
|
||||
session_id: session?.session_id,
|
||||
content: previewText,
|
||||
image_url: imageUrl || undefined,
|
||||
link_url: linkUrl || undefined,
|
||||
platform: 'facebook',
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const createdId = draftRes.data?.data?.post_id?.toString();
|
||||
if (!createdId) throw new Error(draftRes.data?.error || 'Salvare draft eșuată');
|
||||
setPostId(createdId);
|
||||
|
||||
const pubRes = await axios.post(
|
||||
`${FRAMEWORK_BASE}/api/admin/social/publish/${createdId}`,
|
||||
scheduledAt ? { scheduled_at: new Date(scheduledAt).toISOString() } : {},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (pubRes.data?.success && pubRes.data.data) {
|
||||
setExternalUrl(pubRes.data.data.external_url || null);
|
||||
setStatus('published');
|
||||
if (onSuccess) onSuccess(pubRes.data.data.post_id, pubRes.data.data.external_url);
|
||||
} else {
|
||||
throw new Error(pubRes.data?.error || 'Publishing failed');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.error || e?.message || 'Publishing failed');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const minScheduleDate = new Date(Date.now() + 11 * 60 * 1000).toISOString().slice(0, 16);
|
||||
const enabledCount = blocks.filter((b) => b.enabled && b.content.trim()).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
Post to Facebook
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{status === 'loading' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 3 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Se încarcă datele analizei...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(status === 'editing' || status === 'publishing' || status === 'error') && (
|
||||
<Stack spacing={2}>
|
||||
{session && (
|
||||
<Alert severity="info">
|
||||
Analiza <code style={{ fontSize: '0.85em' }}>{session.session_id.slice(0, 8)}...</code>
|
||||
{session.risk_score !== null && session.risk_score !== undefined && (
|
||||
<> · Risc: <strong>{session.risk_score}</strong> ({session.risk_category})</>
|
||||
)}
|
||||
{' '}— bifează blocurile pe care vrei să le incluzi în post și editează textul.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Blocuri ({enabledCount} active din {blocks.length})
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
{blocks.map((block) => (
|
||||
<Paper
|
||||
key={block.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: block.enabled ? 'background.paper' : 'action.disabledBackground',
|
||||
opacity: block.enabled ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={block.enabled}
|
||||
onChange={(e) => updateBlock(block.id, { enabled: e.target.checked })}
|
||||
disabled={status === 'publishing'}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{block.label}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
value={block.content}
|
||||
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={!block.enabled || status === 'publishing'}
|
||||
placeholder="(gol — nu va apărea în post)"
|
||||
/>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PreviewIcon fontSize="small" color="action" />
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Preview post · {charCount} caractere
|
||||
{charCount > 500 && (
|
||||
<Chip label="Lung" color="warning" size="small" sx={{ ml: 1, height: 18 }} />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: '#f5f7fa',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '0.9em',
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{previewText || <em style={{ color: '#999' }}>(post gol — bifează cel puțin un bloc)</em>}
|
||||
</Paper>
|
||||
|
||||
<TextField
|
||||
label="URL imagine (opțional — atașament foto)"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={status === 'publishing'}
|
||||
placeholder="https://didi365.eu/share/image.png"
|
||||
/>
|
||||
<TextField
|
||||
label="URL link (opțional — link clickable atașat la post)"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={status === 'publishing'}
|
||||
placeholder="https://didi365.eu/analyses/..."
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
icon={<ScheduleIcon />}
|
||||
label={
|
||||
scheduledAt
|
||||
? `Programat: ${new Date(scheduledAt).toLocaleString('ro-RO')}`
|
||||
: 'Publicare imediată'
|
||||
}
|
||||
color={scheduledAt ? 'warning' : 'default'}
|
||||
onDelete={scheduledAt ? () => setScheduledAt('') : undefined}
|
||||
/>
|
||||
<Tooltip title="Programează publicarea (min 10 min în viitor)">
|
||||
<TextField
|
||||
type="datetime-local"
|
||||
size="small"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
inputProps={{ min: minScheduleDate }}
|
||||
disabled={status === 'publishing'}
|
||||
sx={{ width: 220 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{status === 'published' && (
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
<Alert severity="success">
|
||||
{scheduledAt ? '✓ Programat cu success pe Facebook!' : '✓ Publicat cu success pe Facebook!'}
|
||||
</Alert>
|
||||
{externalUrl && (
|
||||
<Button
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Deschide pe Facebook
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{status === 'published' ? (
|
||||
<Button onClick={onClose} variant="contained">
|
||||
Închide
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onClose} disabled={status === 'publishing'}>
|
||||
Anulează
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={
|
||||
status === 'publishing' ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<SendIcon />
|
||||
)
|
||||
}
|
||||
onClick={handlePublish}
|
||||
disabled={status !== 'editing' || !previewText.trim()}
|
||||
sx={{ bgcolor: '#1877F2', '&:hover': { bgcolor: '#0e5fc8' } }}
|
||||
>
|
||||
{status === 'publishing'
|
||||
? 'Se publică...'
|
||||
: scheduledAt
|
||||
? 'Programează'
|
||||
: 'Publică pe Facebook'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* AnalysisHistory — color helpers + formatters
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export const getRiskColor = (level: string | null): 'success' | 'warning' | 'error' | 'default' => {
|
||||
switch (level) {
|
||||
case 'VERY_LOW':
|
||||
case 'LOW':
|
||||
return 'success';
|
||||
case 'MEDIUM':
|
||||
case 'HIGH':
|
||||
return 'warning';
|
||||
case 'VERY_HIGH':
|
||||
case 'CRITICAL':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRiskIcon = (level: string | null): React.ReactElement | undefined => {
|
||||
switch (level) {
|
||||
case 'VERY_LOW':
|
||||
case 'LOW':
|
||||
return <CheckCircleIcon fontSize="small" />;
|
||||
case 'MEDIUM':
|
||||
return <InfoIcon fontSize="small" />;
|
||||
case 'HIGH':
|
||||
case 'VERY_HIGH':
|
||||
return <WarningIcon fontSize="small" />;
|
||||
case 'CRITICAL':
|
||||
return <ErrorIcon fontSize="small" />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRiskCategoryColor = (category: string | null): string => {
|
||||
switch (category) {
|
||||
case 'RELIABLE': return '#22c55e';
|
||||
case 'MOSTLY_RELIABLE': return '#84cc16';
|
||||
case 'MIXED': return '#eab308';
|
||||
case 'QUESTIONABLE': return '#f97316';
|
||||
case 'UNRELIABLE': return '#ef4444';
|
||||
case 'DISINFORMATION': return '#7f1d1d';
|
||||
default: return '#9e9e9e';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number | null) => {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ro-RO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* AnalysisHistory — types
|
||||
*/
|
||||
|
||||
export interface AnalysisSession {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
input_type: string;
|
||||
input_preview: string | null;
|
||||
input_url: string | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
total_duration_ms: number | null;
|
||||
source_app: string;
|
||||
risk_score: number | null;
|
||||
risk_category: string | null;
|
||||
risk_level: string | null;
|
||||
confidence: number | null;
|
||||
confidence_level: string | null;
|
||||
techniques_score: string | null;
|
||||
techniques_count: number | null;
|
||||
ai_probability: string | null;
|
||||
ai_verdict: string | null;
|
||||
total_claims: number | null;
|
||||
verified_true: number | null;
|
||||
verified_false: number | null;
|
||||
claims_score: string | null;
|
||||
domain: string | null;
|
||||
domain_verdict: string | null;
|
||||
domain_trust_score: number | null;
|
||||
source_trust_score: number | null;
|
||||
source_verdict: string | null;
|
||||
source_publication: string | null;
|
||||
source_author: string | null;
|
||||
source_platform: string | null;
|
||||
llm_usage: any | null;
|
||||
}
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { AnalysisHistory } from './AnalysisHistory';
|
||||
export { AnalysisDetailModal } from './AnalysisDetailModal';
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* VerdictConfig — fetch wrappers
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComponentWeight,
|
||||
Multiplier,
|
||||
VerdictCategory,
|
||||
RiskMapping,
|
||||
SeverityAssessment,
|
||||
VerdictConfigData,
|
||||
} from './types';
|
||||
|
||||
const FRAMEWORK_API = '/framework/api';
|
||||
const AGENT_API = '/agent-v3/api/v3/pipeline';
|
||||
|
||||
export async function fetchWeights() {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/all`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchVerdicts() {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/all`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchVerdictConfig(): Promise<{ success: boolean; data: VerdictConfigData | null }> {
|
||||
const res = await fetch(`${AGENT_API}/verdict-config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateComponentWeight(id: number, data: Partial<ComponentWeight>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/components/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createMultiplier(data: Partial<Multiplier>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateMultiplier(id: number, data: Partial<Multiplier>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteMultiplier(id: number) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateVerdictCategory(id: number, data: Partial<VerdictCategory>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/categories/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRiskMapping(id: number, data: Partial<RiskMapping>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/risk/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateSeverity(id: string, data: Partial<SeverityAssessment>) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/severity/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchRuntimeConfig(): Promise<{ success: boolean; data: VerdictConfigData | null; error?: string }> {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRuntimeConfig(data: VerdictConfigData) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function syncRedis() {
|
||||
const res = await fetch(`${FRAMEWORK_API}/sync-redis`, { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Slider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||
} from '@mui/material';
|
||||
import type {
|
||||
ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment,
|
||||
DeleteDialogState,
|
||||
} from '../types';
|
||||
|
||||
type DialogState<T> = { open: boolean; data: T | null };
|
||||
|
||||
interface WeightDialogProps {
|
||||
state: DialogState<ComponentWeight>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<ComponentWeight>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const WeightDialog: React.FC<WeightDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Component Weight</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Component" value={state.data.component_name} disabled fullWidth />
|
||||
<Box>
|
||||
<Typography gutterBottom>Weight: {state.data.component_weight}%</Typography>
|
||||
<Slider
|
||||
value={state.data.component_weight}
|
||||
onChange={(_, v) => setState({ ...state, data: { ...state.data!, component_weight: v as number } })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
marks={[{ value: 0, label: '0%' }, { value: 25, label: '25%' }, { value: 50, label: '50%' }, { value: 75, label: '75%' }, { value: 100, label: '100%' }]}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={state.data.description}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface MultiplierDialogProps {
|
||||
state: DialogState<Partial<Multiplier>>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<Partial<Multiplier>>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const MultiplierDialog: React.FC<MultiplierDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{state.data?.multiplier_id ? 'Edit Multiplier' : 'Add Topic Multiplier'}</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={state.data.multiplier_name || ''}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, multiplier_name: e.target.value } })}
|
||||
fullWidth
|
||||
required
|
||||
placeholder="e.g. elections, health, geopolitics"
|
||||
/>
|
||||
<Box>
|
||||
<Typography gutterBottom>Multiplier: {(state.data.multiplier || 1).toFixed(2)}x</Typography>
|
||||
<Slider
|
||||
value={state.data.multiplier || 1}
|
||||
onChange={(_, v) => setState({ ...state, data: { ...state.data!, multiplier: v as number } })}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.05}
|
||||
marks={[{ value: 0.5, label: '0.5x' }, { value: 1, label: '1x' }, { value: 1.5, label: '1.5x' }, { value: 2, label: '2x' }]}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={state.data.description || ''}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!state.data?.multiplier_name}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface CategoryDialogProps {
|
||||
state: DialogState<VerdictCategory>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<VerdictCategory>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const CategoryDialog: React.FC<CategoryDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Verdict Category</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Code" value={state.data.verdict_category_code} onChange={(e) => setState({ ...state, data: { ...state.data!, verdict_category_code: e.target.value } })} fullWidth />
|
||||
<TextField label="Description" value={state.data.description} onChange={(e) => setState({ ...state, data: { ...state.data!, description: e.target.value } })} fullWidth />
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Start Range" type="number" value={state.data.start_range} onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
<TextField label="End Range" type="number" value={state.data.end_range} onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<TextField label="Color" value={state.data.verdict_category_color} onChange={(e) => setState({ ...state, data: { ...state.data!, verdict_category_color: e.target.value } })} fullWidth InputProps={{ startAdornment: <Box sx={{ width: 24, height: 24, bgcolor: state.data.verdict_category_color, borderRadius: 1, mr: 1 }} /> }} />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface RiskDialogProps {
|
||||
state: DialogState<RiskMapping>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<RiskMapping>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const RiskDialog: React.FC<RiskDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Risk Mapping</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Risk Level Name" value={state.data.risk_mapping} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_mapping: e.target.value } })} fullWidth />
|
||||
<TextField label="Level Value" type="number" value={state.data.risk_level} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_level: Number(e.target.value) } })} fullWidth />
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Start Range" type="number" value={state.data.start_range} onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
<TextField label="End Range" type="number" value={state.data.end_range} onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<TextField label="Color" value={state.data.risk_color} onChange={(e) => setState({ ...state, data: { ...state.data!, risk_color: e.target.value } })} fullWidth InputProps={{ startAdornment: <Box sx={{ width: 24, height: 24, bgcolor: state.data.risk_color, borderRadius: 1, mr: 1 }} /> }} />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface SeverityDialogProps {
|
||||
state: DialogState<SeverityAssessment>;
|
||||
setState: React.Dispatch<React.SetStateAction<DialogState<SeverityAssessment>>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const SeverityDialog: React.FC<SeverityDialogProps> = ({ state, setState, onSave }) => (
|
||||
<Dialog open={state.open} onClose={() => setState({ open: false, data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Severity Level</DialogTitle>
|
||||
<DialogContent>
|
||||
{state.data && (
|
||||
<Box pt={1} display="flex" flexDirection="column" gap={2}>
|
||||
<TextField label="Severity ID" value={state.data.severity_id} disabled fullWidth helperText="Primary key — cannot be changed" />
|
||||
<TextField
|
||||
label="Severity Category"
|
||||
value={state.data.severity_category}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, severity_category: e.target.value } })}
|
||||
fullWidth
|
||||
helperText="LOW / MEDIUM / HIGH / CRITICAL"
|
||||
/>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField
|
||||
label="Start Range"
|
||||
type="number"
|
||||
value={state.data.start_range}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End Range"
|
||||
type="number"
|
||||
value={state.data.end_range}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Recommended Action"
|
||||
value={state.data.recomended_action}
|
||||
onChange={(e) => setState({ ...state, data: { ...state.data!, recomended_action: e.target.value } })}
|
||||
fullWidth
|
||||
helperText="MONITOR / REVIEW / ESCALATE / URGENT"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save & Sync</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
interface DeleteDialogProps {
|
||||
state: DeleteDialogState | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteDialog: React.FC<DeleteDialogProps> = ({ state, onCancel, onConfirm }) => (
|
||||
<Dialog open={!!state} onClose={onCancel}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Are you sure you want to delete <strong>{state?.name}</strong>?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={onConfirm}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||
FormControl, InputLabel, Select, MenuItem,
|
||||
} from '@mui/material';
|
||||
import type { VerdictConfigData } from '../types';
|
||||
|
||||
interface Props {
|
||||
state: { open: boolean; data: VerdictConfigData | null };
|
||||
setState: React.Dispatch<React.SetStateAction<{ open: boolean; data: VerdictConfigData | null }>>;
|
||||
updateField: (path: string[], value: unknown) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const RuntimeConfigDialog: React.FC<Props> = ({ state, setState, updateField, onSave }) => (
|
||||
<Dialog
|
||||
open={state.open}
|
||||
onClose={() => setState({ open: false, data: null })}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Edit Verdict Runtime Config</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{state.data && (
|
||||
<Box display="flex" flexDirection="column" gap={3}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Synergy Bonus</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select
|
||||
label="Enabled"
|
||||
value={state.data.synergy.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['synergy', 'enabled'], e.target.value === 'true')}
|
||||
>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold (component score)" type="number" size="small"
|
||||
value={state.data.synergy.threshold}
|
||||
onChange={(e) => updateField(['synergy', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus per component" type="number" size="small"
|
||||
value={state.data.synergy.bonus_per_component}
|
||||
onChange={(e) => updateField(['synergy', 'bonus_per_component'], Number(e.target.value))} />
|
||||
<TextField label="Max bonus" type="number" size="small"
|
||||
value={state.data.synergy.max_bonus}
|
||||
onChange={(e) => updateField(['synergy', 'max_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: False Claims</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.false_claims.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold" type="number" size="small"
|
||||
value={state.data.overrides.false_claims.threshold}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus per claim" type="number" size="small"
|
||||
value={state.data.overrides.false_claims.bonus_per_claim}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'bonus_per_claim'], Number(e.target.value))} />
|
||||
<TextField label="Max bonus" type="number" size="small"
|
||||
value={state.data.overrides.false_claims.max_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'false_claims', 'max_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Severe Techniques</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.severe_techniques.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'severe_techniques', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold" type="number" size="small"
|
||||
value={state.data.overrides.severe_techniques.threshold}
|
||||
onChange={(e) => updateField(['overrides', 'severe_techniques', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus" type="number" size="small"
|
||||
value={state.data.overrides.severe_techniques.bonus}
|
||||
onChange={(e) => updateField(['overrides', 'severe_techniques', 'bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Undisclosed AI</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.undisclosed_ai.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'undisclosed_ai', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Bonus" type="number" size="small"
|
||||
value={state.data.overrides.undisclosed_ai.bonus}
|
||||
onChange={(e) => updateField(['overrides', 'undisclosed_ai', 'bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Untrusted Domain</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.untrusted_domain.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Untrusted bonus" type="number" size="small"
|
||||
value={state.data.overrides.untrusted_domain.untrusted_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'untrusted_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Suspicious bonus" type="number" size="small"
|
||||
value={state.data.overrides.untrusted_domain.suspicious_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'suspicious_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Blacklisted bonus" type="number" size="small"
|
||||
value={state.data.overrides.untrusted_domain.blacklisted_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'untrusted_domain', 'blacklisted_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>Override: Domain Red Flags</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Enabled</InputLabel>
|
||||
<Select label="Enabled"
|
||||
value={state.data.overrides.domain_red_flags.enabled ? 'true' : 'false'}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'enabled'], e.target.value === 'true')}>
|
||||
<MenuItem value="true">ON</MenuItem>
|
||||
<MenuItem value="false">OFF</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Threshold" type="number" size="small"
|
||||
value={state.data.overrides.domain_red_flags.threshold}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'threshold'], Number(e.target.value))} />
|
||||
<TextField label="Bonus per flag" type="number" size="small"
|
||||
value={state.data.overrides.domain_red_flags.bonus_per_flag}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'bonus_per_flag'], Number(e.target.value))} />
|
||||
<TextField label="Max bonus" type="number" size="small"
|
||||
value={state.data.overrides.domain_red_flags.max_bonus}
|
||||
onChange={(e) => updateField(['overrides', 'domain_red_flags', 'max_bonus'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Confidence Bonuses</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
|
||||
<TextField label="Base per component" type="number" size="small"
|
||||
value={state.data.confidence.base_per_component}
|
||||
onChange={(e) => updateField(['confidence', 'base_per_component'], Number(e.target.value))} />
|
||||
<TextField label="Domain strong signal" type="number" size="small"
|
||||
value={state.data.confidence.domain_strong_signal_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'domain_strong_signal_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Domain weak signal" type="number" size="small"
|
||||
value={state.data.confidence.domain_weak_signal_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'domain_weak_signal_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Techniques bonus max" type="number" size="small"
|
||||
value={state.data.confidence.techniques_bonus_max}
|
||||
onChange={(e) => updateField(['confidence', 'techniques_bonus_max'], Number(e.target.value))} />
|
||||
<TextField label="AI HIGH conf bonus" type="number" size="small"
|
||||
value={state.data.confidence.ai_high_confidence_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'ai_high_confidence_bonus'], Number(e.target.value))} />
|
||||
<TextField label="AI MEDIUM conf bonus" type="number" size="small"
|
||||
value={state.data.confidence.ai_medium_confidence_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'ai_medium_confidence_bonus'], Number(e.target.value))} />
|
||||
<TextField label="AI LOW conf bonus" type="number" size="small"
|
||||
value={state.data.confidence.ai_low_confidence_bonus}
|
||||
onChange={(e) => updateField(['confidence', 'ai_low_confidence_bonus'], Number(e.target.value))} />
|
||||
<TextField label="Claims verified bonus max" type="number" size="small"
|
||||
value={state.data.confidence.claims_verified_bonus_max}
|
||||
onChange={(e) => updateField(['confidence', 'claims_verified_bonus_max'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>Confidence Levels (min thresholds)</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(3, 1fr)" gap={2}>
|
||||
<TextField label="HIGH min" type="number" size="small"
|
||||
value={state.data.confidence_levels.HIGH.min}
|
||||
onChange={(e) => updateField(['confidence_levels', 'HIGH', 'min'], Number(e.target.value))} />
|
||||
<TextField label="MEDIUM min" type="number" size="small"
|
||||
value={state.data.confidence_levels.MEDIUM.min}
|
||||
onChange={(e) => updateField(['confidence_levels', 'MEDIUM', 'min'], Number(e.target.value))} />
|
||||
<TextField label="LOW min" type="number" size="small"
|
||||
value={state.data.confidence_levels.LOW.min}
|
||||
onChange={(e) => updateField(['confidence_levels', 'LOW', 'min'], Number(e.target.value))} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setState({ open: false, data: null })}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave}>Save & Sync</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* VerdictConfig — shared visual helpers
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Tooltip, Chip } from '@mui/material';
|
||||
|
||||
export const SEVERITY_COLORS: Record<string, string> = {
|
||||
LOW: '#4caf50',
|
||||
MEDIUM: '#ff9800',
|
||||
HIGH: '#f44336',
|
||||
CRITICAL: '#b71c1c',
|
||||
};
|
||||
|
||||
export const PROFILE_COLORS: Record<string, string> = {
|
||||
text_no_url: '#1976d2',
|
||||
text_with_url: '#0288d1',
|
||||
image: '#7b1fa2',
|
||||
audio: '#e65100',
|
||||
video: '#c62828',
|
||||
url: '#2e7d32',
|
||||
};
|
||||
|
||||
export const RangeBar: React.FC<{
|
||||
items: { start: number; end: number; color: string; label: string }[];
|
||||
}> = ({ items }) => (
|
||||
<Box sx={{ display: 'flex', height: 28, borderRadius: 1, overflow: 'hidden', border: '1px solid #444', width: '100%' }}>
|
||||
{items.map((item, i) => {
|
||||
const width = item.end - item.start + 1;
|
||||
return (
|
||||
<Tooltip key={i} title={`${item.label}: ${item.start}-${item.end}`}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${width}%`,
|
||||
bgcolor: item.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#fff', fontWeight: 600, fontSize: '0.65rem', textShadow: '0 1px 2px rgba(0,0,0,0.6)', whiteSpace: 'nowrap' }}>
|
||||
{width > 8 ? item.label : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ConfigValue: React.FC<{ label: string; value: string | number | boolean }> = ({ label, value }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Chip label={value ? 'ON' : 'OFF'} size="small" color={value ? 'success' : 'default'} />
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* Verdict Configuration Component
|
||||
*
|
||||
* Manages Final Verdict settings across 8 tabs:
|
||||
* 0. Component Weights (editable)
|
||||
* 1. Verdict Categories (editable)
|
||||
* 2. Risk Mappings (editable)
|
||||
* 3. Severity & Actions (editable, syncs to Redis)
|
||||
* 4. Overrides & Synergy (editable, from Redis verdict_config)
|
||||
* 5. Confidence (editable, from Redis verdict_config)
|
||||
* 6. Topic Multipliers (editable, only type=1 active)
|
||||
* 7. Input Profiles (editable, per input type)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Balance as WeightsIcon,
|
||||
Category as CategoryIcon,
|
||||
Warning as RiskIcon,
|
||||
TrendingUp as MultiplierIcon,
|
||||
Shield as ShieldIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Dashboard as ProfilesIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import type {
|
||||
ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment,
|
||||
VerdictConfigData, DeleteDialogState,
|
||||
} from './types';
|
||||
import {
|
||||
fetchWeights, fetchVerdicts, fetchVerdictConfig, fetchRuntimeConfig,
|
||||
updateComponentWeight, createMultiplier, updateMultiplier, deleteMultiplier,
|
||||
updateVerdictCategory, updateRiskMapping, updateSeverity, updateRuntimeConfig,
|
||||
syncRedis,
|
||||
} from './api';
|
||||
import { WeightsTab } from './tabs/WeightsTab';
|
||||
import { CategoriesTab } from './tabs/CategoriesTab';
|
||||
import { RiskTab } from './tabs/RiskTab';
|
||||
import { SeverityTab } from './tabs/SeverityTab';
|
||||
import { OverridesTab } from './tabs/OverridesTab';
|
||||
import { ConfidenceTab } from './tabs/ConfidenceTab';
|
||||
import { MultipliersTab } from './tabs/MultipliersTab';
|
||||
import {
|
||||
WeightDialog, MultiplierDialog, CategoryDialog, RiskDialog, SeverityDialog, DeleteDialog,
|
||||
} from './dialogs/EntityDialogs';
|
||||
import { RuntimeConfigDialog } from './dialogs/RuntimeConfigDialog';
|
||||
import { InputProfilesPanel } from './panels/InputProfilesPanel';
|
||||
|
||||
export const VerdictConfig: React.FC = () => {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [componentWeights, setComponentWeights] = useState<ComponentWeight[]>([]);
|
||||
const [multipliers, setMultipliers] = useState<Multiplier[]>([]);
|
||||
const [verdictCategories, setVerdictCategories] = useState<VerdictCategory[]>([]);
|
||||
const [riskMappings, setRiskMappings] = useState<RiskMapping[]>([]);
|
||||
const [severityAssessments, setSeverityAssessments] = useState<SeverityAssessment[]>([]);
|
||||
const [verdictConfig, setVerdictConfig] = useState<VerdictConfigData | null>(null);
|
||||
|
||||
const [weightDialog, setWeightDialog] = useState<{ open: boolean; data: ComponentWeight | null }>({ open: false, data: null });
|
||||
const [multiplierDialog, setMultiplierDialog] = useState<{ open: boolean; data: Partial<Multiplier> | null }>({ open: false, data: null });
|
||||
const [categoryDialog, setCategoryDialog] = useState<{ open: boolean; data: VerdictCategory | null }>({ open: false, data: null });
|
||||
const [riskDialog, setRiskDialog] = useState<{ open: boolean; data: RiskMapping | null }>({ open: false, data: null });
|
||||
const [severityDialog, setSeverityDialog] = useState<{ open: boolean; data: SeverityAssessment | null }>({ open: false, data: null });
|
||||
const [runtimeConfigDialog, setRuntimeConfigDialog] = useState<{ open: boolean; data: VerdictConfigData | null }>({ open: false, data: null });
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [weightsRes, verdictsRes, runtimeRes, redisRes] = await Promise.all([
|
||||
fetchWeights(),
|
||||
fetchVerdicts(),
|
||||
fetchRuntimeConfig(),
|
||||
fetchVerdictConfig(),
|
||||
]);
|
||||
|
||||
if (weightsRes.success) {
|
||||
setComponentWeights(weightsRes.data.componentWeights || []);
|
||||
setMultipliers(weightsRes.data.multipliers || []);
|
||||
}
|
||||
|
||||
if (verdictsRes.success) {
|
||||
setVerdictCategories(verdictsRes.data.verdictCategories || []);
|
||||
setRiskMappings(verdictsRes.data.riskMappings || []);
|
||||
const sev = (verdictsRes.data.severityAssessments || []).map((s: SeverityAssessment) => ({
|
||||
...s,
|
||||
severity_id: typeof s.severity_id === 'string' ? s.severity_id.trim() : s.severity_id,
|
||||
severity_category: typeof s.severity_category === 'string' ? s.severity_category.trim() : s.severity_category,
|
||||
recomended_action: typeof s.recomended_action === 'string' ? s.recomended_action.trim() : s.recomended_action,
|
||||
}));
|
||||
setSeverityAssessments(sev);
|
||||
}
|
||||
|
||||
// Prefer runtime-config from PG (source of truth). Fall back to Redis read if PG row missing.
|
||||
if (runtimeRes.success && runtimeRes.data) {
|
||||
setVerdictConfig(runtimeRes.data);
|
||||
} else if (redisRes.success && redisRes.data) {
|
||||
setVerdictConfig(redisRes.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const showSuccess = (msg: string) => { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); };
|
||||
const showError = (msg: string) => { setError(msg); setTimeout(() => setError(null), 5000); };
|
||||
|
||||
const handleSaveWeight = async () => {
|
||||
if (!weightDialog.data) return;
|
||||
try {
|
||||
const res = await updateComponentWeight(weightDialog.data.component_weight_id, weightDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess('Weight updated successfully');
|
||||
setWeightDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMultiplier = async () => {
|
||||
if (!multiplierDialog.data) return;
|
||||
try {
|
||||
const isEdit = 'multiplier_id' in multiplierDialog.data && multiplierDialog.data.multiplier_id;
|
||||
const res = isEdit
|
||||
? await updateMultiplier(multiplierDialog.data.multiplier_id as number, multiplierDialog.data)
|
||||
: await createMultiplier(multiplierDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess(isEdit ? 'Multiplier updated' : 'Multiplier created');
|
||||
setMultiplierDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMultiplier = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'multiplier') return;
|
||||
try {
|
||||
const res = await deleteMultiplier(deleteDialog.id);
|
||||
if (res.success) {
|
||||
showSuccess('Multiplier deleted');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCategory = async () => {
|
||||
if (!categoryDialog.data) return;
|
||||
try {
|
||||
const res = await updateVerdictCategory(categoryDialog.data.verdict_category_id, categoryDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess('Category updated');
|
||||
setCategoryDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRisk = async () => {
|
||||
if (!riskDialog.data) return;
|
||||
try {
|
||||
const res = await updateRiskMapping(riskDialog.data.risk_mapping_id, riskDialog.data);
|
||||
if (res.success) {
|
||||
showSuccess('Risk mapping updated');
|
||||
setRiskDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRedis = async () => {
|
||||
try {
|
||||
const res = await syncRedis();
|
||||
if (res.success) {
|
||||
showSuccess('Synced to Redis successfully');
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Sync failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSeverity = async () => {
|
||||
if (!severityDialog.data) return;
|
||||
const { severity_id, severity_category, start_range, end_range, recomended_action } = severityDialog.data;
|
||||
try {
|
||||
const res = await updateSeverity(severity_id, { severity_category, start_range, end_range, recomended_action });
|
||||
if (res.success) {
|
||||
await syncRedis();
|
||||
showSuccess('Severity updated and synced to Redis');
|
||||
setSeverityDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRuntimeConfig = async () => {
|
||||
if (!runtimeConfigDialog.data) return;
|
||||
try {
|
||||
const res = await updateRuntimeConfig(runtimeConfigDialog.data);
|
||||
if (res.success) {
|
||||
await syncRedis();
|
||||
showSuccess('Verdict config updated and synced to Redis');
|
||||
setRuntimeConfigDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else { showError(res.error); }
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const openRuntimeEditor = () => {
|
||||
if (!verdictConfig) return;
|
||||
setRuntimeConfigDialog({ open: true, data: JSON.parse(JSON.stringify(verdictConfig)) });
|
||||
};
|
||||
|
||||
const updateRuntimeField = (path: string[], value: unknown) => {
|
||||
setRuntimeConfigDialog(prev => {
|
||||
if (!prev.data) return prev;
|
||||
const next = JSON.parse(JSON.stringify(prev.data)) as VerdictConfigData;
|
||||
let cursor: Record<string, unknown> = next as unknown as Record<string, unknown>;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
cursor = cursor[path[i]] as Record<string, unknown>;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
return { open: true, data: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const topicMultipliers = multipliers.filter(m => m.multiplier_type === 1);
|
||||
const inactiveMultipliers = multipliers.filter(m => m.multiplier_type !== 1);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h5">Final Verdict Configuration</Typography>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button startIcon={<RefreshIcon />} onClick={fetchData}>Refresh</Button>
|
||||
<Button variant="contained" color="secondary" onClick={handleSyncRedis}>Sync to Redis</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, v) => setTabValue(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab icon={<WeightsIcon />} label={`Weights (${componentWeights.length})`} iconPosition="start" />
|
||||
<Tab icon={<CategoryIcon />} label={`Categories (${verdictCategories.length})`} iconPosition="start" />
|
||||
<Tab icon={<RiskIcon />} label={`Risk Levels (${riskMappings.length})`} iconPosition="start" />
|
||||
<Tab icon={<RiskIcon />} label={`Severity (${severityAssessments.length})`} iconPosition="start" />
|
||||
<Tab icon={<ShieldIcon />} label="Overrides & Synergy" iconPosition="start" />
|
||||
<Tab icon={<PsychologyIcon />} label="Confidence" iconPosition="start" />
|
||||
<Tab icon={<MultiplierIcon />} label={`Multipliers (${topicMultipliers.length})`} iconPosition="start" />
|
||||
<Tab icon={<ProfilesIcon />} label="Input Profiles" iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<WeightsTab
|
||||
componentWeights={componentWeights}
|
||||
onEdit={(w) => setWeightDialog({ open: true, data: w })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 1 && (
|
||||
<CategoriesTab
|
||||
verdictCategories={verdictCategories}
|
||||
onEdit={(c) => setCategoryDialog({ open: true, data: c })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 2 && (
|
||||
<RiskTab
|
||||
riskMappings={riskMappings}
|
||||
onEdit={(r) => setRiskDialog({ open: true, data: r })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 3 && (
|
||||
<SeverityTab
|
||||
severityAssessments={severityAssessments}
|
||||
onEdit={(s) => setSeverityDialog({ open: true, data: s })}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 4 && (
|
||||
<OverridesTab verdictConfig={verdictConfig} onEdit={openRuntimeEditor} />
|
||||
)}
|
||||
{tabValue === 5 && (
|
||||
<ConfidenceTab verdictConfig={verdictConfig} onEdit={openRuntimeEditor} />
|
||||
)}
|
||||
{tabValue === 6 && (
|
||||
<MultipliersTab
|
||||
topicMultipliers={topicMultipliers}
|
||||
inactiveMultipliers={inactiveMultipliers}
|
||||
onAdd={() => setMultiplierDialog({ open: true, data: { multiplier_type: 1, multiplier_name: '', multiplier: 1, description: '' } })}
|
||||
onEdit={(m) => setMultiplierDialog({ open: true, data: m })}
|
||||
onDelete={(m) => setDeleteDialog({ open: true, type: 'multiplier', id: m.multiplier_id, name: m.multiplier_name })}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{tabValue === 7 && (
|
||||
<InputProfilesPanel onSuccess={showSuccess} onError={showError} />
|
||||
)}
|
||||
|
||||
<WeightDialog state={weightDialog} setState={setWeightDialog} onSave={handleSaveWeight} />
|
||||
<MultiplierDialog state={multiplierDialog} setState={setMultiplierDialog} onSave={handleSaveMultiplier} />
|
||||
<CategoryDialog state={categoryDialog} setState={setCategoryDialog} onSave={handleSaveCategory} />
|
||||
<RiskDialog state={riskDialog} setState={setRiskDialog} onSave={handleSaveRisk} />
|
||||
<SeverityDialog state={severityDialog} setState={setSeverityDialog} onSave={handleSaveSeverity} />
|
||||
<RuntimeConfigDialog
|
||||
state={runtimeConfigDialog}
|
||||
setState={setRuntimeConfigDialog}
|
||||
updateField={updateRuntimeField}
|
||||
onSave={handleSaveRuntimeConfig}
|
||||
/>
|
||||
<DeleteDialog
|
||||
state={deleteDialog}
|
||||
onCancel={() => setDeleteDialog(null)}
|
||||
onConfirm={handleDeleteMultiplier}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerdictConfig;
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, Alert, CircularProgress, Card, CardContent, Chip, Slider,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField,
|
||||
} from '@mui/material';
|
||||
import { Dashboard as ProfilesIcon, Save as SaveIcon } from '@mui/icons-material';
|
||||
import { PROFILE_COLORS } from '../helpers';
|
||||
|
||||
interface InputProfilesProps {
|
||||
onSuccess: (msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const InputProfilesPanel: React.FC<InputProfilesProps> = ({ onSuccess, onError }) => {
|
||||
const [profiles, setProfiles] = useState<any[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState<string>('text_no_url');
|
||||
const [editedProfile, setEditedProfile] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/framework/api/input-profiles');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setProfiles(data.data);
|
||||
const current = data.data.find((p: any) => p.profile_code === selectedProfile) || data.data[0];
|
||||
setEditedProfile(JSON.parse(JSON.stringify(current)));
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onError, selectedProfile]);
|
||||
|
||||
useEffect(() => { fetchProfiles(); }, [fetchProfiles]);
|
||||
|
||||
const handleSelectProfile = (code: string) => {
|
||||
setSelectedProfile(code);
|
||||
const p = profiles.find((pr: any) => pr.profile_code === code);
|
||||
if (p) setEditedProfile(JSON.parse(JSON.stringify(p)));
|
||||
};
|
||||
|
||||
const handleWeightChange = (field: string, value: number) => {
|
||||
setEditedProfile((prev: any) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleOverrideToggle = (overrideCode: string) => {
|
||||
setEditedProfile((prev: any) => {
|
||||
const overrides = [...(prev.overrides || [])];
|
||||
const idx = overrides.findIndex((o: any) => o.override_code === overrideCode);
|
||||
if (idx >= 0) overrides[idx] = { ...overrides[idx], enabled: !overrides[idx].enabled };
|
||||
return { ...prev, overrides };
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideValueChange = (overrideCode: string, field: string, value: number) => {
|
||||
setEditedProfile((prev: any) => {
|
||||
const overrides = [...(prev.overrides || [])];
|
||||
const idx = overrides.findIndex((o: any) => o.override_code === overrideCode);
|
||||
if (idx >= 0) overrides[idx] = { ...overrides[idx], [field]: value };
|
||||
return { ...prev, overrides };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editedProfile) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const total = editedProfile.weight_techniques + editedProfile.weight_claims +
|
||||
editedProfile.weight_ai_tampered + editedProfile.weight_source;
|
||||
if (total !== 100) {
|
||||
onError(`Weights must sum to 100% (currently ${total}%)`);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
weight_techniques: editedProfile.weight_techniques,
|
||||
weight_claims: editedProfile.weight_claims,
|
||||
weight_ai_tampered: editedProfile.weight_ai_tampered,
|
||||
weight_source: editedProfile.weight_source,
|
||||
override_cap: editedProfile.override_cap,
|
||||
min_components: editedProfile.min_components,
|
||||
ai_disclosure_multipliers: editedProfile.ai_disclosure_multipliers,
|
||||
}),
|
||||
});
|
||||
const profileData = await profileRes.json();
|
||||
if (!profileData.success) { onError(profileData.error); setSaving(false); return; }
|
||||
|
||||
if (editedProfile.overrides?.length > 0) {
|
||||
const overridesRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}/overrides`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: editedProfile.overrides }),
|
||||
});
|
||||
const overridesData = await overridesRes.json();
|
||||
if (!overridesData.success) { onError(overridesData.error); setSaving(false); return; }
|
||||
}
|
||||
|
||||
onSuccess(`Profile "${editedProfile.profile_name}" saved! Sync to Redis to apply.`);
|
||||
fetchProfiles();
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>;
|
||||
if (!editedProfile) return <Alert severity="info">No profiles found</Alert>;
|
||||
|
||||
const weightTotal = editedProfile.weight_techniques + editedProfile.weight_claims +
|
||||
editedProfile.weight_ai_tampered + editedProfile.weight_source;
|
||||
const origProfile = profiles.find((p: any) => p.profile_code === selectedProfile);
|
||||
const isModified = origProfile && JSON.stringify(origProfile) !== JSON.stringify(editedProfile);
|
||||
|
||||
return (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
<ProfilesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Input Type Profiles — Component weights per input type
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={18} /> : <SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified || saving || weightTotal !== 100}
|
||||
>
|
||||
Save Profile
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{isModified && <Alert severity="warning" sx={{ mb: 2 }}>Unsaved changes</Alert>}
|
||||
{weightTotal !== 100 && <Alert severity="error" sx={{ mb: 2 }}>Weights must sum to 100% (currently {weightTotal}%)</Alert>}
|
||||
|
||||
<Box display="flex" gap={1} mb={3} flexWrap="wrap">
|
||||
{profiles.map((p: any) => (
|
||||
<Chip
|
||||
key={p.profile_code}
|
||||
label={p.profile_name}
|
||||
onClick={() => handleSelectProfile(p.profile_code)}
|
||||
variant={selectedProfile === p.profile_code ? 'filled' : 'outlined'}
|
||||
color={selectedProfile === p.profile_code ? 'primary' : 'default'}
|
||||
sx={{
|
||||
borderColor: PROFILE_COLORS[p.profile_code],
|
||||
...(selectedProfile === p.profile_code && { bgcolor: PROFILE_COLORS[p.profile_code] }),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>Component Weights (total must = 100%)</Typography>
|
||||
{[
|
||||
{ key: 'weight_techniques', label: 'Techniques', color: '#9c27b0' },
|
||||
{ key: 'weight_claims', label: 'Claims', color: '#009688' },
|
||||
{ key: 'weight_ai_tampered', label: 'AI Tampered', color: '#ff5722' },
|
||||
{ key: 'weight_source', label: 'Source', color: '#2e7d32' },
|
||||
].map(({ key, label, color }) => (
|
||||
<Box key={key} display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Typography sx={{ width: 120, color }}>{label}</Typography>
|
||||
<Slider
|
||||
value={editedProfile[key]}
|
||||
onChange={(_, v) => handleWeightChange(key, v as number)}
|
||||
min={0} max={100} step={5}
|
||||
sx={{ flex: 1, color }}
|
||||
/>
|
||||
<Typography sx={{ width: 50, textAlign: 'right', fontWeight: 600 }}>{editedProfile[key]}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
<Chip
|
||||
label={`Total: ${weightTotal}%`}
|
||||
color={weightTotal === 100 ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>Override Rules</Typography>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="body2" color="text.secondary">Cap:</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={editedProfile.override_cap}
|
||||
onChange={(e) => setEditedProfile((prev: any) => ({ ...prev, override_cap: Number(e.target.value) }))}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Override</TableCell>
|
||||
<TableCell width={80}>Active</TableCell>
|
||||
<TableCell width={90}>Threshold</TableCell>
|
||||
<TableCell width={90}>Per Unit</TableCell>
|
||||
<TableCell width={90}>Fixed</TableCell>
|
||||
<TableCell width={90}>Max</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(editedProfile.overrides || []).map((ov: any) => (
|
||||
<TableRow key={ov.override_code} sx={{ opacity: ov.enabled ? 1 : 0.5 }}>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{ov.override_code.replace(/_/g, ' ')}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={ov.enabled ? 'ON' : 'OFF'}
|
||||
size="small"
|
||||
color={ov.enabled ? 'success' : 'default'}
|
||||
onClick={() => handleOverrideToggle(ov.override_code)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.threshold != null && (
|
||||
<TextField size="small" type="number" value={ov.threshold}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'threshold', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.bonus_per_unit != null && (
|
||||
<TextField size="small" type="number" value={ov.bonus_per_unit}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'bonus_per_unit', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.bonus_fixed != null && (
|
||||
<TextField size="small" type="number" value={ov.bonus_fixed}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'bonus_fixed', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ov.max_bonus != null && (
|
||||
<TextField size="small" type="number" value={ov.max_bonus}
|
||||
onChange={(e) => handleOverrideValueChange(ov.override_code, 'max_bonus', Number(e.target.value))}
|
||||
sx={{ width: 70 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>AI Disclosure Multipliers</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
How much AI detection risk is reduced when AI usage is disclosed. Lower = less risk.
|
||||
</Typography>
|
||||
{editedProfile.ai_disclosure_multipliers && Object.entries(editedProfile.ai_disclosure_multipliers).map(([key, val]: [string, any]) => (
|
||||
<Box key={key} display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Typography sx={{ width: 100, textTransform: 'capitalize' }}>{key}</Typography>
|
||||
<Slider
|
||||
value={val}
|
||||
onChange={(_, v) => setEditedProfile((prev: any) => ({
|
||||
...prev,
|
||||
ai_disclosure_multipliers: { ...prev.ai_disclosure_multipliers, [key]: v as number }
|
||||
}))}
|
||||
min={0} max={1} step={0.05}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography sx={{ width: 60, textAlign: 'right', fontWeight: 600 }}>{(val * 100).toFixed(0)}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>INCONCLUSIVE Rules</Typography>
|
||||
<Box display="flex" gap={3} flexWrap="wrap">
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Minimum components for verdict</Typography>
|
||||
<TextField
|
||||
size="small" type="number"
|
||||
value={editedProfile.min_components}
|
||||
onChange={(e) => setEditedProfile((prev: any) => ({ ...prev, min_components: Number(e.target.value) }))}
|
||||
sx={{ width: 80, mt: 1 }}
|
||||
inputProps={{ min: 1, max: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Primary components</Typography>
|
||||
<Box display="flex" gap={0.5} mt={1}>
|
||||
{['techniques', 'claims', 'ai_tampered', 'source'].map(comp => {
|
||||
const isPrimary = (editedProfile.primary_components || []).includes(comp);
|
||||
return (
|
||||
<Chip
|
||||
key={comp}
|
||||
label={comp.replace('_', ' ')}
|
||||
size="small"
|
||||
variant={isPrimary ? 'filled' : 'outlined'}
|
||||
color={isPrimary ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setEditedProfile((prev: any) => {
|
||||
const current = prev.primary_components || [];
|
||||
const updated = isPrimary
|
||||
? current.filter((c: string) => c !== comp)
|
||||
: [...current, comp];
|
||||
return { ...prev, primary_components: updated };
|
||||
});
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { VerdictCategory } from '../types';
|
||||
import { RangeBar } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
verdictCategories: VerdictCategory[];
|
||||
onEdit: (cat: VerdictCategory) => void;
|
||||
}
|
||||
|
||||
export const CategoriesTab: React.FC<Props> = ({ verdictCategories, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Verdict categories map risk scores to human-readable labels. Used by <code>mapToVerdictCategory()</code>.
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<RangeBar
|
||||
items={verdictCategories.map(c => ({
|
||||
start: c.start_range,
|
||||
end: c.end_range,
|
||||
color: c.verdict_category_color,
|
||||
label: c.verdict_category_code,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Score Range</TableCell>
|
||||
<TableCell>Color</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{verdictCategories.map((c) => (
|
||||
<TableRow key={c.verdict_category_id} hover>
|
||||
<TableCell><strong>{c.description}</strong></TableCell>
|
||||
<TableCell><Chip label={c.verdict_category_code} size="small" /></TableCell>
|
||||
<TableCell>{c.start_range} - {c.end_range}</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 24, height: 24, bgcolor: c.verdict_category_color, borderRadius: 1, border: '1px solid #555' }} />
|
||||
<Typography variant="caption">{c.verdict_category_color}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(c)}><EditIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Button, Alert, Card, CardContent, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { VerdictConfigData } from '../types';
|
||||
|
||||
interface Props {
|
||||
verdictConfig: VerdictConfigData | null;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export const ConfidenceTab: React.FC<Props> = ({ verdictConfig, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2} gap={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Confidence scoring parameters. Used by <code>calculateConfidence()</code>.
|
||||
Higher confidence = more data sources produced strong signals.
|
||||
Stored in <code>component_config(pipeline, verdict_config)</code>; saved values auto-sync to Redis.
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!verdictConfig}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit Confidence
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!verdictConfig ? (
|
||||
<Alert severity="warning">
|
||||
verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Base Scoring
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Each of the 4 components (domain, techniques, ai, claims) adds {verdictConfig.confidence.base_per_component} when available.
|
||||
Max base = {verdictConfig.confidence.base_per_component * 4}.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Component Bonuses
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
Additional confidence points based on component signal strength.
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Condition</TableCell>
|
||||
<TableCell align="right">Bonus</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Domain</TableCell>
|
||||
<TableCell>TRUSTED / UNTRUSTED verdict</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.domain_strong_signal_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Domain</TableCell>
|
||||
<TableCell>Other verdict (weak signal)</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.domain_weak_signal_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Techniques</TableCell>
|
||||
<TableCell>Avg technique confidence (max bonus)</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.techniques_bonus_max}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>AI Detection</TableCell>
|
||||
<TableCell>HIGH confidence</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.ai_high_confidence_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>AI Detection</TableCell>
|
||||
<TableCell>MEDIUM confidence</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.ai_medium_confidence_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>AI Detection</TableCell>
|
||||
<TableCell>LOW confidence</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.ai_low_confidence_bonus}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Claims</TableCell>
|
||||
<TableCell>Verified ratio (max bonus)</TableCell>
|
||||
<TableCell align="right">+{verdictConfig.confidence.claims_verified_bonus_max}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Confidence Levels
|
||||
</Typography>
|
||||
<Box display="flex" gap={2} mt={1}>
|
||||
<Chip
|
||||
label={`HIGH >= ${verdictConfig.confidence_levels.HIGH.min}`}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`MEDIUM >= ${verdictConfig.confidence_levels.MEDIUM.min}`}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`LOW >= ${verdictConfig.confidence_levels.LOW.min}`}
|
||||
color="default"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Button, Chip, IconButton,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { Multiplier } from '../types';
|
||||
|
||||
interface Props {
|
||||
topicMultipliers: Multiplier[];
|
||||
inactiveMultipliers: Multiplier[];
|
||||
onAdd: () => void;
|
||||
onEdit: (m: Multiplier) => void;
|
||||
onDelete: (m: Multiplier) => void;
|
||||
}
|
||||
|
||||
export const MultipliersTab: React.FC<Props> = ({
|
||||
topicMultipliers,
|
||||
inactiveMultipliers,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Topic multipliers adjust the final risk score when <code>options.topic</code> is set.
|
||||
Only <strong>Topic</strong> multipliers are applied in code.
|
||||
</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" onClick={onAdd}>
|
||||
Add Topic Multiplier
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" fontWeight={700} mb={1}>
|
||||
Active Topic Multipliers ({topicMultipliers.length})
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Multiplier</TableCell>
|
||||
<TableCell>Effect</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topicMultipliers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Typography variant="body2" color="text.secondary">No topic multipliers configured</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : topicMultipliers.map((m) => (
|
||||
<TableRow key={m.multiplier_id} hover>
|
||||
<TableCell><strong>{m.multiplier_name}</strong></TableCell>
|
||||
<TableCell>{m.multiplier.toFixed(2)}x</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={m.multiplier > 1 ? `+${((m.multiplier - 1) * 100).toFixed(0)}%` : `${((m.multiplier - 1) * 100).toFixed(0)}%`}
|
||||
size="small"
|
||||
color={m.multiplier > 1 ? 'error' : m.multiplier < 1 ? 'success' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{m.description}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(m)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(m)}><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{inactiveMultipliers.length > 0 && (
|
||||
<Box mt={3}>
|
||||
<Typography variant="subtitle2" fontWeight={700} mb={1} color="text.secondary">
|
||||
Inactive Multipliers ({inactiveMultipliers.length})
|
||||
<Chip label="Not implemented in code" size="small" color="default" sx={{ ml: 1 }} />
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Multiplier</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{inactiveMultipliers.map((m) => (
|
||||
<TableRow key={m.multiplier_id} sx={{ opacity: 0.5 }}>
|
||||
<TableCell>
|
||||
<Chip label={m.multiplier_type === 2 ? 'Temporal' : 'Reach'} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>{m.multiplier_name}</TableCell>
|
||||
<TableCell>{m.multiplier.toFixed(2)}x</TableCell>
|
||||
<TableCell>
|
||||
<Chip label="Not implemented" size="small" color="default" variant="outlined" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Button, Alert, Card, CardContent } from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { VerdictConfigData } from '../types';
|
||||
import { ConfigValue } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
verdictConfig: VerdictConfigData | null;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export const OverridesTab: React.FC<Props> = ({ verdictConfig, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2} gap={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Override rules that adjust the final risk score. Used by <code>applyOverrides()</code> and <code>applySynergyBonus()</code>.
|
||||
Stored in <code>component_config(pipeline, verdict_config)</code>; saved values auto-sync to Redis.
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!verdictConfig}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit Overrides & Synergy
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!verdictConfig ? (
|
||||
<Alert severity="warning">
|
||||
verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
|
||||
Synergy Bonus
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
When multiple components show high risk (above threshold), adds a bonus to the final score.
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.synergy.enabled} />
|
||||
<ConfigValue label="Threshold (component score)" value={verdictConfig.synergy.threshold} />
|
||||
<ConfigValue label="Bonus per component" value={`+${verdictConfig.synergy.bonus_per_component}%`} />
|
||||
<ConfigValue label="Max bonus" value={`+${verdictConfig.synergy.max_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Typography variant="subtitle1" fontWeight={700}>Override Rules</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(auto-fill, minmax(320px, 1fr))" gap={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
False Claims
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.false_claims.enabled} />
|
||||
<ConfigValue label="Threshold (min false claims)" value={verdictConfig.overrides.false_claims.threshold} />
|
||||
<ConfigValue label="Bonus per false claim" value={`+${verdictConfig.overrides.false_claims.bonus_per_claim}%`} />
|
||||
<ConfigValue label="Max bonus" value={`+${verdictConfig.overrides.false_claims.max_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Severe Techniques
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.severe_techniques.enabled} />
|
||||
<ConfigValue label="Threshold (min severe count)" value={verdictConfig.overrides.severe_techniques.threshold} />
|
||||
<ConfigValue label="Bonus" value={`+${verdictConfig.overrides.severe_techniques.bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Undisclosed AI
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.undisclosed_ai.enabled} />
|
||||
<ConfigValue label="Bonus" value={`+${verdictConfig.overrides.undisclosed_ai.bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Untrusted Domain
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.untrusted_domain.enabled} />
|
||||
<ConfigValue label="Untrusted bonus" value={`+${verdictConfig.overrides.untrusted_domain.untrusted_bonus}%`} />
|
||||
<ConfigValue label="Suspicious bonus" value={`+${verdictConfig.overrides.untrusted_domain.suspicious_bonus}%`} />
|
||||
<ConfigValue label="Blacklisted bonus" value={`+${verdictConfig.overrides.untrusted_domain.blacklisted_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
|
||||
Domain Red Flags
|
||||
</Typography>
|
||||
<ConfigValue label="Enabled" value={verdictConfig.overrides.domain_red_flags.enabled} />
|
||||
<ConfigValue label="Threshold (min flags)" value={verdictConfig.overrides.domain_red_flags.threshold} />
|
||||
<ConfigValue label="Bonus per flag" value={`+${verdictConfig.overrides.domain_red_flags.bonus_per_flag}%`} />
|
||||
<ConfigValue label="Max bonus" value={`+${verdictConfig.overrides.domain_red_flags.max_bonus}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { RiskMapping } from '../types';
|
||||
import { RangeBar } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
riskMappings: RiskMapping[];
|
||||
onEdit: (risk: RiskMapping) => void;
|
||||
}
|
||||
|
||||
export const RiskTab: React.FC<Props> = ({ riskMappings, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Risk levels provide an alternative categorization focused on risk severity. Used by <code>mapToRiskLevel()</code>.
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<RangeBar
|
||||
items={riskMappings.map(r => ({
|
||||
start: r.start_range,
|
||||
end: r.end_range,
|
||||
color: r.risk_color,
|
||||
label: r.risk_mapping,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Risk Level</TableCell>
|
||||
<TableCell>Level Value</TableCell>
|
||||
<TableCell>Score Range</TableCell>
|
||||
<TableCell>Color</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{riskMappings.map((r) => (
|
||||
<TableRow key={r.risk_mapping_id} hover>
|
||||
<TableCell><strong>{r.risk_mapping}</strong></TableCell>
|
||||
<TableCell><Chip label={r.risk_level} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{r.start_range} - {r.end_range}</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 24, height: 24, bgcolor: r.risk_color, borderRadius: 1, border: '1px solid #555' }} />
|
||||
<Typography variant="caption">{r.risk_color}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(r)}><EditIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { SeverityAssessment } from '../types';
|
||||
import { RangeBar, SEVERITY_COLORS } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
severityAssessments: SeverityAssessment[];
|
||||
onEdit: (sev: SeverityAssessment) => void;
|
||||
}
|
||||
|
||||
export const SeverityTab: React.FC<Props> = ({ severityAssessments, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Severity levels determine recommended actions. Used by <code>mapToSeverity()</code>.
|
||||
Stored in <code>bos_parammgmt.severity_assessment</code>; saved rows auto-sync to Redis.
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<RangeBar
|
||||
items={severityAssessments.map(s => ({
|
||||
start: s.start_range,
|
||||
end: s.end_range,
|
||||
color: SEVERITY_COLORS[s.severity_category] || '#666',
|
||||
label: s.severity_category,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Score Range</TableCell>
|
||||
<TableCell>Recommended Action</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{severityAssessments.map((s) => (
|
||||
<TableRow key={s.severity_id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={s.severity_category}
|
||||
size="small"
|
||||
sx={{ bgcolor: SEVERITY_COLORS[s.severity_category] || '#666', color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{s.start_range} - {s.end_range}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={s.recomended_action} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => onEdit({ ...s })}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
IconButton, Chip,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon } from '@mui/icons-material';
|
||||
import type { ComponentWeight } from '../types';
|
||||
|
||||
interface Props {
|
||||
componentWeights: ComponentWeight[];
|
||||
onEdit: (weight: ComponentWeight) => void;
|
||||
}
|
||||
|
||||
export const WeightsTab: React.FC<Props> = ({ componentWeights, onEdit }) => (
|
||||
<Box py={2}>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Component weights determine how much each analysis component contributes to the final verdict score.
|
||||
Weights should sum to 100%.
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Weight (%)</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{componentWeights.map((w) => (
|
||||
<TableRow key={w.component_weight_id} hover>
|
||||
<TableCell><strong>{w.component_name}</strong></TableCell>
|
||||
<TableCell>
|
||||
<Chip label={`${w.component_weight}%`} size="small" color="primary" />
|
||||
</TableCell>
|
||||
<TableCell>{w.description}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(w)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total: {componentWeights.reduce((sum, w) => sum + w.component_weight, 0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* VerdictConfig — shared types
|
||||
*/
|
||||
|
||||
export interface ComponentWeight {
|
||||
component_weight_id: number;
|
||||
component_name: string;
|
||||
component_weight: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Multiplier {
|
||||
multiplier_id: number;
|
||||
multiplier_type: number;
|
||||
multiplier_name: string;
|
||||
multiplier: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface VerdictCategory {
|
||||
verdict_category_id: number;
|
||||
verdict_category_code: string;
|
||||
description: string;
|
||||
start_range: number;
|
||||
end_range: number;
|
||||
verdict_category_color: string;
|
||||
}
|
||||
|
||||
export interface RiskMapping {
|
||||
risk_mapping_id: number;
|
||||
risk_mapping: string;
|
||||
risk_level: number;
|
||||
start_range: number;
|
||||
end_range: number;
|
||||
risk_color: string;
|
||||
}
|
||||
|
||||
export interface SeverityAssessment {
|
||||
severity_id: string;
|
||||
severity_category: string;
|
||||
start_range: number;
|
||||
end_range: number;
|
||||
recomended_action: string;
|
||||
}
|
||||
|
||||
export interface VerdictConfigData {
|
||||
synergy: {
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
bonus_per_component: number;
|
||||
max_bonus: number;
|
||||
};
|
||||
overrides: {
|
||||
false_claims: { enabled: boolean; threshold: number; bonus_per_claim: number; max_bonus: number };
|
||||
severe_techniques: { enabled: boolean; threshold: number; bonus: number };
|
||||
undisclosed_ai: { enabled: boolean; bonus: number };
|
||||
untrusted_domain: { enabled: boolean; untrusted_bonus: number; suspicious_bonus: number; blacklisted_bonus: number };
|
||||
domain_red_flags: { enabled: boolean; threshold: number; bonus_per_flag: number; max_bonus: number };
|
||||
};
|
||||
confidence: {
|
||||
base_per_component: number;
|
||||
domain_strong_signal_bonus: number;
|
||||
domain_weak_signal_bonus: number;
|
||||
techniques_bonus_max: number;
|
||||
ai_high_confidence_bonus: number;
|
||||
ai_medium_confidence_bonus: number;
|
||||
ai_low_confidence_bonus: number;
|
||||
claims_verified_bonus_max: number;
|
||||
};
|
||||
confidence_levels: {
|
||||
HIGH: { min: number };
|
||||
MEDIUM: { min: number };
|
||||
LOW: { min: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeleteDialogState {
|
||||
open: boolean;
|
||||
type: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* API helpers for the LLM Components Config page.
|
||||
*
|
||||
* Stage assignments + models live in PG (didiFramework /api/providers/*);
|
||||
* vision config is still fetched from agent-v3 /config because vision is
|
||||
* an agent-side concern and isn't mirrored to PG yet.
|
||||
*
|
||||
* fetchStageAssignments groups the flat assignment rows by stage_code AND
|
||||
* tier (free/premium), so each stage exposes both chains for the UI's tier
|
||||
* toggle. Falls back to free when premium is empty (legacy environments).
|
||||
*/
|
||||
import type { ModelConfig, StageAssignment, TierCode } from './types';
|
||||
|
||||
export const FRAMEWORK_API = '/framework/api/providers';
|
||||
|
||||
export async function fetchConfig(apiBase: string) {
|
||||
// Vision config still lives on agent-v3 (Redis-backed). Used only for AI Tampered.
|
||||
const res = await fetch(`${apiBase}/config`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchModels(_apiBase: string) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/models`);
|
||||
const data = await res.json();
|
||||
if (!data.success) return data;
|
||||
|
||||
// Map PG schema to UI shape.
|
||||
const models = (data.data || []).map((m: any) => ({
|
||||
model_key: m.model_code,
|
||||
provider: m.provider_code || m.provider_name,
|
||||
model_name: m.model_name,
|
||||
context_window: m.context_window || 0,
|
||||
cost_input_1m: m.input_cost_per_1m || 0,
|
||||
cost_output_1m: m.output_cost_per_1m || 0,
|
||||
speed_tier: m.context_window >= 128000 ? 'fast' : m.context_window >= 32000 ? 'medium' : 'fast',
|
||||
quality_tier: m.input_cost_per_1m > 3 ? 'premium' : m.input_cost_per_1m > 0.5 ? 'high' : 'standard',
|
||||
_model_id: m.model_id,
|
||||
_provider_id: m.provider_id,
|
||||
}));
|
||||
|
||||
return { success: true, data: { models } };
|
||||
}
|
||||
|
||||
export async function fetchStageAssignments(_apiBase: string) {
|
||||
const res = await fetch(`${FRAMEWORK_API}/assignments`);
|
||||
const data = await res.json();
|
||||
if (!data.success) return data;
|
||||
|
||||
const grouped: Record<string, StageAssignment> = {};
|
||||
for (const a of data.data || []) {
|
||||
const key = a.stage_code;
|
||||
const tier: TierCode = a.tier === 'premium' ? 'premium' : 'free';
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = {
|
||||
stage: key,
|
||||
description: a.stage_name || key,
|
||||
models: [],
|
||||
modelsByTier: { free: [], premium: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const role = a.fallback_order === 1 ? 'primary'
|
||||
: `fallback_${a.fallback_order - 1}` as ModelConfig['role'];
|
||||
|
||||
const entry: ModelConfig = {
|
||||
order: a.fallback_order,
|
||||
role,
|
||||
model_key: a.model_code,
|
||||
name: a.model_name,
|
||||
temperature: parseFloat(a.temperature),
|
||||
max_tokens: a.max_tokens,
|
||||
timeout_ms: a.timeout_ms,
|
||||
_stage_id: a.stage_id,
|
||||
_provider_id: a.provider_id,
|
||||
_model_id: a.model_id,
|
||||
_tier: tier,
|
||||
};
|
||||
|
||||
grouped[key].modelsByTier![tier].push(entry);
|
||||
}
|
||||
|
||||
for (const stage of Object.values(grouped)) {
|
||||
if (stage.modelsByTier) {
|
||||
stage.modelsByTier.free.sort((a, b) => a.order - b.order);
|
||||
stage.modelsByTier.premium.sort((a, b) => a.order - b.order);
|
||||
stage.models = stage.modelsByTier.free;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: grouped };
|
||||
}
|
||||
|
||||
export async function updateStageAssignments(_apiBase: string, data: Record<string, StageAssignment>) {
|
||||
const errors: string[] = [];
|
||||
|
||||
const modelsRes = await fetch(`${FRAMEWORK_API}/models`);
|
||||
const modelsData = await modelsRes.json();
|
||||
const findModel = (modelKey: string) =>
|
||||
(modelsData.data || []).find((m: any) => m.model_code === modelKey);
|
||||
|
||||
for (const [_stageKey, stage] of Object.entries(data)) {
|
||||
const tierChains: [TierCode, ModelConfig[]][] = stage.modelsByTier
|
||||
? (Object.entries(stage.modelsByTier) as [TierCode, ModelConfig[]][])
|
||||
: [['free', stage.models]];
|
||||
|
||||
for (const [_tier, chain] of tierChains) {
|
||||
for (const model of chain) {
|
||||
const stageId = model._stage_id;
|
||||
if (!stageId) continue;
|
||||
|
||||
const body: any = {};
|
||||
if (model.temperature !== undefined) body.temperature = model.temperature;
|
||||
if (model.max_tokens !== undefined) body.max_tokens = model.max_tokens;
|
||||
if (model.timeout_ms !== undefined) body.timeout_ms = model.timeout_ms;
|
||||
|
||||
if (model.model_key) {
|
||||
const found = findModel(model.model_key);
|
||||
if (found) {
|
||||
body.model_id = found.model_id;
|
||||
body.provider_id = found.provider_id;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${FRAMEWORK_API}/assignments/${stageId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.success) {
|
||||
errors.push(result.error || `Failed to update stage ${stageId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true, message: 'Assignments updated in PostgreSQL' };
|
||||
}
|
||||
|
||||
export async function testModel(apiBase: string, modelKey: string) {
|
||||
const res = await fetch(`${apiBase}/test-model`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_key: modelKey }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function syncToRedis(): Promise<{ success: boolean; error?: string; message?: string }> {
|
||||
const res = await fetch('/framework/api/sync-redis', { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Static configuration table for the 6 analysis components.
|
||||
*
|
||||
* Each entry defines display metadata (name/icon/color), the agent-v3 API
|
||||
* base used for /test-model and /analyze, and the 2-stage pipeline shape
|
||||
* (screening + deep, or extraction + verification, etc.).
|
||||
*
|
||||
* `verdict` and `moderation` have empty `stages` because they delegate to
|
||||
* dedicated components rather than the generic stage-assignment editor.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Speed as SpeedIcon, Psychology as PsychologyIcon, Science as ScienceIcon,
|
||||
SmartToy as SmartToyIcon, FactCheck as FactCheckIcon,
|
||||
Gavel as VerdictIcon, TravelExplore as SourceIcon, Shield as ModerationIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ComponentType, ComponentConfig } from './types';
|
||||
|
||||
export const COMPONENT_CONFIGS: Record<ComponentType, ComponentConfig> = {
|
||||
'techniques': {
|
||||
name: 'Manipulation Techniques',
|
||||
icon: <ScienceIcon />,
|
||||
description: '2-Stage Pipeline for detecting manipulation techniques',
|
||||
color: '#9c27b0',
|
||||
apiBase: '/agent-v3/api/v3/techniques',
|
||||
stages: [
|
||||
{
|
||||
key: 'techniques_screening',
|
||||
title: 'Stage 1: SCREENING',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Quick dimension detection (~500 tokens, ~2-5s)',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'techniques_deep',
|
||||
title: 'Stage 2: DEEP ANALYSIS',
|
||||
icon: <PsychologyIcon />,
|
||||
description: 'Per-dimension technique detection (~1-2k tokens)',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
},
|
||||
'ai-tampered': {
|
||||
name: 'AI Tampered Detection',
|
||||
icon: <SmartToyIcon />,
|
||||
description: '2-Stage Pipeline for detecting AI-generated content',
|
||||
color: '#ff5722',
|
||||
apiBase: '/agent-v3/api/v3/ai-tampered',
|
||||
stages: [
|
||||
{
|
||||
key: 'ai_tampered_screening',
|
||||
title: 'Stage 1: SCREENING',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Quick AI detection (~1k tokens)',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'ai_tampered_deep',
|
||||
title: 'Stage 2: DEEP ANALYSIS',
|
||||
icon: <PsychologyIcon />,
|
||||
description: 'Per-category indicator detection',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
hasVision: true,
|
||||
},
|
||||
'claims': {
|
||||
name: 'Claims Verification',
|
||||
icon: <FactCheckIcon />,
|
||||
description: '2-Stage Pipeline for claim extraction and verification',
|
||||
color: '#009688',
|
||||
apiBase: '/agent-v3/api/v3/claims',
|
||||
stages: [
|
||||
{
|
||||
key: 'claims_extraction',
|
||||
title: 'Stage 1: EXTRACTION',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Extract claims from text',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'claims_verification',
|
||||
title: 'Stage 2: VERIFICATION',
|
||||
icon: <FactCheckIcon />,
|
||||
description: 'Verify claims against sources',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
},
|
||||
'source-assessment': {
|
||||
name: 'Source Assessment',
|
||||
icon: <SourceIcon />,
|
||||
description: '2-Stage Pipeline for source credibility assessment (publication, author, platform, domain)',
|
||||
color: '#2e7d32',
|
||||
apiBase: '/agent-v3/api/v3/source-assessment',
|
||||
stages: [
|
||||
{
|
||||
key: 'source_assessment_extraction',
|
||||
title: 'Stage 1: EXTRACTION',
|
||||
icon: <SpeedIcon />,
|
||||
description: 'Extract publication, author, platform from text/transcript',
|
||||
color: '#4caf50',
|
||||
},
|
||||
{
|
||||
key: 'source_assessment_evaluation',
|
||||
title: 'Stage 2: EVALUATION',
|
||||
icon: <PsychologyIcon />,
|
||||
description: 'Classify source using web evidence and framework categories',
|
||||
color: '#2196f3',
|
||||
},
|
||||
],
|
||||
},
|
||||
'verdict': {
|
||||
name: 'Final Verdict',
|
||||
icon: <VerdictIcon />,
|
||||
description: 'Configure verdict weights, categories, risk mappings and multipliers',
|
||||
color: '#7b1fa2',
|
||||
apiBase: '/framework/api',
|
||||
stages: [],
|
||||
},
|
||||
'moderation': {
|
||||
name: 'Moderation',
|
||||
icon: <ModerationIcon />,
|
||||
description: 'HIL triage rules, brain client settings, sensitive topics, roles',
|
||||
color: '#0288d1',
|
||||
apiBase: '/framework/api',
|
||||
stages: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Pure helpers for the Parameters panel — flatten/set nested config objects
|
||||
* and a curated label/tooltip table for the most common scoring keys.
|
||||
*
|
||||
* EXCLUDE_KEYS are scaffolding fields that don't belong in the UI grid
|
||||
* (legacy or computed at runtime); the panel filters them out before render.
|
||||
*/
|
||||
|
||||
export const PARAM_INFO: Record<string, { label: string; tooltip: string }> = {
|
||||
count_scaler_divisor: { label: 'Count Scaler Divisor', tooltip: 'How many techniques needed for full score. 1 technique = 1/N of max. Default 3 means 1 tech=33%, 2=67%, 3+=100%.' },
|
||||
severe_threshold: { label: 'Severe Technique Threshold', tooltip: 'Techniques with severity >= this value are considered "severe" and trigger the severe_techniques override bonus.' },
|
||||
'bonuses.intensity_bonus.cap': { label: 'Intensity Bonus Cap', tooltip: 'Maximum bonus from technique intensity (0-1 scale). Prevents high-intensity single techniques from dominating.' },
|
||||
'bonuses.intensity_bonus.coefficient': { label: 'Intensity Bonus Coefficient', tooltip: 'Multiplier for intensity calculation. Higher = more impact from technique intensity.' },
|
||||
'bonuses.intensity_bonus.type': { label: 'Intensity Bonus Type', tooltip: '"sqrt" = diminishing returns (recommended), "linear" = proportional intensity impact.' },
|
||||
'bonuses.count_bonus.threshold_3': { label: 'Count Bonus (>= 3 techniques)', tooltip: 'Bonus added to manipulation score when 3+ techniques detected (0-1 scale).' },
|
||||
'bonuses.count_bonus.threshold_5': { label: 'Count Bonus (>= 5 techniques)', tooltip: 'Bonus added when 5+ techniques detected. Replaces threshold_3 bonus.' },
|
||||
'bonuses.dimension_bonus.per_dimension': { label: 'Dimension Bonus Per Dimension', tooltip: 'Bonus per unique manipulation dimension detected (D1-D8). Multiple dimensions = broader manipulation.' },
|
||||
'bonuses.dimension_bonus.max_bonus': { label: 'Dimension Bonus Max', tooltip: 'Maximum total bonus from dimensions, regardless of how many are detected.' },
|
||||
'blend_weights.screening': { label: 'Blend: Screening Weight', tooltip: 'How much the screening stage contributes to final AI probability (0-1). Screening + Deep should sum to 1.0.' },
|
||||
'blend_weights.deep': { label: 'Blend: Deep Analysis Weight', tooltip: 'How much the deep analysis stage contributes to final AI probability (0-1).' },
|
||||
undisclosed_threshold: { label: 'Undisclosed AI Threshold', tooltip: 'AI probability (%) above which undisclosed AI triggers the override bonus. Content with disclosed AI is not affected.' },
|
||||
'thresholds.LIKELY_AI': { label: 'Verdict: LIKELY AI (%)', tooltip: 'AI probability >= this value produces LIKELY_AI verdict label.' },
|
||||
'thresholds.POSSIBLY_AI': { label: 'Verdict: POSSIBLY AI (%)', tooltip: 'AI probability >= this value produces POSSIBLY_AI verdict label.' },
|
||||
'thresholds.MIXED': { label: 'Verdict: MIXED (%)', tooltip: 'AI probability >= this value produces MIXED verdict label.' },
|
||||
'disclosure_impact.explicit': { label: 'Disclosure Impact: Explicit', tooltip: 'Risk multiplier when AI is explicitly declared (e.g. "Written by ChatGPT"). Lower = less risk. 0.3 means 30% of original risk.' },
|
||||
'disclosure_impact.partial': { label: 'Disclosure Impact: Partial', tooltip: 'Risk multiplier for partial AI disclosure (e.g. "with AI assistance").' },
|
||||
'disclosure_impact.implied': { label: 'Disclosure Impact: Implied', tooltip: 'Risk multiplier for implied AI disclosure (e.g. mentioning ChatGPT in context).' },
|
||||
'disclosure_impact.none': { label: 'Disclosure Impact: None', tooltip: 'Risk multiplier when AI is NOT disclosed. 1.0 = full risk (no reduction).' },
|
||||
'status_weights.VT': { label: 'Status: Verified True', tooltip: 'Credibility weight for claims verified as true (1.0 = full credibility contribution).' },
|
||||
'status_weights.LT': { label: 'Status: Likely True', tooltip: 'Credibility weight for claims likely true based on sources.' },
|
||||
'status_weights.UV': { label: 'Status: Unverified', tooltip: 'Credibility weight for claims that could not be verified. Lower = more suspicious. 0.5 = neutral.' },
|
||||
'status_weights.OP': { label: 'Status: Opinion as Fact', tooltip: 'Credibility weight for opinions presented as facts. Low value penalizes this practice.' },
|
||||
'status_weights.LF': { label: 'Status: Likely False', tooltip: 'Credibility weight for claims likely false based on contradicting sources.' },
|
||||
'status_weights.VF': { label: 'Status: Verified False', tooltip: 'Credibility weight for verified false claims. 0 = zero credibility contribution.' },
|
||||
all_unverified_credibility: { label: 'All Unverified Default', tooltip: 'Default credibility score (0-1) when ALL claims are unverified with zero web sources. 0.75 = lean credible, 0.5 = neutral, 0.25 = suspicious.' },
|
||||
all_unverified_behavior: { label: 'All Unverified Behavior', tooltip: '"fixed" = use the default value above. "calculate_per_type" = calculate based on claim types and their individual unverified weights.' },
|
||||
'axis_weights.publication': { label: 'Axis: Publication', tooltip: 'Weight of publication/outlet credibility in trust score (0-1). All axes should sum to 1.0.' },
|
||||
'axis_weights.domain': { label: 'Axis: Domain', tooltip: 'Weight of domain analysis (age, SSL, blacklist) in trust score. 0 when no URL available.' },
|
||||
'axis_weights.author': { label: 'Axis: Author', tooltip: 'Weight of author credibility in trust score.' },
|
||||
'axis_weights.platform': { label: 'Axis: Platform', tooltip: 'Weight of platform type (news site, social media, blog) in trust score.' },
|
||||
'verdict_thresholds.TRUSTED': { label: 'Threshold: TRUSTED (>=)', tooltip: 'Trust score >= this value = TRUSTED verdict. Source is considered reliable.' },
|
||||
'verdict_thresholds.NEUTRAL': { label: 'Threshold: NEUTRAL (>=)', tooltip: 'Trust score >= this value = NEUTRAL verdict. Source cannot be confirmed or denied.' },
|
||||
'verdict_thresholds.SUSPICIOUS': { label: 'Threshold: SUSPICIOUS (>=)', tooltip: 'Trust score >= this value = SUSPICIOUS verdict. Below this = UNTRUSTED.' },
|
||||
domain_unavailable_default: { label: 'Domain Unavailable Default', tooltip: 'Default domain axis score (0-100) when no URL is provided. 50 = neutral assumption.' },
|
||||
};
|
||||
|
||||
// Keys to exclude from display (internal / non-scoring).
|
||||
export const EXCLUDE_KEYS = ['base_calculation', 'manipulation_levels', 'warning_flags_triggers',
|
||||
'category_weights', 'risk_calculation', 'confidence_levels', 'source_reliability_weights',
|
||||
'claim_type_weights', 'status_thresholds', 'quick_scores'];
|
||||
|
||||
/** Flatten a nested object into dot-notation key-value pairs. */
|
||||
export function flattenConfig(obj: any, prefix = ''): { key: string; value: any }[] {
|
||||
const result: { key: string; value: any }[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
result.push(...flattenConfig(v, fullKey));
|
||||
} else {
|
||||
result.push({ key: fullKey, value: v });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Set a value in a nested object using dot-notation key. */
|
||||
export function setNestedValue(obj: any, path: string, value: any): any {
|
||||
const clone = JSON.parse(JSON.stringify(obj));
|
||||
const parts = path.split('.');
|
||||
let current = clone;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) current[parts[i]] = {};
|
||||
current = current[parts[i]];
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
return clone;
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* LLM Components Configuration - Unified UI
|
||||
*
|
||||
* Manages stage assignments and models for:
|
||||
* - Techniques V3 (Manipulation Detection)
|
||||
* - AI Tampered (AI Content Detection)
|
||||
* - Claims (Claim Verification)
|
||||
* - Source Assessment (Source credibility)
|
||||
* - Verdict (final scoring) — delegated to <VerdictConfig />
|
||||
* - Moderation — delegated to <ModerationSettings />
|
||||
*
|
||||
* Original 1608-line index.tsx split into:
|
||||
* types.ts — domain types
|
||||
* component-configs.tsx — COMPONENT_CONFIGS table (icons + stages + apiBase)
|
||||
* api.ts — fetch / update / test / sync helpers
|
||||
* helpers.ts — flattenConfig + setNestedValue + PARAM_INFO + EXCLUDE_KEYS
|
||||
* panels/StageAssignmentsPanel — per-stage model chains with tier toggle
|
||||
* panels/VisionModelsPanel — vision cascade for AI Tampered
|
||||
* panels/AvailableModelsPanel — flat models grid + connectivity test
|
||||
* panels/TestAnalysisPanel — quick /analyze runner
|
||||
* panels/PromptsPanel — system+user prompt editor
|
||||
* panels/ParametersPanel — scoring config editor
|
||||
* index.tsx (this) — page shell + state + render dispatcher
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress, Divider,
|
||||
ToggleButton, ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon, Refresh as RefreshIcon,
|
||||
Psychology as PsychologyIcon, SwapVert as SwapVertIcon,
|
||||
Visibility as VisionIcon, Settings as SettingsIcon,
|
||||
Sync as SyncIcon, Description as PromptsTabIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { VerdictConfig } from './VerdictConfig';
|
||||
import ModerationSettings from '../ModerationSettings';
|
||||
import {
|
||||
type ComponentType, type TierCode,
|
||||
type AvailableModel, type StageAssignment, type ModelConfig, type TestResult,
|
||||
} from './types';
|
||||
import { COMPONENT_CONFIGS } from './component-configs';
|
||||
import { fetchConfig, fetchModels, fetchStageAssignments, updateStageAssignments, testModel, syncToRedis } from './api';
|
||||
import { StageAssignmentsPanel } from './panels/StageAssignmentsPanel';
|
||||
import { VisionModelsPanel } from './panels/VisionModelsPanel';
|
||||
import { AvailableModelsPanel } from './panels/AvailableModelsPanel';
|
||||
import { TestAnalysisPanel } from './panels/TestAnalysisPanel';
|
||||
import { PromptsPanel } from './panels/PromptsPanel';
|
||||
import { ParametersPanel } from './panels/ParametersPanel';
|
||||
|
||||
export const LLMComponentsConfig: React.FC = () => {
|
||||
const [selectedComponent, setSelectedComponent] = useState<ComponentType>('techniques');
|
||||
const [selectedTier, setSelectedTier] = useState<TierCode>('free');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
||||
const [stageAssignments, setStageAssignments] = useState<Record<string, StageAssignment>>({});
|
||||
const [visionModels, setVisionModels] = useState<ModelConfig[]>([]);
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
|
||||
const [testText, setTestText] = useState('');
|
||||
const [analysisResult, setAnalysisResult] = useState<any>(null);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
|
||||
const config = COMPONENT_CONFIGS[selectedComponent];
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTestResults({});
|
||||
|
||||
try {
|
||||
const [modelsRes, assignmentsRes, configRes] = await Promise.all([
|
||||
fetchModels(config.apiBase),
|
||||
fetchStageAssignments(config.apiBase),
|
||||
fetchConfig(config.apiBase),
|
||||
]);
|
||||
|
||||
if (modelsRes.success) setAvailableModels(modelsRes.data.models || []);
|
||||
if (assignmentsRes.success) setStageAssignments(assignmentsRes.data || {});
|
||||
if (config.hasVision && configRes.success && configRes.data.vision_models) {
|
||||
setVisionModels(configRes.data.vision_models.models || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.apiBase, config.hasVision]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData, selectedComponent]);
|
||||
|
||||
const handleModelChange = (stageKey: string, modelIndex: number, newModelKey: string) => {
|
||||
setStageAssignments((prev) => {
|
||||
const updated = { ...prev };
|
||||
const stage = { ...updated[stageKey] };
|
||||
const tierMap = { ...(stage.modelsByTier || { free: [], premium: [] }) } as Record<TierCode, ModelConfig[]>;
|
||||
const currentTierModels = [...(tierMap[selectedTier] || [])];
|
||||
if (!currentTierModels[modelIndex]) return prev;
|
||||
currentTierModels[modelIndex] = { ...currentTierModels[modelIndex], model_key: newModelKey };
|
||||
tierMap[selectedTier] = currentTierModels;
|
||||
stage.modelsByTier = tierMap;
|
||||
stage.models = currentTierModels;
|
||||
updated[stageKey] = stage;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await updateStageAssignments(config.apiBase, stageAssignments);
|
||||
if (res.success) {
|
||||
setSuccess('Configuration saved successfully!');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} else {
|
||||
setError(res.error || 'Save failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestModel = async (modelKey: string) => {
|
||||
setTestResults((prev) => ({ ...prev, [modelKey]: { model_key: modelKey, status: 'testing' } }));
|
||||
try {
|
||||
const res = await testModel(config.apiBase, modelKey);
|
||||
setTestResults((prev) => ({ ...prev, [modelKey]: res.data }));
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelKey]: { model_key: modelKey, status: 'error', error: (err as Error).message },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!testText.trim()) return;
|
||||
setAnalyzing(true);
|
||||
setAnalysisResult(null);
|
||||
try {
|
||||
const res = await fetch(`${config.apiBase}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText }),
|
||||
});
|
||||
setAnalysisResult(await res.json());
|
||||
} catch (err) {
|
||||
setAnalysisResult({ success: false, error: (err as Error).message });
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRedis = async () => {
|
||||
setSyncing(true);
|
||||
setSyncMessage(null);
|
||||
try {
|
||||
const result = await syncToRedis();
|
||||
if (result.success) {
|
||||
setSyncMessage('Redis synced successfully!');
|
||||
setSuccess('Redis synced from PostgreSQL');
|
||||
setTimeout(() => { setSyncMessage(null); setSuccess(null); }, 4000);
|
||||
} else {
|
||||
setError(result.error || 'Sync failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSpeedColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'ultra_fast': return 'success';
|
||||
case 'fast': return 'info';
|
||||
case 'medium': return 'warning';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'premium': return 'secondary';
|
||||
case 'high': return 'primary';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && availableModels.length === 0) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const analysisComponents: ComponentType[] = ['techniques', 'ai-tampered', 'claims', 'source-assessment', 'verdict', 'moderation'];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box mb={3} display="flex" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<ToggleButtonGroup
|
||||
value={selectedComponent}
|
||||
exclusive
|
||||
onChange={(_, val) => val && setSelectedComponent(val)}
|
||||
size="large"
|
||||
>
|
||||
{analysisComponents.map((key) => {
|
||||
const cfg = COMPONENT_CONFIGS[key];
|
||||
return (
|
||||
<ToggleButton
|
||||
key={key}
|
||||
value={key}
|
||||
sx={{
|
||||
px: 3,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: `${cfg.color}22`,
|
||||
borderColor: cfg.color,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{cfg.icon}
|
||||
<Typography>{cfg.name}</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
);
|
||||
})}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Verdict + Moderation delegated to dedicated components */}
|
||||
{selectedComponent === 'verdict' ? (
|
||||
<>
|
||||
<VerdictConfig />
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#7b1fa2' }}>
|
||||
<PromptsTabIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Verdict Prompts
|
||||
</Typography>
|
||||
<PromptsPanel componentCode="pipeline" />
|
||||
</>
|
||||
) : selectedComponent === 'moderation' ? (
|
||||
<ModerationSettings />
|
||||
) : (
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ color: config.color }}>
|
||||
{config.icon}
|
||||
<Box component="span" ml={1}>{config.name}</Box>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{config.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button startIcon={<RefreshIcon />} onClick={loadData} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={syncing ? <CircularProgress size={18} /> : <SyncIcon />}
|
||||
onClick={handleSyncRedis}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? 'Syncing...' : 'Sync to Redis'}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={loading} sx={{ bgcolor: config.color }}>
|
||||
Save Configuration
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
{syncMessage && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setSyncMessage(null)}>{syncMessage}</Alert>}
|
||||
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
|
||||
<Tab label="Stage Assignments" icon={<SwapVertIcon />} iconPosition="start" />
|
||||
{config.hasVision && <Tab label="Vision Models" icon={<VisionIcon />} iconPosition="start" />}
|
||||
<Tab label="Available Models" icon={<PsychologyIcon />} iconPosition="start" />
|
||||
<Tab label="Parameters" icon={<SettingsIcon />} iconPosition="start" />
|
||||
<Tab label="Prompts" icon={<PromptsTabIcon />} iconPosition="start" />
|
||||
<Tab label="Test Analysis" icon={<PlayIcon />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<>
|
||||
<Paper sx={{
|
||||
mb: 2, p: 2, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', gap: 2, flexWrap: 'wrap',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Subscription tier</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selectedTier === 'free'
|
||||
? 'Plan 1-3 (Freemium / Starter / Basic) — local + cheap fallbacks'
|
||||
: 'Plan 4-6 (Pro / Business / Enterprise) — premium cloud models'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={selectedTier}
|
||||
exclusive
|
||||
size="small"
|
||||
onChange={(_, v) => v && setSelectedTier(v as TierCode)}
|
||||
>
|
||||
<ToggleButton value="free" sx={{ px: 3 }}>FREE</ToggleButton>
|
||||
<ToggleButton value="premium" sx={{ px: 3 }}>PREMIUM</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
<StageAssignmentsPanel
|
||||
stages={config.stages}
|
||||
stageAssignments={stageAssignments}
|
||||
availableModels={availableModels}
|
||||
testResults={testResults}
|
||||
selectedTier={selectedTier}
|
||||
onModelChange={handleModelChange}
|
||||
onTestModel={handleTestModel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.hasVision && tabValue === 1 && (
|
||||
<VisionModelsPanel visionModels={visionModels} />
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 2 : 1) && (
|
||||
<AvailableModelsPanel
|
||||
models={availableModels}
|
||||
testResults={testResults}
|
||||
onTestModel={handleTestModel}
|
||||
getSpeedColor={getSpeedColor}
|
||||
getQualityColor={getQualityColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 3 : 2) && (
|
||||
<ParametersPanel
|
||||
componentType={selectedComponent}
|
||||
apiBase={config.apiBase}
|
||||
onSuccess={(msg) => { setSuccess(msg); setTimeout(() => setSuccess(null), 4000); }}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 4 : 3) && (
|
||||
<PromptsPanel componentCode={selectedComponent} />
|
||||
)}
|
||||
|
||||
{tabValue === (config.hasVision ? 5 : 4) && (
|
||||
<TestAnalysisPanel
|
||||
componentType={selectedComponent}
|
||||
testText={testText}
|
||||
setTestText={setTestText}
|
||||
analyzing={analyzing}
|
||||
analysisResult={analysisResult}
|
||||
onAnalyze={handleAnalyze}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Available models panel — flat list of every model defined in PG, with
|
||||
* test buttons + the latest test result inline.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Chip, Typography, Button, Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon, Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { AvailableModel, TestResult } from '../types';
|
||||
|
||||
interface Props {
|
||||
models: AvailableModel[];
|
||||
testResults: Record<string, TestResult>;
|
||||
onTestModel: (modelKey: string) => void;
|
||||
getSpeedColor: (tier: string) => any;
|
||||
getQualityColor: (tier: string) => any;
|
||||
}
|
||||
|
||||
export const AvailableModelsPanel: React.FC<Props> = ({
|
||||
models, testResults, onTestModel, getSpeedColor, getQualityColor,
|
||||
}) => (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Context</TableCell>
|
||||
<TableCell>Speed</TableCell>
|
||||
<TableCell>Quality</TableCell>
|
||||
<TableCell>Cost ($/1M)</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Test</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{models.map((model) => {
|
||||
const testResult = testResults[model.model_key];
|
||||
return (
|
||||
<TableRow key={model.model_key}>
|
||||
<TableCell>
|
||||
<Typography fontWeight="bold">{model.model_name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{model.model_key}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={model.provider} size="small" /></TableCell>
|
||||
<TableCell>{(model.context_window / 1000).toFixed(0)}K</TableCell>
|
||||
<TableCell><Chip label={model.speed_tier} size="small" color={getSpeedColor(model.speed_tier)} /></TableCell>
|
||||
<TableCell><Chip label={model.quality_tier} size="small" color={getQualityColor(model.quality_tier)} /></TableCell>
|
||||
<TableCell>
|
||||
{model.cost_input_1m === 0 ? (
|
||||
<Chip label="FREE" size="small" color="success" />
|
||||
) : (
|
||||
`$${model.cost_input_1m} / $${model.cost_output_1m}`
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{testResult?.status === 'connected' && (
|
||||
<Chip icon={<CheckIcon />} label={`${testResult.response_time_ms}ms`} size="small" color="success" />
|
||||
)}
|
||||
{testResult?.status === 'error' && (
|
||||
<Tooltip title={testResult.error}><Chip icon={<CloseIcon />} label="Error" size="small" color="error" /></Tooltip>
|
||||
)}
|
||||
{testResult?.status === 'testing' && <CircularProgress size={20} />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" startIcon={<PlayIcon />} onClick={() => onTestModel(model.model_key)} disabled={testResult?.status === 'testing'}>
|
||||
Test
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Parameters panel — editable scoring config per analysis component.
|
||||
*
|
||||
* Reads from `/api/input-profiles/scoring-config/:component` (PG-backed via
|
||||
* didiFramework, not Redis), flattens nested objects into dot-keys, filters
|
||||
* out scaffolding (EXCLUDE_KEYS), and renders a row per scalar value.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Alert, Button, Chip, Tooltip, CircularProgress,
|
||||
TableContainer, Table, TableHead, TableBody, TableRow, TableCell, TextField,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Save as SaveIcon, Settings as SettingsIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ComponentType } from '../types';
|
||||
import { PARAM_INFO, EXCLUDE_KEYS, flattenConfig, setNestedValue } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
componentType: ComponentType;
|
||||
apiBase: string;
|
||||
onSuccess: (msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const ParametersPanel: React.FC<Props> = ({ componentType, apiBase, onSuccess, onError }) => {
|
||||
void apiBase;
|
||||
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [editedConfig, setEditedConfig] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
setConfig(data.data);
|
||||
setEditedConfig(JSON.parse(JSON.stringify(data.data)));
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [componentType, onError]);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editedConfig) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editedConfig),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.success) {
|
||||
onError(result.error || 'Failed to save');
|
||||
return;
|
||||
}
|
||||
setConfig(JSON.parse(JSON.stringify(editedConfig)));
|
||||
onSuccess('Parameters saved! Sync to Redis to apply.');
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>;
|
||||
if (!editedConfig) return <Alert severity="info">No scoring config found for {componentType}</Alert>;
|
||||
|
||||
const flatParams = flattenConfig(editedConfig)
|
||||
.filter(p => !EXCLUDE_KEYS.some(ex => p.key === ex || p.key.startsWith(ex + '.')));
|
||||
|
||||
const isModified = JSON.stringify(config) !== JSON.stringify(editedConfig);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
<SettingsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Scoring Parameters
|
||||
</Typography>
|
||||
<Box display="flex" gap={1}>
|
||||
{isModified && (
|
||||
<Button variant="text" onClick={() => setEditedConfig(JSON.parse(JSON.stringify(config)))}>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={18} /> : <SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified || saving}
|
||||
>
|
||||
Save Parameters
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isModified && <Alert severity="warning" sx={{ mb: 2 }}>Unsaved changes. Save and then Sync to Redis to apply.</Alert>}
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Parameter</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, width: 200 }}>Value</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, width: 120 }}>Type</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{flatParams.map(({ key, value }) => {
|
||||
const info = PARAM_INFO[key];
|
||||
const label = info?.label || key;
|
||||
const tooltip = info?.tooltip || '';
|
||||
const valueType = typeof value;
|
||||
|
||||
return (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip} arrow placement="right">
|
||||
<InfoIcon sx={{ fontSize: 16, color: 'text.secondary', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" fontFamily="monospace">{key}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{valueType === 'boolean' ? (
|
||||
<Chip
|
||||
label={value ? 'ON' : 'OFF'}
|
||||
size="small"
|
||||
color={value ? 'success' : 'default'}
|
||||
onClick={() => setEditedConfig((prev: any) => setNestedValue(prev, key, !value))}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
) : valueType === 'string' ? (
|
||||
<TextField
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => setEditedConfig((prev: any) => setNestedValue(prev, key, e.target.value))}
|
||||
sx={{ width: 180 }}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (!isNaN(v)) setEditedConfig((prev: any) => setNestedValue(prev, key, v));
|
||||
}}
|
||||
sx={{ width: 120 }}
|
||||
inputProps={{ step: value < 1 ? 0.01 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={valueType} size="small" variant="outlined"
|
||||
color={valueType === 'number' ? 'primary' : valueType === 'boolean' ? 'success' : 'default'} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Prompts panel — edit system_prompt + user_template per (component, stage).
|
||||
*
|
||||
* Fetches the active component's prompts plus the SHARED prompts:
|
||||
* - vision → shown for techniques/ai-tampered/claims (image OCR + AI detect)
|
||||
* - pipeline → shown for verdict (final-verdict explanation prompt)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Alert, Accordion, AccordionSummary, AccordionDetails,
|
||||
TextField, Button, Chip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon, Save as SaveIcon,
|
||||
Description as PromptsTabIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { PromptComponentCode } from '../types';
|
||||
|
||||
interface PromptData {
|
||||
prompt_id: number;
|
||||
component_code: string;
|
||||
stage_code: string;
|
||||
system_prompt: string;
|
||||
user_template: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
componentCode: PromptComponentCode;
|
||||
}
|
||||
|
||||
export const PromptsPanel: React.FC<Props> = ({ componentCode }) => {
|
||||
const [prompts, setPrompts] = useState<PromptData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [editedPrompts, setEditedPrompts] = useState<Record<number, Partial<PromptData>>>({});
|
||||
|
||||
const fetchPrompts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const components = [componentCode];
|
||||
if (['techniques', 'ai-tampered', 'claims'].includes(componentCode)) {
|
||||
components.push('vision');
|
||||
}
|
||||
if (componentCode === 'verdict' || componentCode === 'pipeline') {
|
||||
components.push('pipeline');
|
||||
}
|
||||
|
||||
const allPrompts: PromptData[] = [];
|
||||
for (const comp of components) {
|
||||
const res = await fetch(`/framework/api/providers/prompts?component=${comp}`);
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
allPrompts.push(...data.data);
|
||||
}
|
||||
}
|
||||
|
||||
setPrompts(allPrompts);
|
||||
setEditedPrompts({});
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [componentCode]);
|
||||
|
||||
useEffect(() => { fetchPrompts(); }, [fetchPrompts]);
|
||||
|
||||
const handleFieldChange = (promptId: number, field: 'system_prompt' | 'user_template', value: string) => {
|
||||
setEditedPrompts(prev => ({
|
||||
...prev,
|
||||
[promptId]: { ...prev[promptId], [field]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const isEdited = (promptId: number) => editedPrompts[promptId] !== undefined;
|
||||
|
||||
const handleSave = async (prompt: PromptData) => {
|
||||
const edits = editedPrompts[prompt.prompt_id];
|
||||
if (!edits) return;
|
||||
|
||||
setSaving(prompt.prompt_id);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/framework/api/providers/prompts/${prompt.prompt_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSuccess(`Prompt "${prompt.stage_code}" saved! Remember to Sync to Redis.`);
|
||||
setEditedPrompts(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[prompt.prompt_id];
|
||||
return next;
|
||||
});
|
||||
fetchPrompts();
|
||||
setTimeout(() => setSuccess(null), 5000);
|
||||
} else {
|
||||
setError(data.error || 'Failed to save');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{prompts.length === 0 ? (
|
||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
No prompts configured for {componentCode}. Prompts can be added via API.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
prompts.map((prompt) => {
|
||||
const edited = editedPrompts[prompt.prompt_id];
|
||||
const currentSystem = edited?.system_prompt ?? prompt.system_prompt;
|
||||
const currentUser = edited?.user_template ?? prompt.user_template;
|
||||
|
||||
return (
|
||||
<Accordion key={prompt.prompt_id} defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={2} width="100%">
|
||||
<PromptsTabIcon color="action" />
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6">
|
||||
{prompt.stage_code}
|
||||
{prompt.component_code !== componentCode && (
|
||||
<Chip label={prompt.component_code} size="small" sx={{ ml: 1 }} color="info" variant="outlined" />
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{prompt.description}</Typography>
|
||||
</Box>
|
||||
{isEdited(prompt.prompt_id) && (
|
||||
<Chip label="Modified" size="small" color="warning" />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>System Prompt</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
value={currentSystem}
|
||||
onChange={(e) => handleFieldChange(prompt.prompt_id, 'system_prompt', e.target.value)}
|
||||
sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>User Template</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={8}
|
||||
value={currentUser}
|
||||
onChange={(e) => handleFieldChange(prompt.prompt_id, 'user_template', e.target.value)}
|
||||
sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="flex-end" gap={1}>
|
||||
{isEdited(prompt.prompt_id) && (
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setEditedPrompts(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[prompt.prompt_id];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving === prompt.prompt_id ? <CircularProgress size={18} /> : <SaveIcon />}
|
||||
onClick={() => handleSave(prompt)}
|
||||
disabled={!isEdited(prompt.prompt_id) || saving === prompt.prompt_id}
|
||||
>
|
||||
Save Prompt
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Stage assignments table — one accordion per pipeline stage, with the
|
||||
* tier-specific (free/premium) model chain editable inside.
|
||||
*
|
||||
* If the requested tier is empty (e.g. premium not configured yet), falls
|
||||
* back to the free chain and shows a warning Alert pointing to the API to
|
||||
* create the missing tier rows.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Accordion, AccordionSummary, AccordionDetails, Typography, Chip, Alert,
|
||||
TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
FormControl, Select, MenuItem, IconButton, Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon, PlayArrow as PlayIcon,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { StageConfig, StageAssignment, AvailableModel, TestResult, TierCode } from '../types';
|
||||
|
||||
interface Props {
|
||||
stages: StageConfig[];
|
||||
stageAssignments: Record<string, StageAssignment>;
|
||||
availableModels: AvailableModel[];
|
||||
testResults: Record<string, TestResult>;
|
||||
selectedTier: TierCode;
|
||||
onModelChange: (stageKey: string, modelIndex: number, newModelKey: string) => void;
|
||||
onTestModel: (modelKey: string) => void;
|
||||
}
|
||||
|
||||
export const StageAssignmentsPanel: React.FC<Props> = ({
|
||||
stages, stageAssignments, availableModels, testResults, selectedTier, onModelChange, onTestModel,
|
||||
}) => (
|
||||
<Box>
|
||||
{stages.map(({ key, title, icon, description, color }) => {
|
||||
const stage = stageAssignments[key];
|
||||
if (!stage) return null;
|
||||
|
||||
const tierModels =
|
||||
stage.modelsByTier?.[selectedTier] && stage.modelsByTier[selectedTier].length > 0
|
||||
? stage.modelsByTier[selectedTier]
|
||||
: (stage.modelsByTier?.free && stage.modelsByTier.free.length > 0
|
||||
? stage.modelsByTier.free
|
||||
: stage.models);
|
||||
const tierBadge = selectedTier === 'premium' ? 'PREMIUM' : 'FREE';
|
||||
const tierBadgeColor: 'success' | 'warning' = selectedTier === 'premium' ? 'warning' : 'success';
|
||||
const missingPremium = selectedTier === 'premium'
|
||||
&& (!stage.modelsByTier?.premium || stage.modelsByTier.premium.length === 0);
|
||||
|
||||
return (
|
||||
<Accordion key={`${key}-${selectedTier}`} defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={2} width="100%">
|
||||
<Box sx={{ color }}>{icon}</Box>
|
||||
<Box flex={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Chip label={tierBadge} size="small" color={tierBadgeColor} />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">{description}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{missingPremium && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
No <strong>premium</strong> assignments configured for this stage — showing <strong>free</strong> chain
|
||||
as fallback. Create premium rows via POST /api/providers/assignments with <code>tier: "premium"</code>.
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={80}>Order</TableCell>
|
||||
<TableCell width={120}>Role</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell width={100}>Temp</TableCell>
|
||||
<TableCell width={120}>Max Tokens</TableCell>
|
||||
<TableCell width={120}>Timeout</TableCell>
|
||||
<TableCell width={100}>Status</TableCell>
|
||||
<TableCell width={80}>Test</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tierModels.map((model, index) => {
|
||||
const modelKey = model.model_key || '';
|
||||
const testResult = testResults[modelKey];
|
||||
|
||||
return (
|
||||
<TableRow key={model.order}>
|
||||
<TableCell>
|
||||
<Chip label={model.order} size="small" color={model.order === 1 ? 'primary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={model.role.replace('_', ' ').toUpperCase()}
|
||||
size="small"
|
||||
variant={model.role === 'primary' ? 'filled' : 'outlined'}
|
||||
color={model.role === 'primary' ? 'success' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={modelKey}
|
||||
onChange={(e) => onModelChange(key, index, e.target.value)}
|
||||
>
|
||||
{availableModels.map((m) => (
|
||||
<MenuItem key={m.model_key} value={m.model_key}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography>{m.model_name}</Typography>
|
||||
<Chip label={m.provider} size="small" variant="outlined" />
|
||||
{m.cost_input_1m === 0 && <Chip label="FREE" size="small" color="success" />}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>{model.temperature != null ? model.temperature : '-'}</TableCell>
|
||||
<TableCell>{model.max_tokens != null ? model.max_tokens : '-'}</TableCell>
|
||||
<TableCell>{model.timeout_ms != null ? `${model.timeout_ms / 1000}s` : '-'}</TableCell>
|
||||
<TableCell>
|
||||
{testResult?.status === 'connected' && (
|
||||
<Tooltip title={`${testResult.response_time_ms}ms`}>
|
||||
<Chip icon={<CheckIcon />} label="OK" size="small" color="success" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{testResult?.status === 'error' && (
|
||||
<Tooltip title={testResult.error}>
|
||||
<Chip icon={<CloseIcon />} label="Error" size="small" color="error" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{testResult?.status === 'testing' && <CircularProgress size={20} />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onTestModel(modelKey)} disabled={testResult?.status === 'testing'}>
|
||||
<PlayIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Test Analysis panel — quick-run UI for the active component's /analyze
|
||||
* endpoint. Per-component sample texts let QA reproduce known-good results
|
||||
* without copy-pasting from a doc.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, TextField, Button, Alert, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow as PlayIcon } from '@mui/icons-material';
|
||||
import type { ComponentType } from '../types';
|
||||
|
||||
interface Props {
|
||||
componentType: ComponentType;
|
||||
testText: string;
|
||||
setTestText: (text: string) => void;
|
||||
analyzing: boolean;
|
||||
analysisResult: any;
|
||||
onAnalyze: () => void;
|
||||
}
|
||||
|
||||
const SAMPLE_TEXTS: Record<ComponentType, string> = {
|
||||
'techniques': `URGENT: This is a THREAT to our survival! The government is hiding the truth.
|
||||
We must ACT NOW before it's too late! They don't want you to know about this conspiracy.
|
||||
Share this before they delete it! This is being censored everywhere.`,
|
||||
'ai-tampered': `It is important to note that there are several key considerations to keep in mind.
|
||||
Furthermore, one must acknowledge the multifaceted nature of this issue.
|
||||
In conclusion, while there are valid perspectives on both sides, a balanced approach is recommended.`,
|
||||
'claims': `The Eiffel Tower is 500 meters tall. Romania joined the EU in 2007.
|
||||
The vaccine has a 95% efficacy rate according to clinical trials.
|
||||
The GDP of Romania grew by 5% in 2024.`,
|
||||
'source-assessment': `According to a report by Reuters, the new climate agreement was signed by 50 countries.
|
||||
The article was written by journalist John Smith and published on fortune.com.
|
||||
The data comes from the United Nations Environment Programme.`,
|
||||
'verdict': '',
|
||||
'moderation': '',
|
||||
};
|
||||
|
||||
export const TestAnalysisPanel: React.FC<Props> = ({
|
||||
componentType, testText, setTestText, analyzing, analysisResult, onAnalyze,
|
||||
}) => (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
<Box sx={{ flex: '1 1 45%', minWidth: 400 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Input Text</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder="Enter text to analyze..."
|
||||
/>
|
||||
<Box mt={2} display="flex" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={analyzing ? <CircularProgress size={20} /> : <PlayIcon />}
|
||||
onClick={onAnalyze}
|
||||
disabled={analyzing || !testText.trim()}
|
||||
>
|
||||
{analyzing ? 'Analyzing...' : 'Run Analysis'}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => setTestText(SAMPLE_TEXTS[componentType])}>
|
||||
Load Sample
|
||||
</Button>
|
||||
<Button onClick={() => setTestText('')}>Clear</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 45%', minWidth: 400 }}>
|
||||
<Paper sx={{ p: 2, minHeight: 400 }}>
|
||||
<Typography variant="h6" gutterBottom>Results</Typography>
|
||||
{!analysisResult && !analyzing && (
|
||||
<Typography color="text.secondary">Run an analysis to see results.</Typography>
|
||||
)}
|
||||
{analyzing && (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{analysisResult && !analyzing && (
|
||||
<Box>
|
||||
{analysisResult.success ? (
|
||||
<Box component="pre" sx={{ overflow: 'auto', maxHeight: 400, bgcolor: 'action.hover', p: 2, borderRadius: 1 }}>
|
||||
{JSON.stringify(analysisResult.data, null, 2)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="error">{analysisResult.error}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Vision Models panel — displayed only for AI Tampered (the one component
|
||||
* that runs frame-level analysis through the vision cascade).
|
||||
*
|
||||
* Read-only listing; vision config edits go through the agent-v3 admin API
|
||||
* (not yet PG-backed), so the UI doesn't expose direct row editing here.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Chip, Typography,
|
||||
} from '@mui/material';
|
||||
import { Visibility as VisionIcon } from '@mui/icons-material';
|
||||
import type { ModelConfig } from '../types';
|
||||
|
||||
interface Props {
|
||||
visionModels: ModelConfig[];
|
||||
}
|
||||
|
||||
export const VisionModelsPanel: React.FC<Props> = ({ visionModels }) => (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<VisionIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Vision Models for Image AI Detection
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
These models are used to analyze images for AI-generated content indicators.
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Order</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Timeout</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{visionModels.map((model) => (
|
||||
<TableRow key={model.order}>
|
||||
<TableCell>
|
||||
<Chip label={model.order} size="small" color={model.order === 1 ? 'primary' : 'default'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={(model.role || 'primary').replace('_', ' ').toUpperCase()}
|
||||
size="small"
|
||||
color={model.role === 'primary' ? 'success' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography fontFamily="monospace">{model.model}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{model.name}</TableCell>
|
||||
<TableCell>{model.timeout ? `${model.timeout / 1000}s` : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Domain types for the LLM Components Configuration page.
|
||||
*
|
||||
* The shape mirrors PG-side schemas (assignments / models / configs),
|
||||
* with helper-only fields prefixed `_` (e.g. `_stage_id`) that the UI
|
||||
* uses to call PUT endpoints by id without flowing through serializers.
|
||||
*/
|
||||
import type React from 'react';
|
||||
|
||||
export type ComponentType = 'techniques' | 'ai-tampered' | 'claims' | 'source-assessment' | 'verdict' | 'moderation';
|
||||
export type PromptComponentCode = ComponentType | 'vision' | 'pipeline';
|
||||
export type TierCode = 'free' | 'premium';
|
||||
|
||||
export interface ModelConfig {
|
||||
order: number;
|
||||
role: 'primary' | 'fallback_1' | 'fallback_2' | 'fallback_3';
|
||||
model_key?: string;
|
||||
model?: string; // for vision models
|
||||
name?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
timeout_ms?: number;
|
||||
timeout?: number; // legacy alias used by vision models
|
||||
_stage_id?: number;
|
||||
_provider_id?: number;
|
||||
_model_id?: number;
|
||||
_tier?: TierCode;
|
||||
}
|
||||
|
||||
export interface StageAssignment {
|
||||
stage: string;
|
||||
description: string;
|
||||
models: ModelConfig[];
|
||||
modelsByTier?: Record<TierCode, ModelConfig[]>;
|
||||
}
|
||||
|
||||
export interface AvailableModel {
|
||||
model_key: string;
|
||||
provider: string;
|
||||
model_name: string;
|
||||
context_window: number;
|
||||
cost_input_1m: number;
|
||||
cost_output_1m: number;
|
||||
speed_tier: string;
|
||||
quality_tier: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
model_key: string;
|
||||
status: 'connected' | 'error' | 'testing';
|
||||
response_time_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
color: string;
|
||||
apiBase: string;
|
||||
stages: StageConfig[];
|
||||
hasVision?: boolean;
|
||||
}
|
||||
|
||||
export interface StageConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, Button, Chip, Alert, CircularProgress, Divider, Card, CardContent, Grid,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, ToggleButton, ToggleButtonGroup, TextField,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as BackIcon, CheckCircle as ApproveIcon, EditNote as EditIcon, Block as RejectIcon,
|
||||
PersonAdd as ClaimIcon, Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
getQueueEntry, claimQueueEntry, resolveQueueEntry,
|
||||
priorityLabel, priorityColor, statusColor, ageMinutes,
|
||||
type QueueEntry, type ResolutionAction,
|
||||
} from './api';
|
||||
|
||||
export const ModerationDetail: React.FC = () => {
|
||||
const { queueId } = useParams<{ queueId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [entry, setEntry] = useState<QueueEntry | null>(null);
|
||||
const [session, setSession] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
const [resolveAction, setResolveAction] = useState<ResolutionAction>('approved');
|
||||
const [resolveNotes, setResolveNotes] = useState('');
|
||||
const [resolveCorrections, setResolveCorrections] = useState('{\n \n}');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!queueId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getQueueEntry(queueId);
|
||||
if (res.success && res.data) {
|
||||
setEntry(res.data.queue);
|
||||
setSession(res.data.session);
|
||||
} else {
|
||||
setError(res.error ?? 'Failed to load');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queueId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleClaim = async () => {
|
||||
if (!queueId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await claimQueueEntry(queueId);
|
||||
if (res.success) {
|
||||
setSuccess('Claimed. You can now resolve.');
|
||||
load();
|
||||
} else {
|
||||
setError(res.error ?? 'Claim failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openResolve = (action: ResolutionAction) => {
|
||||
setResolveAction(action);
|
||||
setResolveNotes('');
|
||||
setResolveCorrections('{\n \n}');
|
||||
setResolveOpen(true);
|
||||
};
|
||||
|
||||
const handleResolveSubmit = async () => {
|
||||
if (!queueId) return;
|
||||
let corrections: Record<string, unknown> | null = null;
|
||||
if (resolveAction === 'corrected') {
|
||||
try {
|
||||
corrections = JSON.parse(resolveCorrections);
|
||||
} catch (e) {
|
||||
setError(`Invalid JSON in corrections: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await resolveQueueEntry(queueId, {
|
||||
action: resolveAction,
|
||||
corrections,
|
||||
notes: resolveNotes || null,
|
||||
user_id: 'admin',
|
||||
});
|
||||
if (res.success) {
|
||||
setSuccess(`Resolved as '${resolveAction}'`);
|
||||
setResolveOpen(false);
|
||||
load();
|
||||
} else {
|
||||
setError(res.error ?? 'Resolve failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Alert severity="error" action={<Button onClick={() => navigate('/moderation')}>Back</Button>}>
|
||||
{error ?? 'Not found'}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isActionable = entry.status === 'pending' || entry.status === 'in_review';
|
||||
const inReview = entry.status === 'in_review';
|
||||
|
||||
// Pull common fields from session safely
|
||||
const s = (session ?? {}) as Record<string, unknown>;
|
||||
const inputText = (s.input_text as string | null) ?? null;
|
||||
const inputUrl = (s.input_url as string | null) ?? null;
|
||||
const inputType = (s.input_type as string | null) ?? null;
|
||||
const userEmail = (s.user_email as string | null) ?? null;
|
||||
const riskScore = (s.risk_score as number | null) ?? null;
|
||||
const riskCategory = (s.risk_category as string | null) ?? null;
|
||||
const confidence = (s.confidence as number | null) ?? null;
|
||||
const severity = (s.severity as string | null) ?? null;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Button startIcon={<BackIcon />} onClick={() => navigate('/moderation')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Typography variant="h5">Queue #{entry.queue_id}</Typography>
|
||||
<Chip label={entry.status} color={statusColor(entry.status)} size="small" />
|
||||
<Chip label={priorityLabel(entry.priority)} color={priorityColor(entry.priority)} size="small" />
|
||||
</Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Paper sx={{ p: 2, mb: 2, bgcolor: isActionable ? 'background.paper' : 'action.hover' }}>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
{entry.status === 'pending' && (
|
||||
<Button
|
||||
variant="contained" color="primary" startIcon={<ClaimIcon />}
|
||||
onClick={handleClaim} disabled={submitting}
|
||||
>
|
||||
Claim for Review
|
||||
</Button>
|
||||
)}
|
||||
{inReview && (
|
||||
<>
|
||||
<Button variant="contained" color="success" startIcon={<ApproveIcon />} onClick={() => openResolve('approved')} disabled={submitting}>
|
||||
Approve as is
|
||||
</Button>
|
||||
<Button variant="contained" color="info" startIcon={<EditIcon />} onClick={() => openResolve('corrected')} disabled={submitting}>
|
||||
Resolve with Corrections
|
||||
</Button>
|
||||
<Button variant="outlined" color="error" startIcon={<RejectIcon />} onClick={() => openResolve('rejected')} disabled={submitting}>
|
||||
Reject (low quality input)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isActionable && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status is <strong>{entry.status}</strong>.
|
||||
{entry.resolved_by && ` Resolved by ${entry.resolved_by} as '${entry.resolution_action}'.`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* Left: Session input */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Input</Typography>
|
||||
<Box display="flex" gap={1} mb={1} flexWrap="wrap">
|
||||
<Chip label={`type: ${inputType ?? '?'}`} size="small" variant="outlined" />
|
||||
{userEmail && <Chip label={userEmail} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
{inputText && (
|
||||
<Box sx={{ maxHeight: 400, overflow: 'auto', bgcolor: 'background.default', p: 2, borderRadius: 1, mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{inputText}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{inputUrl && (
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" color="text.secondary">URL</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>{inputUrl}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Right: AI verdict summary */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>AI Verdict</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap" mb={2}>
|
||||
{riskCategory && <Chip label={riskCategory} color="primary" />}
|
||||
{severity && <Chip label={`severity: ${severity}`} variant="outlined" />}
|
||||
</Box>
|
||||
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Risk Score</Typography>
|
||||
<Typography variant="h4">{riskScore ?? '—'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Confidence</Typography>
|
||||
<Typography variant="h4">{confidence ?? '—'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" color="text.secondary">Components</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap" mt={0.5}>
|
||||
{(s.components_run as string[] | null)?.map((c) => (
|
||||
<Chip key={c} label={c} size="small" color="success" variant="outlined" />
|
||||
))}
|
||||
{(s.components_skipped as string[] | null)?.map((c) => (
|
||||
<Chip key={`s-${c}`} label={c} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Queue meta */}
|
||||
<Grid size={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Queue Metadata</Typography>
|
||||
<Box display="grid" gridTemplateColumns="repeat(4, 1fr)" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Reason</Typography>
|
||||
<Typography variant="body2">{entry.enqueue_reason}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Created</Typography>
|
||||
<Typography variant="body2">{ageMinutes(entry.created_at)}m ago</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Assigned</Typography>
|
||||
<Typography variant="body2">{entry.assigned_to ?? '—'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Resolved</Typography>
|
||||
<Typography variant="body2">{entry.resolved_by ?? '—'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{entry.enqueue_meta && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="caption" color="text.secondary">Triage meta</Typography>
|
||||
<pre style={{ background: 'var(--mui-palette-background-default,#111)', color: 'var(--mui-palette-text-primary,#e0e0e0)', padding: 8, borderRadius: 4, fontSize: 12, overflow: 'auto' }}>
|
||||
{JSON.stringify(entry.enqueue_meta, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Resolve Dialog */}
|
||||
<Dialog open={resolveOpen} onClose={() => setResolveOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Resolve Queue Entry #{entry.queue_id}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<ToggleButtonGroup
|
||||
value={resolveAction}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setResolveAction(v)}
|
||||
>
|
||||
<ToggleButton value="approved">Approved (no change)</ToggleButton>
|
||||
<ToggleButton value="corrected">Corrected</ToggleButton>
|
||||
<ToggleButton value="rejected">Rejected</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{resolveAction === 'corrected' && (
|
||||
<>
|
||||
<Alert severity="info">
|
||||
Provide corrections as a JSON diff. Example:<br/>
|
||||
<code style={{ fontSize: 11 }}>
|
||||
{`{ "verdict": { "risk_score": { "from": 67, "to": 45 } }, "techniques": { "removed": ["false_dilemma"] } }`}
|
||||
</code>
|
||||
</Alert>
|
||||
<TextField
|
||||
label="Corrections (JSON)"
|
||||
value={resolveCorrections}
|
||||
onChange={(e) => setResolveCorrections(e.target.value)}
|
||||
multiline
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
sx={{ '& textarea': { fontFamily: 'monospace', fontSize: 13 } }}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Notes (optional)"
|
||||
value={resolveNotes}
|
||||
onChange={(e) => setResolveNotes(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setResolveOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleResolveSubmit} disabled={submitting}>
|
||||
{submitting ? 'Saving…' : 'Confirm Resolve'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationDetail;
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Chip, IconButton, ToggleButtonGroup, ToggleButton, CircularProgress, Alert, Button, Pagination,
|
||||
} from '@mui/material';
|
||||
import { Refresh as RefreshIcon, OpenInNew as OpenIcon, Shield as ShieldIcon } from '@mui/icons-material';
|
||||
import { listQueue, priorityLabel, priorityColor, statusColor, ageMinutes, type QueueEntry, type QueueStatus } from './api';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const ModerationQueue: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<QueueEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<QueueStatus | 'all'>('pending');
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: { status?: string; priority?: string; limit: number; offset: number } = {
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
};
|
||||
if (statusFilter !== 'all') params.status = statusFilter;
|
||||
if (priorityFilter) params.priority = priorityFilter;
|
||||
const res = await listQueue(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data);
|
||||
setTotal(res.total ?? res.data.length);
|
||||
} else {
|
||||
setError(res.error ?? 'Failed to load queue');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, priorityFilter, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Auto-refresh every 30s when on pending tab
|
||||
useEffect(() => {
|
||||
if (statusFilter !== 'pending') return;
|
||||
const t = setInterval(load, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [statusFilter, load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
<ShieldIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#0288d1' }} />
|
||||
Moderation Queue
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sessions flagged by triage or user reports for human review.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button variant="outlined" onClick={() => navigate('/moderation/stats')}>
|
||||
View Stats
|
||||
</Button>
|
||||
<Button startIcon={<RefreshIcon />} onClick={load} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box display="flex" gap={2} flexWrap="wrap" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Status</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={statusFilter}
|
||||
exclusive
|
||||
onChange={(_, v) => v && (setPage(1), setStatusFilter(v))}
|
||||
>
|
||||
<ToggleButton value="pending">Pending</ToggleButton>
|
||||
<ToggleButton value="in_review">In Review</ToggleButton>
|
||||
<ToggleButton value="resolved">Resolved</ToggleButton>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Priority</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={priorityFilter}
|
||||
exclusive
|
||||
onChange={(_, v) => (setPage(1), setPriorityFilter(v ?? ''))}
|
||||
>
|
||||
<ToggleButton value="">Any</ToggleButton>
|
||||
<ToggleButton value="1">1</ToggleButton>
|
||||
<ToggleButton value="2">2</ToggleButton>
|
||||
<ToggleButton value="3">3</ToggleButton>
|
||||
<ToggleButton value="4">4</ToggleButton>
|
||||
<ToggleButton value="5">5</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ ml: 'auto' }} color="text.secondary">
|
||||
{total} total
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
<Paper>
|
||||
{loading && items.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>
|
||||
) : items.length === 0 ? (
|
||||
<Box p={4} textAlign="center">
|
||||
<Typography color="text.secondary">No items match these filters.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Queue ID</TableCell>
|
||||
<TableCell>Priority</TableCell>
|
||||
<TableCell>Reason</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Assigned</TableCell>
|
||||
<TableCell>Age</TableCell>
|
||||
<TableCell>Session</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((it) => (
|
||||
<TableRow key={it.queue_id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/moderation/${it.queue_id}`)}>
|
||||
<TableCell>#{it.queue_id}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={priorityLabel(it.priority)} color={priorityColor(it.priority)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={it.enqueue_reason} variant="outlined" size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={it.status} color={statusColor(it.status)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{it.assigned_to ?? '—'}</TableCell>
|
||||
<TableCell>{ageMinutes(it.created_at)}m</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{it.session_id.slice(0, 8)}…
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/moderation/${it.queue_id}`); }}>
|
||||
<OpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<Pagination count={totalPages} page={page} onChange={(_, p) => setPage(p)} size="small" />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationQueue;
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Paper, Card, CardContent, Grid, CircularProgress, Alert, Button, Chip } from '@mui/material';
|
||||
import { Refresh as RefreshIcon, ArrowBack as BackIcon } from '@mui/icons-material';
|
||||
import { getStats, type QueueStats } from './api';
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
pending: '#ff9800',
|
||||
in_review: '#0288d1',
|
||||
resolved_24h: '#2e7d32',
|
||||
};
|
||||
|
||||
export const ModerationStats: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<QueueStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getStats();
|
||||
if (res.success && res.data) setStats(res.data);
|
||||
else setError(res.error ?? 'Failed to load stats');
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return <Box display="flex" justifyContent="center" p={4}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Button startIcon={<BackIcon />} onClick={() => navigate('/moderation')}>Queue</Button>
|
||||
<Typography variant="h4">Moderation Stats</Typography>
|
||||
</Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{stats && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.pending}` }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">Pending</Typography>
|
||||
<Typography variant="h2">{stats.pending}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Awaiting moderator claim</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.in_review}` }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">In Review</Typography>
|
||||
<Typography variant="h2">{stats.in_review}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Currently being moderated</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined" sx={{ borderTop: `4px solid ${STAT_COLORS.resolved_24h}` }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">Resolved (24h)</Typography>
|
||||
<Typography variant="h2">{stats.resolved_24h}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Completed in last 24h</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Open by Priority</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
{Object.keys(stats.by_priority).length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No open entries.</Typography>
|
||||
) : (
|
||||
Object.entries(stats.by_priority).map(([k, v]) => (
|
||||
<Chip key={k} label={`${k}: ${v}`} color={k === 'p1' ? 'error' : k === 'p2' ? 'warning' : 'default'} />
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Avg time in queue</Typography>
|
||||
<Typography variant="h4">
|
||||
{stats.avg_time_in_queue_ms != null
|
||||
? `${(stats.avg_time_in_queue_ms / 1000 / 60).toFixed(1)}m`
|
||||
: '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
From enqueue to claim. Lower is better; spike means moderator capacity issue.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationStats;
|
||||
135
backend/admin-dashboard/src/components/Moderation/api.ts
Normal file
135
backend/admin-dashboard/src/components/Moderation/api.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* API client for Moderation pages — talks to agent-v3 via /agent-v3/api/v3/moderation/*.
|
||||
* (Nginx proxies the path; in dev/staging it goes direct.)
|
||||
*/
|
||||
|
||||
const AGENT_API = '/agent-v3/api/v3/moderation';
|
||||
|
||||
export type EnqueueReason = 'flagged' | 'low_confidence' | 'sensitive_topic' | 'mixed' | 'none';
|
||||
export type QueueStatus = 'pending' | 'in_review' | 'resolved' | 'declined' | 'auto_closed';
|
||||
export type ResolutionAction = 'approved' | 'corrected' | 'rejected';
|
||||
|
||||
export interface QueueEntry {
|
||||
queue_id: number | string;
|
||||
session_id: string;
|
||||
priority: number;
|
||||
enqueue_reason: EnqueueReason;
|
||||
enqueue_meta: Record<string, unknown> | null;
|
||||
status: QueueStatus;
|
||||
assigned_to: string | null;
|
||||
assigned_at: string | null;
|
||||
resolved_at: string | null;
|
||||
resolved_by: string | null;
|
||||
resolution_action: ResolutionAction | null;
|
||||
time_in_queue_ms: number | null;
|
||||
time_in_review_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface QueueDetailResponse {
|
||||
queue: QueueEntry;
|
||||
session: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
pending: number;
|
||||
in_review: number;
|
||||
resolved_24h: number;
|
||||
by_priority: Record<string, number>;
|
||||
avg_time_in_queue_ms: number | null;
|
||||
}
|
||||
|
||||
export interface ResolveBody {
|
||||
action: ResolutionAction;
|
||||
corrections?: Record<string, unknown> | null;
|
||||
notes?: string | null;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function asJson<T>(res: Response): Promise<ApiResponse<T>> {
|
||||
try { return (await res.json()) as ApiResponse<T>; }
|
||||
catch { return { success: false, error: `${res.status} ${res.statusText}` }; }
|
||||
}
|
||||
|
||||
async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const headers: HeadersInit = {
|
||||
...(init.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
export interface ListQueueParams {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigned_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function listQueue(params: ListQueueParams = {}): Promise<ApiResponse<QueueEntry[]>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.status) q.set('status', params.status);
|
||||
if (params.priority) q.set('priority', params.priority);
|
||||
if (params.assigned_to) q.set('assigned_to', params.assigned_to);
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
if (params.offset) q.set('offset', String(params.offset));
|
||||
const res = await authedFetch(`${AGENT_API}/queue?${q}`);
|
||||
return asJson<QueueEntry[]>(res);
|
||||
}
|
||||
|
||||
export async function getQueueEntry(queueId: number | string): Promise<ApiResponse<QueueDetailResponse>> {
|
||||
const res = await authedFetch(`${AGENT_API}/queue/${queueId}`);
|
||||
return asJson<QueueDetailResponse>(res);
|
||||
}
|
||||
|
||||
export async function claimQueueEntry(queueId: number | string, userId?: string): Promise<ApiResponse<QueueEntry>> {
|
||||
const res = await authedFetch(`${AGENT_API}/queue/${queueId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userId ? { user_id: userId } : {}),
|
||||
});
|
||||
return asJson<QueueEntry>(res);
|
||||
}
|
||||
|
||||
export async function resolveQueueEntry(queueId: number | string, body: ResolveBody): Promise<ApiResponse<QueueEntry>> {
|
||||
const res = await authedFetch(`${AGENT_API}/queue/${queueId}/resolve`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return asJson<QueueEntry>(res);
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<ApiResponse<QueueStats>> {
|
||||
const res = await authedFetch(`${AGENT_API}/stats`);
|
||||
return asJson<QueueStats>(res);
|
||||
}
|
||||
|
||||
// Helpers for UI
|
||||
export function priorityLabel(p: number): string {
|
||||
return ({ 1: 'Critical (flagged)', 2: 'High', 3: 'Low confidence', 4: 'Sensitive topic', 5: 'Random' } as Record<number, string>)[p] ?? `P${p}`;
|
||||
}
|
||||
|
||||
export function priorityColor(p: number): 'error' | 'warning' | 'info' | 'success' | 'default' {
|
||||
return ({ 1: 'error', 2: 'warning', 3: 'info', 4: 'info', 5: 'default' } as const)[p as 1 | 2 | 3 | 4 | 5] ?? 'default';
|
||||
}
|
||||
|
||||
export function statusColor(s: QueueStatus): 'warning' | 'info' | 'success' | 'error' | 'default' {
|
||||
return ({ pending: 'warning', in_review: 'info', resolved: 'success', declined: 'default', auto_closed: 'default' } as const)[s] ?? 'default';
|
||||
}
|
||||
|
||||
export function ageMinutes(createdAt: string): number {
|
||||
return Math.round((Date.now() - new Date(createdAt).getTime()) / 60000);
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, FormGroup, Checkbox, Alert } from '@mui/material';
|
||||
import { Save as SaveIcon, Info as InfoIcon } from '@mui/icons-material';
|
||||
import type { ModerationConfig } from './api';
|
||||
import { updateModerationConfig } from './api';
|
||||
|
||||
interface Props {
|
||||
config: ModerationConfig;
|
||||
onSaved: (next: ModerationConfig) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const BrainClientCard: React.FC<Props> = ({ config, onSaved, onError }) => {
|
||||
const [draft, setDraft] = useState<ModerationConfig>(config);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { setDraft(config); }, [config]);
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateModerationConfig({
|
||||
brain_enabled: draft.brain_enabled,
|
||||
brain_url: draft.brain_url,
|
||||
brain_lookup_timeout_ms: draft.brain_lookup_timeout_ms,
|
||||
brain_write_timeout_ms: draft.brain_write_timeout_ms,
|
||||
brain_confidence_min_silver: Number(draft.brain_confidence_min_silver),
|
||||
brain_semantic_threshold: Number(draft.brain_semantic_threshold),
|
||||
brain_per_component: draft.brain_per_component,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
onSaved(res.data);
|
||||
} else {
|
||||
onError(res.error ?? 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700}>Brain Client</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
How didi-backend talks to didi-brain. CLIENT settings only.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.brain_enabled}
|
||||
onChange={(e) => setDraft({ ...draft, brain_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={draft.brain_enabled ? 'Enabled' : 'Disabled'}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 2 }}>
|
||||
These are <strong>client</strong> settings (how didi calls brain). Brain server config
|
||||
(TTL, embeddings, eviction) lives in the AI platform dashboard.
|
||||
</Alert>
|
||||
|
||||
{!draft.brain_enabled && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Brain is OFF. Executors run LLM normally without checking cache.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="grid" gridTemplateColumns="2fr 1fr 1fr" gap={2}>
|
||||
<TextField
|
||||
size="small" fullWidth label="Brain URL"
|
||||
value={draft.brain_url}
|
||||
onChange={(e) => setDraft({ ...draft, brain_url: e.target.value })}
|
||||
helperText="HTTP(S) endpoint of didi-brain (e.g. http://10.11.10.13:8090)"
|
||||
/>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Lookup timeout (ms)"
|
||||
value={draft.brain_lookup_timeout_ms}
|
||||
onChange={(e) => setDraft({ ...draft, brain_lookup_timeout_ms: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Write timeout (ms)"
|
||||
value={draft.brain_write_timeout_ms}
|
||||
onChange={(e) => setDraft({ ...draft, brain_write_timeout_ms: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={3} mt={3}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Confidence threshold for silver write</Typography>
|
||||
<Slider
|
||||
value={Number(draft.brain_confidence_min_silver)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(_, v) => setDraft({ ...draft, brain_confidence_min_silver: Number(v) })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
LLM results with confidence < {draft.brain_confidence_min_silver} get bronze (not served), ≥ get silver
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Semantic match threshold (cosine distance)</Typography>
|
||||
<Slider
|
||||
value={Number(draft.brain_semantic_threshold)}
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.005}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(_, v) => setDraft({ ...draft, brain_semantic_threshold: Number(v) })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Lower = more strict match. Default 0.08 ≈ similarity 0.92
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography variant="body2" gutterBottom>Per-component cache opt-in</Typography>
|
||||
<FormGroup row>
|
||||
{(['techniques', 'ai_tampered', 'claims'] as const).map((comp) => (
|
||||
<FormControlLabel
|
||||
key={comp}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!draft.brain_per_component?.[comp]}
|
||||
onChange={(e) => setDraft({
|
||||
...draft,
|
||||
brain_per_component: { ...draft.brain_per_component, [comp]: e.target.checked },
|
||||
})}
|
||||
/>
|
||||
}
|
||||
label={comp.replace('_', ' ')}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Brain Client'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Switch, Table, TableBody, TableCell, TableHead, TableRow, Chip } from '@mui/material';
|
||||
import type { ModerationRole } from './api';
|
||||
import { updateModerationRole } from './api';
|
||||
|
||||
interface Props {
|
||||
roles: ModerationRole[];
|
||||
onChanged: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const TOGGLE_FIELDS: Array<keyof ModerationRole> = ['can_resolve', 'can_escalate', 'can_force_gold_brain', 'is_active'];
|
||||
|
||||
export const RolesCard: React.FC<Props> = ({ roles, onChanged, onError }) => {
|
||||
const toggle = async (code: string, field: keyof ModerationRole, value: boolean) => {
|
||||
try {
|
||||
const res = await updateModerationRole(code, { [field]: value } as Partial<ModerationRole>);
|
||||
if (res.success) onChanged();
|
||||
else onError(res.error ?? 'Update failed');
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={700}>Roles & Permissions</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Maps Keycloak realm roles to HIL actions. Toggle changes auto-sync to Redis.
|
||||
</Typography>
|
||||
|
||||
<Table size="small" sx={{ mt: 2 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell align="center">can_resolve</TableCell>
|
||||
<TableCell align="center">can_escalate</TableCell>
|
||||
<TableCell align="center">can_force_gold_brain</TableCell>
|
||||
<TableCell align="center">active</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.role_code} hover>
|
||||
<TableCell>
|
||||
<Box>
|
||||
<Chip label={role.role_code} size="small" color="primary" variant="outlined" />
|
||||
<Typography variant="caption" display="block" color="text.secondary" mt={0.5}>
|
||||
{role.role_label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
{TOGGLE_FIELDS.map((field) => (
|
||||
<TableCell key={field} align="center">
|
||||
<Switch
|
||||
checked={!!role[field]}
|
||||
onChange={(e) => toggle(role.role_code, field, e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card, CardContent, Typography, Box, Chip, TextField, Button, IconButton, Switch, FormControlLabel,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, MenuItem, Stack, Alert, Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Restore as RestoreIcon,
|
||||
Tune as TuneIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { SensitiveTopic, Volatility } from './api';
|
||||
import { createSensitiveTopic, deleteSensitiveTopic, updateSensitiveTopic } from './api';
|
||||
|
||||
interface Props {
|
||||
topics: SensitiveTopic[];
|
||||
onChanged: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const VOLATILITY_LABEL: Record<Volatility, string> = {
|
||||
volatile: 'Volatile',
|
||||
evolving: 'Evolving',
|
||||
stable: 'Stable',
|
||||
};
|
||||
|
||||
const VOLATILITY_COLOR: Record<Volatility, 'error' | 'warning' | 'success'> = {
|
||||
volatile: 'error',
|
||||
evolving: 'warning',
|
||||
stable: 'success',
|
||||
};
|
||||
|
||||
// Sensible per-volatility defaults to seed the form when creating new topics
|
||||
// or switching the volatility in the edit dialog.
|
||||
const VOLATILITY_DEFAULTS: Record<
|
||||
Volatility,
|
||||
{ cache_ttl_hours: number; recency_window_days: number; half_life_days: number }
|
||||
> = {
|
||||
volatile: { cache_ttl_hours: 24, recency_window_days: 7, half_life_days: 3 },
|
||||
evolving: { cache_ttl_hours: 168, recency_window_days: 14, half_life_days: 14 },
|
||||
stable: { cache_ttl_hours: 720, recency_window_days: 180, half_life_days: 180 },
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Edit volatility dialog
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EditDialogProps {
|
||||
topic: SensitiveTopic | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const EditVolatilityDialog: React.FC<EditDialogProps> = ({ topic, onClose, onSaved, onError }) => {
|
||||
const open = topic !== null;
|
||||
const initialVol: Volatility = (topic?.volatility ?? 'evolving') as Volatility;
|
||||
const [volatility, setVolatility] = useState<Volatility>(initialVol);
|
||||
const [ttl, setTtl] = useState<number>(topic?.cache_ttl_hours ?? 720);
|
||||
const [window_, setWindow] = useState<number>(topic?.recency_window_days ?? 30);
|
||||
const [halfLife, setHalfLife] = useState<number>(
|
||||
Number(topic?.half_life_days ?? 30)
|
||||
);
|
||||
const [atomicPath, setAtomicPath] = useState<string>(topic?.atomic_path_prefix ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// When the dialog re-opens for a different topic, reset state.
|
||||
React.useEffect(() => {
|
||||
if (topic) {
|
||||
setVolatility((topic.volatility ?? 'evolving') as Volatility);
|
||||
setTtl(topic.cache_ttl_hours ?? 720);
|
||||
setWindow(topic.recency_window_days ?? 30);
|
||||
setHalfLife(Number(topic.half_life_days ?? 30));
|
||||
setAtomicPath(topic.atomic_path_prefix ?? '');
|
||||
}
|
||||
}, [topic]);
|
||||
|
||||
const applyVolatilityDefaults = (v: Volatility) => {
|
||||
setVolatility(v);
|
||||
const d = VOLATILITY_DEFAULTS[v];
|
||||
setTtl(d.cache_ttl_hours);
|
||||
setWindow(d.recency_window_days);
|
||||
setHalfLife(d.half_life_days);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!topic) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const trimmedPath = atomicPath.trim();
|
||||
const res = await updateSensitiveTopic(topic.topic_id, {
|
||||
volatility,
|
||||
cache_ttl_hours: ttl,
|
||||
recency_window_days: window_,
|
||||
half_life_days: halfLife,
|
||||
// Empty string explicitly tells the backend to clear the value (NULL).
|
||||
atomic_path_prefix: trimmedPath === '' ? '' : trimmedPath,
|
||||
});
|
||||
if (res.success) {
|
||||
onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
onError(res.error ?? 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Volatility config — {topic?.topic_code ?? ''}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
These values are read by didi-brain to set cache TTL, recency window, and
|
||||
age-decay half-life for content tagged with this topic. Pick a tier to seed
|
||||
sensible defaults, then fine-tune if needed.
|
||||
</Alert>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Volatility tier"
|
||||
value={volatility}
|
||||
onChange={(e) => applyVolatilityDefaults(e.target.value as Volatility)}
|
||||
>
|
||||
<MenuItem value="volatile">Volatile (war, breaking news, daily politics)</MenuItem>
|
||||
<MenuItem value="evolving">Evolving (economy, health, ongoing trials)</MenuItem>
|
||||
<MenuItem value="stable">Stable (settled science, history)</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Cache TTL (hours)"
|
||||
value={ttl}
|
||||
onChange={(e) => setTtl(Number(e.target.value))}
|
||||
inputProps={{ min: 1, max: 26280 }}
|
||||
helperText="Hard cap: brain truncates verdicts touching this topic to this many hours"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Recency window (days)"
|
||||
value={window_}
|
||||
onChange={(e) => setWindow(Number(e.target.value))}
|
||||
inputProps={{ min: 1, max: 365 }}
|
||||
helperText="For volatile topics, evidence older than this is dropped from /v1/gather"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label="Half-life (days)"
|
||||
value={halfLife}
|
||||
onChange={(e) => setHalfLife(Number(e.target.value))}
|
||||
inputProps={{ min: 0.1, step: 0.5 }}
|
||||
helperText="Recency boost decays with this half-life when ranking evidence"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Atomic taxonomy path prefix (optional)"
|
||||
value={atomicPath}
|
||||
onChange={(e) => setAtomicPath(e.target.value)}
|
||||
placeholder="e.g. Topics/Health/ or Topics/Politics/Elections"
|
||||
helperText={
|
||||
'Maps this policy topic to a path in the brain knowledge graph. ' +
|
||||
'Leave empty if no mapping. Used by classifier to also tag atoms ' +
|
||||
'for retrieval. No leading slash. Trailing slash is allowed.'
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Main card
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const SensitiveTopicsCard: React.FC<Props> = ({ topics, onChanged, onError }) => {
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newVolatility, setNewVolatility] = useState<Volatility>('evolving');
|
||||
const [newAtomicPath, setNewAtomicPath] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<SensitiveTopic | null>(null);
|
||||
|
||||
const filtered = topics.filter((t) => showInactive || t.is_active);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newCode.trim() || !newLabel.trim()) {
|
||||
onError('Both topic_code and label are required');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const defaults = VOLATILITY_DEFAULTS[newVolatility];
|
||||
const trimmedPath = newAtomicPath.trim();
|
||||
const res = await createSensitiveTopic({
|
||||
topic_code: newCode.trim(),
|
||||
topic_label: newLabel.trim(),
|
||||
volatility: newVolatility,
|
||||
...defaults,
|
||||
...(trimmedPath ? { atomic_path_prefix: trimmedPath } : {}),
|
||||
});
|
||||
if (res.success) {
|
||||
setNewCode('');
|
||||
setNewLabel('');
|
||||
setNewVolatility('evolving');
|
||||
setNewAtomicPath('');
|
||||
onChanged();
|
||||
} else {
|
||||
onError(res.error ?? 'Create failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (id: number) => {
|
||||
try {
|
||||
const res = await deleteSensitiveTopic(id);
|
||||
if (res.success) onChanged();
|
||||
else onError(res.error ?? 'Delete failed');
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (id: number) => {
|
||||
try {
|
||||
const res = await updateSensitiveTopic(id, { is_active: true });
|
||||
if (res.success) onChanged();
|
||||
else onError(res.error ?? 'Reactivate failed');
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700}>Sensitive Topics</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Topics that trigger HIL review when detected, with per-topic brain cache volatility config.
|
||||
Auto-syncs to Redis on change.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} />}
|
||||
label="Show inactive"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1} my={2}>
|
||||
{filtered.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">No topics.</Typography>
|
||||
)}
|
||||
{filtered.map((t) => {
|
||||
const volatility = (t.volatility ?? 'evolving') as Volatility;
|
||||
return (
|
||||
<Box
|
||||
key={t.topic_id}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
p={1}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: t.is_active ? 'background.paper' : 'action.hover',
|
||||
opacity: t.is_active ? 1 : 0.65,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={VOLATILITY_LABEL[volatility]}
|
||||
color={VOLATILITY_COLOR[volatility]}
|
||||
sx={{ minWidth: 80 }}
|
||||
/>
|
||||
<Box flex={1} minWidth={0}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t.topic_code}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
{t.topic_label}
|
||||
</Typography>
|
||||
{t.atomic_path_prefix && (
|
||||
<Tooltip title="Maps to atomic-server taxonomy path (used by brain classifier)">
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'primary.main',
|
||||
fontSize: 11,
|
||||
mt: 0.25,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
↳ {t.atomic_path_prefix}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Cache TTL (hours)">
|
||||
<Chip size="small" label={`TTL ${t.cache_ttl_hours ?? '—'}h`} variant="outlined" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Recency window (days)">
|
||||
<Chip size="small" label={`${t.recency_window_days ?? '—'}d window`} variant="outlined" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit volatility config">
|
||||
<IconButton size="small" onClick={() => setEditing(t)}>
|
||||
<TuneIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{t.is_active ? (
|
||||
<Tooltip title="Deactivate">
|
||||
<IconButton size="small" onClick={() => handleDeactivate(t.topic_id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Reactivate">
|
||||
<IconButton size="small" onClick={() => handleReactivate(t.topic_id)}>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" gap={1} alignItems="flex-start" mt={2} flexWrap="wrap">
|
||||
<TextField
|
||||
size="small" label="topic_code"
|
||||
placeholder="e.g. fraud_scams"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value.toLowerCase())}
|
||||
helperText="lowercase, [a-z0-9_]"
|
||||
sx={{ width: 180 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small" label="topic_label"
|
||||
placeholder="Human readable name"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
sx={{ minWidth: 180, flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Volatility"
|
||||
value={newVolatility}
|
||||
onChange={(e) => setNewVolatility(e.target.value as Volatility)}
|
||||
sx={{ width: 140 }}
|
||||
>
|
||||
<MenuItem value="volatile">Volatile</MenuItem>
|
||||
<MenuItem value="evolving">Evolving</MenuItem>
|
||||
<MenuItem value="stable">Stable</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Atomic path (optional)"
|
||||
placeholder="Topics/Health/"
|
||||
value={newAtomicPath}
|
||||
onChange={(e) => setNewAtomicPath(e.target.value)}
|
||||
helperText="Brain taxonomy bridge"
|
||||
sx={{ minWidth: 200, flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAdd}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? 'Adding…' : 'Add'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<EditVolatilityDialog
|
||||
topic={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, Alert } from '@mui/material';
|
||||
import { Save as SaveIcon } from '@mui/icons-material';
|
||||
import type { ModerationConfig } from './api';
|
||||
import { updateModerationConfig } from './api';
|
||||
|
||||
interface Props {
|
||||
config: ModerationConfig;
|
||||
onSaved: (next: ModerationConfig) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const TriageCard: React.FC<Props> = ({ config, onSaved, onError }) => {
|
||||
const [draft, setDraft] = useState<ModerationConfig>(config);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
React.useEffect(() => { setDraft(config); }, [config]);
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateModerationConfig({
|
||||
triage_enabled: draft.triage_enabled,
|
||||
confidence_low: Number(draft.confidence_low),
|
||||
risk_grey_min: Number(draft.risk_grey_min),
|
||||
risk_grey_max: Number(draft.risk_grey_max),
|
||||
queue_relax_at: draft.queue_relax_at,
|
||||
queue_strict_at: draft.queue_strict_at,
|
||||
auto_tune_enabled: draft.auto_tune_enabled,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
onSaved(res.data);
|
||||
} else {
|
||||
onError(res.error ?? 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700}>Triage</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Decide which sessions enter the moderation queue. Edits sync to Redis on save.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.triage_enabled}
|
||||
onChange={(e) => setDraft({ ...draft, triage_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={draft.triage_enabled ? 'Enabled' : 'Disabled'}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!draft.triage_enabled && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Triage is OFF. No sessions will enter the moderation queue.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={3} mt={2}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Confidence threshold (low)</Typography>
|
||||
<Slider
|
||||
value={Number(draft.confidence_low)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(_, v) => setDraft({ ...draft, confidence_low: Number(v) })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sessions with confidence < {draft.confidence_low} go to queue
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>Risk grey zone</Typography>
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
<TextField
|
||||
size="small" type="number" label="min"
|
||||
value={draft.risk_grey_min}
|
||||
onChange={(e) => setDraft({ ...draft, risk_grey_min: e.target.value })}
|
||||
/>
|
||||
<Typography>—</Typography>
|
||||
<TextField
|
||||
size="small" type="number" label="max"
|
||||
value={draft.risk_grey_max}
|
||||
onChange={(e) => setDraft({ ...draft, risk_grey_max: e.target.value })}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sensitive topics with risk in this band go to queue
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Auto-relax at pending"
|
||||
helperText="If queue ≥ this many pending, drop topic filter"
|
||||
value={draft.queue_relax_at}
|
||||
onChange={(e) => setDraft({ ...draft, queue_relax_at: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth label="Auto-strict at pending"
|
||||
helperText="If queue ≤ this many pending, broaden triage"
|
||||
value={draft.queue_strict_at}
|
||||
onChange={(e) => setDraft({ ...draft, queue_strict_at: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.auto_tune_enabled}
|
||||
onChange={(e) => setDraft({ ...draft, auto_tune_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Auto-tune thresholds (cron)"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Triage'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
171
backend/admin-dashboard/src/components/ModerationSettings/api.ts
Normal file
171
backend/admin-dashboard/src/components/ModerationSettings/api.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* API client for Moderation Settings — talks to didiFramework on /framework/api/*.
|
||||
* All endpoints from migration 011 + Phase 1.2 routes.
|
||||
*/
|
||||
|
||||
const FRAMEWORK_API = '/framework/api';
|
||||
|
||||
export interface ModerationConfig {
|
||||
config_id: number;
|
||||
triage_enabled: boolean;
|
||||
confidence_low: string | number;
|
||||
risk_grey_min: string | number;
|
||||
risk_grey_max: string | number;
|
||||
queue_relax_at: number;
|
||||
queue_strict_at: number;
|
||||
auto_tune_enabled: boolean;
|
||||
brain_enabled: boolean;
|
||||
brain_url: string;
|
||||
brain_lookup_timeout_ms: number;
|
||||
brain_write_timeout_ms: number;
|
||||
brain_confidence_min_silver: string | number;
|
||||
brain_semantic_threshold: string | number;
|
||||
brain_per_component: { techniques: boolean; ai_tampered: boolean; claims: boolean };
|
||||
updated_by: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type Volatility = 'volatile' | 'evolving' | 'stable';
|
||||
|
||||
export interface SensitiveTopic {
|
||||
topic_id: number;
|
||||
topic_code: string;
|
||||
topic_label: string;
|
||||
is_active: boolean;
|
||||
// Phase D1 — drives brain cache TTL + recency boost.
|
||||
// Optional in TS so legacy callers / older API responses still type-check;
|
||||
// backend always returns them after migration 012.
|
||||
volatility?: Volatility;
|
||||
cache_ttl_hours?: number;
|
||||
recency_window_days?: number;
|
||||
half_life_days?: number | string; // numeric column comes back as string
|
||||
// Migration 014 — bridge to atomic-server taxonomy. Optional, NULL when no
|
||||
// mapping is defined. Format: "Topics/Health/" or "Topics/Politics/Elections".
|
||||
atomic_path_prefix?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SensitiveTopicVolatilityPatch {
|
||||
volatility?: Volatility;
|
||||
cache_ttl_hours?: number;
|
||||
recency_window_days?: number;
|
||||
half_life_days?: number;
|
||||
// Empty string clears the value (server maps '' → NULL).
|
||||
atomic_path_prefix?: string | null;
|
||||
}
|
||||
|
||||
export interface ModerationRole {
|
||||
role_code: string;
|
||||
role_label: string;
|
||||
can_resolve: boolean;
|
||||
can_escalate: boolean;
|
||||
can_force_gold_brain: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function asJson<T>(res: Response): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
} catch {
|
||||
return { success: false, error: `${res.status} ${res.statusText}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
const headers: HeadersInit = {
|
||||
...(init.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
// ─── moderation-config ───
|
||||
export async function getModerationConfig(): Promise<ApiResponse<ModerationConfig>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`);
|
||||
return asJson<ModerationConfig>(res);
|
||||
}
|
||||
|
||||
export async function updateModerationConfig(patch: Partial<ModerationConfig>): Promise<ApiResponse<ModerationConfig>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return asJson<ModerationConfig>(res);
|
||||
}
|
||||
|
||||
// ─── sensitive-topics ───
|
||||
export async function listSensitiveTopics(active: 'true' | 'false' | 'all' = 'all'): Promise<ApiResponse<SensitiveTopic[]>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics?active=${active}`);
|
||||
return asJson<SensitiveTopic[]>(res);
|
||||
}
|
||||
|
||||
export async function createSensitiveTopic(
|
||||
body: {
|
||||
topic_code: string;
|
||||
topic_label: string;
|
||||
is_active?: boolean;
|
||||
} & SensitiveTopicVolatilityPatch
|
||||
): Promise<ApiResponse<SensitiveTopic>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return asJson<SensitiveTopic>(res);
|
||||
}
|
||||
|
||||
export async function updateSensitiveTopic(
|
||||
id: number,
|
||||
patch: {
|
||||
topic_label?: string;
|
||||
is_active?: boolean;
|
||||
} & SensitiveTopicVolatilityPatch
|
||||
): Promise<ApiResponse<SensitiveTopic>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return asJson<SensitiveTopic>(res);
|
||||
}
|
||||
|
||||
export async function deleteSensitiveTopic(id: number): Promise<ApiResponse<void>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, { method: 'DELETE' });
|
||||
return asJson<void>(res);
|
||||
}
|
||||
|
||||
// ─── moderation-roles ───
|
||||
export async function listModerationRoles(): Promise<ApiResponse<ModerationRole[]>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles`);
|
||||
return asJson<ModerationRole[]>(res);
|
||||
}
|
||||
|
||||
export async function updateModerationRole(code: string, patch: Partial<ModerationRole>): Promise<ApiResponse<ModerationRole>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles/${encodeURIComponent(code)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return asJson<ModerationRole>(res);
|
||||
}
|
||||
|
||||
// ─── sync-redis ───
|
||||
export async function syncRedis(): Promise<ApiResponse<{ keys_written: number }>> {
|
||||
const res = await authedFetch(`${FRAMEWORK_API}/sync-redis`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return asJson<{ keys_written: number }>(res);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, Alert, CircularProgress, Snackbar, Button } from '@mui/material';
|
||||
import { Refresh as RefreshIcon, CloudSync as SyncIcon } from '@mui/icons-material';
|
||||
import {
|
||||
getModerationConfig,
|
||||
listSensitiveTopics,
|
||||
listModerationRoles,
|
||||
syncRedis,
|
||||
type ModerationConfig,
|
||||
type SensitiveTopic,
|
||||
type ModerationRole,
|
||||
} from './api';
|
||||
import { TriageCard } from './TriageCard';
|
||||
import { BrainClientCard } from './BrainClientCard';
|
||||
import { SensitiveTopicsCard } from './SensitiveTopicsCard';
|
||||
import { RolesCard } from './RolesCard';
|
||||
|
||||
export const ModerationSettings: React.FC = () => {
|
||||
const [config, setConfig] = useState<ModerationConfig | null>(null);
|
||||
const [topics, setTopics] = useState<SensitiveTopic[]>([]);
|
||||
const [roles, setRoles] = useState<ModerationRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cfgRes, topRes, rolRes] = await Promise.all([
|
||||
getModerationConfig(),
|
||||
listSensitiveTopics('all'),
|
||||
listModerationRoles(),
|
||||
]);
|
||||
if (cfgRes.success && cfgRes.data) setConfig(cfgRes.data);
|
||||
else setError(cfgRes.error ?? 'Failed to load config');
|
||||
if (topRes.success && topRes.data) setTopics(topRes.data);
|
||||
if (rolRes.success && rolRes.data) setRoles(rolRes.data);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAll(); }, [loadAll]);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await syncRedis();
|
||||
if (res.success) {
|
||||
setSuccess(`Synced to Redis (${res.data?.keys_written ?? '?'} keys)`);
|
||||
} else {
|
||||
setError(res.error ?? 'Sync failed');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigSaved = async (next: ModerationConfig) => {
|
||||
setConfig(next);
|
||||
setSuccess('Config saved. Syncing to Redis…');
|
||||
await handleSync();
|
||||
};
|
||||
|
||||
const handleTopicsChanged = async () => {
|
||||
const res = await listSensitiveTopics('all');
|
||||
if (res.success && res.data) setTopics(res.data);
|
||||
setSuccess('Topics updated. Syncing to Redis…');
|
||||
await handleSync();
|
||||
};
|
||||
|
||||
const handleRolesChanged = async () => {
|
||||
const res = await listModerationRoles();
|
||||
if (res.success && res.data) setRoles(res.data);
|
||||
setSuccess('Roles updated. Syncing to Redis…');
|
||||
await handleSync();
|
||||
};
|
||||
|
||||
if (loading && !config) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !config) {
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Alert severity="error" action={<Button onClick={loadAll}>Retry</Button>}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ color: '#0288d1' }}>
|
||||
Moderation Settings
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Triage rules + brain client + sensitive topics + roles. All settings persist in PG and sync to Redis.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button startIcon={<RefreshIcon />} onClick={loadAll} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={syncing ? <CircularProgress size={18} /> : <SyncIcon />}
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? 'Syncing…' : 'Sync to Redis'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={3}>
|
||||
<TriageCard config={config} onSaved={handleConfigSaved} onError={setError} />
|
||||
<BrainClientCard config={config} onSaved={handleConfigSaved} onError={setError} />
|
||||
<SensitiveTopicsCard topics={topics} onChanged={handleTopicsChanged} onError={setError} />
|
||||
<RolesCard roles={roles} onChanged={handleRolesChanged} onError={setError} />
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={!!success}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccess(null)}
|
||||
message={success}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationSettings;
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Dry-run dialog — resolves & shows the execution plan for a pipeline without
|
||||
* dispatching (Run Console-lite: "ce ar rula", no credits consumed). Also
|
||||
* offers a real async run + cancel so the operator can exercise INT-3 live.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography,
|
||||
Select, MenuItem, TextField, Chip, CircularProgress, Alert, Paper, Divider,
|
||||
Stack, Table, TableBody, TableCell, TableRow,
|
||||
} from '@mui/material';
|
||||
import { pipelinesApi, DryRunResult, PipelineProfile } from './pipelinesApi';
|
||||
|
||||
const MEDIA_TYPES = ['text', 'url', 'image', 'audio', 'video'];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
profile: PipelineProfile | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Map a pipeline profile_code to the media_type its dry-run should use.
|
||||
function defaultMediaType(code: string): string {
|
||||
if (code.startsWith('text')) return 'text';
|
||||
if (['image', 'audio', 'video', 'url'].includes(code)) return code;
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export const DryRunDialog: React.FC<Props> = ({ open, profile, onClose }) => {
|
||||
const [mediaType, setMediaType] = useState('text');
|
||||
const [text, setText] = useState('Guvernul ascunde adevărul despre acest subiect, conform unor surse anonime neverificate.');
|
||||
const [planType, setPlanType] = useState(1);
|
||||
const [result, setResult] = useState<DryRunResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (profile) { setMediaType(defaultMediaType(profile.profile_code)); setResult(null); setError(null); }
|
||||
}, [profile]);
|
||||
|
||||
const runDryRun = async () => {
|
||||
setLoading(true); setError(null); setResult(null);
|
||||
try {
|
||||
const r = await pipelinesApi.dryRun({
|
||||
media_type: mediaType,
|
||||
text: mediaType === 'text' || mediaType === 'url' ? text : undefined,
|
||||
url: mediaType === 'url' ? 'https://example.com/article' : undefined,
|
||||
media_url: ['image', 'audio', 'video'].includes(mediaType) ? 'https://example.com/media' : undefined,
|
||||
user_id: 'dashboard-dryrun',
|
||||
plan_type: planType,
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
Dry-run — {profile?.profile_name}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Rezolvă planul de execuție fără dispatch (zero credite consumate)
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Select size="small" value={mediaType} onChange={e => setMediaType(e.target.value)}>
|
||||
{MEDIA_TYPES.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
<Select size="small" value={planType} onChange={e => setPlanType(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5, 6].map(p => <MenuItem key={p} value={p}>plan {p}</MenuItem>)}
|
||||
</Select>
|
||||
<Button variant="contained" onClick={runDryRun} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : 'Resolve plan'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{(mediaType === 'text' || mediaType === 'url') && (
|
||||
<TextField
|
||||
fullWidth multiline minRows={2} size="small" sx={{ mb: 2 }}
|
||||
label="Sample text" value={text} onChange={e => setText(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{result && (
|
||||
<Box>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="overline" color="text.secondary">Flow</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{result.flow}</Typography>
|
||||
</Paper>
|
||||
|
||||
{result.verdict_profile && (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Verdict profile: {result.verdict_profile.profile_code}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.5 }}>
|
||||
{Object.entries(result.verdict_profile.weights).map(([k, v]) => (
|
||||
<Chip key={k} size="small" label={`${k}: ${v}`} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Typography variant="overline" color="text.secondary">Nodes ({result.nodes.length})</Typography>
|
||||
{result.nodes.map(n => (
|
||||
<Paper key={n.component} variant="outlined" sx={{ p: 1.5, mb: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Chip size="small" color="primary" label={n.component} />
|
||||
{n.queue && <Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{n.queue}</Typography>}
|
||||
{n.timeout_ms && <Chip size="small" variant="outlined" label={`${n.timeout_ms / 1000}s`} />}
|
||||
</Stack>
|
||||
{n.depends_on && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
depends_on: {n.depends_on.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
{n.produces && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{n.produces.map(p => <Chip key={p} size="small" variant="outlined" label={p} sx={{ mr: 0.5, mb: 0.5 }} />)}
|
||||
</Box>
|
||||
)}
|
||||
{n.stages && Object.entries(n.stages).map(([stage, tiers]) => (
|
||||
<Box key={stage} sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{stage}</Typography>
|
||||
{Object.entries(tiers).map(([tier, models]) => (
|
||||
<Table key={tier} size="small" sx={{ mb: 0.5 }}>
|
||||
<TableBody>
|
||||
{models.map(m => (
|
||||
<TableRow key={`${tier}-${m.order}`}>
|
||||
<TableCell sx={{ py: 0.2, border: 0, width: 60 }}>
|
||||
<Typography variant="caption" color="text.secondary">{tier}</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.2, border: 0, width: 80 }}>
|
||||
<Typography variant="caption">{m.role}</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.2, border: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{m.model_key}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{result.skipped.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="overline" color="text.secondary">Skipped</Typography>
|
||||
{result.skipped.map(s => (
|
||||
<Typography key={s.component} variant="caption" display="block" color="text.secondary">
|
||||
{s.component} — {s.reason}
|
||||
</Typography>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>{result.note}</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Pipelines page — CRUD + lifecycle over the REAL pipeline definitions
|
||||
* (input_type_profile) plus dry-run (Run Console-lite). Modul 1 caiet:
|
||||
* „Workflow Builder: CRUD/versionare pipeline; Run Console".
|
||||
*
|
||||
* Backed by /framework/api/pipelines + /agent-v3/api/v3/pipeline/dry-run.
|
||||
* NOT the dead pipelineApi (services/api/pipeline.ts).
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableHead, TableRow, TableCell, TableBody,
|
||||
Chip, IconButton, Button, Stack, CircularProgress, Alert, Tooltip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField, Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as DryRunIcon,
|
||||
ContentCopy as CloneIcon,
|
||||
ToggleOn as ActivateIcon,
|
||||
ToggleOff as DeactivateIcon,
|
||||
History as VersionsIcon,
|
||||
Restore as RestoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Download as ExportIcon,
|
||||
Upload as ImportIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { pipelinesApi, PipelineProfile, PipelineVersion } from './pipelinesApi';
|
||||
import { DryRunDialog } from './DryRunDialog';
|
||||
|
||||
export const PipelinesPage: React.FC = () => {
|
||||
const [profiles, setProfiles] = useState<PipelineProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const [dryRunProfile, setDryRunProfile] = useState<PipelineProfile | null>(null);
|
||||
const [cloneSource, setCloneSource] = useState<PipelineProfile | null>(null);
|
||||
const [cloneCode, setCloneCode] = useState('');
|
||||
const [cloneName, setCloneName] = useState('');
|
||||
const [versionsFor, setVersionsFor] = useState<string | null>(null);
|
||||
const [versions, setVersions] = useState<PipelineVersion[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
setProfiles(await pipelinesApi.list());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const toggleActive = async (p: PipelineProfile) => {
|
||||
setBusy(p.profile_code);
|
||||
try {
|
||||
if (p.is_active) await pipelinesApi.deactivate(p.profile_code);
|
||||
else await pipelinesApi.activate(p.profile_code);
|
||||
await load();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const doClone = async () => {
|
||||
if (!cloneSource || !cloneCode) return;
|
||||
setBusy(cloneSource.profile_code);
|
||||
try {
|
||||
await pipelinesApi.clone(cloneSource.profile_code, cloneCode, cloneName || undefined);
|
||||
setCloneSource(null); setCloneCode(''); setCloneName('');
|
||||
await load();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const showVersions = async (code: string) => {
|
||||
if (versionsFor === code) { setVersionsFor(null); return; }
|
||||
try {
|
||||
setVersions(await pipelinesApi.versions(code));
|
||||
setVersionsFor(code);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
|
||||
const restore = async (code: string, versionId: number) => {
|
||||
setBusy(code);
|
||||
try {
|
||||
await pipelinesApi.restore(code, versionId);
|
||||
await load();
|
||||
setVersions(await pipelinesApi.versions(code));
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const exportProfile = async (code: string) => {
|
||||
try {
|
||||
const full = await pipelinesApi.get(code); // includes overrides
|
||||
const blob = new Blob([JSON.stringify(full, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `pipeline-${code}.json`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
|
||||
const importProfile = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result));
|
||||
// Prefix new_code to avoid clashing with the source it was exported from.
|
||||
const base = parsed.profile_code || 'imported';
|
||||
await pipelinesApi.import({ ...parsed, new_code: `${base}_import` });
|
||||
await load();
|
||||
} catch (e) { setError(`Import failed: ${(e as Error).message}`); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>Pipelines</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Definiții de pipeline (componente, ponderi, praguri per tip de input) — clonare, versionare, activare, dry-run
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
component="label"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ImportIcon />}
|
||||
>
|
||||
Import JSON
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
hidden
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) importProfile(f); e.target.value = ''; }}
|
||||
/>
|
||||
</Button>
|
||||
<IconButton onClick={load}><RefreshIcon /></IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<Paper variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Pipeline</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Weights (tech / claims / ai / source)</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{profiles.map(p => (
|
||||
<React.Fragment key={p.profile_code}>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.profile_name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }} color="text.secondary">{p.profile_code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" color={p.is_active ? 'success' : 'default'} label={p.is_active ? 'active' : 'inactive'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{p.weight_techniques} / {p.weight_claims} / {p.weight_ai_tampered} / {p.weight_source}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Dry-run (resolve plan)">
|
||||
<IconButton size="small" onClick={() => setDryRunProfile(p)}><DryRunIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Clone">
|
||||
<IconButton size="small" onClick={() => { setCloneSource(p); setCloneCode(`${p.profile_code}_copy`); }}><CloneIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={p.is_active ? 'Deactivate' : 'Activate'}>
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy === p.profile_code} onClick={() => toggleActive(p)}>
|
||||
{p.is_active ? <DeactivateIcon /> : <ActivateIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Export JSON">
|
||||
<IconButton size="small" onClick={() => exportProfile(p.profile_code)}><ExportIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Version history">
|
||||
<IconButton size="small" onClick={() => showVersions(p.profile_code)}><VersionsIcon /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ p: 0, border: 0 }}>
|
||||
<Collapse in={versionsFor === p.profile_code} unmountOnExit>
|
||||
<Box sx={{ p: 2, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="overline" color="text.secondary">Version history</Typography>
|
||||
{versions.length === 0 && <Typography variant="body2" color="text.secondary">No versions yet</Typography>}
|
||||
{versions.map(v => (
|
||||
<Stack key={v.version_id} direction="row" spacing={2} alignItems="center" sx={{ py: 0.5 }}>
|
||||
<Chip size="small" label={`v${v.version_no}`} />
|
||||
<Typography variant="body2">{v.change_note}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(v.changed_at).toLocaleString()} {v.changed_by ? `· ${v.changed_by}` : ''}
|
||||
</Typography>
|
||||
<Tooltip title="Restore this version">
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy === p.profile_code} onClick={() => restore(p.profile_code, v.version_id)}>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<DryRunDialog open={!!dryRunProfile} profile={dryRunProfile} onClose={() => setDryRunProfile(null)} />
|
||||
|
||||
<Dialog open={!!cloneSource} onClose={() => setCloneSource(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Clone pipeline</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
From <b>{cloneSource?.profile_code}</b>. The clone starts inactive.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth size="small" label="New code" sx={{ mb: 2 }}
|
||||
value={cloneCode} onChange={e => setCloneCode(e.target.value)}
|
||||
helperText="lowercase, [a-z0-9_-]"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth size="small" label="New name (optional)"
|
||||
value={cloneName} onChange={e => setCloneName(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCloneSource(null)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={doClone} disabled={!cloneCode || busy === cloneSource?.profile_code}>Clone</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipelinesPage;
|
||||
115
backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts
Normal file
115
backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Pipelines API client — talks to the REAL endpoints:
|
||||
* - didiFramework /framework/api/pipelines/* (input_type_profile = pipeline definition)
|
||||
* - agent-v3 /agent-v3/api/v3/pipeline/dry-run and /:id/cancel
|
||||
*
|
||||
* NOT the dead pipelineApi (services/api/pipeline.ts → /api/v1/pipelines,
|
||||
* which has no backend). Everything here is backed by live routes.
|
||||
*/
|
||||
const FW = '/framework/api/pipelines';
|
||||
const AGENT = '/agent-v3/api/v3/pipeline';
|
||||
|
||||
function authHeader(): Record<string, string> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function req<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader(), ...(init?.headers || {}) },
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body?.success === false) {
|
||||
throw new Error(body?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface PipelineProfile {
|
||||
profile_code: string;
|
||||
profile_name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
weight_techniques: number;
|
||||
weight_claims: number;
|
||||
weight_ai_tampered: number;
|
||||
weight_source: number;
|
||||
min_components: number;
|
||||
override_cap: number;
|
||||
overrides?: unknown[];
|
||||
}
|
||||
|
||||
export interface PipelineVersion {
|
||||
version_id: number;
|
||||
version_no: number;
|
||||
changed_at: string;
|
||||
changed_by: string | null;
|
||||
change_note: string;
|
||||
}
|
||||
|
||||
export interface DryRunResult {
|
||||
media_type: string;
|
||||
plan_type: number;
|
||||
flow: string;
|
||||
results_queue: string;
|
||||
nodes: Array<{
|
||||
component: string;
|
||||
queue?: string;
|
||||
priority?: number;
|
||||
timeout_ms?: number;
|
||||
depends_on?: string[];
|
||||
produces?: string[];
|
||||
stages?: Record<string, Record<string, Array<{ order: number; role: string; model_key: string; provider: string; timeout_ms?: number }>>>;
|
||||
prompt_config_keys?: string[];
|
||||
}>;
|
||||
skipped: Array<{ component: string; reason: string }>;
|
||||
verdict_profile: {
|
||||
profile_code: string;
|
||||
profile_name: string;
|
||||
weights: Record<string, number>;
|
||||
min_components: number;
|
||||
override_cap: number;
|
||||
} | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const pipelinesApi = {
|
||||
list: () => req<{ data: PipelineProfile[]; count: number }>(FW).then(r => r.data),
|
||||
|
||||
get: (code: string) => req<{ data: PipelineProfile }>(`${FW}/${code}`).then(r => r.data),
|
||||
|
||||
clone: (code: string, new_code: string, new_name?: string) =>
|
||||
req<{ data: PipelineProfile }>(`${FW}/${code}/clone`, {
|
||||
method: 'POST', body: JSON.stringify({ new_code, new_name }),
|
||||
}).then(r => r.data),
|
||||
|
||||
activate: (code: string) => req(`${FW}/${code}/activate`, { method: 'POST' }),
|
||||
deactivate: (code: string) => req(`${FW}/${code}/deactivate`, { method: 'POST' }),
|
||||
|
||||
update: (code: string, patch: Partial<PipelineProfile> & { change_note?: string }) =>
|
||||
req<{ data: PipelineProfile; version_saved: number }>(`${FW}/${code}`, {
|
||||
method: 'PUT', body: JSON.stringify(patch),
|
||||
}),
|
||||
|
||||
versions: (code: string) =>
|
||||
req<{ data: PipelineVersion[] }>(`${FW}/${code}/versions`).then(r => r.data),
|
||||
|
||||
restore: (code: string, versionId: number) =>
|
||||
req(`${FW}/${code}/versions/${versionId}/restore`, { method: 'POST' }),
|
||||
|
||||
dryRun: (body: { media_type: string; text?: string; url?: string; media_url?: string; user_id?: string; plan_type?: number; options?: unknown }) =>
|
||||
req<{ data: DryRunResult }>(`${AGENT}/dry-run`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}).then(r => r.data),
|
||||
|
||||
cancel: (sessionId: string, user_id?: string) =>
|
||||
req(`${AGENT}/${sessionId}/cancel`, {
|
||||
method: 'POST', body: JSON.stringify({ user_id }),
|
||||
}),
|
||||
|
||||
import: (payload: unknown) =>
|
||||
req<{ data: PipelineProfile }>(`${FW}/import`, {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
}).then(r => r.data),
|
||||
};
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* LLM Providers Management - Full CRUD
|
||||
*
|
||||
* Manages LLM providers, models, component assignments, and API keys.
|
||||
* Integrated with PostgreSQL backend via /framework/api/providers/* endpoints.
|
||||
*
|
||||
* Original 1159-line file split into:
|
||||
* ProvidersManagement.types.ts — domain types + TabPanel + API_BASE
|
||||
* api.ts — 13 fetch wrappers
|
||||
* tabs/{Providers,Models,Assignments,ApiKeys}Tab.tsx — 4 entity tables
|
||||
* dialogs/{Provider,Model,Assignment,ApiKey,DeleteConfirm}Dialog.tsx — 5 dialogs
|
||||
* ProvidersManagement.tsx (this) — state + handlers + render orchestrator
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Container, Paper, Typography, Tabs, Tab, Button, CircularProgress, Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Key as KeyIcon, Memory as ModelIcon,
|
||||
Assignment as AssignmentIcon, CloudQueue as ProviderIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey,
|
||||
TabPanel,
|
||||
} from './ProvidersManagement.types';
|
||||
import {
|
||||
fetchAll,
|
||||
createProvider, updateProvider, deleteProvider,
|
||||
createModel, updateModel, deleteModel,
|
||||
createAssignment, updateAssignment, deleteAssignment,
|
||||
createApiKey, updateApiKey, deleteApiKey,
|
||||
testProvider,
|
||||
} from './api';
|
||||
import { ProvidersTab } from './tabs/ProvidersTab';
|
||||
import { ModelsTab } from './tabs/ModelsTab';
|
||||
import { AssignmentsTab } from './tabs/AssignmentsTab';
|
||||
import { ApiKeysTab } from './tabs/ApiKeysTab';
|
||||
import { ProviderDialog } from './dialogs/ProviderDialog';
|
||||
import { ModelDialog } from './dialogs/ModelDialog';
|
||||
import { AssignmentDialog } from './dialogs/AssignmentDialog';
|
||||
import { ApiKeyDialog } from './dialogs/ApiKeyDialog';
|
||||
import { DeleteConfirmDialog } from './dialogs/DeleteConfirmDialog';
|
||||
|
||||
export const ProvidersManagement: React.FC = () => {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [providers, setProviders] = useState<LlmProvider[]>([]);
|
||||
const [models, setModels] = useState<LlmModel[]>([]);
|
||||
const [assignments, setAssignments] = useState<ComponentAssignment[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
|
||||
// Dialog states
|
||||
const [providerDialog, setProviderDialog] = useState<{ open: boolean; data: Partial<LlmProvider> | null }>({ open: false, data: null });
|
||||
const [modelDialog, setModelDialog] = useState<{ open: boolean; data: Partial<LlmModel> | null }>({ open: false, data: null });
|
||||
const [assignmentDialog, setAssignmentDialog] = useState<{ open: boolean; data: Partial<ComponentAssignment> | null }>({ open: false, data: null });
|
||||
const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; data: Partial<ApiKey & { api_key_value?: string }> | null }>({ open: false, data: null });
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; type: string; id: number; name: string } | null>(null);
|
||||
|
||||
// Test connection state
|
||||
const [testingProvider, setTestingProvider] = useState<number | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ providerId: number; success: boolean; latencyMs?: number; error?: string } | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchAll();
|
||||
if (data.success) {
|
||||
setProviders(data.data.providers || []);
|
||||
setModels(data.data.models || []);
|
||||
setAssignments(data.data.assignments || []);
|
||||
setApiKeys(data.data.keys || []);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch data');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const showSuccess = (message: string) => {
|
||||
setSuccess(message);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
};
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 5000);
|
||||
};
|
||||
|
||||
// ─── Provider CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveProvider = async () => {
|
||||
const data = providerDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'provider_id' in data && data.provider_id;
|
||||
const result = isEdit
|
||||
? await updateProvider(data.provider_id as number, data)
|
||||
: await createProvider(data);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'Provider updated successfully' : 'Provider created successfully');
|
||||
setProviderDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'provider') return;
|
||||
try {
|
||||
const result = await deleteProvider(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('Provider deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleProviderActive = async (provider: LlmProvider) => {
|
||||
try {
|
||||
const result = await updateProvider(provider.provider_id, { is_active: !provider.is_active });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestProvider = async (providerId: number) => {
|
||||
setTestingProvider(providerId);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await testProvider(providerId);
|
||||
setTestResult({
|
||||
providerId,
|
||||
success: result.data?.success || false,
|
||||
latencyMs: result.data?.latencyMs,
|
||||
error: result.data?.error,
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({
|
||||
providerId,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Test failed',
|
||||
});
|
||||
} finally {
|
||||
setTestingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Model CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
const data = modelDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'model_id' in data && data.model_id;
|
||||
const result = isEdit
|
||||
? await updateModel(data.model_id as number, data)
|
||||
: await createModel(data);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'Model updated successfully' : 'Model created successfully');
|
||||
setModelDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'model') return;
|
||||
try {
|
||||
const result = await deleteModel(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('Model deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleModelActive = async (model: LlmModel) => {
|
||||
try {
|
||||
const result = await updateModel(model.model_id, { is_active: !model.is_active });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Assignment CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveAssignment = async () => {
|
||||
const data = assignmentDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'assignment_id' in data && data.assignment_id;
|
||||
const result = isEdit
|
||||
? await updateAssignment(data.assignment_id as number, data)
|
||||
: await createAssignment(data);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'Assignment updated successfully' : 'Assignment created successfully');
|
||||
setAssignmentDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAssignment = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'assignment') return;
|
||||
try {
|
||||
const result = await deleteAssignment(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('Assignment deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAssignmentEnabled = async (assignment: ComponentAssignment) => {
|
||||
try {
|
||||
const result = await updateAssignment(assignment.assignment_id, { is_enabled: !assignment.is_enabled });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── API Key CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveApiKey = async () => {
|
||||
const data = apiKeyDialog.data;
|
||||
if (!data) return;
|
||||
try {
|
||||
const isEdit = 'api_key_id' in data && data.api_key_id;
|
||||
const result = isEdit
|
||||
? await updateApiKey(data.api_key_id as number, data)
|
||||
: await createApiKey(data as { provider_id: number; key_name: string; api_key_value: string });
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(isEdit ? 'API Key updated successfully' : 'API Key created successfully');
|
||||
setApiKeyDialog({ open: false, data: null });
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApiKey = async () => {
|
||||
if (!deleteDialog || deleteDialog.type !== 'apikey') return;
|
||||
try {
|
||||
const result = await deleteApiKey(deleteDialog.id);
|
||||
if (result.success) {
|
||||
showSuccess('API Key deleted successfully');
|
||||
setDeleteDialog(null);
|
||||
fetchData();
|
||||
} else {
|
||||
showError(result.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleApiKeyActive = async (key: ApiKey) => {
|
||||
try {
|
||||
const result = await updateApiKey(key.api_key_id, { is_active: !key.is_active });
|
||||
if (result.success) fetchData();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Dispatcher: which delete handler runs based on deleteDialog.type ────
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteDialog) return;
|
||||
switch (deleteDialog.type) {
|
||||
case 'provider': return handleDeleteProvider();
|
||||
case 'model': return handleDeleteModel();
|
||||
case 'assignment': return handleDeleteAssignment();
|
||||
case 'apikey': return handleDeleteApiKey();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>LLM Providers</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Manage LLM providers, models, assignments, and API keys
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button startIcon={<RefreshIcon />} onClick={fetchData} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
|
||||
|
||||
{loading && providers.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Tabs value={tabValue} onChange={(_e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab icon={<ProviderIcon />} label={`Providers (${providers.length})`} iconPosition="start" />
|
||||
<Tab icon={<ModelIcon />} label={`Models (${models.length})`} iconPosition="start" />
|
||||
<Tab icon={<AssignmentIcon />} label={`Assignments (${assignments.length})`} iconPosition="start" />
|
||||
<Tab icon={<KeyIcon />} label={`API Keys (${apiKeys.length})`} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<ProvidersTab
|
||||
providers={providers}
|
||||
testingProvider={testingProvider}
|
||||
testResult={testResult}
|
||||
onAdd={() => setProviderDialog({
|
||||
open: true,
|
||||
data: { provider_code: '', provider_name: '', base_url: '', auth_type: 'bearer', is_active: true, priority: 100, rate_limit_rpm: 60, rate_limit_tpm: 100000, description: '' },
|
||||
})}
|
||||
onEdit={(p) => setProviderDialog({ open: true, data: p })}
|
||||
onDelete={(p) => setDeleteDialog({ open: true, type: 'provider', id: p.provider_id, name: p.provider_name })}
|
||||
onToggleActive={handleToggleProviderActive}
|
||||
onTest={handleTestProvider}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<ModelsTab
|
||||
models={models}
|
||||
hasProviders={providers.length > 0}
|
||||
onAdd={() => setModelDialog({
|
||||
open: true,
|
||||
data: { provider_id: providers[0]?.provider_id, model_code: '', model_name: '', context_window: 32000, max_output_tokens: 4096, input_cost_per_1m: 0, output_cost_per_1m: 0, supports_streaming: true, supports_tools: true, supports_vision: false, is_active: true, description: '' },
|
||||
})}
|
||||
onEdit={(m) => setModelDialog({ open: true, data: m })}
|
||||
onDelete={(m) => setDeleteDialog({ open: true, type: 'model', id: m.model_id, name: m.model_name })}
|
||||
onToggleActive={handleToggleModelActive}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<AssignmentsTab
|
||||
assignments={assignments}
|
||||
hasProvidersAndModels={providers.length > 0 && models.length > 0}
|
||||
onAdd={() => setAssignmentDialog({
|
||||
open: true,
|
||||
data: { component_code: '', component_name: '', provider_id: providers[0]?.provider_id, model_id: models[0]?.model_id, temperature: 0.3, max_tokens: 4096, timeout_ms: 120000, is_enabled: true, description: '' },
|
||||
})}
|
||||
onEdit={(a) => setAssignmentDialog({ open: true, data: a })}
|
||||
onDelete={(a) => setDeleteDialog({ open: true, type: 'assignment', id: a.assignment_id, name: a.component_name })}
|
||||
onToggleEnabled={handleToggleAssignmentEnabled}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<ApiKeysTab
|
||||
apiKeys={apiKeys}
|
||||
hasProviders={providers.length > 0}
|
||||
onAdd={() => setApiKeyDialog({
|
||||
open: true,
|
||||
data: { provider_id: providers[0]?.provider_id, key_name: '', api_key_value: '', is_active: true },
|
||||
})}
|
||||
onEdit={(k) => setApiKeyDialog({ open: true, data: k })}
|
||||
onDelete={(k) => setDeleteDialog({ open: true, type: 'apikey', id: k.api_key_id, name: k.key_name })}
|
||||
onToggleActive={handleToggleApiKeyActive}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<ProviderDialog
|
||||
open={providerDialog.open}
|
||||
data={providerDialog.data}
|
||||
onClose={() => setProviderDialog({ open: false, data: null })}
|
||||
onChange={(data) => setProviderDialog({ open: true, data })}
|
||||
onSave={handleSaveProvider}
|
||||
/>
|
||||
|
||||
<ModelDialog
|
||||
open={modelDialog.open}
|
||||
data={modelDialog.data}
|
||||
providers={providers}
|
||||
onClose={() => setModelDialog({ open: false, data: null })}
|
||||
onChange={(data) => setModelDialog({ open: true, data })}
|
||||
onSave={handleSaveModel}
|
||||
/>
|
||||
|
||||
<AssignmentDialog
|
||||
open={assignmentDialog.open}
|
||||
data={assignmentDialog.data}
|
||||
providers={providers}
|
||||
models={models}
|
||||
onClose={() => setAssignmentDialog({ open: false, data: null })}
|
||||
onChange={(data) => setAssignmentDialog({ open: true, data })}
|
||||
onSave={handleSaveAssignment}
|
||||
/>
|
||||
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialog.open}
|
||||
data={apiKeyDialog.data}
|
||||
providers={providers}
|
||||
onClose={() => setApiKeyDialog({ open: false, data: null })}
|
||||
onChange={(data) => setApiKeyDialog({ open: true, data })}
|
||||
onSave={handleSaveApiKey}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteDialog}
|
||||
name={deleteDialog?.name ?? ''}
|
||||
onCancel={() => setDeleteDialog(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProvidersManagement;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Domain types for the Providers Management page + a small TabPanel utility.
|
||||
*
|
||||
* All four entities (Provider, Model, ComponentAssignment, ApiKey) map 1:1
|
||||
* to /framework/api/providers/* endpoints and are joined into a single
|
||||
* `/all` payload by the backend so we can render the dashboard with one fetch.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export const API_BASE = '/framework/api/providers';
|
||||
|
||||
export interface LlmProvider {
|
||||
provider_id: number;
|
||||
provider_code: string;
|
||||
provider_name: string;
|
||||
base_url: string;
|
||||
auth_type: string;
|
||||
is_active: boolean;
|
||||
priority: number;
|
||||
rate_limit_rpm: number;
|
||||
rate_limit_tpm: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface LlmModel {
|
||||
model_id: number;
|
||||
provider_id: number;
|
||||
provider_code?: string;
|
||||
provider_name?: string;
|
||||
model_code: string;
|
||||
model_name: string;
|
||||
context_window: number;
|
||||
max_output_tokens: number;
|
||||
input_cost_per_1m: number;
|
||||
output_cost_per_1m: number;
|
||||
supports_streaming: boolean;
|
||||
supports_tools: boolean;
|
||||
supports_vision: boolean;
|
||||
is_active: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ComponentAssignment {
|
||||
assignment_id: number;
|
||||
component_code: string;
|
||||
component_name: string;
|
||||
provider_id: number;
|
||||
provider_code?: string;
|
||||
model_id: number;
|
||||
model_code?: string;
|
||||
fallback_provider_id: number | null;
|
||||
fallback_provider_code?: string;
|
||||
fallback_model_id: number | null;
|
||||
fallback_model_code?: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
timeout_ms: number;
|
||||
is_enabled: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
api_key_id: number;
|
||||
provider_id: number;
|
||||
provider_code?: string;
|
||||
key_name: string;
|
||||
key_prefix: string;
|
||||
is_active: boolean;
|
||||
usage_count: number;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => (
|
||||
<div role="tabpanel" hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Thin fetch wrappers for the /framework/api/providers/* CRUD surface.
|
||||
*
|
||||
* Each function returns whatever the backend wraps in its response (typically
|
||||
* `{ success, data?, error? }`). Callers check `.success` and use `.data` /
|
||||
* surface `.error` themselves — no error handling here so the page can show
|
||||
* its own UX for failures (toasts, inline errors).
|
||||
*/
|
||||
import {
|
||||
API_BASE,
|
||||
type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey,
|
||||
} from './ProvidersManagement.types';
|
||||
|
||||
export async function fetchAll() {
|
||||
const res = await fetch(`${API_BASE}/all`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createProvider(data: Partial<LlmProvider>) {
|
||||
const res = await fetch(`${API_BASE}/configs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateProvider(id: number, data: Partial<LlmProvider>) {
|
||||
const res = await fetch(`${API_BASE}/configs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: number) {
|
||||
const res = await fetch(`${API_BASE}/configs/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createModel(data: Partial<LlmModel>) {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateModel(id: number, data: Partial<LlmModel>) {
|
||||
const res = await fetch(`${API_BASE}/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteModel(id: number) {
|
||||
const res = await fetch(`${API_BASE}/models/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createAssignment(data: Partial<ComponentAssignment>) {
|
||||
const res = await fetch(`${API_BASE}/assignments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAssignment(id: number, data: Partial<ComponentAssignment>) {
|
||||
const res = await fetch(`${API_BASE}/assignments/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAssignment(id: number) {
|
||||
const res = await fetch(`${API_BASE}/assignments/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createApiKey(data: { provider_id: number; key_name: string; api_key_value: string; is_active?: boolean }) {
|
||||
const res = await fetch(`${API_BASE}/keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateApiKey(id: number, data: Partial<ApiKey & { api_key_value?: string }>) {
|
||||
const res = await fetch(`${API_BASE}/keys/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id: number) {
|
||||
const res = await fetch(`${API_BASE}/keys/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function testProvider(providerId: number) {
|
||||
const res = await fetch(`${API_BASE}/test/${providerId}`, { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Add/Edit dialog for an ApiKey row.
|
||||
*
|
||||
* `api_key_value` is a transient field — only sent to the backend on create
|
||||
* or when explicitly changed during edit. Backend stores it encrypted and
|
||||
* only returns the prefix on subsequent reads.
|
||||
*
|
||||
* Toggle button hides/shows the key value with a password-style mask.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, IconButton,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import type { ApiKey, LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<ApiKey & { api_key_value?: string }> | null;
|
||||
providers: LlmProvider[];
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<ApiKey & { api_key_value?: string }>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const ApiKeyDialog: React.FC<Props> = ({ open, data, providers, onClose, onChange, onSave }) => {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
if (!data) return null;
|
||||
const isEdit = 'api_key_id' in data && !!data.api_key_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit API Key' : 'Add API Key'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value) })}>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Key Name" value={data.key_name || ''} onChange={(e) => onChange({ ...data, key_name: e.target.value })} required fullWidth placeholder="Primary Key, Backup Key, etc." />
|
||||
<TextField
|
||||
label={isEdit ? 'New API Key (leave empty to keep existing)' : 'API Key'}
|
||||
value={data.api_key_value || ''}
|
||||
onChange={(e) => onChange({ ...data, api_key_value: e.target.value })}
|
||||
required={!isEdit}
|
||||
fullWidth
|
||||
type={showKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowKey(!showKey)} edge="end">
|
||||
{showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.provider_id || !data.key_name || (!isEdit && !data.api_key_value)}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Add/Edit dialog for a ComponentAssignment row.
|
||||
*
|
||||
* Maps a component (techniques/claims_extract/etc) to a primary provider+model
|
||||
* with optional fallback provider+model. Models dropdown filters to only show
|
||||
* models belonging to the currently-selected provider.
|
||||
*
|
||||
* Fallback section is fully optional — empty fallback_provider_id disables
|
||||
* the fallback model dropdown.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, Typography,
|
||||
} from '@mui/material';
|
||||
import type { ComponentAssignment, LlmProvider, LlmModel } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<ComponentAssignment> | null;
|
||||
providers: LlmProvider[];
|
||||
models: LlmModel[];
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<ComponentAssignment>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const AssignmentDialog: React.FC<Props> = ({ open, data, providers, models, onClose, onChange, onSave }) => {
|
||||
if (!data) return null;
|
||||
const isEdit = 'assignment_id' in data && !!data.assignment_id;
|
||||
|
||||
const filteredModels = models.filter((m) => m.provider_id === data.provider_id);
|
||||
const filteredFallbackModels = models.filter((m) => m.provider_id === data.fallback_provider_id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit Assignment' : 'Add Assignment'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<TextField label="Component Code" value={data.component_code || ''} onChange={(e) => onChange({ ...data, component_code: e.target.value })} required fullWidth placeholder="techniques, claims_extract, etc." />
|
||||
<TextField label="Component Name" value={data.component_name || ''} onChange={(e) => onChange({ ...data, component_name: e.target.value })} required fullWidth />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Primary Provider/Model</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<FormControl sx={{ flex: 1 }} required>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value), model_id: undefined })}>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ flex: 1 }} required>
|
||||
<InputLabel>Model</InputLabel>
|
||||
<Select value={data.model_id || ''} label="Model" onChange={(e) => onChange({ ...data, model_id: Number(e.target.value) })}>
|
||||
{filteredModels.map((m) => (
|
||||
<MenuItem key={m.model_id} value={m.model_id}>{m.model_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Fallback Provider/Model (Optional)</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Fallback Provider</InputLabel>
|
||||
<Select value={data.fallback_provider_id || ''} label="Fallback Provider" onChange={(e) => onChange({ ...data, fallback_provider_id: e.target.value ? Number(e.target.value) : null, fallback_model_id: null })}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Fallback Model</InputLabel>
|
||||
<Select value={data.fallback_model_id || ''} label="Fallback Model" onChange={(e) => onChange({ ...data, fallback_model_id: e.target.value ? Number(e.target.value) : null })} disabled={!data.fallback_provider_id}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{filteredFallbackModels.map((m) => (
|
||||
<MenuItem key={m.model_id} value={m.model_id}>{m.model_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Settings</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Temperature" type="number" value={data.temperature || 0.3} onChange={(e) => onChange({ ...data, temperature: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.1, min: 0, max: 2 }} />
|
||||
<TextField label="Max Tokens" type="number" value={data.max_tokens || 4096} onChange={(e) => onChange({ ...data, max_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Timeout (ms)" type="number" value={data.timeout_ms || 120000} onChange={(e) => onChange({ ...data, timeout_ms: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
|
||||
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
|
||||
<FormControlLabel control={<Checkbox checked={data.is_enabled !== false} onChange={(e) => onChange({ ...data, is_enabled: e.target.checked })} />} label="Enabled" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.component_code || !data.component_name || !data.provider_id || !data.model_id}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Generic delete-confirmation dialog used by all four entity types.
|
||||
* Caller dispatches the actual delete via onConfirm based on the row's `type`.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
name: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteConfirmDialog: React.FC<Props> = ({ open, name, onCancel, onConfirm }) => (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete <strong>{name}</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={onConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Add/Edit dialog for an LlmModel row.
|
||||
* Provider dropdown is required; cost fields default to $0 (FREE).
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button,
|
||||
} from '@mui/material';
|
||||
import type { LlmModel, LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<LlmModel> | null;
|
||||
providers: LlmProvider[];
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<LlmModel>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const ModelDialog: React.FC<Props> = ({ open, data, providers, onClose, onChange, onSave }) => {
|
||||
if (!data) return null;
|
||||
const isEdit = 'model_id' in data && !!data.model_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit Model' : 'Add Model'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={data.provider_id || ''} label="Provider" onChange={(e) => onChange({ ...data, provider_id: Number(e.target.value) })}>
|
||||
{providers.map((p) => (
|
||||
<MenuItem key={p.provider_id} value={p.provider_id}>{p.provider_name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Model Code" value={data.model_code || ''} onChange={(e) => onChange({ ...data, model_code: e.target.value })} required fullWidth placeholder="gpt-4o, claude-3-opus, etc." />
|
||||
<TextField label="Model Name" value={data.model_name || ''} onChange={(e) => onChange({ ...data, model_name: e.target.value })} required fullWidth />
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Context Window" type="number" value={data.context_window || 32000} onChange={(e) => onChange({ ...data, context_window: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Max Output Tokens" type="number" value={data.max_output_tokens || 4096} onChange={(e) => onChange({ ...data, max_output_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Input Cost ($/1M)" type="number" value={data.input_cost_per_1m || 0} onChange={(e) => onChange({ ...data, input_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} />
|
||||
<TextField label="Output Cost ($/1M)" type="number" value={data.output_cost_per_1m || 0} onChange={(e) => onChange({ ...data, output_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} />
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<FormControlLabel control={<Checkbox checked={data.supports_streaming !== false} onChange={(e) => onChange({ ...data, supports_streaming: e.target.checked })} />} label="Streaming" />
|
||||
<FormControlLabel control={<Checkbox checked={data.supports_tools !== false} onChange={(e) => onChange({ ...data, supports_tools: e.target.checked })} />} label="Tools" />
|
||||
<FormControlLabel control={<Checkbox checked={data.supports_vision === true} onChange={(e) => onChange({ ...data, supports_vision: e.target.checked })} />} label="Vision" />
|
||||
</Box>
|
||||
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
|
||||
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.provider_id || !data.model_code || !data.model_name}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Add/Edit dialog for an LlmProvider row.
|
||||
* Save is disabled until provider_code + provider_name are non-empty.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl,
|
||||
InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button,
|
||||
} from '@mui/material';
|
||||
import type { LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
data: Partial<LlmProvider> | null;
|
||||
onClose: () => void;
|
||||
onChange: (data: Partial<LlmProvider>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const ProviderDialog: React.FC<Props> = ({ open, data, onClose, onChange, onSave }) => {
|
||||
if (!data) return null;
|
||||
const isEdit = 'provider_id' in data && !!data.provider_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{isEdit ? 'Edit Provider' : 'Add Provider'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} pt={1}>
|
||||
<TextField label="Provider Code" value={data.provider_code || ''} onChange={(e) => onChange({ ...data, provider_code: e.target.value })} required fullWidth />
|
||||
<TextField label="Provider Name" value={data.provider_name || ''} onChange={(e) => onChange({ ...data, provider_name: e.target.value })} required fullWidth />
|
||||
<TextField label="Base URL" value={data.base_url || ''} onChange={(e) => onChange({ ...data, base_url: e.target.value })} fullWidth placeholder="https://api.example.com/v1" />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Auth Type</InputLabel>
|
||||
<Select value={data.auth_type || 'bearer'} label="Auth Type" onChange={(e) => onChange({ ...data, auth_type: e.target.value })}>
|
||||
<MenuItem value="bearer">Bearer Token</MenuItem>
|
||||
<MenuItem value="api_key">API Key Header</MenuItem>
|
||||
<MenuItem value="x-api-key">X-API-Key Header</MenuItem>
|
||||
<MenuItem value="none">No Auth</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box display="flex" gap={2}>
|
||||
<TextField label="Priority" type="number" value={data.priority || 100} onChange={(e) => onChange({ ...data, priority: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Rate Limit (RPM)" type="number" value={data.rate_limit_rpm || 60} onChange={(e) => onChange({ ...data, rate_limit_rpm: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
<TextField label="Rate Limit (TPM)" type="number" value={data.rate_limit_tpm || 100000} onChange={(e) => onChange({ ...data, rate_limit_tpm: parseInt(e.target.value) })} sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<TextField label="Description" value={data.description || ''} onChange={(e) => onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth />
|
||||
<FormControlLabel control={<Checkbox checked={data.is_active !== false} onChange={(e) => onChange({ ...data, is_active: e.target.checked })} />} label="Active" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onSave} disabled={!data.provider_code || !data.provider_name}>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { ProvidersManagement } from './ProvidersManagement';
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* API Keys tab — list of stored keys with usage telemetry.
|
||||
*
|
||||
* Backend redacts the key value to a prefix; frontend never sees the full key
|
||||
* after creation. Edit dialog requires re-entering it if rotation is needed.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { ApiKey } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
apiKeys: ApiKey[];
|
||||
hasProviders: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (k: ApiKey) => void;
|
||||
onDelete: (k: ApiKey) => void;
|
||||
onToggleActive: (k: ApiKey) => void;
|
||||
}
|
||||
|
||||
export const ApiKeysTab: React.FC<Props> = ({ apiKeys, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProviders}>
|
||||
Add API Key
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key Name</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Prefix</TableCell>
|
||||
<TableCell>Usage</TableCell>
|
||||
<TableCell>Last Used</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{apiKeys.map((k) => (
|
||||
<TableRow key={k.api_key_id} hover>
|
||||
<TableCell><strong>{k.key_name}</strong></TableCell>
|
||||
<TableCell><Chip label={k.provider_code} size="small" /></TableCell>
|
||||
<TableCell><code>{k.key_prefix}...</code></TableCell>
|
||||
<TableCell>{k.usage_count} calls</TableCell>
|
||||
<TableCell>{k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : 'Never'}</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={k.is_active} onChange={() => onToggleActive(k)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(k)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(k)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Assignments tab — maps each component to a primary provider+model with
|
||||
* an optional fallback. Settings column packs temp/max/timeout into one
|
||||
* caption row to keep the table scannable.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch, Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { ComponentAssignment } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
assignments: ComponentAssignment[];
|
||||
hasProvidersAndModels: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (a: ComponentAssignment) => void;
|
||||
onDelete: (a: ComponentAssignment) => void;
|
||||
onToggleEnabled: (a: ComponentAssignment) => void;
|
||||
}
|
||||
|
||||
export const AssignmentsTab: React.FC<Props> = ({ assignments, hasProvidersAndModels, onAdd, onEdit, onDelete, onToggleEnabled }) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProvidersAndModels}>
|
||||
Add Assignment
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Provider / Model</TableCell>
|
||||
<TableCell>Fallback</TableCell>
|
||||
<TableCell>Settings</TableCell>
|
||||
<TableCell>Enabled</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{assignments.map((a) => (
|
||||
<TableRow key={a.assignment_id} hover>
|
||||
<TableCell>
|
||||
<strong>{a.component_name}</strong>
|
||||
<Typography variant="caption" display="block" color="text.secondary">{a.component_code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={a.provider_code} size="small" sx={{ mr: 0.5 }} />
|
||||
<Chip label={a.model_code} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{a.fallback_provider_code ? (
|
||||
<>
|
||||
<Chip label={a.fallback_provider_code} size="small" sx={{ mr: 0.5 }} />
|
||||
<Chip label={a.fallback_model_code} size="small" variant="outlined" />
|
||||
</>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">
|
||||
temp: {a.temperature} | max: {a.max_tokens} | timeout: {a.timeout_ms}ms
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={a.is_enabled} onChange={() => onToggleEnabled(a)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(a)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(a)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Models tab — list of LLM models with provider, context, cost, features.
|
||||
*
|
||||
* Cost column shows "FREE" chip when input cost is $0; otherwise shows
|
||||
* "$<input> / $<output>" per 1M tokens. Features are 3 small chips
|
||||
* (Streaming/Tools/Vision) shown only when supported.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch, Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import type { LlmModel } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
models: LlmModel[];
|
||||
hasProviders: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (m: LlmModel) => void;
|
||||
onDelete: (m: LlmModel) => void;
|
||||
onToggleActive: (m: LlmModel) => void;
|
||||
}
|
||||
|
||||
export const ModelsTab: React.FC<Props> = ({ models, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd} disabled={!hasProviders}>
|
||||
Add Model
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Context</TableCell>
|
||||
<TableCell>Cost (per 1M)</TableCell>
|
||||
<TableCell>Features</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{models.map((m) => (
|
||||
<TableRow key={m.model_id} hover>
|
||||
<TableCell>
|
||||
<strong>{m.model_name}</strong>
|
||||
<Typography variant="caption" display="block" color="text.secondary">{m.model_code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={m.provider_code} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{(m.context_window / 1000).toFixed(0)}K</TableCell>
|
||||
<TableCell>
|
||||
{m.input_cost_per_1m === 0
|
||||
? <Chip label="FREE" size="small" color="success" />
|
||||
: `$${m.input_cost_per_1m} / $${m.output_cost_per_1m}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.supports_streaming && <Chip label="Stream" size="small" sx={{ mr: 0.5 }} />}
|
||||
{m.supports_tools && <Chip label="Tools" size="small" sx={{ mr: 0.5 }} />}
|
||||
{m.supports_vision && <Chip label="Vision" size="small" color="primary" />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={m.is_active} onChange={() => onToggleActive(m)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => onEdit(m)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(m)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Providers tab — list of LLM providers with test-connection action.
|
||||
*
|
||||
* Test-connection runs against the backend's /test/:providerId endpoint
|
||||
* and shows ✓ / ✗ + latency_ms inline next to the play button. Result
|
||||
* stays visible until the next click (no auto-clear).
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Button, Chip, IconButton, Switch, Tooltip, CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon,
|
||||
PlayArrow as TestIcon, CheckCircle, Cancel,
|
||||
} from '@mui/icons-material';
|
||||
import type { LlmProvider } from '../ProvidersManagement.types';
|
||||
|
||||
interface Props {
|
||||
providers: LlmProvider[];
|
||||
testingProvider: number | null;
|
||||
testResult: { providerId: number; success: boolean; latencyMs?: number; error?: string } | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (p: LlmProvider) => void;
|
||||
onDelete: (p: LlmProvider) => void;
|
||||
onToggleActive: (p: LlmProvider) => void;
|
||||
onTest: (id: number) => void;
|
||||
}
|
||||
|
||||
export const ProvidersTab: React.FC<Props> = ({
|
||||
providers, testingProvider, testResult,
|
||||
onAdd, onEdit, onDelete, onToggleActive, onTest,
|
||||
}) => (
|
||||
<>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={onAdd}>
|
||||
Add Provider
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Provider</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Base URL</TableCell>
|
||||
<TableCell>Auth</TableCell>
|
||||
<TableCell>Priority</TableCell>
|
||||
<TableCell>Rate Limits</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.provider_id} hover>
|
||||
<TableCell><strong>{p.provider_name}</strong></TableCell>
|
||||
<TableCell><Chip label={p.provider_code} size="small" /></TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<Tooltip title={p.base_url}><span>{p.base_url}</span></Tooltip>
|
||||
</TableCell>
|
||||
<TableCell><Chip label={p.auth_type} size="small" variant="outlined" /></TableCell>
|
||||
<TableCell>{p.priority}</TableCell>
|
||||
<TableCell>{p.rate_limit_rpm} RPM / {p.rate_limit_tpm} TPM</TableCell>
|
||||
<TableCell>
|
||||
<Switch checked={p.is_active} onChange={() => onToggleActive(p)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Test Connection">
|
||||
<IconButton size="small" onClick={() => onTest(p.provider_id)} disabled={testingProvider === p.provider_id}>
|
||||
{testingProvider === p.provider_id ? <CircularProgress size={16} /> : <TestIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{testResult?.providerId === p.provider_id && (
|
||||
<Tooltip title={testResult.success ? `${testResult.latencyMs}ms` : testResult.error}>
|
||||
{testResult.success
|
||||
? <CheckCircle color="success" fontSize="small" />
|
||||
: <Cancel color="error" fontSize="small" />}
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton size="small" onClick={() => onEdit(p)}><EditIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(p)} color="error"><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* RolesDialog — multi-select Keycloak realm roles for a user.
|
||||
*
|
||||
* UX:
|
||||
* 1. On open, load realm roles + user's current roles.
|
||||
* 2. Show checkbox list with description tooltip.
|
||||
* 3. Save → PUT /api/admin/users/:id/roles with the desired list.
|
||||
* Server diffs add/remove and reports counts.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
interface RealmRole {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PutRolesResponse {
|
||||
success: boolean;
|
||||
added?: string[];
|
||||
removed?: string[];
|
||||
errors?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/framework';
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token
|
||||
? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
: { 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
export const RolesDialog: React.FC<Props> = ({ open, userId, userEmail, onClose, onSaved }) => {
|
||||
const [allRoles, setAllRoles] = useState<RealmRole[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [initial, setInitial] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [rolesRes, userRolesRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/admin/realm-roles`, { headers: authHeaders() }),
|
||||
fetch(`${API_BASE}/api/admin/users/${userId}/roles`, { headers: authHeaders() }),
|
||||
]);
|
||||
const roles = await rolesRes.json();
|
||||
const userRoles = await userRolesRes.json();
|
||||
if (cancelled) return;
|
||||
if (!roles.success || !userRoles.success) {
|
||||
setError((roles.error || userRoles.error) ?? 'Failed to load roles');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const all = (roles.data ?? []) as RealmRole[];
|
||||
const have = new Set<string>((userRoles.data?.roles ?? []) as string[]);
|
||||
setAllRoles(all);
|
||||
setSelected(new Set(have));
|
||||
setInitial(new Set(have));
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, userId]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase();
|
||||
if (!f) return allRoles;
|
||||
return allRoles.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(f) ||
|
||||
r.description.toLowerCase().includes(f),
|
||||
);
|
||||
}, [allRoles, filter]);
|
||||
|
||||
const diffSummary = useMemo(() => {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
selected.forEach((n) => {
|
||||
if (!initial.has(n)) added.push(n);
|
||||
});
|
||||
initial.forEach((n) => {
|
||||
if (!selected.has(n)) removed.push(n);
|
||||
});
|
||||
return { added, removed };
|
||||
}, [selected, initial]);
|
||||
|
||||
const toggle = (name: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users/${userId}/roles`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ roles: Array.from(selected) }),
|
||||
});
|
||||
const data = (await res.json()) as PutRolesResponse;
|
||||
if (!data.success) {
|
||||
setError(data.error ?? `Saved with errors: ${(data.errors ?? []).join('; ')}`);
|
||||
if (res.status === 207) onSaved();
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = diffSummary.added.length > 0 || diffSummary.removed.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Realm roles — {userEmail}
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Realm: didi-clients (Keycloak SSO cluster)
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && (
|
||||
<Stack alignItems="center" py={4}>
|
||||
<CircularProgress size={28} />
|
||||
</Stack>
|
||||
)}
|
||||
{!loading && (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Filter roles"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
{filtered.length === 0 && (
|
||||
<Alert severity="info">No roles match the filter.</Alert>
|
||||
)}
|
||||
<List dense disablePadding sx={{ maxHeight: 400, overflowY: 'auto' }}>
|
||||
{filtered.map((r) => (
|
||||
<ListItem key={r.name} disableGutters sx={{ py: 0 }}>
|
||||
<FormControlLabel
|
||||
sx={{ width: '100%' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selected.has(r.name)}
|
||||
onChange={() => toggle(r.name)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Tooltip title={r.description || '—'}>
|
||||
<ListItemText
|
||||
primary={r.name}
|
||||
secondary={r.description || undefined}
|
||||
primaryTypographyProps={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
}}
|
||||
secondaryTypographyProps={{ fontSize: 11 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{dirty && (
|
||||
<Alert severity="warning">
|
||||
{diffSummary.added.length > 0 && (
|
||||
<div>
|
||||
<strong>Add:</strong> {diffSummary.added.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{diffSummary.removed.length > 0 && (
|
||||
<div>
|
||||
<strong>Remove:</strong> {diffSummary.removed.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* UsageHistoryModal — last N analyses + total cheltuit pentru un user.
|
||||
*
|
||||
* Reads bos_sysadmin.ai_credit_usage via /api/admin/users/:id/usage-history.
|
||||
* The endpoint returns rows with whatever columns the table has — we render
|
||||
* defensively (display known fields if present, fall back to a small pretty
|
||||
* JSON for unknown columns).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { Close as CloseIcon, Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
|
||||
interface UsageRow {
|
||||
// Best-effort columns. Names match what we observed in the schema; any
|
||||
// extra columns the row has are still rendered via JSON fallback.
|
||||
internet_user_id?: number;
|
||||
session_id?: string;
|
||||
input_type?: string;
|
||||
credits_used?: number;
|
||||
used_at?: string;
|
||||
created_at?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface UsageResponse {
|
||||
success: boolean;
|
||||
data: UsageRow[];
|
||||
stats?: { rows: number; total_credits: number };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/framework';
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
const TYPE_COLOR: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'info'> = {
|
||||
text: 'primary',
|
||||
url: 'info',
|
||||
image: 'secondary',
|
||||
audio: 'warning',
|
||||
video: 'success',
|
||||
};
|
||||
|
||||
function fmt(s: unknown): string {
|
||||
if (!s) return '—';
|
||||
try {
|
||||
return new Date(s as string).toLocaleString();
|
||||
} catch {
|
||||
return String(s);
|
||||
}
|
||||
}
|
||||
|
||||
function pickWhen(r: UsageRow): unknown {
|
||||
return r.used_at ?? r.created_at ?? (r as Record<string, unknown>).timestamp ?? null;
|
||||
}
|
||||
|
||||
export const UsageHistoryModal: React.FC<Props> = ({ open, userId, userEmail, onClose }) => {
|
||||
const [rows, setRows] = useState<UsageRow[]>([]);
|
||||
const [stats, setStats] = useState<{ rows: number; total_credits: number } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/users/${userId}/usage-history?limit=100`,
|
||||
{ headers: authHeaders() },
|
||||
);
|
||||
const data = (await res.json()) as UsageResponse;
|
||||
if (!data.success) {
|
||||
setError(data.error ?? 'Failed to load usage history');
|
||||
return;
|
||||
}
|
||||
setRows(data.data ?? []);
|
||||
setStats(data.stats ?? null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, userId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="h6">Usage history — {userEmail}</Typography>
|
||||
{stats && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{stats.rows} rows · {stats.total_credits} credits used (sum)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<IconButton onClick={load} title="Refresh">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && (
|
||||
<Stack alignItems="center" py={4}>
|
||||
<CircularProgress size={28} />
|
||||
</Stack>
|
||||
)}
|
||||
{!loading && error && <Alert severity="error">{error}</Alert>}
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<Alert severity="info">No usage rows recorded for this user.</Alert>
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<TableContainer>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Input type</TableCell>
|
||||
<TableCell align="right">Credits</TableCell>
|
||||
<TableCell>Session</TableCell>
|
||||
<TableCell>Other</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((r, idx) => {
|
||||
const inputType = (r.input_type ?? '') as string;
|
||||
const credits = r.credits_used ??
|
||||
(r as Record<string, unknown>).credits ??
|
||||
(r as Record<string, unknown>).cost ??
|
||||
null;
|
||||
const session = (r.session_id ?? '') as string;
|
||||
|
||||
// Other columns — exclude the ones we already show.
|
||||
const known = new Set(['internet_user_id','session_id','input_type','credits_used','used_at','created_at','credits','cost']);
|
||||
const extras: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(r)) {
|
||||
if (!known.has(k)) extras[k] = v;
|
||||
}
|
||||
const extrasStr = Object.keys(extras).length === 0
|
||||
? '—'
|
||||
: JSON.stringify(extras).slice(0, 80);
|
||||
|
||||
return (
|
||||
<TableRow key={idx} hover>
|
||||
<TableCell>
|
||||
<Typography variant="caption">{fmt(pickWhen(r))}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{inputType ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={inputType}
|
||||
color={TYPE_COLOR[inputType] ?? 'default'}
|
||||
/>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{credits === null ? '—' : String(credits)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
title={session}
|
||||
>
|
||||
{session ? session.slice(0, 12) + '…' : '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', color: 'text.secondary' }}
|
||||
>
|
||||
{extrasStr}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Stack,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import { LockReset as LockResetIcon } from '@mui/icons-material';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
creditsRemained: number;
|
||||
creditsSpent: number;
|
||||
subscriptionPlanId: number;
|
||||
subscriptionPlanName: string;
|
||||
subscriptionStatus: number;
|
||||
isActive: boolean;
|
||||
keycloakId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SubscriptionPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
creditsIncluded: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface UserEditModalProps {
|
||||
open: boolean;
|
||||
user: User;
|
||||
plans: SubscriptionPlan[];
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/framework';
|
||||
|
||||
export const UserEditModal: React.FC<UserEditModalProps> = ({
|
||||
open,
|
||||
user,
|
||||
plans,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
isActive: true,
|
||||
creditsRemained: 0,
|
||||
subscriptionPlanId: 1,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Phase U — group select + reset password (live values from Keycloak)
|
||||
const [allGroups, setAllGroups] = useState<{ id: string; name: string }[]>([]);
|
||||
const [currentGroup, setCurrentGroup] = useState<string>('');
|
||||
const [initialGroup, setInitialGroup] = useState<string>('');
|
||||
const [resetSent, setResetSent] = useState<string | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
phone: user.phone || '',
|
||||
isActive: user.isActive,
|
||||
creditsRemained: user.creditsRemained || 0,
|
||||
subscriptionPlanId: user.subscriptionPlanId || 1,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Load Keycloak groups + the user's current group when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open || !user?.id) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [groupsRes, userGroupRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/admin/groups`, { headers: getAuthHeader() as any }),
|
||||
fetch(`${API_BASE}/api/admin/users/${user.id}/group`, { headers: getAuthHeader() as any }),
|
||||
]);
|
||||
const groupsData = await groupsRes.json();
|
||||
const userGroupData = await userGroupRes.json();
|
||||
if (cancelled) return;
|
||||
if (groupsData.success) setAllGroups(groupsData.data ?? []);
|
||||
const g = (userGroupData.data?.groups ?? [])[0] ?? '';
|
||||
setCurrentGroup(g);
|
||||
setInitialGroup(g);
|
||||
} catch {
|
||||
// best-effort — leave dropdown empty if Keycloak unreachable
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, user?.id]);
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const handleChange = (field: string) => (event: any) => {
|
||||
const value = event.target.type === 'checkbox'
|
||||
? event.target.checked
|
||||
: event.target.value;
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Update user profile
|
||||
const profileResponse = await fetch(`${API_BASE}/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
phone: formData.phone,
|
||||
isActive: formData.isActive,
|
||||
creditsRemained: formData.creditsRemained,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
const data = await profileResponse.json();
|
||||
throw new Error(data.error || 'Failed to update user');
|
||||
}
|
||||
|
||||
// Update subscription if changed
|
||||
if (formData.subscriptionPlanId !== user.subscriptionPlanId) {
|
||||
const subscriptionResponse = await fetch(
|
||||
`${API_BASE}/api/admin/users/${user.id}/subscription`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
planId: formData.subscriptionPlanId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!subscriptionResponse.ok) {
|
||||
const data = await subscriptionResponse.json();
|
||||
throw new Error(data.error || 'Failed to update subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Update Keycloak group if changed (Phase U)
|
||||
if (currentGroup !== initialGroup) {
|
||||
const grpRes = await fetch(
|
||||
`${API_BASE}/api/admin/users/${user.id}/group`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ group: currentGroup || null }),
|
||||
}
|
||||
);
|
||||
if (!grpRes.ok) {
|
||||
const data = await grpRes.json();
|
||||
throw new Error(data.error || 'Failed to update group');
|
||||
}
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
setResetting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users/${user.id}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader() as Record<string, string>,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ lifespanSeconds: 86400 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(
|
||||
data.error ||
|
||||
data.detail ||
|
||||
`Reset request failed (HTTP ${res.status})`,
|
||||
);
|
||||
}
|
||||
setResetSent(`Reset email queued for ${user.email}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedPlan = plans.find(p => p.id === formData.subscriptionPlanId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
{/* Email (readonly) */}
|
||||
<TextField
|
||||
label="Email"
|
||||
value={user.email}
|
||||
disabled
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* First Name */}
|
||||
<TextField
|
||||
label="First Name"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange('firstName')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Last Name */}
|
||||
<TextField
|
||||
label="Last Name"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange('lastName')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Phone */}
|
||||
<TextField
|
||||
label="Phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange('phone')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Subscription & Credits
|
||||
</Typography>
|
||||
|
||||
{/* Subscription Plan */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Subscription Plan</InputLabel>
|
||||
<Select
|
||||
value={formData.subscriptionPlanId}
|
||||
onChange={handleChange('subscriptionPlanId')}
|
||||
label="Subscription Plan"
|
||||
>
|
||||
{plans.map(plan => (
|
||||
<MenuItem key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.creditsIncluded} credits)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedPlan && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Plan includes {selectedPlan.creditsIncluded} credits.
|
||||
{selectedPlan.price > 0 && ` Price: $${selectedPlan.price}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Credits */}
|
||||
<TextField
|
||||
label="Credits Remaining"
|
||||
type="number"
|
||||
value={formData.creditsRemained}
|
||||
onChange={handleChange('creditsRemained')}
|
||||
fullWidth
|
||||
size="small"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Credits spent: {user.creditsSpent}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Keycloak group
|
||||
</Typography>
|
||||
|
||||
{/* Phase U — Keycloak group (single-select).
|
||||
Empty value = remove from all groups. */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Group</InputLabel>
|
||||
<Select
|
||||
value={currentGroup}
|
||||
onChange={(e) => setCurrentGroup(e.target.value as string)}
|
||||
label="Group"
|
||||
disabled={!user.keycloakId}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>(no group)</em>
|
||||
</MenuItem>
|
||||
{allGroups.map((g) => (
|
||||
<MenuItem key={g.id} value={g.name}>
|
||||
{g.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{!user.keycloakId && (
|
||||
<Typography variant="caption" color="warning.main">
|
||||
No keycloak_id — sync this user first to manage their group.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Status */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onChange={handleChange('isActive')}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={formData.isActive ? 'Active' : 'Inactive'}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
variant="outlined"
|
||||
startIcon={resetting ? <CircularProgress size={16} /> : <LockResetIcon />}
|
||||
disabled={resetting || !user.keycloakId}
|
||||
color="warning"
|
||||
>
|
||||
Send password reset email
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Triggers Keycloak's UPDATE_PASSWORD flow. 24h lifespan.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
<Snackbar
|
||||
open={resetSent !== null}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setResetSent(null)}
|
||||
message={resetSent ?? ''}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Pure helpers + constants for UserManagement.
|
||||
*
|
||||
* getAuthHeader() reads the Keycloak token from localStorage and returns
|
||||
* either a Bearer header or an empty object. Empty header is fine in staging
|
||||
* when Keycloak auth bypass is on; backend ignores missing auth then.
|
||||
*/
|
||||
export function fmtBytes(n: number | undefined): string {
|
||||
if (!n || n <= 0) return '0';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function fmtDate(s: string): string {
|
||||
if (!s) return '—';
|
||||
try {
|
||||
return new Date(s).toLocaleString();
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
export const ACTION_COLORS: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'error'> = {
|
||||
'user.update': 'primary',
|
||||
'user.delete': 'error',
|
||||
'user.sync': 'success',
|
||||
'user.email_verified': 'secondary',
|
||||
'user.subscription': 'primary',
|
||||
'user.roles': 'warning',
|
||||
'user.group': 'warning',
|
||||
'user.reset_password': 'warning',
|
||||
};
|
||||
|
||||
export function getAuthHeader(): Record<string, string> {
|
||||
const token = localStorage.getItem('keycloak_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* USER MANAGEMENT — admin page for managing users / plans / audit log.
|
||||
*
|
||||
* Original 1153-line file split into:
|
||||
* UserManagement.types.ts — User, SubscriptionPlan, AuditEntry, etc.
|
||||
* UserManagement.helpers.ts — fmtBytes, fmtDate, ACTION_COLORS, getAuthHeader
|
||||
* tabs/UsersTab.tsx — filters + bulk toolbar + users table
|
||||
* tabs/PlansTab.tsx — plans inline editor
|
||||
* tabs/AuditLogTab.tsx — audit log table + filters
|
||||
* dialogs/DeleteUserDialog.tsx — hard-delete confirmation
|
||||
* dialogs/BulkCreditsDialog.tsx — bulk-set-credits dialog
|
||||
* UserManagement.tsx (this) — state + handlers + tab dispatcher
|
||||
*
|
||||
* Bulk operations are sequential (never parallel) so audit-log ordering is
|
||||
* stable and we don't hammer Keycloak with concurrent writes.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Container, Paper, Typography, Alert, Tab, Tabs, Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People as PeopleIcon,
|
||||
CreditCard as PlansIcon,
|
||||
ReceiptLong as AuditIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { UserEditModal } from './UserEditModal';
|
||||
import { RolesDialog } from './RolesDialog';
|
||||
import { UsageHistoryModal } from './UsageHistoryModal';
|
||||
import { UsersTab } from './tabs/UsersTab';
|
||||
import { PlansTab } from './tabs/PlansTab';
|
||||
import { AuditLogTab } from './tabs/AuditLogTab';
|
||||
import { DeleteUserDialog } from './dialogs/DeleteUserDialog';
|
||||
import { BulkCreditsDialog } from './dialogs/BulkCreditsDialog';
|
||||
import { AddUserDialog } from './dialogs/AddUserDialog';
|
||||
import {
|
||||
type User, type SubscriptionPlan, type AuditEntry, type PaginationInfo,
|
||||
type AuditFiltersState, type AuditPaginationState, type PlanFormState,
|
||||
API_BASE,
|
||||
} from './UserManagement.types';
|
||||
import { getAuthHeader } from './UserManagement.helpers';
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const { hasRole } = useAuth();
|
||||
void hasRole;
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [snackMsg, setSnackMsg] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
page: 1, limit: 20, total: 0, totalPages: 0,
|
||||
});
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [planFilter, setPlanFilter] = useState<string>('');
|
||||
const [syncFilter, setSyncFilter] = useState<'all' | 'synced' | 'keycloak_only'>('all');
|
||||
|
||||
// UI state
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||
const [rolesUser, setRolesUser] = useState<User | null>(null);
|
||||
const [usageUser, setUsageUser] = useState<User | null>(null);
|
||||
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
|
||||
|
||||
// Bulk selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [bulkMenuAnchor, setBulkMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [bulkCreditsDialog, setBulkCreditsDialog] = useState(false);
|
||||
const [bulkCreditsValue, setBulkCreditsValue] = useState<number>(100);
|
||||
|
||||
// Plans tab state
|
||||
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [planForm, setPlanForm] = useState<PlanFormState>({ name: '', description: '', creditsIncluded: 0, price: 0 });
|
||||
const [savingPlan, setSavingPlan] = useState(false);
|
||||
|
||||
// Audit Log tab state
|
||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
const [auditFilters, setAuditFilters] = useState<AuditFiltersState>({ action: '', actor: '', since: '' });
|
||||
const [auditPagination, setAuditPagination] = useState<AuditPaginationState>({ limit: 50, offset: 0, total: 0 });
|
||||
|
||||
// ─── data fetching ────────────────────────────────────────────────────────
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedIds(new Set());
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.append('search', searchQuery);
|
||||
if (syncFilter !== 'all') params.append('sync_status', syncFilter);
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/admin/users?${params.toString()}`, {
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) throw new Error('Admin access required');
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
const data = await response.json();
|
||||
let allUsers: User[] = data.data || [];
|
||||
|
||||
// Client-side plan filter (server doesn't support it).
|
||||
if (planFilter) {
|
||||
const id = parseInt(planFilter, 10);
|
||||
allUsers = allUsers.filter((u) => u.subscriptionPlanId === id);
|
||||
}
|
||||
|
||||
const total = allUsers.length;
|
||||
setUsers(allUsers);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total,
|
||||
totalPages: Math.ceil(total / prev.limit) || 1,
|
||||
}));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, planFilter, syncFilter]);
|
||||
|
||||
const fetchPlans = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/admin/plans`, {
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPlans(data.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch plans:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAudit = useCallback(async () => {
|
||||
setAuditLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(auditPagination.limit),
|
||||
offset: String(auditPagination.offset),
|
||||
});
|
||||
if (auditFilters.action) params.append('action', auditFilters.action);
|
||||
if (auditFilters.actor) params.append('actor', auditFilters.actor);
|
||||
if (auditFilters.since) params.append('since', auditFilters.since);
|
||||
const res = await fetch(`${API_BASE}/api/admin/audit-log?${params.toString()}`, {
|
||||
headers: getAuthHeader(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setAuditEntries(data.data || []);
|
||||
setAuditPagination((p) => ({ ...p, total: data.total ?? 0 }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch audit log:', err);
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [auditFilters, auditPagination.limit, auditPagination.offset]);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
useEffect(() => { if (activeTab === 2) fetchAudit(); }, [activeTab, fetchAudit]);
|
||||
|
||||
// ─── derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
const pagedUsers = useMemo(() => {
|
||||
const start = (pagination.page - 1) * pagination.limit;
|
||||
return users.slice(start, start + pagination.limit);
|
||||
}, [users, pagination.page, pagination.limit]);
|
||||
|
||||
const allOnPageSelected = pagedUsers.length > 0 &&
|
||||
pagedUsers.every((u) => u.id !== 0 && selectedIds.has(u.id));
|
||||
const someOnPageSelected = pagedUsers.some((u) => u.id !== 0 && selectedIds.has(u.id));
|
||||
|
||||
// ─── handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handlePageChange = (_e: unknown, newPage: number) => {
|
||||
setPagination((prev) => ({ ...prev, page: newPage + 1 }));
|
||||
};
|
||||
const handleRowsPerPageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPagination((prev) => ({ ...prev, limit: parseInt(e.target.value, 10), page: 1 }));
|
||||
};
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (user: User) => {
|
||||
setUserToDelete(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!userToDelete) return;
|
||||
try {
|
||||
// Hard delete (Keycloak + PG + bucket cascade) instead of just deactivating —
|
||||
// matches the destructive intent of the trash icon.
|
||||
const response = await fetch(`${API_BASE}/api/admin/users/${userToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete user');
|
||||
}
|
||||
fetchUsers();
|
||||
setSnackMsg(`User ${userToDelete.email} deleted`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserUpdated = () => {
|
||||
fetchUsers();
|
||||
setEditModalOpen(false);
|
||||
setSelectedUser(null);
|
||||
};
|
||||
|
||||
const handleEmailVerifiedToggle = async (user: User) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/admin/users/${user.id}/email-verified`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emailVerified: !user.emailVerified }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update email verification');
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === user.id ? { ...u, emailVerified: !u.emailVerified } : u))
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncUser = async (user: User) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/admin/users/sync`, {
|
||||
method: 'POST',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keycloakId: user.keycloakId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to sync user');
|
||||
}
|
||||
fetchUsers();
|
||||
setSnackMsg(`User ${user.email} synced`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── plans tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleEditPlan = (plan: SubscriptionPlan) => {
|
||||
setEditingPlan(plan);
|
||||
setPlanForm({
|
||||
name: plan.name,
|
||||
description: plan.description || '',
|
||||
creditsIncluded: plan.creditsIncluded,
|
||||
price: plan.price,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
if (!editingPlan) return;
|
||||
setSavingPlan(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/admin/plans/${editingPlan.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(planForm),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to update plan');
|
||||
}
|
||||
setEditingPlan(null);
|
||||
fetchPlans();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSavingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── bulk operations ──────────────────────────────────────────────────────
|
||||
|
||||
const toggleSelectOne = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAllOnPage = () => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allOnPageSelected) {
|
||||
pagedUsers.forEach((u) => next.delete(u.id));
|
||||
} else {
|
||||
pagedUsers.forEach((u) => {
|
||||
if (u.id !== 0) next.add(u.id);
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Generic sequential bulk runner — never parallel, to avoid hammering Keycloak
|
||||
// and keep audit log entries cleanly ordered.
|
||||
const runBulk = async (
|
||||
label: string,
|
||||
fn: (u: User) => Promise<void>,
|
||||
) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
setBulkMenuAnchor(null);
|
||||
setError(null);
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
for (const id of ids) {
|
||||
const user = users.find((u) => u.id === id);
|
||||
if (!user) continue;
|
||||
try {
|
||||
await fn(user);
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
console.error(`bulk ${label} failed for ${user.email}:`, e);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
setSnackMsg(`Bulk ${label}: ${ok} ok, ${fail} failed`);
|
||||
setSelectedIds(new Set());
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const bulkActivate = (active: boolean) =>
|
||||
runBulk(active ? 'activate' : 'deactivate', async (u) => {
|
||||
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: active }),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
});
|
||||
|
||||
const bulkEmailVerify = () =>
|
||||
runBulk('email-verify', async (u) => {
|
||||
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}/email-verified`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emailVerified: true }),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
});
|
||||
|
||||
const bulkSetCredits = () =>
|
||||
runBulk(`set-credits=${bulkCreditsValue}`, async (u) => {
|
||||
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...getAuthHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ creditsRemained: bulkCreditsValue }),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
});
|
||||
|
||||
const bulkDelete = () =>
|
||||
runBulk('delete', async (u) => {
|
||||
const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeader(),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
});
|
||||
|
||||
// ─── render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>User Management</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Manage users, subscriptions, credits, roles, groups, and audit history
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||
<Tab icon={<PeopleIcon />} iconPosition="start" label="Users" />
|
||||
<Tab icon={<PlansIcon />} iconPosition="start" label="Subscription Plans" />
|
||||
<Tab icon={<AuditIcon />} iconPosition="start" label="Audit Log" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{activeTab === 0 && (
|
||||
<UsersTab
|
||||
users={users}
|
||||
pagedUsers={pagedUsers}
|
||||
plans={plans}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
planFilter={planFilter}
|
||||
setPlanFilter={setPlanFilter}
|
||||
syncFilter={syncFilter}
|
||||
setSyncFilter={setSyncFilter}
|
||||
fetchUsers={fetchUsers}
|
||||
selectedIds={selectedIds}
|
||||
setSelectedIds={setSelectedIds}
|
||||
allOnPageSelected={allOnPageSelected}
|
||||
someOnPageSelected={someOnPageSelected}
|
||||
toggleSelectOne={toggleSelectOne}
|
||||
toggleSelectAllOnPage={toggleSelectAllOnPage}
|
||||
bulkMenuAnchor={bulkMenuAnchor}
|
||||
setBulkMenuAnchor={setBulkMenuAnchor}
|
||||
bulkActivate={bulkActivate}
|
||||
bulkEmailVerify={bulkEmailVerify}
|
||||
bulkDelete={bulkDelete}
|
||||
setBulkCreditsDialog={setBulkCreditsDialog}
|
||||
handlePageChange={handlePageChange}
|
||||
handleRowsPerPageChange={handleRowsPerPageChange}
|
||||
handleEditUser={handleEditUser}
|
||||
handleDeleteClick={handleDeleteClick}
|
||||
handleEmailVerifiedToggle={handleEmailVerifiedToggle}
|
||||
handleSyncUser={handleSyncUser}
|
||||
setRolesUser={setRolesUser}
|
||||
setUsageUser={setUsageUser}
|
||||
onOpenAddUser={() => setAddUserDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<PlansTab
|
||||
plans={plans}
|
||||
editingPlan={editingPlan}
|
||||
setEditingPlan={setEditingPlan}
|
||||
planForm={planForm}
|
||||
setPlanForm={setPlanForm}
|
||||
savingPlan={savingPlan}
|
||||
handleEditPlan={handleEditPlan}
|
||||
handleSavePlan={handleSavePlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<AuditLogTab
|
||||
auditEntries={auditEntries}
|
||||
auditLoading={auditLoading}
|
||||
auditFilters={auditFilters}
|
||||
setAuditFilters={setAuditFilters}
|
||||
auditPagination={auditPagination}
|
||||
setAuditPagination={setAuditPagination}
|
||||
fetchAudit={fetchAudit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── dialogs / modals ───────────────────────────────────────────── */}
|
||||
|
||||
{selectedUser && (
|
||||
<UserEditModal
|
||||
open={editModalOpen}
|
||||
user={selectedUser}
|
||||
plans={plans}
|
||||
onClose={() => { setEditModalOpen(false); setSelectedUser(null); }}
|
||||
onSave={handleUserUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rolesUser && (
|
||||
<RolesDialog
|
||||
open={rolesUser !== null}
|
||||
userId={rolesUser.id}
|
||||
userEmail={rolesUser.email}
|
||||
onClose={() => setRolesUser(null)}
|
||||
onSaved={() => { fetchUsers(); setSnackMsg('Roles updated'); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usageUser && (
|
||||
<UsageHistoryModal
|
||||
open={usageUser !== null}
|
||||
userId={usageUser.id}
|
||||
userEmail={usageUser.email}
|
||||
onClose={() => setUsageUser(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteUserDialog
|
||||
open={deleteDialogOpen}
|
||||
user={userToDelete}
|
||||
onCancel={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
||||
<BulkCreditsDialog
|
||||
open={bulkCreditsDialog}
|
||||
selectedCount={selectedIds.size}
|
||||
bulkCreditsValue={bulkCreditsValue}
|
||||
setBulkCreditsValue={setBulkCreditsValue}
|
||||
onCancel={() => setBulkCreditsDialog(false)}
|
||||
onApply={() => { setBulkCreditsDialog(false); bulkSetCredits(); }}
|
||||
/>
|
||||
|
||||
<AddUserDialog
|
||||
open={addUserDialogOpen}
|
||||
plans={plans}
|
||||
apiBase={API_BASE}
|
||||
authHeader={getAuthHeader()}
|
||||
onClose={() => setAddUserDialogOpen(false)}
|
||||
onCreated={({ email, realm }) => {
|
||||
setSnackMsg(`User ${email} created in ${realm}`);
|
||||
fetchUsers();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={snackMsg !== null}
|
||||
autoHideDuration={5000}
|
||||
onClose={() => setSnackMsg(null)}
|
||||
message={snackMsg ?? ''}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Shared types for the UserManagement page + its tabs/dialogs.
|
||||
*
|
||||
* User has both PG fields (id, plan, credits) and Keycloak-only echo fields
|
||||
* (keycloakId, syncStatus). syncStatus='keycloak_only' means the user exists
|
||||
* in Keycloak but hasn't been imported into PG yet — surface as a warning row.
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
creditsRemained: number;
|
||||
creditsSpent: number;
|
||||
storageUsedBytes?: number;
|
||||
storageLimitBytes?: number;
|
||||
storagePct?: number;
|
||||
subscriptionPlanId: number;
|
||||
subscriptionPlanName: string;
|
||||
subscriptionStatus: number;
|
||||
isActive: boolean;
|
||||
keycloakId: string;
|
||||
createdAt: string;
|
||||
emailVerified: boolean;
|
||||
syncStatus: 'synced' | 'keycloak_only';
|
||||
roles?: string[];
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
creditsIncluded: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
auditId: number;
|
||||
internetUserId: number | null;
|
||||
targetEmail: string | null;
|
||||
targetKeycloakId: string | null;
|
||||
actorKeycloakId: string | null;
|
||||
actorEmail: string | null;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
requestIp: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AuditFiltersState {
|
||||
action: string;
|
||||
actor: string;
|
||||
since: string;
|
||||
}
|
||||
|
||||
export interface AuditPaginationState {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PlanFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
creditsIncluded: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export const API_BASE = '/framework';
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* Add User dialog — create user in Keycloak + PG from admin.
|
||||
*
|
||||
* Supports both realms (didi-clients = end-users, didi-admins = operators).
|
||||
* - didi-clients: creates Keycloak user + person/internet_user/subscription
|
||||
* rows in PG with the selected plan + MinIO prefix.
|
||||
* - didi-admins: only Keycloak side (operator account, no PG profile).
|
||||
*
|
||||
* The user is created with a temporary password and the UPDATE_PASSWORD
|
||||
* required action so they MUST change it at first login.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import type { SubscriptionPlan } from '../UserManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
plans: SubscriptionPlan[];
|
||||
apiBase: string;
|
||||
authHeader: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onCreated: (info: { email: string; realm: string }) => void;
|
||||
}
|
||||
|
||||
const CLIENT_ROLES = ['viewer', 'analyst', 'api_user'];
|
||||
const ADMIN_ROLES = ['admin', 'moderator', 'senior_moderator'];
|
||||
|
||||
const CLIENT_GROUPS = [
|
||||
{ name: '(none)', value: '' },
|
||||
{ name: 'free-users', value: 'free-users' },
|
||||
{ name: 'paid-users', value: 'paid-users' },
|
||||
{ name: 'enterprise-users', value: 'enterprise-users' },
|
||||
];
|
||||
|
||||
export const AddUserDialog: React.FC<Props> = (p) => {
|
||||
const [realm, setRealm] = useState<'didi-clients' | 'didi-admins'>('didi-clients');
|
||||
const [email, setEmail] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [planId, setPlanId] = useState<number>(1);
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [groupName, setGroupName] = useState<string>('');
|
||||
const [forceUpdatePwd, setForceUpdatePwd] = useState(true);
|
||||
const [emailVerified, setEmailVerified] = useState(true);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (p.open) {
|
||||
setRealm('didi-clients');
|
||||
setEmail('');
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
setPassword(genPassword());
|
||||
setShowPassword(false);
|
||||
setPlanId(1);
|
||||
setRoles([]);
|
||||
setGroupName('');
|
||||
setForceUpdatePwd(true);
|
||||
setEmailVerified(true);
|
||||
setError(null);
|
||||
}
|
||||
}, [p.open]);
|
||||
|
||||
// When switching realm, reset role + group to safe defaults.
|
||||
useEffect(() => {
|
||||
setRoles([]);
|
||||
setGroupName('');
|
||||
}, [realm]);
|
||||
|
||||
const availableRoles = realm === 'didi-admins' ? ADMIN_ROLES : CLIENT_ROLES;
|
||||
const isAdmin = realm === 'didi-admins';
|
||||
|
||||
function toggleRole(name: string) {
|
||||
setRoles((cur) => (cur.includes(name) ? cur.filter((r) => r !== name) : [...cur, name]));
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setError(null);
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
setError('Invalid email');
|
||||
return;
|
||||
}
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setError('First and last name are required');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
realm,
|
||||
email: email.trim().toLowerCase(),
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
password,
|
||||
emailVerified,
|
||||
requiredActions: forceUpdatePwd ? ['UPDATE_PASSWORD'] : [],
|
||||
roles,
|
||||
};
|
||||
if (!isAdmin) {
|
||||
body.planId = planId;
|
||||
if (groupName) body.groupName = groupName;
|
||||
}
|
||||
|
||||
const res = await fetch(`${p.apiBase}/api/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...p.authHeader },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
setError(data.error || `HTTP ${res.status}`);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
p.onCreated({ email: body.email as string, realm });
|
||||
p.onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Network error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={p.open} onClose={p.onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create new user</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Realm</InputLabel>
|
||||
<Select value={realm} onChange={(e) => setRealm(e.target.value as any)} label="Realm">
|
||||
<MenuItem value="didi-clients">didi-clients — end user (analyst / viewer)</MenuItem>
|
||||
<MenuItem value="didi-admins">didi-admins — operator (admin / moderator)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="First name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
helperText="Will be used as both email and username in Keycloak"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Stack direction="row" spacing={1} alignItems="stretch">
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Temporary password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
sx={{ minWidth: 64, flexShrink: 0 }}
|
||||
>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setPassword(genPassword())}
|
||||
sx={{ minWidth: 80, flexShrink: 0 }}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 1.5 }}>
|
||||
Min 8 characters. User must change it at first login.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={forceUpdatePwd} onChange={(e) => setForceUpdatePwd(e.target.checked)} />}
|
||||
label="Force password change at first login (UPDATE_PASSWORD)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} />}
|
||||
label="Mark email as verified"
|
||||
/>
|
||||
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Subscription plan</InputLabel>
|
||||
<Select value={planId} onChange={(e) => setPlanId(Number(e.target.value))} label="Subscription plan">
|
||||
{p.plans.map((pl) => (
|
||||
<MenuItem key={pl.id} value={pl.id}>
|
||||
{pl.name} ({pl.creditsIncluded ?? '?'} credits)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Group (optional)</InputLabel>
|
||||
<Select value={groupName} onChange={(e) => setGroupName(e.target.value)} label="Group (optional)">
|
||||
{CLIENT_GROUPS.map((g) => (
|
||||
<MenuItem key={g.value || 'none'} value={g.value}>
|
||||
{g.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Realm roles ({realm})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{availableRoles.map((r) => (
|
||||
<FormControlLabel
|
||||
key={r}
|
||||
control={<Checkbox size="small" checked={roles.includes(r)} onChange={() => toggleRole(r)} />}
|
||||
label={r}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<Alert severity="info">
|
||||
Operator accounts ({realm}) get only a Keycloak entry — no PG profile, no credits, no plan.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? 'Creating…' : 'Create user'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate a strong random password: 16 chars from mixed alphabet.
|
||||
function genPassword(): string {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||||
const digits = '23456789';
|
||||
const symbols = '!@#$%&*';
|
||||
const all = upper + lower + digits + symbols;
|
||||
const out: string[] = [];
|
||||
// Guarantee at least one of each class.
|
||||
out.push(upper[Math.floor(Math.random() * upper.length)]);
|
||||
out.push(lower[Math.floor(Math.random() * lower.length)]);
|
||||
out.push(digits[Math.floor(Math.random() * digits.length)]);
|
||||
out.push(symbols[Math.floor(Math.random() * symbols.length)]);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
out.push(all[Math.floor(Math.random() * all.length)]);
|
||||
}
|
||||
// Shuffle
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Bulk credits dialog — sets the same credits value on N selected users.
|
||||
* Apply triggers the parent's bulkSetCredits which runs sequentially per user.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
selectedCount: number;
|
||||
bulkCreditsValue: number;
|
||||
setBulkCreditsValue: (n: number) => void;
|
||||
onCancel: () => void;
|
||||
onApply: () => void;
|
||||
}
|
||||
|
||||
export const BulkCreditsDialog: React.FC<Props> = (p) => (
|
||||
<Dialog open={p.open} onClose={p.onCancel} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Set credits for {p.selectedCount} users</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="Credits"
|
||||
value={p.bulkCreditsValue}
|
||||
onChange={(e) => p.setBulkCreditsValue(Number(e.target.value))}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onCancel}>Cancel</Button>
|
||||
<Button variant="contained" onClick={p.onApply}>
|
||||
Apply to {p.selectedCount} users
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Confirmation dialog for hard-delete (Keycloak + PG + MinIO cascade).
|
||||
* Shown with the trash-icon flow; bulk delete uses window.confirm directly.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material';
|
||||
import type { User } from '../UserManagement.types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
user: User | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const DeleteUserDialog: React.FC<Props> = ({ open, user, onCancel, onConfirm }) => (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<DialogTitle>Hard-delete user</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This permanently deletes <strong>{user?.email}</strong> from
|
||||
Keycloak, PostgreSQL, and MinIO. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button onClick={onConfirm} color="error" variant="contained">
|
||||
Delete forever
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { UserManagement } from './UserManagement';
|
||||
export { UserEditModal } from './UserEditModal';
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* AUDIT LOG TAB — paginated audit-trail table for admin actions.
|
||||
*
|
||||
* Filters: action (preset list), actor email substring, since (datetime-local).
|
||||
* Payload column shows truncated JSON with hover tooltip showing pretty-printed
|
||||
* full payload.
|
||||
*
|
||||
* Pagination is offset-based (no "load more" — admins want page jumps).
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
TablePagination, TextField, Button, CircularProgress, FormControl, InputLabel,
|
||||
Select, MenuItem, Chip, Typography, Stack, Tooltip,
|
||||
} from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
import type { AuditEntry, AuditFiltersState, AuditPaginationState } from '../UserManagement.types';
|
||||
import { fmtDate, ACTION_COLORS } from '../UserManagement.helpers';
|
||||
|
||||
interface Props {
|
||||
auditEntries: AuditEntry[];
|
||||
auditLoading: boolean;
|
||||
auditFilters: AuditFiltersState;
|
||||
setAuditFilters: React.Dispatch<React.SetStateAction<AuditFiltersState>>;
|
||||
auditPagination: AuditPaginationState;
|
||||
setAuditPagination: React.Dispatch<React.SetStateAction<AuditPaginationState>>;
|
||||
fetchAudit: () => void;
|
||||
}
|
||||
|
||||
export const AuditLogTab: React.FC<Props> = (p) => {
|
||||
return (
|
||||
<>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Action</InputLabel>
|
||||
<Select
|
||||
value={p.auditFilters.action}
|
||||
onChange={(e) => p.setAuditFilters({ ...p.auditFilters, action: e.target.value })}
|
||||
label="Action"
|
||||
>
|
||||
<MenuItem value="">All actions</MenuItem>
|
||||
<MenuItem value="user.update">Update</MenuItem>
|
||||
<MenuItem value="user.delete">Delete</MenuItem>
|
||||
<MenuItem value="user.sync">Sync</MenuItem>
|
||||
<MenuItem value="user.email_verified">Email verified</MenuItem>
|
||||
<MenuItem value="user.subscription">Subscription</MenuItem>
|
||||
<MenuItem value="user.roles">Roles</MenuItem>
|
||||
<MenuItem value="user.group">Group</MenuItem>
|
||||
<MenuItem value="user.reset_password">Reset password</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Actor email contains…"
|
||||
value={p.auditFilters.actor}
|
||||
onChange={(e) => p.setAuditFilters({ ...p.auditFilters, actor: e.target.value })}
|
||||
sx={{ minWidth: 220 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="datetime-local"
|
||||
label="Since"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={p.auditFilters.since}
|
||||
onChange={(e) => p.setAuditFilters({ ...p.auditFilters, since: e.target.value })}
|
||||
/>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={p.fetchAudit}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 170 }}>When</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
<TableCell>Target</TableCell>
|
||||
<TableCell>Actor</TableCell>
|
||||
<TableCell>Payload</TableCell>
|
||||
<TableCell sx={{ width: 120 }}>IP</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.auditLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<CircularProgress />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : p.auditEntries.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">No audit entries.</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
p.auditEntries.map((e) => (
|
||||
<TableRow key={e.auditId} hover>
|
||||
<TableCell>
|
||||
<Typography variant="caption">{fmtDate(e.createdAt)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={e.action}
|
||||
size="small"
|
||||
color={ACTION_COLORS[e.action] ?? 'default'}
|
||||
sx={{ fontFamily: 'monospace', fontSize: 10 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{e.targetEmail ?? (e.internetUserId ? `id=${e.internetUserId}` : '—')}
|
||||
{e.targetKeycloakId && (
|
||||
<Typography variant="caption" display="block" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
||||
{e.targetKeycloakId.slice(0, 8)}…
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{e.actorEmail ?? <Typography variant="caption" color="text.disabled">staging</Typography>}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={<pre style={{ margin: 0 }}>{JSON.stringify(e.payload, null, 2)}</pre>}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', cursor: 'help' }}
|
||||
>
|
||||
{JSON.stringify(e.payload).slice(0, 80)}
|
||||
{JSON.stringify(e.payload).length > 80 ? '…' : ''}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
|
||||
{e.requestIp ?? '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={p.auditPagination.total}
|
||||
page={p.auditPagination.offset / p.auditPagination.limit}
|
||||
onPageChange={(_, np) => p.setAuditPagination((prev) => ({ ...prev, offset: np * prev.limit }))}
|
||||
rowsPerPage={p.auditPagination.limit}
|
||||
onRowsPerPageChange={(e) => p.setAuditPagination((prev) => ({
|
||||
...prev,
|
||||
limit: parseInt(e.target.value, 10),
|
||||
offset: 0,
|
||||
}))}
|
||||
rowsPerPageOptions={[25, 50, 100, 200]}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* PLANS TAB — subscription plans table with inline edit.
|
||||
*
|
||||
* editingPlan === plan.id puts that row into edit mode (each cell becomes a
|
||||
* TextField). Save commits via PUT /api/admin/plans/:id; cancel just clears
|
||||
* editingPlan. Other rows stay read-only during edit.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
TextField, IconButton, Button, Typography, Stack,
|
||||
} from '@mui/material';
|
||||
import { Edit as EditIcon, Save as SaveIcon } from '@mui/icons-material';
|
||||
import type { SubscriptionPlan, PlanFormState } from '../UserManagement.types';
|
||||
|
||||
interface Props {
|
||||
plans: SubscriptionPlan[];
|
||||
editingPlan: SubscriptionPlan | null;
|
||||
setEditingPlan: (p: SubscriptionPlan | null) => void;
|
||||
planForm: PlanFormState;
|
||||
setPlanForm: React.Dispatch<React.SetStateAction<PlanFormState>>;
|
||||
savingPlan: boolean;
|
||||
handleEditPlan: (p: SubscriptionPlan) => void;
|
||||
handleSavePlan: () => void;
|
||||
}
|
||||
|
||||
export const PlansTab: React.FC<Props> = (p) => {
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Subscription Plans</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell align="right">Credits</TableCell>
|
||||
<TableCell align="right">Price</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.plans.map((plan) => (
|
||||
<TableRow key={plan.id} hover>
|
||||
<TableCell>{plan.id}</TableCell>
|
||||
<TableCell>
|
||||
{p.editingPlan?.id === plan.id ? (
|
||||
<TextField
|
||||
size="small"
|
||||
value={p.planForm.name}
|
||||
onChange={(e) => p.setPlanForm({ ...p.planForm, name: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
plan.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.editingPlan?.id === plan.id ? (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={p.planForm.description}
|
||||
onChange={(e) => p.setPlanForm({ ...p.planForm, description: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
plan.description || '—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{p.editingPlan?.id === plan.id ? (
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{ width: 100 }}
|
||||
value={p.planForm.creditsIncluded}
|
||||
onChange={(e) => p.setPlanForm({ ...p.planForm, creditsIncluded: Number(e.target.value) })}
|
||||
/>
|
||||
) : (
|
||||
plan.creditsIncluded
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{p.editingPlan?.id === plan.id ? (
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{ width: 100 }}
|
||||
value={p.planForm.price}
|
||||
onChange={(e) => p.setPlanForm({ ...p.planForm, price: Number(e.target.value) })}
|
||||
/>
|
||||
) : (
|
||||
plan.price
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{p.editingPlan?.id === plan.id ? (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="center">
|
||||
<IconButton size="small" onClick={p.handleSavePlan} disabled={p.savingPlan}>
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Button size="small" onClick={() => p.setEditingPlan(null)}>Cancel</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<IconButton size="small" onClick={() => p.handleEditPlan(plan)} title="Edit plan">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
/**
|
||||
* USERS TAB — filters + bulk action toolbar + users table.
|
||||
*
|
||||
* Receives all state + handlers from parent (UserManagement). Two visual modes:
|
||||
* - normal row: hover, edit/roles/usage/delete actions
|
||||
* - keycloak_only row: highlighted yellow + Sync button (no edit/delete
|
||||
* until the user is imported into PG)
|
||||
*
|
||||
* Bulk toolbar appears only when selectedIds.size > 0; uses sequential-bulk
|
||||
* runner from parent so audit log entries stay cleanly ordered.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box, Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
TablePagination, TextField, InputAdornment, IconButton, Chip, Button,
|
||||
CircularProgress, FormControl, InputLabel, Select, MenuItem, Divider, Menu,
|
||||
Switch, Tooltip, Typography, Checkbox, LinearProgress, Stack, Toolbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon, Edit as EditIcon, Delete as DeleteIcon,
|
||||
Refresh as RefreshIcon, Sync as SyncIcon, Warning as WarningIcon,
|
||||
AdminPanelSettings as RolesIcon, History as HistoryIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { User, SubscriptionPlan, PaginationInfo } from '../UserManagement.types';
|
||||
import { fmtBytes } from '../UserManagement.helpers';
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
pagedUsers: User[];
|
||||
plans: SubscriptionPlan[];
|
||||
loading: boolean;
|
||||
pagination: PaginationInfo;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (s: string) => void;
|
||||
planFilter: string;
|
||||
setPlanFilter: (s: string) => void;
|
||||
syncFilter: 'all' | 'synced' | 'keycloak_only';
|
||||
setSyncFilter: (s: 'all' | 'synced' | 'keycloak_only') => void;
|
||||
fetchUsers: () => void;
|
||||
selectedIds: Set<number>;
|
||||
setSelectedIds: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
allOnPageSelected: boolean;
|
||||
someOnPageSelected: boolean;
|
||||
toggleSelectOne: (id: number) => void;
|
||||
toggleSelectAllOnPage: () => void;
|
||||
bulkMenuAnchor: HTMLElement | null;
|
||||
setBulkMenuAnchor: (el: HTMLElement | null) => void;
|
||||
bulkActivate: (active: boolean) => void;
|
||||
bulkEmailVerify: () => void;
|
||||
bulkDelete: () => void;
|
||||
setBulkCreditsDialog: (open: boolean) => void;
|
||||
handlePageChange: (e: unknown, p: number) => void;
|
||||
handleRowsPerPageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleEditUser: (u: User) => void;
|
||||
handleDeleteClick: (u: User) => void;
|
||||
handleEmailVerifiedToggle: (u: User) => void;
|
||||
handleSyncUser: (u: User) => void;
|
||||
setRolesUser: (u: User | null) => void;
|
||||
setUsageUser: (u: User | null) => void;
|
||||
onOpenAddUser: () => void;
|
||||
}
|
||||
|
||||
export const UsersTab: React.FC<Props> = (p) => {
|
||||
void p.users;
|
||||
return (
|
||||
<>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<TextField
|
||||
placeholder="Search by email or name..."
|
||||
value={p.searchQuery}
|
||||
onChange={(e) => p.setSearchQuery(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 250 }}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Plan</InputLabel>
|
||||
<Select value={p.planFilter} onChange={(e) => p.setPlanFilter(e.target.value)} label="Plan">
|
||||
<MenuItem value="">All Plans</MenuItem>
|
||||
{p.plans.map((plan) => (
|
||||
<MenuItem key={plan.id} value={plan.id.toString()}>{plan.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<InputLabel>Sync</InputLabel>
|
||||
<Select
|
||||
value={p.syncFilter}
|
||||
onChange={(e) => p.setSyncFilter(e.target.value as any)}
|
||||
label="Sync"
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="synced">Synced</MenuItem>
|
||||
<MenuItem value="keycloak_only">Keycloak only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={p.fetchUsers}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Button variant="contained" color="primary" onClick={p.onOpenAddUser}>
|
||||
+ Add user
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Bulk action toolbar — only visible when items are selected */}
|
||||
{p.selectedIds.size > 0 && (
|
||||
<Paper sx={{ p: 1, mb: 2, bgcolor: 'action.selected' }}>
|
||||
<Toolbar variant="dense" sx={{ minHeight: 48, gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{p.selectedIds.size} selected
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<MoreVertIcon />}
|
||||
onClick={(e) => p.setBulkMenuAnchor(e.currentTarget)}
|
||||
>
|
||||
Bulk actions
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={p.bulkMenuAnchor}
|
||||
open={p.bulkMenuAnchor !== null}
|
||||
onClose={() => p.setBulkMenuAnchor(null)}
|
||||
>
|
||||
<MenuItem onClick={() => p.bulkActivate(true)}>Activate</MenuItem>
|
||||
<MenuItem onClick={() => p.bulkActivate(false)}>Deactivate</MenuItem>
|
||||
<MenuItem onClick={() => p.bulkEmailVerify()}>Mark email verified</MenuItem>
|
||||
<MenuItem onClick={() => { p.setBulkMenuAnchor(null); p.setBulkCreditsDialog(true); }}>
|
||||
Set credits to…
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (window.confirm(`Hard-delete ${p.selectedIds.size} users (Keycloak + PG + MinIO)?`)) {
|
||||
p.bulkDelete();
|
||||
} else {
|
||||
p.setBulkMenuAnchor(null);
|
||||
}
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
Delete (hard) ⚠
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Button size="small" onClick={() => p.setSelectedIds(new Set())}>
|
||||
Clear selection
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
size="small"
|
||||
indeterminate={p.someOnPageSelected && !p.allOnPageSelected}
|
||||
checked={p.allOnPageSelected}
|
||||
onChange={p.toggleSelectAllOnPage}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Sync</TableCell>
|
||||
<TableCell>Plan</TableCell>
|
||||
<TableCell>Group</TableCell>
|
||||
<TableCell>Roles</TableCell>
|
||||
<TableCell align="center">Verified</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
<TableCell align="right">Credits</TableCell>
|
||||
<TableCell sx={{ minWidth: 120 }}>Storage</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} align="center" sx={{ py: 4 }}>
|
||||
<CircularProgress />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : p.pagedUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">No users found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
p.pagedUsers.map((user) => {
|
||||
const isKcOnly = user.syncStatus === 'keycloak_only';
|
||||
const isSelected = user.id !== 0 && p.selectedIds.has(user.id);
|
||||
const roles = user.roles ?? [];
|
||||
const groupName = (user.groups ?? [])[0];
|
||||
const storagePct = (user.storagePct ?? 0) * 100;
|
||||
return (
|
||||
<TableRow
|
||||
key={user.keycloakId || user.id}
|
||||
hover
|
||||
selected={isSelected}
|
||||
sx={isKcOnly ? { bgcolor: 'warning.light' } : {}}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={isSelected}
|
||||
onChange={() => p.toggleSelectOne(user.id)}
|
||||
disabled={user.id === 0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{user.email}
|
||||
</Typography>
|
||||
{user.keycloakId && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
||||
{user.keycloakId.slice(0, 8)}…
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{user.firstName} {user.lastName}</TableCell>
|
||||
<TableCell>
|
||||
{isKcOnly ? (
|
||||
<Chip icon={<WarningIcon />} label="KC only" size="small" color="warning" />
|
||||
) : (
|
||||
<Chip label="Synced" size="small" color="success" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.subscriptionPlanName || 'N/A'}
|
||||
size="small"
|
||||
color={
|
||||
user.subscriptionPlanName?.includes('Enterprise') ? 'primary'
|
||||
: user.subscriptionPlanName?.includes('Pro') ? 'secondary'
|
||||
: 'default'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{groupName ? (
|
||||
<Chip label={groupName} size="small" variant="outlined" />
|
||||
) : (
|
||||
<Typography variant="caption" color="text.disabled">—</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200 }}>
|
||||
{roles.length === 0 ? (
|
||||
<Typography variant="caption" color="text.disabled">—</Typography>
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{roles.slice(0, 3).map((r) => (
|
||||
<Chip
|
||||
key={r}
|
||||
label={r}
|
||||
size="small"
|
||||
sx={{ fontSize: 10, height: 20 }}
|
||||
/>
|
||||
))}
|
||||
{roles.length > 3 && (
|
||||
<Tooltip title={roles.slice(3).join(', ')}>
|
||||
<Chip label={`+${roles.length - 3}`} size="small" sx={{ fontSize: 10, height: 20 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title={user.emailVerified ? 'Click to unverify' : 'Click to verify'}>
|
||||
<Switch
|
||||
checked={user.emailVerified}
|
||||
onChange={() => p.handleEmailVerifiedToggle(user)}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.isActive ? 'Active' : 'Inactive'}
|
||||
size="small"
|
||||
color={user.isActive ? 'success' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={`Spent: ${user.creditsSpent}`}>
|
||||
<Typography variant="body2">{user.creditsRemained}</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isKcOnly || !user.storageLimitBytes ? (
|
||||
<Typography variant="caption" color="text.disabled">—</Typography>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={`${fmtBytes(user.storageUsedBytes)} of ${fmtBytes(user.storageLimitBytes)}`}
|
||||
>
|
||||
<Box sx={{ minWidth: 110 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(100, storagePct)}
|
||||
color={storagePct > 90 ? 'error' : storagePct > 70 ? 'warning' : 'primary'}
|
||||
sx={{ height: 6, borderRadius: 3, mb: 0.3 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{storagePct.toFixed(0)}% · {fmtBytes(user.storageUsedBytes)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isKcOnly ? (
|
||||
<Tooltip title="Sync to PostgreSQL (Free plan + 100 credits)">
|
||||
<IconButton size="small" onClick={() => p.handleSyncUser(user)} color="warning">
|
||||
<SyncIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.25}>
|
||||
<Tooltip title="Edit user">
|
||||
<IconButton size="small" onClick={() => p.handleEditUser(user)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Roles">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => p.setRolesUser(user)}
|
||||
disabled={!user.keycloakId}
|
||||
>
|
||||
<RolesIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Usage history">
|
||||
<IconButton size="small" onClick={() => p.setUsageUser(user)}>
|
||||
<HistoryIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete (hard)">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => p.handleDeleteClick(user)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={p.pagination.total}
|
||||
page={p.pagination.page - 1}
|
||||
onPageChange={p.handlePageChange}
|
||||
rowsPerPage={p.pagination.limit}
|
||||
onRowsPerPageChange={p.handleRowsPerPageChange}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const LoginRedirect: React.FC = () => {
|
||||
const { isAuthenticated, login, initialized } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
if (!isAuthenticated) {
|
||||
login();
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, login, navigate, initialized]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
Redirecting to login...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
/** Single role gate (kept for back-compat with existing usage). */
|
||||
requiredRole?: string;
|
||||
/** Any-of role gate — user passes if they have at least one of these roles. */
|
||||
requiredAnyRole?: string[];
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredRole,
|
||||
requiredAnyRole,
|
||||
}) => {
|
||||
const { isAuthenticated, hasRole, initialized } = useAuth();
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requiredRole && !hasRole(requiredRole)) {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
if (requiredAnyRole && requiredAnyRole.length > 0) {
|
||||
const allowed = requiredAnyRole.some((r) => hasRole(r));
|
||||
if (!allowed) {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
51
backend/admin-dashboard/src/components/auth/Unauthorized.tsx
Normal file
51
backend/admin-dashboard/src/components/auth/Unauthorized.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Button, Paper } from '@mui/material';
|
||||
import { Block as BlockIcon } from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export const Unauthorized: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const userEmail = (user as { email?: string } | undefined)?.email ?? 'unknown';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'background.default',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Paper sx={{ p: 6, maxWidth: 520, textAlign: 'center' }} elevation={3}>
|
||||
<BlockIcon sx={{ fontSize: 72, color: 'error.main', mb: 2 }} />
|
||||
<Typography variant="h4" gutterBottom>403 — Access Denied</Typography>
|
||||
<Typography variant="body1" color="text.secondary" mb={1}>
|
||||
Your account does not have permission to access the DIDI admin dashboard.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
||||
Logged in as <strong>{userEmail}</strong>. Required roles:
|
||||
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>admin</Box>
|
||||
/
|
||||
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>moderator</Box>
|
||||
/
|
||||
<Box component="span" sx={{ mx: 0.5, fontFamily: 'monospace' }}>senior_moderator</Box>.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={3}>
|
||||
End users (clients) should use the public web app, not the admin dashboard.
|
||||
</Typography>
|
||||
<Box display="flex" gap={2} justifyContent="center">
|
||||
<Button variant="outlined" onClick={() => (window.location.href = '/')}>
|
||||
Go to Public App
|
||||
</Button>
|
||||
<Button variant="contained" color="error" onClick={() => logout()}>
|
||||
Log out
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Unauthorized;
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
Divider,
|
||||
Chip,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Storage as StorageIcon,
|
||||
Security as SecurityIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
DataObject as DatabaseIcon,
|
||||
Speed as MonitoringIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { ServiceCard } from '../services/ServiceCard';
|
||||
import { services, checkServiceHealth, ServiceStatus } from '../../services/api';
|
||||
import { serviceGroups } from '../../config/serviceGroups';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export const ServicesDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>(services);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const theme = useTheme();
|
||||
const isSmallMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const groupFilter = searchParams.get('group');
|
||||
|
||||
const checkAllServices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
services.map(service => checkServiceHealth(service))
|
||||
);
|
||||
setServiceStatuses(results);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Failed to check services');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isInitialMount.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
checkAllServices();
|
||||
}
|
||||
const interval = setInterval(checkAllServices, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkAllServices]);
|
||||
|
||||
const handleRefresh = async (serviceName: string) => {
|
||||
const service = serviceStatuses.find(s => s.name === serviceName);
|
||||
if (service) {
|
||||
const updated = await checkServiceHealth(service);
|
||||
setServiceStatuses(prev =>
|
||||
prev.map(s => s.name === serviceName ? updated : s)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccess = (service: ServiceStatus) => {
|
||||
if (service.name === 'didi-framework') {
|
||||
navigate('/framework');
|
||||
} else if (service.name === 'didi-agent-v3') {
|
||||
navigate('/llm-components');
|
||||
} else if (service.uiUrl) {
|
||||
window.open(service.uiUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && serviceStatuses.every(s => s.status === 'unknown')) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getGroupIcon = (icon: string) => {
|
||||
switch (icon) {
|
||||
case 'database': return <DatabaseIcon />;
|
||||
case 'storage': return <StorageIcon />;
|
||||
case 'security': return <SecurityIcon />;
|
||||
case 'monitoring': return <MonitoringIcon />;
|
||||
case 'analytics': return <AnalyticsIcon />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 2, md: 4 }, px: { xs: 2, sm: 3 } }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Service Monitor
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{groupFilter
|
||||
? `Viewing: ${serviceGroups.find(g => g.id === groupFilter)?.title || 'Unknown'}`
|
||||
: 'Monitor and access all backend services'
|
||||
}
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
|
||||
{serviceGroups
|
||||
.filter(group => !groupFilter || group.id === groupFilter)
|
||||
.map((group) => {
|
||||
const groupServices = serviceStatuses.filter(service =>
|
||||
group.services.includes(service.name)
|
||||
);
|
||||
if (groupServices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper key={group.id} elevation={1} sx={{ mb: { xs: 3, md: 4 }, p: { xs: 2, md: 3 }, borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ color: group.color, display: { xs: 'none', sm: 'block' } }}>
|
||||
{getGroupIcon(group.icon)}
|
||||
</Box>
|
||||
<Typography variant={isSmallMobile ? "h6" : "h5"} sx={{ fontWeight: 600 }}>
|
||||
{group.title}
|
||||
</Typography>
|
||||
<Chip label={`${groupServices.length} services`} size="small" sx={{ ml: { xs: 0, sm: 'auto' } }} />
|
||||
</Box>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3, display: { xs: 'none', md: 'block' } }}>
|
||||
{group.description}
|
||||
</Typography>
|
||||
<Divider sx={{ mb: { xs: 2, md: 3 } }} />
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
},
|
||||
gap: { xs: 1.5, md: 2 },
|
||||
}}>
|
||||
{groupServices.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.name}
|
||||
service={service}
|
||||
onRefresh={() => handleRefresh(service.name)}
|
||||
onAccess={() => handleAccess(service)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Position,
|
||||
MarkerType,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
Typography,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { useFrameworkData } from './context/FrameworkDataContext';
|
||||
import StepNode from './nodes/StepNode';
|
||||
import IntakeDetailPanel from './panels/IntakeDetailPanel';
|
||||
import TechniqueDetailPanel from './panels/TechniqueDetailPanel';
|
||||
import SourceDetailPanel from './panels/SourceDetailPanel';
|
||||
import ClaimsDetailPanel from './panels/ClaimsDetailPanel';
|
||||
import VerdictDetailPanel from './panels/VerdictDetailPanel';
|
||||
import { AnalysisStep, StepId, StepNodeData } from './types';
|
||||
|
||||
// Definim tipurile de noduri custom
|
||||
const nodeTypes = {
|
||||
stepNode: StepNode,
|
||||
};
|
||||
|
||||
// Pașii de analiză
|
||||
const analysisSteps: AnalysisStep[] = [
|
||||
{
|
||||
id: 'intake',
|
||||
title: 'Content Intake',
|
||||
description: 'Receive input, preprocess, detect type & language',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'techniques',
|
||||
title: 'Technique Detection',
|
||||
description: 'Analyze D1, D2, D3, D5, D7, D8 dimensions',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
title: 'Source Assessment',
|
||||
description: 'Platform detection, source credibility scoring',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'claims',
|
||||
title: 'Claims Extraction',
|
||||
description: 'Extract verifiable claims, prioritize',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'verdict',
|
||||
title: 'Verdict & Risk',
|
||||
description: 'Aggregate scores, apply multipliers, final verdict',
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
// Poziții pentru noduri (layout scară - diagonal de la stânga-sus la dreapta-jos)
|
||||
const nodePositions: Record<StepId, { x: number; y: number }> = {
|
||||
intake: { x: 50, y: 0 },
|
||||
techniques: { x: 200, y: 140 },
|
||||
sources: { x: 350, y: 280 },
|
||||
claims: { x: 500, y: 420 },
|
||||
verdict: { x: 650, y: 560 },
|
||||
};
|
||||
|
||||
interface AnalysisFlowVisualizationProps {
|
||||
// Opțional: date de la o analiză în curs
|
||||
analysisId?: string;
|
||||
onStepClick?: (stepId: StepId) => void;
|
||||
}
|
||||
|
||||
const AnalysisFlowVisualization: React.FC<AnalysisFlowVisualizationProps> = ({
|
||||
analysisId,
|
||||
onStepClick,
|
||||
}) => {
|
||||
const { loading, error, dimensions, techniques, verdictCategories } = useFrameworkData();
|
||||
const [selectedStep, setSelectedStep] = useState<StepId | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Crează nodurile pentru ReactFlow - fără date hardcodate
|
||||
const initialNodes: Node<StepNodeData>[] = useMemo(() => {
|
||||
return analysisSteps.map(step => ({
|
||||
id: step.id,
|
||||
type: 'stepNode',
|
||||
position: nodePositions[step.id],
|
||||
data: {
|
||||
step,
|
||||
// Scorurile vin de la o analiză reală, nu hardcodate
|
||||
score: undefined,
|
||||
details: step.id === 'techniques'
|
||||
? {
|
||||
dimensions: dimensions.length,
|
||||
techniques: techniques.length,
|
||||
}
|
||||
: undefined,
|
||||
onExpand: () => handleNodeExpand(step.id),
|
||||
},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}));
|
||||
}, [dimensions.length, techniques.length]);
|
||||
|
||||
// Crează edge-urile (conexiunile între noduri) - stil smoothstep pentru layout diagonal
|
||||
const initialEdges: Edge[] = useMemo(() => [
|
||||
{
|
||||
id: 'e-intake-techniques',
|
||||
source: 'intake',
|
||||
target: 'techniques',
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#4caf50', strokeWidth: 3 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#4caf50', width: 20, height: 20 },
|
||||
},
|
||||
{
|
||||
id: 'e-techniques-sources',
|
||||
source: 'techniques',
|
||||
target: 'sources',
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#4caf50', strokeWidth: 3 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#4caf50', width: 20, height: 20 },
|
||||
},
|
||||
{
|
||||
id: 'e-sources-claims',
|
||||
source: 'sources',
|
||||
target: 'claims',
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#1976d2', strokeWidth: 3 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#1976d2', width: 20, height: 20 },
|
||||
},
|
||||
{
|
||||
id: 'e-claims-verdict',
|
||||
source: 'claims',
|
||||
target: 'verdict',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#9e9e9e', strokeWidth: 3, strokeDasharray: '8,4' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#9e9e9e', width: 20, height: 20 },
|
||||
},
|
||||
], []);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
const handleNodeExpand = useCallback((stepId: StepId) => {
|
||||
setSelectedStep(stepId);
|
||||
setDrawerOpen(true);
|
||||
|
||||
if (onStepClick) {
|
||||
onStepClick(stepId);
|
||||
}
|
||||
}, [onStepClick]);
|
||||
|
||||
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
|
||||
handleNodeExpand(node.id as StepId);
|
||||
}, [handleNodeExpand]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={400}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>Loading framework data...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
Error loading framework data: {error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', minHeight: 800 }}>
|
||||
{/* Header cu statistici */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Analysis Flow Visualization
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>{dimensions.length}</strong> Dimensions loaded
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>{techniques.length}</strong> Techniques available
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>{verdictCategories.length}</strong> Verdict categories
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Data source: <code>bos_parammgmt</code> (DB)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* ReactFlow Canvas */}
|
||||
<Box sx={{ height: 700, border: '1px solid #e0e0e0', borderRadius: 2, bgcolor: '#fafafa' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const status = (node.data as StepNodeData)?.step?.status;
|
||||
if (status === 'completed') return '#4caf50';
|
||||
if (status === 'active') return '#1976d2';
|
||||
return '#9e9e9e';
|
||||
}}
|
||||
/>
|
||||
<Background color="#f5f5f5" gap={16} />
|
||||
</ReactFlow>
|
||||
</Box>
|
||||
|
||||
{/* Legendă */}
|
||||
<Paper sx={{ p: 1.5, mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" fontWeight="bold">Legend:</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, bgcolor: '#4caf50', borderRadius: '50%' }} />
|
||||
<Typography variant="caption">Completed</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, bgcolor: '#1976d2', borderRadius: '50%' }} />
|
||||
<Typography variant="caption">Active</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 12, height: 12, bgcolor: '#9e9e9e', borderRadius: '50%' }} />
|
||||
<Typography variant="caption">Pending</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||
Click on a node to see details
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Drawer pentru detalii */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
PaperProps={{
|
||||
sx: { width: { xs: '100%', sm: 600, md: 700 } },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">
|
||||
{selectedStep ? analysisSteps.find(s => s.id === selectedStep)?.title : 'Details'}
|
||||
</Typography>
|
||||
<IconButton onClick={() => setDrawerOpen(false)}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Afișează panoul corect în funcție de pasul selectat */}
|
||||
{selectedStep === 'intake' && <IntakeDetailPanel />}
|
||||
|
||||
{selectedStep === 'techniques' && (
|
||||
<TechniqueDetailPanel />
|
||||
)}
|
||||
|
||||
{selectedStep === 'sources' && <SourceDetailPanel />}
|
||||
|
||||
{selectedStep === 'claims' && <ClaimsDetailPanel />}
|
||||
|
||||
{selectedStep === 'verdict' && (
|
||||
<VerdictDetailPanel />
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisFlowVisualization;
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import {
|
||||
FrameworkData,
|
||||
Dimension,
|
||||
Subdimension,
|
||||
Technique,
|
||||
TechniqueIndicator,
|
||||
TechniqueValidationRule,
|
||||
VerdictCategory,
|
||||
RiskMapping,
|
||||
Platform,
|
||||
PlatformModifier,
|
||||
SourceType,
|
||||
SourceCredibility,
|
||||
DomainAgeScore,
|
||||
DomainRiskLevel,
|
||||
DomainRedFlag,
|
||||
AuthorClassification,
|
||||
AuthorCredibility,
|
||||
ClaimStatus,
|
||||
ClaimType,
|
||||
ConfidenceLevel,
|
||||
Interpretation,
|
||||
ComponentWeight,
|
||||
WeightScenario,
|
||||
Multiplier,
|
||||
SeverityAssessment,
|
||||
} from '../types';
|
||||
|
||||
const FRAMEWORK_API_URL = '/framework';
|
||||
|
||||
interface FrameworkDataContextType extends FrameworkData {
|
||||
refresh: () => Promise<void>;
|
||||
getTechniquesByDimension: (dimensionId: number) => Technique[];
|
||||
getIndicatorsByTechnique: (techniqueId: number) => TechniqueIndicator[];
|
||||
getRulesByTechnique: (techniqueId: number) => TechniqueValidationRule[];
|
||||
getVerdictByScore: (score: number) => VerdictCategory | undefined;
|
||||
getRiskByScore: (score: number) => RiskMapping | undefined;
|
||||
}
|
||||
|
||||
const initialState: FrameworkData = {
|
||||
dimensions: [],
|
||||
subdimensions: [],
|
||||
techniques: [],
|
||||
indicators: [],
|
||||
validationRules: [],
|
||||
verdictCategories: [],
|
||||
riskMappings: [],
|
||||
// Source Assessment - complete hierarchy
|
||||
platforms: [],
|
||||
platformModifiers: [],
|
||||
sourceTypes: [],
|
||||
sourceCredibility: [],
|
||||
domainAgeScores: [],
|
||||
domainRiskLevels: [],
|
||||
domainRedFlags: [],
|
||||
authorClassifications: [],
|
||||
authorCredibility: [],
|
||||
// Claims Analysis - complete hierarchy
|
||||
claimStatus: [],
|
||||
claimTypes: [],
|
||||
confidenceLevels: [],
|
||||
interpretations: [],
|
||||
// Weights & multipliers
|
||||
componentWeights: [],
|
||||
weightScenarios: [],
|
||||
multipliers: [],
|
||||
// Severity Assessment
|
||||
severityAssessments: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const FrameworkDataContext = createContext<FrameworkDataContextType | null>(null);
|
||||
|
||||
export const useFrameworkData = (): FrameworkDataContextType => {
|
||||
const context = useContext(FrameworkDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useFrameworkData must be used within a FrameworkDataProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const FrameworkDataProvider: React.FC<ProviderProps> = ({ children }) => {
|
||||
const [data, setData] = useState<FrameworkData>(initialState);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setData(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Fetch all data in parallel - including Source Assessment and Claims Analysis
|
||||
const [
|
||||
dimensionsRes,
|
||||
subdimensionsRes,
|
||||
techniquesRes,
|
||||
indicatorsRes,
|
||||
validationRulesRes,
|
||||
verdictsRes,
|
||||
riskRes,
|
||||
platformsRes,
|
||||
platformModifiersRes,
|
||||
sourcesRes,
|
||||
sourceCredibilityRes,
|
||||
domainAgeRes,
|
||||
domainRiskRes,
|
||||
domainRedFlagsRes,
|
||||
authorClassRes,
|
||||
authorCredRes,
|
||||
claimStatusRes,
|
||||
claimTypesRes,
|
||||
confidenceRes,
|
||||
interpretationRes,
|
||||
weightsComponentsRes,
|
||||
weightsScenariosRes,
|
||||
multipliersRes,
|
||||
severityRes,
|
||||
] = await Promise.all([
|
||||
fetch(`${FRAMEWORK_API_URL}/api/dimensions`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/subdimensions`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/techniques`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/indicators`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/validation-rules`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/verdicts/categories`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/verdicts/risk`),
|
||||
// Source Assessment - Platform section
|
||||
fetch(`${FRAMEWORK_API_URL}/api/platforms`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/platform-modifiers`),
|
||||
// Source Assessment - Publication section
|
||||
fetch(`${FRAMEWORK_API_URL}/api/sources`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/source-credibility`),
|
||||
// Source Assessment - Domain section
|
||||
fetch(`${FRAMEWORK_API_URL}/api/domain-age-scores`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/domain-risk-levels`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/domain-red-flags`),
|
||||
// Source Assessment - Author section
|
||||
fetch(`${FRAMEWORK_API_URL}/api/author-classifications`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/author-credibility`),
|
||||
// Claims Analysis
|
||||
fetch(`${FRAMEWORK_API_URL}/api/claims/status`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/claims/types`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/claims/confidence`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/claims/interpretation`),
|
||||
// Weights & multipliers
|
||||
fetch(`${FRAMEWORK_API_URL}/api/weights/components`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/weights/scenarios`),
|
||||
fetch(`${FRAMEWORK_API_URL}/api/weights/multipliers`),
|
||||
// Severity Assessment
|
||||
fetch(`${FRAMEWORK_API_URL}/api/verdicts/severity`),
|
||||
]);
|
||||
|
||||
const [
|
||||
dimensionsData,
|
||||
subdimensionsData,
|
||||
techniquesData,
|
||||
indicatorsData,
|
||||
validationRulesData,
|
||||
verdictsData,
|
||||
riskData,
|
||||
platformsData,
|
||||
platformModifiersData,
|
||||
sourcesData,
|
||||
sourceCredibilityData,
|
||||
domainAgeData,
|
||||
domainRiskData,
|
||||
domainRedFlagsData,
|
||||
authorClassData,
|
||||
authorCredData,
|
||||
claimStatusData,
|
||||
claimTypesData,
|
||||
confidenceData,
|
||||
interpretationData,
|
||||
weightsComponentsData,
|
||||
weightsScenariosData,
|
||||
multipliersData,
|
||||
severityData,
|
||||
] = await Promise.all([
|
||||
dimensionsRes.json(),
|
||||
subdimensionsRes.json(),
|
||||
techniquesRes.json(),
|
||||
indicatorsRes.json(),
|
||||
validationRulesRes.json(),
|
||||
verdictsRes.json(),
|
||||
riskRes.json(),
|
||||
platformsRes.json(),
|
||||
platformModifiersRes.json(),
|
||||
sourcesRes.json(),
|
||||
sourceCredibilityRes.json(),
|
||||
domainAgeRes.json(),
|
||||
domainRiskRes.json(),
|
||||
domainRedFlagsRes.json(),
|
||||
authorClassRes.json(),
|
||||
authorCredRes.json(),
|
||||
claimStatusRes.json(),
|
||||
claimTypesRes.json(),
|
||||
confidenceRes.json(),
|
||||
interpretationRes.json(),
|
||||
weightsComponentsRes.json(),
|
||||
weightsScenariosRes.json(),
|
||||
multipliersRes.json(),
|
||||
severityRes.json(),
|
||||
]);
|
||||
|
||||
setData({
|
||||
dimensions: dimensionsData.success ? dimensionsData.data : [],
|
||||
subdimensions: subdimensionsData.success ? subdimensionsData.data : [],
|
||||
techniques: techniquesData.success ? techniquesData.data : [],
|
||||
indicators: indicatorsData.success ? indicatorsData.data : [],
|
||||
validationRules: validationRulesData.success ? validationRulesData.data : [],
|
||||
verdictCategories: verdictsData.success ? verdictsData.data : [],
|
||||
riskMappings: riskData.success ? riskData.data : [],
|
||||
// Source Assessment - complete hierarchy
|
||||
platforms: platformsData.success ? platformsData.data : [],
|
||||
platformModifiers: platformModifiersData.success ? platformModifiersData.data : [],
|
||||
sourceTypes: sourcesData.success ? sourcesData.data : [],
|
||||
sourceCredibility: sourceCredibilityData.success ? sourceCredibilityData.data : [],
|
||||
domainAgeScores: domainAgeData.success ? domainAgeData.data : [],
|
||||
domainRiskLevels: domainRiskData.success ? domainRiskData.data : [],
|
||||
domainRedFlags: domainRedFlagsData.success ? domainRedFlagsData.data : [],
|
||||
authorClassifications: authorClassData.success ? authorClassData.data : [],
|
||||
authorCredibility: authorCredData.success ? authorCredData.data : [],
|
||||
// Claims Analysis - complete hierarchy
|
||||
claimStatus: claimStatusData.success ? claimStatusData.data : [],
|
||||
claimTypes: claimTypesData.success ? claimTypesData.data : [],
|
||||
confidenceLevels: confidenceData.success ? confidenceData.data : [],
|
||||
interpretations: interpretationData.success ? interpretationData.data : [],
|
||||
// Weights & multipliers
|
||||
componentWeights: weightsComponentsData.success ? weightsComponentsData.data : [],
|
||||
weightScenarios: weightsScenariosData.success ? weightsScenariosData.data : [],
|
||||
multipliers: multipliersData.success ? multipliersData.data : [],
|
||||
// Severity Assessment
|
||||
severityAssessments: severityData.success ? severityData.data : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch framework data',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Helper: Get techniques by dimension (via subdimension mapping)
|
||||
const getTechniquesByDimension = useCallback((dimensionId: number): Technique[] => {
|
||||
const dimSubdimensions = data.subdimensions.filter(s => s.dimension_id === dimensionId);
|
||||
const subdimIds = dimSubdimensions.map(s => s.subdimension_id);
|
||||
return data.techniques.filter(t => subdimIds.includes(t.subdimension_id));
|
||||
}, [data.subdimensions, data.techniques]);
|
||||
|
||||
// Helper: Get indicators by technique
|
||||
const getIndicatorsByTechnique = useCallback((techniqueId: number): TechniqueIndicator[] => {
|
||||
return data.indicators.filter(i => i.technique_id === techniqueId);
|
||||
}, [data.indicators]);
|
||||
|
||||
// Helper: Get validation rules by technique
|
||||
const getRulesByTechnique = useCallback((techniqueId: number): TechniqueValidationRule[] => {
|
||||
return data.validationRules.filter(r => r.technique_id === techniqueId);
|
||||
}, [data.validationRules]);
|
||||
|
||||
// Helper: Get verdict category by score
|
||||
const getVerdictByScore = useCallback((score: number): VerdictCategory | undefined => {
|
||||
return data.verdictCategories.find(v => score >= v.start_range && score <= v.end_range);
|
||||
}, [data.verdictCategories]);
|
||||
|
||||
// Helper: Get risk mapping by score
|
||||
const getRiskByScore = useCallback((score: number): RiskMapping | undefined => {
|
||||
return data.riskMappings.find(r => score >= r.start_range && score <= r.end_range);
|
||||
}, [data.riskMappings]);
|
||||
|
||||
const contextValue: FrameworkDataContextType = {
|
||||
...data,
|
||||
refresh: fetchData,
|
||||
getTechniquesByDimension,
|
||||
getIndicatorsByTechnique,
|
||||
getRulesByTechnique,
|
||||
getVerdictByScore,
|
||||
getRiskByScore,
|
||||
};
|
||||
|
||||
return (
|
||||
<FrameworkDataContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FrameworkDataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrameworkDataContext;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// AnalysisFlowVisualization - Main export
|
||||
// Vizualizează pipeline-ul de analiză în 5 pași cu date din DB
|
||||
|
||||
export { default } from './AnalysisFlowVisualization';
|
||||
export { default as AnalysisFlowVisualization } from './AnalysisFlowVisualization';
|
||||
|
||||
// Context & Hook
|
||||
export { FrameworkDataProvider, useFrameworkData } from './context/FrameworkDataContext';
|
||||
|
||||
// Nodes
|
||||
export { default as StepNode } from './nodes/StepNode';
|
||||
|
||||
// Panels
|
||||
export { default as IntakeDetailPanel } from './panels/IntakeDetailPanel';
|
||||
export { default as TechniqueDetailPanel } from './panels/TechniqueDetailPanel';
|
||||
export { default as SourceDetailPanel } from './panels/SourceDetailPanel';
|
||||
export { default as ClaimsDetailPanel } from './panels/ClaimsDetailPanel';
|
||||
export { default as VerdictDetailPanel } from './panels/VerdictDetailPanel';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from 'reactflow';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Input as IntakeIcon,
|
||||
Psychology as TechniqueIcon,
|
||||
Source as SourceIcon,
|
||||
FactCheck as ClaimsIcon,
|
||||
Gavel as VerdictIcon,
|
||||
ExpandMore as ExpandIcon,
|
||||
CheckCircle as CompletedIcon,
|
||||
RadioButtonUnchecked as PendingIcon,
|
||||
PlayCircle as ActiveIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { StepNodeData, StepId } from '../types';
|
||||
|
||||
const stepIcons: Record<StepId, React.ReactNode> = {
|
||||
intake: <IntakeIcon />,
|
||||
techniques: <TechniqueIcon />,
|
||||
sources: <SourceIcon />,
|
||||
claims: <ClaimsIcon />,
|
||||
verdict: <VerdictIcon />,
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
pending: <PendingIcon fontSize="small" color="disabled" />,
|
||||
active: <ActiveIcon fontSize="small" color="primary" />,
|
||||
completed: <CompletedIcon fontSize="small" color="success" />,
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
pending: 'default',
|
||||
active: 'primary',
|
||||
completed: 'success',
|
||||
} as const;
|
||||
|
||||
interface StepNodeProps extends NodeProps<StepNodeData> {}
|
||||
|
||||
const StepNode: React.FC<StepNodeProps> = ({ data, selected }) => {
|
||||
const { step, score, details, onExpand } = data;
|
||||
const Icon = stepIcons[step.id];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Input handle - not for intake (pe stânga pentru layout scară) */}
|
||||
{step.id !== 'intake' && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
style={{ background: '#1976d2', width: 10, height: 10, borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
minWidth: 220,
|
||||
maxWidth: 280,
|
||||
border: selected ? '2px solid #1976d2' : '1px solid #e0e0e0',
|
||||
boxShadow: selected ? 4 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: '#1976d2',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: step.status === 'completed' ? 'success.light' :
|
||||
step.status === 'active' ? 'primary.light' : 'grey.200',
|
||||
borderRadius: 1,
|
||||
p: 0.5,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{Icon}
|
||||
</Box>
|
||||
<Typography variant="subtitle2" fontWeight="bold">
|
||||
{step.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{statusIcons[step.status]}
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
{step.description}
|
||||
</Typography>
|
||||
|
||||
{/* Score (if available) */}
|
||||
{score !== undefined && (
|
||||
<Box mb={1}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={0.5}>
|
||||
<Typography variant="caption" fontWeight="medium">
|
||||
Score
|
||||
</Typography>
|
||||
<Typography variant="caption" fontWeight="bold">
|
||||
{score.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(score, 100)}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'grey.200',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 3,
|
||||
bgcolor: score > 70 ? 'error.main' : score > 40 ? 'warning.main' : 'success.main',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Details preview */}
|
||||
{details && Object.keys(details).length > 0 && (
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5} mb={1}>
|
||||
{Object.entries(details).slice(0, 3).map(([key, value]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${value}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.65rem', height: 20 }}
|
||||
/>
|
||||
))}
|
||||
{Object.keys(details).length > 3 && (
|
||||
<Chip
|
||||
label={`+${Object.keys(details).length - 3}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '0.65rem', height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Expand button */}
|
||||
{onExpand && (
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
<Tooltip title="View details">
|
||||
<IconButton size="small" onClick={onExpand}>
|
||||
<ExpandIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Output handle - not for verdict (pe dreapta pentru layout scară) */}
|
||||
{step.id !== 'verdict' && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
style={{ background: '#1976d2', width: 10, height: 10, borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(StepNode);
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Typography, Paper, Chip, IconButton, Collapse, Divider } from '@mui/material';
|
||||
import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material';
|
||||
import { colors } from './colors';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
itemCount: number;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const ClaimsCategoryRow: React.FC<Props> = ({
|
||||
title,
|
||||
icon,
|
||||
itemCount,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
subtitle,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 1,
|
||||
border: '1px solid',
|
||||
borderColor: expanded ? colors.primary : colors.border,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: expanded ? colors.surfaceAlt : '#fff',
|
||||
'&:hover': { bgcolor: colors.surfaceAlt },
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" sx={{ p: 0, color: colors.secondary }}>
|
||||
{expanded ? <ExpandIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ color: colors.primary }}>{icon}</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: colors.text }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={`${itemCount} items`}
|
||||
size="small"
|
||||
sx={{ bgcolor: colors.primary, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5, bgcolor: colors.surface }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
export const colors = {
|
||||
primary: '#1a237e',
|
||||
secondary: '#37474f',
|
||||
accent: '#455a64',
|
||||
surface: '#f8f9fa',
|
||||
surfaceAlt: '#eceff1',
|
||||
border: '#cfd8dc',
|
||||
borderLight: '#e0e0e0',
|
||||
text: '#212121',
|
||||
textSecondary: '#616161',
|
||||
success: '#2e7d32',
|
||||
warning: '#e65100',
|
||||
error: '#c62828',
|
||||
info: '#0277bd',
|
||||
};
|
||||
|
||||
export const getWeightColor = (weight: number): string => {
|
||||
if (weight >= 0.8) return colors.success;
|
||||
if (weight >= 0.6) return '#558b2f';
|
||||
if (weight >= 0.5) return colors.warning;
|
||||
if (weight >= 0.4) return '#d84315';
|
||||
return colors.error;
|
||||
};
|
||||
|
||||
export const getStatusColorHex = (colorName: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
green: '#2e7d32',
|
||||
lightgreen: '#558b2f',
|
||||
gray: '#757575',
|
||||
orange: '#e65100',
|
||||
red: '#c62828',
|
||||
blue: '#1565c0',
|
||||
darkgray: '#424242',
|
||||
};
|
||||
return map[colorName] || '#757575';
|
||||
};
|
||||
|
||||
export const getConfidenceColorHex = (colorName: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
yellow: '#f9a825',
|
||||
orange: '#e65100',
|
||||
red: '#c62828',
|
||||
darkred: '#b71c1c',
|
||||
};
|
||||
return map[colorName] || '#757575';
|
||||
};
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Paper, Chip, LinearProgress, Divider } from '@mui/material';
|
||||
import {
|
||||
FactCheck as ClaimIcon,
|
||||
Category as TypeIcon,
|
||||
Verified as VerifiedIcon,
|
||||
Speed as SpeedIcon,
|
||||
Handshake as ConcordanceIcon,
|
||||
ArrowForward as ArrowIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useFrameworkData } from '../../context/FrameworkDataContext';
|
||||
import { colors } from './colors';
|
||||
import { ClaimsCategoryRow } from './ClaimsCategoryRow';
|
||||
import { WeightBar, StatusBadge, ClaimTypeCard, ConfidenceLevelRow, InterpretationRow } from './rows';
|
||||
|
||||
const ClaimsDetailPanel: React.FC = () => {
|
||||
const {
|
||||
claimStatus,
|
||||
claimTypes,
|
||||
confidenceLevels,
|
||||
interpretations,
|
||||
componentWeights,
|
||||
loading,
|
||||
} = useFrameworkData();
|
||||
|
||||
const claimsWeight = componentWeights.find(cw => cw.component_name === 'claims');
|
||||
const sortedClaimTypes = [...claimTypes].sort((a, b) => b.base_weight - a.base_weight);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography sx={{ color: colors.textSecondary }}>Loading claims data...</Typography>
|
||||
<LinearProgress sx={{ mt: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '8px',
|
||||
bgcolor: colors.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ClaimIcon sx={{ color: '#fff' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
|
||||
Claims Analysis
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
|
||||
Claim verification and truth status determination
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Formula Box */}
|
||||
<Paper elevation={0} sx={{ p: 2, mb: 2, bgcolor: colors.primary, borderRadius: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.7)', letterSpacing: 1, display: 'block', mb: 0.5 }}>
|
||||
Confidence Formula
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600, color: '#fff', mb: 1.5 }}>
|
||||
Confidence(claim) = W_type x W_sources x W_concordance x W_recency
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.2)', mb: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||
Claims contribute {claimsWeight?.component_weight || 25}% to final risk score
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Stats */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.primary, fontWeight: 700 }}>{claimTypes.length}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Claim Types</Typography>
|
||||
</Paper>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.success, fontWeight: 700 }}>{claimStatus.length}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Status Values</Typography>
|
||||
</Paper>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.warning, fontWeight: 700 }}>{confidenceLevels.length}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Confidence</Typography>
|
||||
</Paper>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.accent, fontWeight: 700 }}>{interpretations.length}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Concordance</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Processing Flow */}
|
||||
<Paper elevation={0} sx={{ p: 1.5, mb: 2, bgcolor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 1 }}>
|
||||
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
|
||||
Processing Flow
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip label="1. Extract Claims" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="2. Classify Type" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="3. Verify Sources" size="small" variant="outlined" sx={{ borderColor: colors.warning }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="4. Concordance" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="5. Status" size="small" sx={{ bgcolor: colors.primary, color: '#fff' }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Category 1: Claim Types */}
|
||||
<ClaimsCategoryRow
|
||||
title="Claim Types (W_type)"
|
||||
subtitle="Claim categories with base weights"
|
||||
icon={<TypeIcon />}
|
||||
itemCount={claimTypes.length}
|
||||
defaultExpanded
|
||||
>
|
||||
<Paper elevation={0} sx={{ p: 1.5, mb: 1.5, border: `1px solid ${colors.borderLight}`, borderRadius: 1 }}>
|
||||
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
|
||||
Weight Overview
|
||||
</Typography>
|
||||
{sortedClaimTypes.map(ct => (
|
||||
<WeightBar
|
||||
key={ct.claim_type_id}
|
||||
weight={ct.base_weight}
|
||||
label={`${ct.claim_type_code} - ${ct.claim_type_name}`}
|
||||
/>
|
||||
))}
|
||||
</Paper>
|
||||
{sortedClaimTypes.map(ct => (
|
||||
<ClaimTypeCard key={ct.claim_type_id} claimType={ct} />
|
||||
))}
|
||||
</ClaimsCategoryRow>
|
||||
|
||||
{/* Category 2: Claim Status */}
|
||||
<ClaimsCategoryRow
|
||||
title="Claim Status"
|
||||
subtitle="Verification result - final output"
|
||||
icon={<VerifiedIcon />}
|
||||
itemCount={claimStatus.length}
|
||||
defaultExpanded
|
||||
>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: colors.success, letterSpacing: 1, display: 'block', mb: 0.5 }}>
|
||||
Verified / Likely
|
||||
</Typography>
|
||||
{claimStatus
|
||||
.filter(s => ['VT', 'LT', 'UV'].includes(s.claim_code))
|
||||
.map(status => <StatusBadge key={status.claim_id} status={status} />)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: colors.error, letterSpacing: 1, display: 'block', mb: 0.5 }}>
|
||||
False / Other
|
||||
</Typography>
|
||||
{claimStatus
|
||||
.filter(s => ['LF', 'VF', 'OP', 'NV'].includes(s.claim_code))
|
||||
.map(status => <StatusBadge key={status.claim_id} status={status} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ClaimsCategoryRow>
|
||||
|
||||
{/* Category 3: Confidence Levels */}
|
||||
<ClaimsCategoryRow
|
||||
title="Confidence Levels"
|
||||
subtitle="Confidence thresholds with recommended actions"
|
||||
icon={<SpeedIcon />}
|
||||
itemCount={confidenceLevels.length}
|
||||
>
|
||||
{confidenceLevels.map(level => (
|
||||
<ConfidenceLevelRow key={level.confidence_id} level={level} />
|
||||
))}
|
||||
</ClaimsCategoryRow>
|
||||
|
||||
{/* Category 4: Interpretation (W_concordance) */}
|
||||
<ClaimsCategoryRow
|
||||
title="Source Concordance (W_concordance)"
|
||||
subtitle="Agreement level between sources"
|
||||
icon={<ConcordanceIcon />}
|
||||
itemCount={interpretations.length}
|
||||
>
|
||||
{interpretations.map((item, index) => (
|
||||
<InterpretationRow
|
||||
key={item.interpretation_id}
|
||||
item={item}
|
||||
index={index}
|
||||
total={interpretations.length}
|
||||
/>
|
||||
))}
|
||||
</ClaimsCategoryRow>
|
||||
|
||||
{/* Decision Matrix */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 1.5,
|
||||
bgcolor: '#fff3e0',
|
||||
border: `1px solid ${colors.warning}`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: colors.warning, mb: 1 }}>
|
||||
Decision Matrix
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: colors.textSecondary, mb: 1 }}>
|
||||
Final status is determined by combining:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip label="Claim Type" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>x</Typography>
|
||||
<Chip label="Nr. Sources" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>x</Typography>
|
||||
<Chip label="Concordance" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>x</Typography>
|
||||
<Chip label="Recency" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="Final Status" size="small" sx={{ bgcolor: colors.success, color: '#fff', fontWeight: 600 }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaimsDetailPanel;
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Paper, Chip, Tooltip, LinearProgress } from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Psychology as LLMIcon,
|
||||
HelpOutline as HelpIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ClaimStatus, ClaimType, ConfidenceLevel, Interpretation } from '../../types';
|
||||
import { colors, getWeightColor, getStatusColorHex, getConfidenceColorHex } from './colors';
|
||||
|
||||
export const WeightBar: React.FC<{ weight: number; label: string }> = ({ weight, label }) => {
|
||||
const percent = weight * 100;
|
||||
const color = getWeightColor(weight);
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: colors.text }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color }}>{percent.toFixed(0)}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percent}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: colors.surfaceAlt,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusBadge: React.FC<{ status: ClaimStatus }> = ({ status }) => {
|
||||
const bgColor = getStatusColorHex(status.claim_color);
|
||||
const hasRange = status.start_range !== null && status.end_range !== null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
mb: 0.5,
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: colors.borderLight,
|
||||
borderLeft: `3px solid ${bgColor}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: bgColor,
|
||||
mr: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text }}>
|
||||
{status.claim_code} - {status.claim_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
{hasRange ? (
|
||||
<Chip
|
||||
label={`${status.start_range}-${status.end_range}%`}
|
||||
size="small"
|
||||
sx={{ bgcolor: bgColor, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="N/A"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: bgColor, color: bgColor }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClaimTypeCard: React.FC<{ claimType: ClaimType }> = ({ claimType }) => {
|
||||
const weightColor = getWeightColor(claimType.base_weight);
|
||||
|
||||
const getMethodIcon = (method: string) => {
|
||||
if (method.includes('LLM')) return <LLMIcon sx={{ fontSize: 16 }} />;
|
||||
if (method.includes('Web Search')) return <SearchIcon sx={{ fontSize: 16 }} />;
|
||||
if (method.includes('Not verifiable')) return <HelpIcon sx={{ fontSize: 16 }} />;
|
||||
return <SearchIcon sx={{ fontSize: 16 }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
mb: 1,
|
||||
border: '1px solid',
|
||||
borderColor: colors.borderLight,
|
||||
borderLeft: `3px solid ${weightColor}`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={claimType.claim_type_code}
|
||||
size="small"
|
||||
sx={{ bgcolor: weightColor, color: '#fff', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, color: colors.text }}>
|
||||
{claimType.claim_type_name}
|
||||
</Typography>
|
||||
<Tooltip title="Base Weight">
|
||||
<Chip
|
||||
label={`${(claimType.base_weight * 100).toFixed(0)}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: weightColor, color: weightColor, fontWeight: 600 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary, display: 'block', mb: 0.5 }}>
|
||||
{claimType.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{getMethodIcon(claimType.verification_method)}
|
||||
<Typography variant="caption" sx={{ color: colors.info }}>
|
||||
{claimType.verification_method}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfidenceLevelRow: React.FC<{ level: ConfidenceLevel }> = ({ level }) => {
|
||||
const color = getConfidenceColorHex(level.confidence_color);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
mb: 0.5,
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '4px',
|
||||
bgcolor: color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
mr: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{level.confidence_level}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text, textTransform: 'capitalize' }}>
|
||||
{level.confidence_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
|
||||
Action: {level.action}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${level.start_range}-${level.end_range}%`}
|
||||
size="small"
|
||||
sx={{ bgcolor: color, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const InterpretationRow: React.FC<{ item: Interpretation; index: number; total: number }> = ({ item, index, total }) => {
|
||||
const percent = index / (total - 1);
|
||||
const r = Math.round(198 - (percent * 152));
|
||||
const g = Math.round(40 + (percent * 86));
|
||||
const b = Math.round(40 + (percent * 10));
|
||||
const color = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
mb: 0.5,
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: colors.borderLight,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<TrendingUpIcon sx={{ color, mr: 1, fontSize: 18 }} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text }}>
|
||||
{item.interpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${item.start_range}-${item.end_range}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: color, color, fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Input as IntakeIcon,
|
||||
TextFields as TextIcon,
|
||||
Image as ImageIcon,
|
||||
AudioFile as AudioIcon,
|
||||
VideoFile as VideoIcon,
|
||||
Link as UrlIcon,
|
||||
Translate as LangIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Block as BlockIcon,
|
||||
ArrowForward as ArrowIcon,
|
||||
FilterAlt as FilterIcon,
|
||||
Settings as ProcessIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// ============================================================================
|
||||
// DESIGN SYSTEM - Professional colors
|
||||
// ============================================================================
|
||||
const colors = {
|
||||
primary: '#1a237e',
|
||||
secondary: '#37474f',
|
||||
accent: '#455a64',
|
||||
surface: '#f8f9fa',
|
||||
border: '#e0e0e0',
|
||||
text: '#212121',
|
||||
textSecondary: '#616161',
|
||||
success: '#2e7d32',
|
||||
warning: '#e65100',
|
||||
error: '#c62828',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// STEP ITEM COMPONENT
|
||||
// ============================================================================
|
||||
interface StepItemProps {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const StepItem: React.FC<StepItemProps> = ({ number, title, description }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '4px',
|
||||
bgcolor: colors.primary,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.text }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT TYPE CHIP
|
||||
// ============================================================================
|
||||
interface ContentTypeChipProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
const ContentTypeChip: React.FC<ContentTypeChipProps> = ({ icon, label, sublabel }) => (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderColor: colors.border,
|
||||
bgcolor: '#fff',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: colors.secondary }}>{icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{sublabel && (
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary, fontSize: '0.65rem' }}>
|
||||
{sublabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
const IntakeDetailPanel: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '8px',
|
||||
bgcolor: colors.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IntakeIcon sx={{ color: '#fff' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
|
||||
Content Intake
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
|
||||
Pre-processing and eligibility verification
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Processing Pipeline */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
bgcolor: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: colors.secondary,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
display: 'block',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
Processing Pipeline
|
||||
</Typography>
|
||||
|
||||
<StepItem
|
||||
number={1}
|
||||
title="Receive Input"
|
||||
description="Accept text, image, audio, video, or URL content"
|
||||
/>
|
||||
<StepItem
|
||||
number={2}
|
||||
title="Pre-Processing"
|
||||
description="OCR for images, STT for audio, content extraction for URLs"
|
||||
/>
|
||||
<StepItem
|
||||
number={3}
|
||||
title="Language Detection"
|
||||
description="Identify content language (RO/EN supported)"
|
||||
/>
|
||||
<StepItem
|
||||
number={4}
|
||||
title="Eligibility Check"
|
||||
description="Verify content meets analysis criteria"
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Supported Content Types */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
bgcolor: '#fff',
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: colors.secondary,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
display: 'block',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
Supported Content Types
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<ContentTypeChip icon={<TextIcon fontSize="small" />} label="Text" sublabel="Direct input" />
|
||||
<ContentTypeChip icon={<ImageIcon fontSize="small" />} label="Image" sublabel="OCR extraction" />
|
||||
<ContentTypeChip icon={<AudioIcon fontSize="small" />} label="Audio" sublabel="Speech-to-text" />
|
||||
<ContentTypeChip icon={<VideoIcon fontSize="small" />} label="Video" sublabel="Frame + audio" />
|
||||
<ContentTypeChip icon={<UrlIcon fontSize="small" />} label="URL" sublabel="Content fetch" />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Supported Languages */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
bgcolor: '#fff',
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<LangIcon sx={{ color: colors.secondary, fontSize: 20 }} />
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: colors.secondary,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Supported Languages
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label="RO - Romanian"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: colors.success,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="EN - English"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: colors.success,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Eligibility Criteria */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
bgcolor: '#fff',
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<FilterIcon sx={{ color: colors.secondary, fontSize: 20 }} />
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: colors.secondary,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Eligibility Criteria
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
|
||||
<Typography variant="body2" sx={{ color: colors.text }}>
|
||||
Content must be in supported language (RO/EN)
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
|
||||
<Typography variant="body2" sx={{ color: colors.text }}>
|
||||
Minimum 50 characters of extractable text
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
|
||||
<Typography variant="body2" sx={{ color: colors.text }}>
|
||||
Contains verifiable claims or statements
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BlockIcon sx={{ color: colors.error, fontSize: 18 }} />
|
||||
<Typography variant="body2" sx={{ color: colors.text }}>
|
||||
Not pure opinion, satire, or fiction
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Refusal Policy */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
bgcolor: '#fafafa',
|
||||
border: `1px solid ${colors.error}`,
|
||||
borderLeft: `4px solid ${colors.error}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<BlockIcon sx={{ color: colors.error, fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: colors.error }}>
|
||||
Refusal Policy
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: colors.textSecondary, mb: 1 }}>
|
||||
Content will be refused if:
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: colors.text }}>
|
||||
- Language not supported
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.text }}>
|
||||
- Insufficient extractable content
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.text }}>
|
||||
- Pure entertainment/fiction content
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.text }}>
|
||||
- Technical/code content without claims
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.text }}>
|
||||
- Duplicate of recently analyzed content
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Output Flow */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<ProcessIcon sx={{ color: colors.secondary, fontSize: 20 }} />
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: colors.secondary,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Output
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label="Eligible"
|
||||
size="small"
|
||||
sx={{ bgcolor: colors.success, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ color: colors.text }}>
|
||||
Proceeds to <strong>Technique Detection</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
|
||||
<Chip
|
||||
label="Ineligible"
|
||||
size="small"
|
||||
sx={{ bgcolor: colors.error, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ color: colors.text }}>
|
||||
Returns refusal with reason code
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntakeDetailPanel;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, LinearProgress } from '@mui/material';
|
||||
import { colors } from './colors';
|
||||
|
||||
export const WeightBar: React.FC<{ weight: number; label: string }> = ({ weight, label }) => (
|
||||
<Box sx={{ mb: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: colors.text }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: colors.primary }}>{weight}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={weight}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: colors.surfaceAlt,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: colors.primary, borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* SourceDetailPanel — design system colors
|
||||
*/
|
||||
export const colors = {
|
||||
primary: '#1a237e',
|
||||
secondary: '#37474f',
|
||||
accent: '#455a64',
|
||||
surface: '#f8f9fa',
|
||||
surfaceAlt: '#eceff1',
|
||||
border: '#cfd8dc',
|
||||
borderLight: '#e0e0e0',
|
||||
text: '#212121',
|
||||
textSecondary: '#616161',
|
||||
success: '#2e7d32',
|
||||
warning: '#e65100',
|
||||
error: '#c62828',
|
||||
info: '#0277bd',
|
||||
};
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* SourceDetailPanel — collapsible container components
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Typography, Paper, Chip, Tooltip, IconButton, Collapse, Divider } from '@mui/material';
|
||||
import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material';
|
||||
import { colors } from './colors';
|
||||
|
||||
interface SourceCategoryRowProps {
|
||||
title: string;
|
||||
weight: number;
|
||||
icon: React.ReactNode;
|
||||
itemCount: number;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const SourceCategoryRow: React.FC<SourceCategoryRowProps> = ({
|
||||
title,
|
||||
weight,
|
||||
icon,
|
||||
itemCount,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 1,
|
||||
border: '1px solid',
|
||||
borderColor: expanded ? colors.primary : colors.border,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: expanded ? colors.surfaceAlt : '#fff',
|
||||
'&:hover': { bgcolor: colors.surfaceAlt },
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" sx={{ p: 0, color: colors.secondary }}>
|
||||
{expanded ? <ExpandIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ color: colors.primary }}>{icon}</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ flexGrow: 1, fontWeight: 600, color: colors.text }}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="Weight in source score">
|
||||
<Chip
|
||||
label={`${weight}%`}
|
||||
size="small"
|
||||
sx={{ bgcolor: colors.primary, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Chip
|
||||
label={`${itemCount}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: colors.border }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5, bgcolor: colors.surface }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubsectionRowProps {
|
||||
title: string;
|
||||
itemCount: number;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const SubsectionRow: React.FC<SubsectionRowProps> = ({
|
||||
title,
|
||||
itemCount,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
ml: 2,
|
||||
border: '1px solid',
|
||||
borderColor: expanded ? colors.accent : colors.borderLight,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
p: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: '#fff',
|
||||
'&:hover': { bgcolor: colors.surface },
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" sx={{ p: 0, color: colors.secondary }}>
|
||||
{expanded ? <ExpandIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, fontWeight: 500, color: colors.text }}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={`${itemCount} items`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem', borderColor: colors.borderLight }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1, bgcolor: colors.surface }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Paper, Chip, Divider } from '@mui/material';
|
||||
import {
|
||||
Web as PlatformIcon,
|
||||
Source as SourceIcon,
|
||||
Domain as DomainIcon,
|
||||
Person as AuthorIcon,
|
||||
Warning as WarningIcon,
|
||||
Security as SecurityIcon,
|
||||
ArrowForward as ArrowIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useFrameworkData } from '../../context/FrameworkDataContext';
|
||||
import { colors } from './colors';
|
||||
import { WeightBar } from './WeightBar';
|
||||
import { SourceCategoryRow, SubsectionRow } from './containers';
|
||||
import { SourceTypeRow, SourceCredibilityRow } from './rows/source';
|
||||
import { PlatformRow, PlatformModifierRow } from './rows/platform';
|
||||
import { DomainAgeRow, DomainRiskRow, DomainRedFlagRow } from './rows/domain';
|
||||
import { AuthorClassificationRow, AuthorCredibilityRow } from './rows/author';
|
||||
|
||||
const SourceDetailPanel: React.FC = () => {
|
||||
const {
|
||||
platforms,
|
||||
platformModifiers,
|
||||
sourceTypes,
|
||||
sourceCredibility,
|
||||
domainAgeScores,
|
||||
domainRiskLevels,
|
||||
domainRedFlags,
|
||||
authorClassifications,
|
||||
authorCredibility,
|
||||
} = useFrameworkData();
|
||||
|
||||
const totalItems = platforms.length + platformModifiers.length + sourceTypes.length +
|
||||
sourceCredibility.length + domainAgeScores.length + domainRiskLevels.length +
|
||||
domainRedFlags.length + authorClassifications.length + authorCredibility.length;
|
||||
|
||||
const positiveModifiers = platformModifiers.filter(m => m.score > 0).length +
|
||||
authorCredibility.filter(c => c.impact > 0).length;
|
||||
const negativeModifiers = platformModifiers.filter(m => m.score < 0).length +
|
||||
authorCredibility.filter(c => c.impact < 0).length +
|
||||
domainRedFlags.length;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '8px',
|
||||
bgcolor: colors.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SecurityIcon sx={{ color: '#fff' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
|
||||
Source Assessment
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>
|
||||
Shared module - Claims, Manipulation, Verdict
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Formula Box */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
bgcolor: colors.primary,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.7)', letterSpacing: 1, display: 'block', mb: 0.5 }}>
|
||||
Source Score Formula
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: 'monospace', fontWeight: 600, color: '#fff', mb: 1.5 }}
|
||||
>
|
||||
SOURCE_SCORE = (Publication x 35%) + (Domain x 25%) + (Author x 25%) + (Platform x 15%)
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.2)', mb: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||
Applied modifiers: domain_multiplier x author_modifier
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Weight Distribution */}
|
||||
<Paper elevation={0} sx={{ p: 1.5, mb: 2, border: `1px solid ${colors.border}`, borderRadius: 1 }}>
|
||||
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
|
||||
Weight Distribution
|
||||
</Typography>
|
||||
<WeightBar weight={35} label="Publication Type" />
|
||||
<WeightBar weight={25} label="Domain Analysis" />
|
||||
<WeightBar weight={25} label="Author Assessment" />
|
||||
<WeightBar weight={15} label="Platform" />
|
||||
</Paper>
|
||||
|
||||
{/* Stats */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.primary, fontWeight: 700 }}>{totalItems}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Parameters</Typography>
|
||||
</Paper>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.success, fontWeight: 700 }}>{positiveModifiers}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Positive</Typography>
|
||||
</Paper>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.error, fontWeight: 700 }}>{negativeModifiers}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Negative</Typography>
|
||||
</Paper>
|
||||
<Paper elevation={0} sx={{ flex: 1, p: 1, textAlign: 'center', border: `1px solid ${colors.border}` }}>
|
||||
<Typography variant="h6" sx={{ color: colors.warning, fontWeight: 700 }}>{domainRedFlags.length}</Typography>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary }}>Red Flags</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Processing Flow */}
|
||||
<Paper elevation={0} sx={{ p: 1.5, mb: 2, bgcolor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 1 }}>
|
||||
<Typography variant="overline" sx={{ color: colors.secondary, letterSpacing: 1, display: 'block', mb: 1 }}>
|
||||
Processing Flow
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip label="1. Extract Metadata" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="2. Identify Platform" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="3. Domain Check" size="small" variant="outlined" sx={{ borderColor: colors.warning }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="4. Author Assess" size="small" variant="outlined" sx={{ borderColor: colors.border }} />
|
||||
<ArrowIcon sx={{ color: colors.textSecondary, fontSize: 16 }} />
|
||||
<Chip label="5. Calculate" size="small" sx={{ bgcolor: colors.primary, color: '#fff' }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Categories */}
|
||||
<SourceCategoryRow
|
||||
title="Publication Type"
|
||||
weight={35}
|
||||
icon={<SourceIcon />}
|
||||
itemCount={sourceTypes.length + sourceCredibility.length}
|
||||
defaultExpanded
|
||||
>
|
||||
<SubsectionRow title="Source Types" itemCount={sourceTypes.length} defaultExpanded>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary, display: 'block', mb: 1, fontFamily: 'monospace' }}>
|
||||
Table: source_type | Base scores 5-100%
|
||||
</Typography>
|
||||
{sourceTypes.map(item => <SourceTypeRow key={item.source_type_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
<SubsectionRow title="Credibility Modifiers" itemCount={sourceCredibility.length}>
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary, display: 'block', mb: 1, fontFamily: 'monospace' }}>
|
||||
Table: source_credibility | Multipliers
|
||||
</Typography>
|
||||
{sourceCredibility.map(item => <SourceCredibilityRow key={item.source_credibility_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
</SourceCategoryRow>
|
||||
|
||||
<SourceCategoryRow
|
||||
title="Domain Analysis"
|
||||
weight={25}
|
||||
icon={<DomainIcon />}
|
||||
itemCount={domainAgeScores.length + domainRiskLevels.length + domainRedFlags.length}
|
||||
>
|
||||
<SubsectionRow title="Domain Age Scores" itemCount={domainAgeScores.length} defaultExpanded>
|
||||
{domainAgeScores.map(item => <DomainAgeRow key={item.domain_age_score} item={item} />)}
|
||||
</SubsectionRow>
|
||||
<SubsectionRow title="Domain Risk Levels" itemCount={domainRiskLevels.length}>
|
||||
{domainRiskLevels.map(item => <DomainRiskRow key={item.domain_risk_level_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
<SubsectionRow title="Red Flags" itemCount={domainRedFlags.length}>
|
||||
{domainRedFlags.map(item => <DomainRedFlagRow key={item.domain_red_flag_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
</SourceCategoryRow>
|
||||
|
||||
<SourceCategoryRow
|
||||
title="Author Assessment"
|
||||
weight={25}
|
||||
icon={<AuthorIcon />}
|
||||
itemCount={authorClassifications.length + authorCredibility.length}
|
||||
>
|
||||
<SubsectionRow title="Classifications" itemCount={authorClassifications.length} defaultExpanded>
|
||||
{authorClassifications.map(item => <AuthorClassificationRow key={item.author_classification_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
<SubsectionRow title="Credibility Indicators" itemCount={authorCredibility.length}>
|
||||
{authorCredibility.map(item => <AuthorCredibilityRow key={item.author_credibility_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
</SourceCategoryRow>
|
||||
|
||||
<SourceCategoryRow
|
||||
title="Platform"
|
||||
weight={15}
|
||||
icon={<PlatformIcon />}
|
||||
itemCount={platforms.length + platformModifiers.length}
|
||||
>
|
||||
<SubsectionRow title="Platforms" itemCount={platforms.length} defaultExpanded>
|
||||
{platforms.map(item => <PlatformRow key={item.platform_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
<SubsectionRow title="Modifiers" itemCount={platformModifiers.length}>
|
||||
{platformModifiers.map(item => <PlatformModifierRow key={item.platform_modifier_id} item={item} />)}
|
||||
</SubsectionRow>
|
||||
</SourceCategoryRow>
|
||||
|
||||
{/* Unknown Source Penalties */}
|
||||
<Paper elevation={0} sx={{ mt: 2, p: 1.5, bgcolor: '#fff3e0', border: `1px solid ${colors.warning}`, borderRadius: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<WarningIcon sx={{ color: colors.warning, fontSize: 18 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: colors.warning }}>
|
||||
Unknown Source Penalties
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label="Confidence: x0.50" size="small" sx={{ bgcolor: colors.warning, color: '#fff' }} />
|
||||
<Chip label="Cap: 70% max" size="small" sx={{ bgcolor: colors.warning, color: '#fff' }} />
|
||||
<Chip label="Risk floor: 30% min" size="small" sx={{ bgcolor: colors.error, color: '#fff' }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* DB Source */}
|
||||
<Typography variant="caption" sx={{ color: colors.textSecondary, mt: 1.5, display: 'block', fontFamily: 'monospace' }}>
|
||||
Source: bos_parammgmt @ didi-postgres:5000/DIDI
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourceDetailPanel;
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import { CheckCircle as CheckIcon, Cancel as CancelIcon } from '@mui/icons-material';
|
||||
import type { AuthorClassification, AuthorCredibility } from '../../../types';
|
||||
import { colors } from '../colors';
|
||||
|
||||
export const AuthorClassificationRow: React.FC<{ item: AuthorClassification }> = ({ item }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
p: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: '#fff',
|
||||
border: '1px solid',
|
||||
borderColor: colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
bgcolor: colors.surfaceAlt,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{item.author_classification_code}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, color: colors.text }}>
|
||||
{item.author_classification_name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`x${(item.score / 100).toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: item.score >= 100 ? colors.success : item.score >= 70 ? colors.warning : colors.error,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const AuthorCredibilityRow: React.FC<{ item: AuthorCredibility }> = ({ item }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
p: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: '#fff',
|
||||
border: '1px solid',
|
||||
borderColor: colors.borderLight,
|
||||
borderLeft: `3px solid ${item.impact >= 0 ? colors.success : colors.error}`,
|
||||
}}
|
||||
>
|
||||
{item.impact >= 0 ? (
|
||||
<CheckIcon sx={{ color: colors.success, fontSize: 18 }} />
|
||||
) : (
|
||||
<CancelIcon sx={{ color: colors.error, fontSize: 18 }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, color: colors.text }}>
|
||||
{item.author_credibility}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${item.impact >= 0 ? '+' : ''}${item.impact} pts`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: item.impact >= 0 ? colors.success : colors.error,
|
||||
color: item.impact >= 0 ? colors.success : colors.error,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue