Files
challenge-pl/src/pages/admin/AdminDashboard.tsx
T

253 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}