diff --git a/scripts/git-commit.sh b/scripts/git-commit.sh new file mode 100644 index 0000000..1f2c40c --- /dev/null +++ b/scripts/git-commit.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: git-commit.sh message paths..." >&2 + exit 1 +fi + +MSG=$1 +shift +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$REPO_ROOT" + +git add "$@" +/usr/bin/git commit -m "$MSG" +git log -1 --oneline diff --git a/src/App.tsx b/src/App.tsx index e38828a..c8d2943 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,12 +3,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Spin } from 'antd'; import ProtectedRoute from './components/ProtectedRoute'; +import RoleGuard from './components/RoleGuard'; import AdminLayout from './layouts/AdminLayout'; import LoginPage from './pages/auth/LoginPage'; import ProfilePage from './pages/profile/ProfilePage'; import DashboardPage from './pages/dashboard/DashboardPage'; +import MonitoringPage from './pages/monitoring/MonitoringPage'; import UserListPage from './pages/users/UserListPage'; import UserDetailPage from './pages/users/UserDetailPage'; +import CalendarListPage from './pages/calendars/CalendarListPage'; +import CalendarDetailPage from './pages/calendars/CalendarDetailPage'; import EventListPage from './pages/events/EventListPage'; import EventDetailPage from './pages/events/EventDetailPage'; import ReportListPage from './pages/reports/ReportListPage'; @@ -48,24 +52,42 @@ const App: React.FC = () => { } /> }> }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + + }> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + + + }> + } /> + } /> + } /> + @@ -74,4 +96,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/api/calendarsApi.ts b/src/api/calendarsApi.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/MetricIndicator.tsx b/src/components/MetricIndicator.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index a8498fb..4d1a8f4 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,32 +1,33 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; -import { Spin } from 'antd'; -import { useAuthStore } from '../store/authStore'; - -interface Props { - allowedRoles?: string[]; -} - -const ProtectedRoute: React.FC = ({ allowedRoles }) => { - const { isAuthenticated, isInitialized, user } = useAuthStore(); - - if (!isInitialized) { - return ( -
- -
- ); - } - - if (!isAuthenticated) { - return ; - } - - if (allowedRoles && user && !allowedRoles.includes(user.role)) { - return ; - } - - return ; -}; - -export default ProtectedRoute; \ No newline at end of file +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { Spin } from 'antd'; +import { useAuthStore } from '../store/authStore'; +import ForbiddenPage from '../pages/errors/ForbiddenPage'; + +interface Props { + allowedRoles?: string[]; +} + +const ProtectedRoute: React.FC = ({ allowedRoles }) => { + const { isAuthenticated, isInitialized, user } = useAuthStore(); + + if (!isInitialized) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (allowedRoles && user && !allowedRoles.includes(user.role)) { + return ; + } + + return ; +}; + +export default ProtectedRoute; diff --git a/src/components/RoleGuard.tsx b/src/components/RoleGuard.tsx new file mode 100644 index 0000000..3306de9 --- /dev/null +++ b/src/components/RoleGuard.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; +import ForbiddenPage from '../pages/errors/ForbiddenPage'; +import type { AdminRole } from '../config/adminAccess'; + +interface Props { + allowedRoles?: AdminRole[]; +} + +/** Ограничение вложенных маршрутов по роли (после аутентификации). */ +const RoleGuard: React.FC = ({ allowedRoles }) => { + const user = useAuthStore((s) => s.user); + + if (allowedRoles && user && !allowedRoles.includes(user.role as AdminRole)) { + return ; + } + + return ; +}; + +export default RoleGuard; diff --git a/src/config/adminAccess.ts b/src/config/adminAccess.ts new file mode 100644 index 0000000..3a0974a --- /dev/null +++ b/src/config/adminAccess.ts @@ -0,0 +1,54 @@ +/** Роли доступа к разделам админки (согласовано с меню и маршрутами). */ +export type AdminRole = 'superadmin' | 'admin' | 'moderator' | 'support'; + +export const ROUTE_ACCESS: Record = { + '/dashboard': undefined, + '/profile': undefined, + '/monitoring': undefined, + '/users': ['superadmin', 'admin'], + '/calendars': ['superadmin', 'admin'], + '/events': ['superadmin', 'admin'], + '/reports': ['superadmin', 'admin', 'moderator'], + '/reviews': ['superadmin', 'admin', 'moderator'], + '/banned-words': ['superadmin', 'admin'], + '/tickets': ['superadmin', 'admin', 'support'], + '/subscriptions': ['superadmin', 'admin'], + '/admins': ['superadmin'], + '/audit': ['superadmin'], +}; + +export const MENU_ITEMS: Array<{ + key: string; + label: string; + roles?: AdminRole[]; +}> = [ + { key: '/dashboard', label: 'Дашборд' }, + { key: '/users', label: 'Пользователи', roles: ['superadmin', 'admin'] }, + { key: '/calendars', label: 'Календари', roles: ['superadmin', 'admin'] }, + { key: '/events', label: 'События', roles: ['superadmin', 'admin'] }, + { key: '/reports', label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] }, + { key: '/reviews', label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] }, + { key: '/banned-words', label: 'Бан-слова', roles: ['superadmin', 'admin'] }, + { key: '/tickets', label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] }, + { key: '/subscriptions', label: 'Подписки', roles: ['superadmin', 'admin'] }, + { key: '/admins', label: 'Администраторы', roles: ['superadmin'] }, + { key: '/audit', label: 'Аудит', roles: ['superadmin'] }, + { key: '/monitoring', label: 'Мониторинг' }, +]; + +export function canAccessRoute(pathname: string, role: string | undefined): boolean { + if (!role) return false; + const base = pathname.split('/').slice(0, 2).join('/') || '/'; + const allowed = ROUTE_ACCESS[base]; + if (allowed === undefined) { + return ROUTE_ACCESS[pathname] === undefined || ROUTE_ACCESS[pathname]!.includes(role as AdminRole); + } + return allowed.includes(role as AdminRole); +} + +export function filterMenuByRole( + items: T[], + role: string | undefined +): T[] { + return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole))); +} diff --git a/src/hooks/useAdminWebSocket.ts b/src/hooks/useAdminWebSocket.ts index 54b0d01..5f0e860 100644 --- a/src/hooks/useAdminWebSocket.ts +++ b/src/hooks/useAdminWebSocket.ts @@ -1,100 +1,143 @@ -import { useEffect, useRef } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useAuthStore } from '../store/authStore'; - -type WsMessage = { - type: 'report_created' | 'ticket_created'; - data?: { - report_id?: string; - ticket_id?: string; - target_type?: string; - target_id?: string; - reason?: string; - }; - status?: string; - channel?: string; - timestamp?: number; -}; - -export const useAdminWebSocket = () => { - const queryClient = useQueryClient(); - const accessToken = useAuthStore((s) => s.accessToken); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const reconnectTimeoutRef = useRef | null>(null); - const pingIntervalRef = useRef | null>(null); - const lastMessageTimestamp = useRef(0); - const wsRef = useRef(null); - - useEffect(() => { - if (!isAuthenticated || !accessToken) return; - - let isMounted = true; - const connect = () => { - const wsUrl = import.meta.env.DEV - ? `ws://localhost:5173/admin/ws?token=${accessToken}` - : `wss://${window.location.host}/admin/ws?token=${accessToken}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - if (!isMounted) return; - console.log('[WS] Connected'); - ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' })); - ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' })); - - pingIntervalRef.current = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ action: 'ping' })); - } - }, 30_000); - }; - - ws.onmessage = (event) => { - if (!isMounted) return; - try { - const msg: WsMessage = JSON.parse(event.data); - console.log('[WS] Message:', msg); - - if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) { - return; - } - if (msg.timestamp) { - lastMessageTimestamp.current = msg.timestamp; - } - - if (msg.type === 'report_created' || msg.type === 'ticket_created') { - window.dispatchEvent( - new CustomEvent('admin-ws-message', { detail: msg }) - ); - if (msg.type === 'report_created') { - queryClient.invalidateQueries({ queryKey: ['reports'] }); - } else if (msg.type === 'ticket_created') { - queryClient.invalidateQueries({ queryKey: ['tickets'] }); - queryClient.invalidateQueries({ queryKey: ['ticket-stats'] }); - } - } - } catch (e) { - console.warn('[WS] Failed to parse message:', e); - } - }; - - ws.onclose = () => { - if (!isMounted) return; - console.log('[WS] Disconnected, will reconnect in 5s'); - if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); - reconnectTimeoutRef.current = setTimeout(connect, 5000); - }; - - // Не вызываем ws.close() при ошибке, браузер закроет сокет сам - }; - - connect(); - - return () => { - isMounted = false; - if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); - if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); - // Никакого принудительного закрытия wsRef.current – браузер сам управится - }; - }, [isAuthenticated, accessToken, queryClient]); -}; \ No newline at end of file +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAuthStore } from '../store/authStore'; +import { useMetricsStore } from '../store/metricsStore'; + +type WsMessage = { + type: 'report_created' | 'ticket_created' | 'node_metric'; + data?: any; + status?: string; + channel?: string; + timestamp?: number; +}; + +export const useAdminWebSocket = () => { + const queryClient = useQueryClient(); + const accessToken = useAuthStore((s) => s.accessToken); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const setCurrentMetric = useMetricsStore((s) => s.setCurrent); + const addToAllHistory = useMetricsStore((s) => s.addToAllHistory); + + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const pingIntervalRef = useRef | null>(null); + const lastMessageTimestamp = useRef(0); + const shouldReconnectRef = useRef(true); + + const clearTimers = () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + }; + + const closeSocket = () => { + clearTimers(); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.onerror = null; + wsRef.current.onmessage = null; + wsRef.current.onopen = null; + if (wsRef.current.readyState === WebSocket.OPEN || + wsRef.current.readyState === WebSocket.CONNECTING) { + wsRef.current.close(); + } + wsRef.current = null; + } + }; + + useEffect(() => { + if (!isAuthenticated || !accessToken) { + shouldReconnectRef.current = false; + closeSocket(); + return; + } + + shouldReconnectRef.current = true; + let isMounted = true; + + const connect = () => { + if (!isMounted || !shouldReconnectRef.current) return; + + closeSocket(); + + const wsUrl = import.meta.env.DEV + ? `ws://localhost:5173/admin/ws?token=${accessToken}` + : `wss://${window.location.host}/admin/ws?token=${accessToken}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!isMounted || !shouldReconnectRef.current) return; + console.log('[WS] Connected'); + ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' })); + ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' })); + + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'ping' })); + } + }, 30_000); + }; + + ws.onmessage = (event) => { + if (!isMounted) return; + try { + const msg: WsMessage = JSON.parse(event.data); + console.log('[WS] Message:', msg); + + if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) { + return; + } + if (msg.timestamp) { + lastMessageTimestamp.current = msg.timestamp; + } + + if (msg.type === 'report_created' || msg.type === 'ticket_created') { + window.dispatchEvent( + new CustomEvent('admin-ws-message', { detail: msg }) + ); + if (msg.type === 'report_created') { + queryClient.invalidateQueries({ queryKey: ['reports'] }); + } else if (msg.type === 'ticket_created') { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + queryClient.invalidateQueries({ queryKey: ['ticket-stats'] }); + } + } else if (msg.type === 'node_metric') { + setCurrentMetric(msg.data); + addToAllHistory(msg.data); + } + } catch (e) { + console.warn('[WS] Failed to parse message:', e); + } + }; + + ws.onclose = (event) => { + if (!isMounted || !shouldReconnectRef.current) return; + console.log('[WS] Disconnected:', event.reason); + clearTimers(); + wsRef.current = null; + reconnectTimeoutRef.current = setTimeout(connect, 5000); + }; + + ws.onerror = (error) => { + if (!isMounted) return; + console.error('[WS] Error:', error); + ws.close(); + }; + }; + + connect(); + + return () => { + isMounted = false; + shouldReconnectRef.current = false; + closeSocket(); + }; + }, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]); +}; diff --git a/src/hooks/useCalendars.ts b/src/hooks/useCalendars.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 140d7d4..893adc7 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -1,281 +1,344 @@ -import React, { useEffect, useState } from 'react'; -import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge } from 'antd'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { useAuthStore } from '../store/authStore'; -import { useAdminWebSocket } from '../hooks/useAdminWebSocket'; -import { - DashboardOutlined, - UserOutlined, - CalendarOutlined, - WarningOutlined, - StarOutlined, - StopOutlined, - BugOutlined, - DollarOutlined, - TeamOutlined, - AuditOutlined, - LogoutOutlined, - SettingOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, -} from '@ant-design/icons'; - -const { Header, Sider, Content } = Layout; -const { Text } = Typography; - -const AdminLayout: React.FC = () => { - useAdminWebSocket(); - const navigate = useNavigate(); - const location = useLocation(); - const { user, logout } = useAuthStore(); - const { token: { colorBgContainer } } = theme.useToken(); - const [collapsed, setCollapsed] = useState(false); - - // Обработка WebSocket-уведомлений - useEffect(() => { - const handler = (event: CustomEvent) => { - const msg = event.detail; - if (msg.type === 'report_created') { - const { report_id, target_type, target_id, reason } = msg.data || {}; - notification.info({ - title: 'Новая жалоба', - description: ( - - Жалоба{' '} - {report_id ? ( - { - e.preventDefault(); - navigate(`/reports/${report_id}`); - }} - style={{ fontWeight: 600 }} - > - #{report_id} - - ) : ( - '#?' - )}{' '} - на {target_type || 'неизвестный тип'}{' '} - {target_id ? ( - { - e.preventDefault(); - navigate(`/${target_type}s/${target_id}`); - }} - style={{ fontWeight: 600 }} - > - {target_id} - - ) : ( - '?' - )}{' '} - {reason ? `(${reason})` : ''} - - ), - placement: 'topRight', - }); - } else if (msg.type === 'ticket_created') { - const { ticket_id } = msg.data || {}; - notification.info({ - title: 'Новый тикет', - description: ( - - Тикет{' '} - {ticket_id ? ( - { - e.preventDefault(); - navigate(`/tickets/${ticket_id}`); - }} - style={{ fontWeight: 600 }} - > - #{ticket_id} - - ) : ( - 'без ID' - )}{' '} - создан - - ), - placement: 'topRight', - }); - } - }; - - window.addEventListener('admin-ws-message', handler as EventListener); - return () => window.removeEventListener('admin-ws-message', handler as EventListener); - }, [navigate]); - - const menuItems = [ - { key: '/dashboard', icon: , label: 'Дашборд' }, - { key: '/users', icon: , label: 'Пользователи', roles: ['superadmin', 'admin'] }, - { key: '/events', icon: , label: 'События', roles: ['superadmin', 'admin'] }, - { key: '/reports', icon: , label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] }, - { key: '/reviews', icon: , label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] }, - { key: '/banned-words', icon: , label: 'Бан-слова', roles: ['superadmin', 'admin'] }, - { key: '/tickets', icon: , label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] }, - { key: '/subscriptions', icon: , label: 'Подписки', roles: ['superadmin', 'admin'] }, - { key: '/admins', icon: , label: 'Администраторы', roles: ['superadmin'] }, - { key: '/audit', icon: , label: 'Аудит', roles: ['superadmin'] }, - ]; - - const filteredMenu = menuItems.filter( - (item) => !item.roles || (user && item.roles.includes(user.role)) - ); - - const handleLogout = async () => { - await logout(); - navigate('/login'); - }; - - const getDisplayName = () => { - const nick = user?.nickname; - const email = user?.email; - if (nick && nick !== '-' && nick !== 'undefined') return nick; - if (email && email !== '-' && email !== 'undefined') return email; - return user?.id ?? '—'; - }; - - return ( - - -
- {collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE} -
- navigate(key)} - style={{ - flex: 1, - overflowY: 'auto', - overflowX: 'hidden', - marginBottom: 60, // отступ, чтобы меню не заходило под нижний блок - }} - /> -
- , - label: 'Мой профиль', - onClick: () => navigate('/profile'), - }, - { type: 'divider' }, - { - key: 'logout', - icon: , - label: 'Выйти', - onClick: handleLogout, - }, - ], - }} - trigger={['click']} - placement="topLeft" - > - - - -
- - -
- - - - - -
{/* Резерв */}
-
- - - -
- - ); -}; - +import React, { useEffect, useState } from 'react'; +import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip, Progress } from 'antd'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; +import { useAdminWebSocket } from '../hooks/useAdminWebSocket'; +import { useMetricsStore } from '../store/metricsStore'; +import { + DashboardOutlined, + UserOutlined, + CalendarOutlined, + WarningOutlined, + StarOutlined, + StopOutlined, + BugOutlined, + DollarOutlined, + TeamOutlined, + AuditOutlined, + LogoutOutlined, + SettingOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + CloudServerOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { MENU_ITEMS, filterMenuByRole } from '../config/adminAccess'; + +const { Header, Sider, Content } = Layout; +const { Text } = Typography; + +const NODE_STATS_THRESHOLD = 5; + +const AdminLayout: React.FC = () => { + useAdminWebSocket(); + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuthStore(); + const { token: { colorBgContainer } } = theme.useToken(); + const [collapsed, setCollapsed] = useState(false); + + const allHistory = useMetricsStore((s) => s.allHistory); + const current = useMetricsStore((s) => s.current); + const previous = useMetricsStore((s) => s.previous); + + useEffect(() => { + const handler = (event: CustomEvent) => { + const msg = event.detail; + if (msg.type === 'report_created') { + const { report_id, target_type, target_id, reason } = msg.data || {}; + notification.info({ + title: 'Новая жалоба', + description: ( + + Жалоба{' '} + {report_id ? ( + { e.preventDefault(); navigate(`/reports/${report_id}`); }} style={{ fontWeight: 600 }}> + #{report_id} + + ) : ('#?')}{' '} + на {target_type || 'неизвестный тип'}{' '} + {target_id ? ( + { e.preventDefault(); navigate(`/${target_type}s/${target_id}`); }} style={{ fontWeight: 600 }}> + {target_id} + + ) : ('?')}{' '} + {reason ? `(${reason})` : ''} + + ), + placement: 'topRight', + }); + } else if (msg.type === 'ticket_created') { + const { ticket_id } = msg.data || {}; + notification.info({ + title: 'Новый тикет', + description: ( + + Тикет{' '} + {ticket_id ? ( + { e.preventDefault(); navigate(`/tickets/${ticket_id}`); }} style={{ fontWeight: 600 }}> + #{ticket_id} + + ) : ('без ID')}{' '} + создан + + ), + placement: 'topRight', + }); + } + }; + window.addEventListener('admin-ws-message', handler as EventListener); + return () => window.removeEventListener('admin-ws-message', handler as EventListener); + }, [navigate]); + + const onlineNodes = React.useMemo(() => { + const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute'); + const recentMetrics = allHistory.filter(m => dayjs(m.timestamp).isAfter(cutoff)); + const nodes = new Set(recentMetrics.map(m => m.node)); + return Array.from(nodes); + }, [allHistory]); + + const latestByNode = React.useMemo(() => { + const map = new Map(); + const recent = allHistory.slice(-1000); + for (let i = recent.length - 1; i >= 0; i--) { + const m = recent[i]; + if (!map.has(m.node)) { + map.set(m.node, m); + } + } + return map; + }, [allHistory]); + + const menuIconByKey: Record = { + '/dashboard': , + '/users': , + '/calendars': , + '/events': , + '/reports': , + '/reviews': , + '/banned-words': , + '/tickets': , + '/subscriptions': , + '/admins': , + '/audit': , + '/monitoring': , + }; + + const menuItems = MENU_ITEMS.map((item) => ({ + key: item.key, + icon: menuIconByKey[item.key], + label: item.label, + roles: item.roles, + })); + + const filteredMenu = filterMenuByRole(menuItems, user?.role); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + const getDisplayName = () => { + const nick = user?.nickname; + const email = user?.email; + if (nick && nick !== '-' && nick !== 'undefined') return nick; + if (email && email !== '-' && email !== 'undefined') return email; + return user?.id ?? '—'; + }; + + const NodeIndicator: React.FC<{ node: string; stats: any; prevStats?: any }> = ({ node, stats, prevStats }) => { + const cpu = stats?.cpu_utilization ?? 0; + const totalMemory = (stats?.memory_total ?? 0) + (stats?.memory_available ?? 0); + const usedMemory = stats?.memory_total ?? 0; + const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0; + + const prevCpu = prevStats?.cpu_utilization; + const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0); + const prevUsed = prevStats?.memory_total ?? 0; + const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null; + + const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a'; + const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff'; + + return ( + +
{node}
+
CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}
+
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%) + {prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
+ + } + > +
+ ''} + size={48} + strokeColor={memColor} + railColor="#f0f0f0" + strokeWidth={6} + style={{ position: 'absolute', top: 0, left: 0 }} + /> + ''} + size={32} + strokeColor={cpuColor} + railColor="#f0f0f0" + strokeWidth={6} + style={{ position: 'absolute', top: 8, left: 8 }} + /> +
+ {cpu.toFixed(0)}% +
+
+
+ ); + }; + + return ( + + +
+ {collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE} +
+ navigate(key)} + style={{ + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + }} + /> +
+ , + label: 'Мой профиль', + onClick: () => navigate('/profile'), + }, + { type: 'divider' }, + { + key: 'logout', + icon: , + label: 'Выйти', + onClick: handleLogout, + }, + ], + }} + trigger={['click']} + placement="topLeft" + > + + +
+ + +
+ + + + + {onlineNodes.map(node => { + const stats = latestByNode.get(node); + const prevStats = previous?.node === node ? previous : undefined; + return ; + })} + +
{/* Резерв */}
+
+ + + +
+ + ); +}; + export default AdminLayout; \ No newline at end of file diff --git a/src/pages/calendars/CalendarDetailPage.tsx b/src/pages/calendars/CalendarDetailPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/calendars/CalendarListPage.tsx b/src/pages/calendars/CalendarListPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/errors/ForbiddenPage.tsx b/src/pages/errors/ForbiddenPage.tsx new file mode 100644 index 0000000..5d4666b --- /dev/null +++ b/src/pages/errors/ForbiddenPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Button, Result } from 'antd'; +import { useNavigate } from 'react-router-dom'; + +const ForbiddenPage: React.FC = () => { + const navigate = useNavigate(); + return ( + navigate('/dashboard')}> + На дашборд + + } + /> + ); +}; + +export default ForbiddenPage; diff --git a/src/pages/monitoring/MonitoringPage.tsx b/src/pages/monitoring/MonitoringPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/tickets/TicketListPage.tsx b/src/pages/tickets/TicketListPage.tsx index acd114c..0fc862b 100644 --- a/src/pages/tickets/TicketListPage.tsx +++ b/src/pages/tickets/TicketListPage.tsx @@ -1,158 +1,168 @@ -import React, { useState } from 'react'; -import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd'; -import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; -import { Link, useNavigate } from 'react-router-dom'; -import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets'; -import { useAdmin } from '../../hooks/useAdmins'; -import { Ticket, TicketListParams } from '../../types/api'; -import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; - -const TicketListPage: React.FC = () => { - const navigate = useNavigate(); - const [params, setParams] = useState({ limit: 20, offset: 0, sort: 'last_seen', order: 'desc' }); - const { data, isLoading } = useTickets(params); - const deleteTicket = useDeleteTicket(); - const { data: stats } = useTicketStats(); - - const isBadValue = (val: any) => - val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; - - // Резолвер администратора для колонки "Назначен" - const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => { - const { data: admin, isLoading: loading } = useAdmin(adminId || ''); - if (!adminId || adminId === '-' || adminId === 'undefined') return -; - if (loading) return ; - if (!admin) return {adminId}; - const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email; - return {!isBadValue(name) ? name : admin.id}; - }; - - const handleTableChange = ( - pagination: any, - _filters: any, - sorter: SorterResult | SorterResult[] - ) => { - const s = Array.isArray(sorter) ? sorter[0] : sorter; - setParams(prev => ({ - ...prev, - sort: s.field as string, - order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, - offset: ((pagination.current - 1) * pagination.pageSize) || 0, - limit: pagination.pageSize || prev.limit, - })); - }; - - const columns: ColumnsType = [ - { - title: , - key: 'detail', - width: 48, - align: 'center', - render: (_, record) => ( - -