Refactor project structure and integrate Redux for state management. Update configuration files for new project name and add Webpack plugins for environment variables. Implement user authentication with Keycloak and create a context for challenge management. Add various components for user interaction, including dashboards and task workspaces. Enhance API integration and add error handling utilities. Introduce analytics and polling mechanisms for improved user experience.
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user