Remove Keycloak integration and related authentication logic. Update dashboard to exclude admin page and simplify user management. Introduce new components for chain selection, header, and login form. Refactor main page to handle user authentication and task selection more effectively. Update API routes for challenge-related endpoints.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-11-04 11:06:34 +03:00
parent 9b511a8e1e
commit b2eaaebd7f
17 changed files with 329 additions and 792 deletions

View File

@ -33,7 +33,6 @@
"eslint-plugin-react": "^7.36.1", "eslint-plugin-react": "^7.36.1",
"express": "^4.19.2", "express": "^4.19.2",
"globals": "^15.9.0", "globals": "^15.9.0",
"keycloak-js": "^26.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",

View File

@ -12,7 +12,6 @@ import type {
SystemStats, SystemStats,
UserStats, UserStats,
} from '../types' } from '../types'
import { keycloak } from '../kc'
const normalizeBaseUrl = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url) const normalizeBaseUrl = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url)
const backendBaseUrl = normalizeBaseUrl(getConfigValue('challenge.api')) const backendBaseUrl = normalizeBaseUrl(getConfigValue('challenge.api'))
@ -22,23 +21,8 @@ export const api = createApi({
reducerPath: 'challengeApi', reducerPath: 'challengeApi',
baseQuery: fetchBaseQuery({ baseQuery: fetchBaseQuery({
baseUrl: challengeBaseUrl, baseUrl: challengeBaseUrl,
fetchFn: async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const response = await fetch(input, init)
if (response.status === 403) keycloak.login()
return response
},
prepareHeaders: (headers) => { prepareHeaders: (headers) => {
headers.set('Content-Type', 'application/json;charset=utf-8') headers.set('Content-Type', 'application/json;charset=utf-8')
if (keycloak?.token) {
headers.set('Authorization', `Bearer ${keycloak.token}`)
}
return headers return headers
}, },
}), }),

View File

@ -1,7 +0,0 @@
import Keycloak from 'keycloak-js'
export const keycloak = new Keycloak({
url: KC_URL,
realm: KC_REALM,
clientId: KC_CLIENT_ID,
})

View File

@ -1,10 +0,0 @@
import { createSlice } from '@reduxjs/toolkit'
import { UserData } from '../types'
export const userSlice = createSlice({
name: 'user',
initialState: null as UserData,
reducers: {
}
})

View File

@ -295,105 +295,3 @@ export interface ABTestMetrics {
satisfactionScore?: number satisfactionScore?: number
} }
/**
* Данные токена аутентификации
*/
interface TokenData {
/** Время истечения токена */
exp: number;
/** Время выдачи токена */
iat: number;
/** Время аутентификации */
auth_time: number;
/** Уникальный идентификатор токена */
jti: string;
/** Издатель токена */
iss: string;
/** Аудитория токена */
aud: string[];
/** Идентификатор пользователя */
sub: string;
/** Тип токена */
typ: string;
/** Идентификатор клиента */
azp: string;
/** Одноразовое значение */
nonce: string;
/** Состояние сессии */
session_state: string;
/** Уровень аутентификации */
acr: string;
/** Разрешенные источники */
"allowed-origins": string[];
/** Доступ к области */
realm_access: Realmaccess;
/** Доступ к ресурсам */
resource_access: Resourceaccess;
/** Область действия токена */
scope: string;
/** Идентификатор сессии */
sid: string;
/** Подтвержден ли email */
email_verified: boolean;
/** Полное имя пользователя */
name: string;
/** Предпочитаемое имя пользователя */
preferred_username: string;
/** Имя пользователя */
given_name: string;
/** Фамилия пользователя */
family_name: string;
/** Email пользователя */
email: string;
}
/**
* Доступ к ресурсам
*/
interface Resourceaccess {
/** Доступ к журналу */
journal: Realmaccess;
}
/**
* Доступ к области
*/
interface Realmaccess {
/** Роли пользователя */
roles: (string | "teacher")[];
}
/**
* Расширенные данные пользователя
*/
export interface UserData extends TokenData {
/** Идентификатор пользователя */
sub: string;
/** URL аватара пользователя */
gravatar: string;
/** Подтвержден ли email */
email_verified: boolean;
/** Дополнительные атрибуты пользователя */
attributes: Record<string, string[]>;
/** Полное имя пользователя */
name: string;
/** Предпочитаемое имя пользователя */
preferred_username: string;
/** Имя пользователя */
given_name: string;
/** Фамилия пользователя */
family_name: string;
/** Email пользователя */
email: string;
}
/**
* Базовый ответ API
*/
export type BaseResponse<Data> = {
/** Успешность операции */
success: boolean;
/** Данные ответа */
body: Data;
};

View File

@ -0,0 +1,117 @@
import React from 'react'
import {
Box,
Button,
Heading,
SimpleGrid,
Text,
VStack,
} from '@chakra-ui/react'
import type { ChallengeChain } from '../__data__/types'
import { useChallenge } from '../context/ChallengeContext'
interface ChainSelectorProps {
onSelectChain: (chain: ChallengeChain) => void
}
export const ChainSelector = ({ onSelectChain }: ChainSelectorProps) => {
const { chains, personalDashboard } = useChallenge()
if (chains.length === 0) {
return (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={8}
maxW="600px"
textAlign="center"
>
<Heading size="md" mb={4}>
Нет доступных цепочек
</Heading>
<Text color="gray.600">
Свяжитесь с преподавателем для получения доступа к заданиям.
</Text>
</Box>
</Box>
)
}
return (
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
<Box>
<Heading size="lg" mb={2}>
Выберите цепочку заданий
</Heading>
<Text color="gray.600">
Выберите цепочку заданий для начала работы. Вы можете проходить несколько цепочек параллельно.
</Text>
</Box>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{chains.map((chain) => {
const chainStats = personalDashboard?.activeChains.find(
(stat) => stat.chainId === chain.id
)
const completedTasks = chainStats?.completedTasks ?? 0
const progress = chainStats?.progress ?? 0
return (
<Box
key={chain.id}
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={6}
transition="all 0.2s"
_hover={{ shadow: 'md', borderColor: 'teal.300' }}
>
<VStack align="stretch" spacing={4} height="full">
<Box flex="1">
<Heading size="md" mb={3}>
{chain.name}
</Heading>
<Text color="gray.600" fontSize="sm" mb={3}>
Заданий в цепочке: {chain.tasks.length}
</Text>
{completedTasks > 0 && (
<Box>
<Text fontSize="sm" color="gray.500" mb={1}>
Прогресс: {completedTasks} / {chain.tasks.length}
</Text>
<Box bg="gray.100" borderRadius="full" height="8px" overflow="hidden">
<Box
bg="teal.500"
height="full"
width={`${progress}%`}
transition="width 0.3s"
/>
</Box>
</Box>
)}
</Box>
<Button
onClick={() => onSelectChain(chain)}
colorScheme="teal"
width="full"
>
{completedTasks > 0 ? 'Продолжить' : 'Начать'}
</Button>
</VStack>
</Box>
)
})}
</SimpleGrid>
</VStack>
</Box>
)
}

29
src/components/Header.tsx Normal file
View File

@ -0,0 +1,29 @@
import React from 'react'
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
import { useChallenge } from '../context/ChallengeContext'
export const Header = () => {
const { nickname, logout } = useChallenge()
if (!nickname) return null
return (
<Box bg="white" borderBottomWidth="1px" borderColor="gray.200" py={4} px={{ base: 4, md: 8 }}>
<Flex maxW="1200px" mx="auto" justify="space-between" align="center">
<Box>
<Heading size="md" color="teal.600">
Challenge Platform
</Heading>
<Text fontSize="sm" color="gray.600" mt={1}>
{nickname}
</Text>
</Box>
<Button onClick={logout} variant="ghost" size="sm">
Выйти
</Button>
</Flex>
</Box>
)
}

View File

@ -0,0 +1,99 @@
import React, { useState } from 'react'
import {
Box,
Button,
Heading,
Input,
Text,
VStack,
} from '@chakra-ui/react'
import { useChallenge } from '../context/ChallengeContext'
export const LoginForm = () => {
const [fullName, setFullName] = useState('')
const [error, setError] = useState('')
const { login, isAuthLoading } = useChallenge()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedName = fullName.trim()
if (!trimmedName) {
setError('Пожалуйста, введите ваше ФИО')
return
}
if (trimmedName.length < 3) {
setError('ФИО должно содержать минимум 3 символа')
return
}
try {
setError('')
await login(trimmedName)
} catch (err) {
setError('Произошла ошибка при входе. Попробуйте снова.')
console.error('Login error:', err)
}
}
return (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={8}
maxW="480px"
w="full"
>
<VStack spacing={6} align="stretch">
<Box textAlign="center">
<Heading size="lg" color="teal.600" mb={2}>
Challenge Platform
</Heading>
<Text color="gray.600">
Добро пожаловать! Введите ваше ФИО для начала работы
</Text>
</Box>
<form onSubmit={handleSubmit}>
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Ваше ФИО
</Text>
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Иванов Иван Иванович"
size="lg"
autoFocus
isInvalid={!!error}
/>
{error && (
<Text color="red.500" fontSize="sm" mt={2}>
{error}
</Text>
)}
</Box>
<Button
type="submit"
colorScheme="teal"
size="lg"
isLoading={isAuthLoading}
isDisabled={!fullName.trim()}
>
Войти
</Button>
</VStack>
</form>
</VStack>
</Box>
</Box>
)
}

View File

@ -1,200 +0,0 @@
import React, { useMemo, useState } from 'react'
import {
Box,
Button,
Grid,
GridItem,
Heading,
NumberInput,
NumberInputInput,
Stat,
StatHelpText,
StatLabel,
StatValueText,
Text,
VStack,
} from '@chakra-ui/react'
import type { ABTestMetrics } from '../../__data__/types'
import { compareVariants } from '../../utils/analytics'
interface VariantFormState {
submissionRate: number
completionRate: number
retryRate: number
timeToFirstSubmission: number
sessionDuration: number
satisfactionScore?: number
}
const createVariantState = (): VariantFormState => ({
submissionRate: 0,
completionRate: 0,
retryRate: 0,
timeToFirstSubmission: 0,
sessionDuration: 0,
satisfactionScore: undefined,
})
const buildMetrics = (variant: 'A' | 'B', state: VariantFormState): ABTestMetrics => ({
variant,
submissionRate: state.submissionRate,
completionRate: state.completionRate,
retryRate: state.retryRate,
timeToFirstSubmission: state.timeToFirstSubmission,
sessionDuration: state.sessionDuration,
satisfactionScore: state.satisfactionScore,
})
const MetricInput = ({
label,
value,
onChange,
suffix,
}: {
label: string
value: number
onChange: (value: number) => void
suffix?: string
}) => (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={1}>
{label}
</Text>
<NumberInput value={value} min={0} onChange={(_, val) => onChange(Number.isNaN(val) ? 0 : val)}>
<NumberInputInput />
</NumberInput>
{suffix && (
<StatHelpText fontSize="xs" color="gray.500">
{suffix}
</StatHelpText>
)}
</Box>
)
export const ABTestPanel = () => {
const [variantA, setVariantA] = useState<VariantFormState>(createVariantState)
const [variantB, setVariantB] = useState<VariantFormState>(createVariantState)
const [comparison, setComparison] = useState<ReturnType<typeof compareVariants> | null>(null)
const handleCompare = () => {
const metricsA = buildMetrics('A', variantA)
const metricsB = buildMetrics('B', variantB)
setComparison(compareVariants(metricsA, metricsB))
}
const hasData = useMemo(
() =>
Object.values(variantA).some((value) => value !== 0) ||
Object.values(variantB).some((value) => value !== 0),
[variantA, variantB],
)
return (
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
<Heading size="sm" mb={4}>
A/B тест: сравнение вариантов
</Heading>
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4} mb={4}>
<GridItem>
<Heading size="xs" mb={2}>
Вариант A
</Heading>
<VStack spacing={3} align="stretch">
<MetricInput
label="Submission Rate (%)"
value={variantA.submissionRate}
onChange={(value) => setVariantA((prev) => ({ ...prev, submissionRate: value }))}
suffix="Процент пользователей, отправивших хотя бы одно решение"
/>
<MetricInput
label="Completion Rate (%)"
value={variantA.completionRate}
onChange={(value) => setVariantA((prev) => ({ ...prev, completionRate: value }))}
/>
<MetricInput
label="Retry Rate (%)"
value={variantA.retryRate}
onChange={(value) => setVariantA((prev) => ({ ...prev, retryRate: value }))}
/>
<MetricInput
label="Time to First Submission (мин)"
value={variantA.timeToFirstSubmission}
onChange={(value) => setVariantA((prev) => ({ ...prev, timeToFirstSubmission: value }))}
/>
<MetricInput
label="Session Duration (мин)"
value={variantA.sessionDuration}
onChange={(value) => setVariantA((prev) => ({ ...prev, sessionDuration: value }))}
/>
</VStack>
</GridItem>
<GridItem>
<Heading size="xs" mb={2}>
Вариант B
</Heading>
<VStack spacing={3} align="stretch">
<MetricInput
label="Submission Rate (%)"
value={variantB.submissionRate}
onChange={(value) => setVariantB((prev) => ({ ...prev, submissionRate: value }))}
/>
<MetricInput
label="Completion Rate (%)"
value={variantB.completionRate}
onChange={(value) => setVariantB((prev) => ({ ...prev, completionRate: value }))}
/>
<MetricInput
label="Retry Rate (%)"
value={variantB.retryRate}
onChange={(value) => setVariantB((prev) => ({ ...prev, retryRate: value }))}
/>
<MetricInput
label="Time to First Submission (мин)"
value={variantB.timeToFirstSubmission}
onChange={(value) => setVariantB((prev) => ({ ...prev, timeToFirstSubmission: value }))}
/>
<MetricInput
label="Session Duration (мин)"
value={variantB.sessionDuration}
onChange={(value) => setVariantB((prev) => ({ ...prev, sessionDuration: value }))}
/>
</VStack>
</GridItem>
</Grid>
<Button onClick={handleCompare} colorScheme="teal" isDisabled={!hasData}>
Сравнить варианты
</Button>
{comparison && (
<Box mt={4} borderWidth="1px" borderRadius="md" borderColor="teal.200" bg="teal.50" p={4}>
<Heading size="xs" mb={2}>
Результат сравнения
</Heading>
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
<Stat>
<StatLabel>Δ Submission Rate</StatLabel>
<StatValueText>{comparison.submissionRateDiff.toFixed(1)}%</StatValueText>
<StatHelpText>Положительное значение рост у варианта B</StatHelpText>
</Stat>
<Stat>
<StatLabel>Δ Completion Rate</StatLabel>
<StatValueText>{comparison.completionRateDiff.toFixed(1)}%</StatValueText>
<StatHelpText>Положительное значение рост у варианта B</StatHelpText>
</Stat>
</Grid>
<Stat mt={4}>
<StatLabel>Победитель</StatLabel>
<StatValueText>Вариант {comparison.winner}</StatValueText>
<StatHelpText>Основано на сравнении коэффициента завершения</StatHelpText>
</Stat>
</Box>
)}
</Box>
)
}

View File

@ -2,7 +2,7 @@ import React, { Suspense } from 'react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { URLs } from './__data__/urls' import { URLs } from './__data__/urls'
import { AdminPage, MainPage } from './pages' import { MainPage } from './pages'
const PageWrapper = ({ children }: React.PropsWithChildren) => ( const PageWrapper = ({ children }: React.PropsWithChildren) => (
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense> <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
@ -19,16 +19,6 @@ export const Dashboard = () => {
</PageWrapper> </PageWrapper>
} }
/> />
{URLs.admin.isOn && (
<Route
path={URLs.admin.url}
element={
<PageWrapper>
<AdminPage />
</PageWrapper>
}
/>
)}
</Routes> </Routes>
) )
} }

View File

@ -5,9 +5,7 @@ import i18next from 'i18next'
import { i18nextReactInitConfig } from '@brojs/cli' import { i18nextReactInitConfig } from '@brojs/cli'
import App from './app' import App from './app'
import { keycloak } from "./__data__/kc"
import { createStore } from "./__data__/store" import { createStore } from "./__data__/store"
import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard'
i18next.t = i18next.t.bind(i18next) i18next.t = i18next.t.bind(i18next)
const i18nextPromise = i18nextReactInitConfig(i18next) const i18nextPromise = i18nextReactInitConfig(i18next)
@ -17,41 +15,7 @@ export default (props) => <App {...props} />
let rootElement: ReactDOM.Root let rootElement: ReactDOM.Root
export const mount = async (Component, element = document.getElementById('app')) => { export const mount = async (Component, element = document.getElementById('app')) => {
let user = null const store = createStore()
try {
if (isAuthLoopBlocked()) {
await i18nextPromise
rootElement = ReactDOM.createRoot(element)
rootElement.render(<button onClick={() => keycloak.login()} style={{
width: '100%',
height: '100%',
backgroundColor: 'red',
margin: 'auto'
}}>Login</button>)
return
}
recordAuthAttempt()
await keycloak.init({
onLoad: 'login-required'
// onLoad: 'check-sso'
})
const userInfo = await keycloak.loadUserInfo()
if (userInfo && keycloak.tokenParsed) {
user = { ...userInfo, ...keycloak.tokenParsed }
} else {
console.error('No userInfo or tokenParsed', userInfo, keycloak.tokenParsed)
}
clearAuthAttempts()
} catch (error) {
console.error('Failed to initialize adapter:', error)
// keycloak.login()
}
const store = createStore({ user })
await i18nextPromise await i18nextPromise
rootElement = ReactDOM.createRoot(element) rootElement = ReactDOM.createRoot(element)

View File

@ -1,252 +0,0 @@
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>
)
}

View File

@ -1,4 +0,0 @@
import { AdminDashboard } from './AdminDashboard'
export default AdminDashboard

View File

@ -1,4 +1,3 @@
import { lazy } from 'react' import { lazy } from 'react'
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main')) export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
export const AdminPage = lazy(() => import(/* webpackChunkName: 'admin' */'./admin'))

View File

@ -1,21 +1,22 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { import {
Alert,
AlertIndicator,
Box, Box,
Button,
Flex, Flex,
Heading, Heading,
SimpleGrid,
Text, Text,
VStack,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { Alert } from '@chakra-ui/react/alert'
import type { ChallengeChain, ChallengeTask } from '../../__data__/types' import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
import { useChallenge } from '../../context/ChallengeContext' import { useChallenge } from '../../context/ChallengeContext'
import { MobileDashboard, PersonalDashboard, TaskWorkspace } from '../../components/personal' import { TaskWorkspace } from '../../components/personal'
import { Header } from '../../components/Header'
import { LoginForm } from '../../components/LoginForm'
import { ChainSelector } from '../../components/ChainSelector'
export const MainPage = () => { export const MainPage = () => {
const { nickname, personalDashboard, chains, eventEmitter } = useChallenge() const { nickname, eventEmitter } = useChallenge()
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null) const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null) const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
const [isOffline, setIsOffline] = useState(() => const [isOffline, setIsOffline] = useState(() =>
@ -24,18 +25,9 @@ export const MainPage = () => {
const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null) const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null)
const notificationTimeoutRef = useRef<number | null>(null) const notificationTimeoutRef = useRef<number | null>(null)
const isTaskSelected = Boolean(selectedChain && selectedTask) const handleSelectChain = (chain: ChallengeChain) => {
const pageTitle = useMemo(() => {
if (nickname) {
return `Привет, ${nickname}!`
}
return 'Challenge Platform'
}, [nickname])
const handleSelectTask = (task: ChallengeTask, chain: ChallengeChain) => {
setSelectedChain(chain) setSelectedChain(chain)
setSelectedTask(task) setSelectedTask(chain.tasks[0])
} }
const handleTaskComplete = () => { const handleTaskComplete = () => {
@ -46,21 +38,20 @@ export const MainPage = () => {
if (nextTask) { if (nextTask) {
setSelectedTask(nextTask) setSelectedTask(nextTask)
} else { } else {
// Цепочка завершена, возвращаемся к выбору
setSelectedChain(null) setSelectedChain(null)
setSelectedTask(null) setSelectedTask(null)
} }
} }
const fallbackTask = useMemo(() => { const handleBackToChains = () => {
if (selectedTask) return selectedTask setSelectedChain(null)
if (selectedChain) return selectedChain.tasks[0] setSelectedTask(null)
if (chains.length) return chains[0].tasks[0] }
return null
}, [chains, selectedChain, selectedTask])
useEffect(() => { useEffect(() => {
const unsubscribe = eventEmitter.on('submission_completed', (event) => { const unsubscribe = eventEmitter.on('submission_completed', (event) => {
const submission = (event.data as any)?.submission const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission
const accepted = submission?.status === 'accepted' const accepted = submission?.status === 'accepted'
const title = accepted ? 'Задание принято' : 'Задание требует доработки' const title = accepted ? 'Задание принято' : 'Задание требует доработки'
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
@ -94,65 +85,63 @@ export const MainPage = () => {
} }
}, []) }, [])
// Если пользователь не авторизован, показываем форму входа
if (!nickname) {
return <LoginForm />
}
// Если цепочка не выбрана, показываем селектор цепочек
if (!selectedChain) {
return (
<>
<Header />
<ChainSelector onSelectChain={handleSelectChain} />
</>
)
}
// Показываем выбранную цепочку и задания
return ( return (
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}> <>
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto"> <Header />
{notification && ( <Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
<Alert status={notification.status} borderRadius="md"> <Box maxW="1200px" mx="auto">
<AlertIndicator /> {notification && (
<Box ml={3}> <Alert.Root status={notification.status} borderRadius="md" mb={4}>
<Text fontWeight="semibold">{notification.title}</Text> <Alert.Indicator />
{notification.description && <Text fontSize="sm">{notification.description}</Text>} <Box ml={3}>
<Text fontWeight="semibold">{notification.title}</Text>
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
</Box>
</Alert.Root>
)}
{isOffline && (
<Alert.Root status="warning" borderRadius="md" mb={4}>
<Alert.Indicator />
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
</Alert.Root>
)}
<Flex justify="space-between" align="center" mb={8}>
<Box>
<Heading size="lg" mb={1}>
{selectedChain.name}
</Heading>
<Text color="gray.600">
Задание {selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1} из {selectedChain.tasks.length}
</Text>
</Box> </Box>
</Alert> <Button onClick={handleBackToChains} variant="outline">
)} Вернуться к цепочкам
</Button>
</Flex>
{isOffline && ( {selectedTask && (
<Alert status="warning" borderRadius="md"> <TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
<AlertIndicator /> )}
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
</Alert>
)}
<Box>
<Heading size="lg" mb={1}>
{pageTitle}
</Heading>
<Text color="gray.600">Следите за прогрессом и отправляйте решения в одном месте.</Text>
</Box> </Box>
</Box>
<MobileDashboard /> </>
<SimpleGrid columns={{ base: 1, xl: 2 }} spacing={6} alignItems="start">
<PersonalDashboard onSelectTask={handleSelectTask} />
<Box position="sticky" top={8} height="fit-content">
<Heading size="md" mb={4}>
Рабочее пространство
</Heading>
{personalDashboard && isTaskSelected && selectedTask && selectedChain ? (
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
) : fallbackTask ? (
<TaskWorkspace task={fallbackTask} onTaskComplete={handleTaskComplete} />
) : (
<Flex
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
bg="white"
height="260px"
align="center"
justify="center"
>
<Text color="gray.500" textAlign="center" px={6}>
Нет доступных заданий. Попросите преподавателя открыть цепочку или попробуйте позже.
</Text>
</Flex>
)}
</Box>
</SimpleGrid>
</VStack>
</Box>
) )
} }

View File

@ -1,59 +0,0 @@
const STORAGE_KEY = 'auth.loop.attempts'
const DEFAULT_WINDOW_MS = 2_000
const DEFAULT_THRESHOLD = 2
const readAttempts = (): number[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter((n) => typeof n === 'number')
return []
} catch {
return []
}
}
const writeAttempts = (attempts: number[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts))
} catch {
// ignore
}
}
export const recordAuthAttempt = () => {
const now = Date.now()
const attempts = readAttempts()
const updated = [...attempts, now].slice(-10)
writeAttempts(updated)
}
export const clearAuthAttempts = () => {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// ignore
}
}
export const getRecentAttempts = (windowMs: number = DEFAULT_WINDOW_MS): number[] => {
const now = Date.now()
return readAttempts().filter((ts) => now - ts <= windowMs)
}
export const isAuthLoopBlocked = (
windowMs: number = DEFAULT_WINDOW_MS,
threshold: number = DEFAULT_THRESHOLD,
): boolean => {
return getRecentAttempts(windowMs).length >= threshold
}
export const AUTH_LOOP_GUARD = {
recordAuthAttempt,
clearAuthAttempts,
getRecentAttempts,
isAuthLoopBlocked,
}

View File

@ -22,15 +22,16 @@ router.use((req, res, next) => {
next() next()
}) })
router.post('/auth', (req, res) => { // Challenge API endpoints
router.post('/challenge/auth', (req, res) => {
res.json(readJson('auth.json')) res.json(readJson('auth.json'))
}) })
router.get('/chains', (req, res) => { router.get('/challenge/chains', (req, res) => {
res.json(readJson('chains.json')) res.json(readJson('chains.json'))
}) })
router.get('/chain/:id', (req, res) => { router.get('/challenge/chain/:id', (req, res) => {
const chains = readJson('chains.json') const chains = readJson('chains.json')
const chain = chains.find((item) => item.id === req.params.id || item._id === req.params.id) const chain = chains.find((item) => item.id === req.params.id || item._id === req.params.id)
if (!chain) { if (!chain) {
@ -39,7 +40,7 @@ router.get('/chain/:id', (req, res) => {
return res.json(chain) return res.json(chain)
}) })
router.get('/task/:id', (req, res) => { router.get('/challenge/task/:id', (req, res) => {
const chains = readJson('chains.json') const chains = readJson('chains.json')
const task = chains const task = chains
.flatMap((chain) => chain.tasks || []) .flatMap((chain) => chain.tasks || [])
@ -52,12 +53,12 @@ router.get('/task/:id', (req, res) => {
return res.json(task) return res.json(task)
}) })
router.post('/submit', (req, res) => { router.post('/challenge/submit', (req, res) => {
const response = readJson('submit.json') const response = readJson('submit.json')
res.json(response) res.json(response)
}) })
router.get('/check-status/:queueId', (req, res) => { router.get('/challenge/check-status/:queueId', (req, res) => {
const statuses = readJson('queue-status.json') const statuses = readJson('queue-status.json')
const status = statuses[req.params.queueId] const status = statuses[req.params.queueId]
@ -68,7 +69,7 @@ router.get('/check-status/:queueId', (req, res) => {
return res.json(status) return res.json(status)
}) })
router.get('/user/:userId/stats', (req, res) => { router.get('/challenge/user/:userId/stats', (req, res) => {
const statsMap = readJson('user-stats.json') const statsMap = readJson('user-stats.json')
const stats = statsMap[req.params.userId] const stats = statsMap[req.params.userId]
@ -79,17 +80,17 @@ router.get('/user/:userId/stats', (req, res) => {
return res.json(stats) return res.json(stats)
}) })
router.get('/user/:userId/submissions', (req, res) => { router.get('/challenge/user/:userId/submissions', (req, res) => {
const submissionsMap = readJson('user-submissions.json') const submissionsMap = readJson('user-submissions.json')
const submissions = submissionsMap[req.params.userId] || [] const submissions = submissionsMap[req.params.userId] || []
return res.json(submissions) return res.json(submissions)
}) })
router.get('/stats', (req, res) => { router.get('/challenge/stats', (req, res) => {
res.json(readJson('system-stats.json')) res.json(readJson('system-stats.json'))
}) })
router.get('/submissions', (req, res) => { router.get('/challenge/submissions', (req, res) => {
res.json(readJson('submissions.json')) res.json(readJson('submissions.json'))
}) })