253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
import React, { useMemo, useState } from 'react'
|
||
import {
|
||
Badge,
|
||
Box,
|
||
HStack,
|
||
Heading,
|
||
SimpleGrid,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableColumnHeader,
|
||
TableContainer,
|
||
TableHeader,
|
||
TableRow,
|
||
Text,
|
||
VStack,
|
||
Select,
|
||
} from '@chakra-ui/react'
|
||
|
||
import {
|
||
useGetAllSubmissionsQuery,
|
||
useGetChainsQuery,
|
||
useGetSystemStatsQuery,
|
||
} from '../../__data__/api/api'
|
||
import type { ChallengeChain } from '../../__data__/types'
|
||
import { StatCard } from '../../components/personal'
|
||
import { ABTestPanel } from '../../components/admin/ABTestPanel'
|
||
import { mapTaskMetrics, detectIssues, msToMinutes } from '../../utils/analytics'
|
||
import { keycloak } from '../../__data__/kc'
|
||
|
||
const formatNumber = (value: number | undefined) => {
|
||
if (!value && value !== 0) return '—'
|
||
return Intl.NumberFormat('ru-RU').format(value)
|
||
}
|
||
|
||
const hasTeacherRole = () => {
|
||
try {
|
||
return keycloak?.hasResourceRole?.('teacher', 'journal') ?? false
|
||
} catch (error) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export const AdminDashboard = () => {
|
||
const isTeacher = hasTeacherRole()
|
||
const { data: systemStats, isLoading } = useGetSystemStatsQuery()
|
||
const { data: chains = [] } = useGetChainsQuery(undefined, { skip: !isTeacher })
|
||
const { data: submissions = [] } = useGetAllSubmissionsQuery(undefined, { skip: !isTeacher })
|
||
|
||
const issues = useMemo(() => (systemStats ? detectIssues(systemStats) : []), [systemStats])
|
||
const taskMetrics = useMemo(() => mapTaskMetrics(submissions), [submissions])
|
||
const [difficultyFilter, setDifficultyFilter] = useState<'all' | 'easy' | 'medium' | 'hard'>('all')
|
||
|
||
const filteredTaskMetrics = useMemo(() => {
|
||
if (difficultyFilter === 'all') return taskMetrics
|
||
return taskMetrics.filter((metric) => metric.difficulty === difficultyFilter)
|
||
}, [difficultyFilter, taskMetrics])
|
||
|
||
if (!isTeacher) {
|
||
return (
|
||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={8} maxW="480px" textAlign="center">
|
||
<Heading size="md" mb={4}>
|
||
Требуется роль преподавателя
|
||
</Heading>
|
||
<Text color="gray.600">
|
||
У вас нет доступа к панели администратора. Обратитесь к администратору Keycloak для назначения роли
|
||
<Badge ml={2} colorScheme="purple">
|
||
teacher
|
||
</Badge>
|
||
.
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
if (isLoading || !systemStats) {
|
||
return (
|
||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
||
<Text color="gray.500">Загружаем системные метрики...</Text>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||
<Heading size="lg">Панель преподавателя</Heading>
|
||
|
||
<SimpleGrid minChildWidth="200px" spacing={4}>
|
||
<StatCard title="Пользователей" value={formatNumber(systemStats.users)} icon="👥" />
|
||
<StatCard title="Заданий" value={formatNumber(systemStats.tasks)} icon="🧩" />
|
||
<StatCard title="Цепочек" value={formatNumber(systemStats.chains)} icon="🔗" />
|
||
<StatCard title="Всего проверок" value={formatNumber(systemStats.submissions.total)} icon="✅" />
|
||
<StatCard
|
||
title="В ожидании"
|
||
value={formatNumber(systemStats.queue.waiting)}
|
||
icon="⏳"
|
||
/>
|
||
<StatCard
|
||
title="Среднее время проверки"
|
||
value={`${msToMinutes(systemStats.averageCheckTimeMs)} мин`}
|
||
icon="⏱️"
|
||
/>
|
||
</SimpleGrid>
|
||
|
||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||
<Heading size="sm" mb={3}>
|
||
Статус очереди
|
||
</Heading>
|
||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||
<Box>
|
||
<Text fontSize="sm" color="gray.500">
|
||
Всего в очереди
|
||
</Text>
|
||
<Text fontSize="lg" fontWeight="semibold">
|
||
{formatNumber(systemStats.queue.queueLength)}
|
||
</Text>
|
||
</Box>
|
||
<Box>
|
||
<Text fontSize="sm" color="gray.500">
|
||
В ожидании
|
||
</Text>
|
||
<Text fontSize="lg" fontWeight="semibold">
|
||
{formatNumber(systemStats.queue.waiting)}
|
||
</Text>
|
||
</Box>
|
||
<Box>
|
||
<Text fontSize="sm" color="gray.500">
|
||
В обработке
|
||
</Text>
|
||
<Text fontSize="lg" fontWeight="semibold">
|
||
{formatNumber(systemStats.queue.currentlyProcessing)} / {formatNumber(systemStats.queue.maxConcurrency)}
|
||
</Text>
|
||
</Box>
|
||
</SimpleGrid>
|
||
</Box>
|
||
|
||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||
<Heading size="sm" mb={3}>
|
||
Проблемные области
|
||
</Heading>
|
||
|
||
{issues.length === 0 ? (
|
||
<Text color="green.500">Критических проблем не обнаружено.</Text>
|
||
) : (
|
||
<VStack align="stretch" spacing={3}>
|
||
{issues.map((issue) => (
|
||
<Box
|
||
key={`${issue.type}-${issue.message}`}
|
||
borderWidth="1px"
|
||
borderRadius="md"
|
||
borderColor={issue.severity === 'high' ? 'red.200' : 'yellow.200'}
|
||
bg={issue.severity === 'high' ? 'red.50' : 'yellow.50'}
|
||
p={3}
|
||
>
|
||
<Text fontWeight="medium">{issue.message}</Text>
|
||
<Text fontSize="sm" color="gray.600">
|
||
Сущность: {issue.affectedEntity} · Важность: {issue.severity}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
)}
|
||
</Box>
|
||
|
||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||
<HStack justify="space-between" mb={4} align="center">
|
||
<Heading size="sm">Метрики по заданиям</Heading>
|
||
<Select
|
||
size="sm"
|
||
width="200px"
|
||
value={difficultyFilter}
|
||
onChange={(event) => setDifficultyFilter(event.target.value as typeof difficultyFilter)}
|
||
>
|
||
<option value="all">Все сложности</option>
|
||
<option value="easy">Лёгкие</option>
|
||
<option value="medium">Средние</option>
|
||
<option value="hard">Сложные</option>
|
||
</Select>
|
||
</HStack>
|
||
|
||
{filteredTaskMetrics.length === 0 ? (
|
||
<Text color="gray.500">Недостаточно данных о проверках для построения аналитики.</Text>
|
||
) : (
|
||
<Box overflowX="auto">
|
||
<Table size="sm" variant="simple">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableColumnHeader>Задание</TableColumnHeader>
|
||
<TableColumnHeader textAlign="right">Попыток</TableColumnHeader>
|
||
<TableColumnHeader textAlign="right">Успешность</TableColumnHeader>
|
||
<TableColumnHeader textAlign="right">Сред. попыток</TableColumnHeader>
|
||
<TableColumnHeader textAlign="right">Сред. время (мин)</TableColumnHeader>
|
||
<TableColumnHeader>Сложность</TableColumnHeader>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredTaskMetrics.map((metric) => (
|
||
<TableRow key={metric.taskId}>
|
||
<TableCell>
|
||
<Text fontWeight="medium">{metric.title}</Text>
|
||
<Text fontSize="xs" color="gray.500">
|
||
ID: {metric.taskId}
|
||
</Text>
|
||
</TableCell>
|
||
<TableCell textAlign="right">{formatNumber(metric.attemptsCount)}</TableCell>
|
||
<TableCell textAlign="right">{formatNumber(Math.round(metric.successRate))}%</TableCell>
|
||
<TableCell textAlign="right">{formatNumber(Math.round(metric.avgAttempts * 10) / 10)}</TableCell>
|
||
<TableCell textAlign="right">{formatNumber(Math.round(msToMinutes(metric.avgTimeToComplete * 1000)))}</TableCell>
|
||
<TableCell>
|
||
<Badge colorScheme={metric.difficulty === 'hard' ? 'red' : metric.difficulty === 'medium' ? 'yellow' : 'green'}>
|
||
{metric.difficulty}
|
||
</Badge>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||
<Heading size="sm" mb={3}>
|
||
Цепочки заданий
|
||
</Heading>
|
||
|
||
{chains.length === 0 ? (
|
||
<Text color="gray.500">Цепочки не найдены. Создайте первое задание, чтобы начать.</Text>
|
||
) : (
|
||
<VStack align="stretch" spacing={3}>
|
||
{chains.map((chain: ChallengeChain) => (
|
||
<Box key={chain.id} borderWidth="1px" borderRadius="md" borderColor="gray.200" p={3}>
|
||
<Text fontWeight="medium">{chain.name}</Text>
|
||
<Text fontSize="sm" color="gray.600">
|
||
Заданий: {chain.tasks.length} · Последнее обновление:{' '}
|
||
{new Date(chain.updatedAt).toLocaleString()}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
)}
|
||
</Box>
|
||
|
||
<ABTestPanel />
|
||
</VStack>
|
||
</Box>
|
||
)
|
||
}
|
||
|