Route guards, WS lifecycle, ticket stats. Refs EventHub/EventHubFrontAdmin#3

This commit is contained in:
2026-07-07 21:03:46 +03:00
parent 896e3bcd35
commit ecfa1a66bb
18 changed files with 1331 additions and 955 deletions
+16
View File
@@ -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
+40 -18
View File
@@ -3,12 +3,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Spin } from 'antd'; import { Spin } from 'antd';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import RoleGuard from './components/RoleGuard';
import AdminLayout from './layouts/AdminLayout'; import AdminLayout from './layouts/AdminLayout';
import LoginPage from './pages/auth/LoginPage'; import LoginPage from './pages/auth/LoginPage';
import ProfilePage from './pages/profile/ProfilePage'; import ProfilePage from './pages/profile/ProfilePage';
import DashboardPage from './pages/dashboard/DashboardPage'; import DashboardPage from './pages/dashboard/DashboardPage';
import MonitoringPage from './pages/monitoring/MonitoringPage';
import UserListPage from './pages/users/UserListPage'; import UserListPage from './pages/users/UserListPage';
import UserDetailPage from './pages/users/UserDetailPage'; 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 EventListPage from './pages/events/EventListPage';
import EventDetailPage from './pages/events/EventDetailPage'; import EventDetailPage from './pages/events/EventDetailPage';
import ReportListPage from './pages/reports/ReportListPage'; import ReportListPage from './pages/reports/ReportListPage';
@@ -48,24 +52,42 @@ const App: React.FC = () => {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AdminLayout />}> <Route element={<AdminLayout />}>
<Route path="/" element={<Navigate to="/dashboard" />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route element={<RoleGuard />}>
<Route path="/users" element={<UserListPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users/:id" element={<UserDetailPage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/events" element={<EventListPage />} /> <Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/events/:id" element={<EventDetailPage />} /> </Route>
<Route path="/reports" element={<ReportListPage />} />
<Route path="/reports/:id" element={<ReportDetailPage />} /> <Route element={<RoleGuard allowedRoles={['superadmin', 'admin']} />}>
<Route path="/reviews" element={<ReviewListPage />} /> <Route path="/users" element={<UserListPage />} />
<Route path="/reviews/:id" element={<ReviewDetailPage />} /> <Route path="/users/:id" element={<UserDetailPage />} />
<Route path="/banned-words" element={<BannedWordsPage />} /> <Route path="/calendars" element={<CalendarListPage />} />
<Route path="/tickets" element={<TicketListPage />} /> <Route path="/calendars/:id" element={<CalendarDetailPage />} />
<Route path="/tickets/:id" element={<TicketDetailPage />} /> <Route path="/events" element={<EventListPage />} />
<Route path="/subscriptions" element={<SubscriptionListPage />} /> <Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/admins" element={<AdminListPage />} /> <Route path="/banned-words" element={<BannedWordsPage />} />
<Route path="/admins/:id" element={<AdminDetailPage />} /> <Route path="/subscriptions" element={<SubscriptionListPage />} />
<Route path="/audit" element={<AuditPage />} /> </Route>
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
<Route path="/reports" element={<ReportListPage />} />
<Route path="/reports/:id" element={<ReportDetailPage />} />
<Route path="/reviews" element={<ReviewListPage />} />
<Route path="/reviews/:id" element={<ReviewDetailPage />} />
</Route>
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
<Route path="/tickets" element={<TicketListPage />} />
<Route path="/tickets/:id" element={<TicketDetailPage />} />
</Route>
<Route element={<RoleGuard allowedRoles={['superadmin']} />}>
<Route path="/admins" element={<AdminListPage />} />
<Route path="/admins/:id" element={<AdminDetailPage />} />
<Route path="/audit" element={<AuditPage />} />
</Route>
</Route> </Route>
</Route> </Route>
</Routes> </Routes>
View File
View File
+2 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Navigate, Outlet } from 'react-router-dom'; import { Navigate, Outlet } from 'react-router-dom';
import { Spin } from 'antd'; import { Spin } from 'antd';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import ForbiddenPage from '../pages/errors/ForbiddenPage';
interface Props { interface Props {
allowedRoles?: string[]; allowedRoles?: string[];
@@ -23,7 +24,7 @@ const ProtectedRoute: React.FC<Props> = ({ allowedRoles }) => {
} }
if (allowedRoles && user && !allowedRoles.includes(user.role)) { if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />; return <ForbiddenPage />;
} }
return <Outlet />; return <Outlet />;
+22
View File
@@ -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<Props> = ({ allowedRoles }) => {
const user = useAuthStore((s) => s.user);
if (allowedRoles && user && !allowedRoles.includes(user.role as AdminRole)) {
return <ForbiddenPage />;
}
return <Outlet />;
};
export default RoleGuard;
+54
View File
@@ -0,0 +1,54 @@
/** Роли доступа к разделам админки (согласовано с меню и маршрутами). */
export type AdminRole = 'superadmin' | 'admin' | 'moderator' | 'support';
export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
'/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<T extends { roles?: AdminRole[] }>(
items: T[],
role: string | undefined
): T[] {
return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole)));
}
+63 -20
View File
@@ -1,16 +1,11 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useMetricsStore } from '../store/metricsStore';
type WsMessage = { type WsMessage = {
type: 'report_created' | 'ticket_created'; type: 'report_created' | 'ticket_created' | 'node_metric';
data?: { data?: any;
report_id?: string;
ticket_id?: string;
target_type?: string;
target_id?: string;
reason?: string;
};
status?: string; status?: string;
channel?: string; channel?: string;
timestamp?: number; timestamp?: number;
@@ -20,24 +15,65 @@ export const useAdminWebSocket = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastMessageTimestamp = useRef<number>(0); const lastMessageTimestamp = useRef<number>(0);
const wsRef = useRef<WebSocket | null>(null); 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(() => { useEffect(() => {
if (!isAuthenticated || !accessToken) return; if (!isAuthenticated || !accessToken) {
shouldReconnectRef.current = false;
closeSocket();
return;
}
shouldReconnectRef.current = true;
let isMounted = true; let isMounted = true;
const connect = () => { const connect = () => {
if (!isMounted || !shouldReconnectRef.current) return;
closeSocket();
const wsUrl = import.meta.env.DEV const wsUrl = import.meta.env.DEV
? `ws://localhost:5173/admin/ws?token=${accessToken}` ? `ws://localhost:5173/admin/ws?token=${accessToken}`
: `wss://${window.location.host}/admin/ws?token=${accessToken}`; : `wss://${window.location.host}/admin/ws?token=${accessToken}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
if (!isMounted) return; if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Connected'); console.log('[WS] Connected');
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' })); ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' })); ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
@@ -72,29 +108,36 @@ export const useAdminWebSocket = () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); queryClient.invalidateQueries({ queryKey: ['tickets'] });
queryClient.invalidateQueries({ queryKey: ['ticket-stats'] }); queryClient.invalidateQueries({ queryKey: ['ticket-stats'] });
} }
} else if (msg.type === 'node_metric') {
setCurrentMetric(msg.data);
addToAllHistory(msg.data);
} }
} catch (e) { } catch (e) {
console.warn('[WS] Failed to parse message:', e); console.warn('[WS] Failed to parse message:', e);
} }
}; };
ws.onclose = () => { ws.onclose = (event) => {
if (!isMounted) return; if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Disconnected, will reconnect in 5s'); console.log('[WS] Disconnected:', event.reason);
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); clearTimers();
wsRef.current = null;
reconnectTimeoutRef.current = setTimeout(connect, 5000); reconnectTimeoutRef.current = setTimeout(connect, 5000);
}; };
// Не вызываем ws.close() при ошибке, браузер закроет сокет сам ws.onerror = (error) => {
if (!isMounted) return;
console.error('[WS] Error:', error);
ws.close();
};
}; };
connect(); connect();
return () => { return () => {
isMounted = false; isMounted = false;
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); shouldReconnectRef.current = false;
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); closeSocket();
// Никакого принудительного закрытия wsRef.current – браузер сам управится
}; };
}, [isAuthenticated, accessToken, queryClient]); }, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]);
}; };
View File
+126 -63
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge } from 'antd'; import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip, Progress } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useAdminWebSocket } from '../hooks/useAdminWebSocket'; import { useAdminWebSocket } from '../hooks/useAdminWebSocket';
import { useMetricsStore } from '../store/metricsStore';
import { import {
DashboardOutlined, DashboardOutlined,
UserOutlined, UserOutlined,
@@ -18,11 +19,16 @@ import {
SettingOutlined, SettingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
CloudServerOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs';
import { MENU_ITEMS, filterMenuByRole } from '../config/adminAccess';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
const NODE_STATS_THRESHOLD = 5;
const AdminLayout: React.FC = () => { const AdminLayout: React.FC = () => {
useAdminWebSocket(); useAdminWebSocket();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -31,7 +37,10 @@ const AdminLayout: React.FC = () => {
const { token: { colorBgContainer } } = theme.useToken(); const { token: { colorBgContainer } } = theme.useToken();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
// Обработка WebSocket-уведомлений const allHistory = useMetricsStore((s) => s.allHistory);
const current = useMetricsStore((s) => s.current);
const previous = useMetricsStore((s) => s.previous);
useEffect(() => { useEffect(() => {
const handler = (event: CustomEvent) => { const handler = (event: CustomEvent) => {
const msg = event.detail; const msg = event.detail;
@@ -43,34 +52,16 @@ const AdminLayout: React.FC = () => {
<span> <span>
Жалоба{' '} Жалоба{' '}
{report_id ? ( {report_id ? (
<a <a href={`/reports/${report_id}`} onClick={(e) => { e.preventDefault(); navigate(`/reports/${report_id}`); }} style={{ fontWeight: 600 }}>
href={`/reports/${report_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/reports/${report_id}`);
}}
style={{ fontWeight: 600 }}
>
#{report_id} #{report_id}
</a> </a>
) : ( ) : ('#?')}{' '}
'#?'
)}{' '}
на {target_type || 'неизвестный тип'}{' '} на {target_type || 'неизвестный тип'}{' '}
{target_id ? ( {target_id ? (
<a <a href={`/${target_type}s/${target_id}`} onClick={(e) => { e.preventDefault(); navigate(`/${target_type}s/${target_id}`); }} style={{ fontWeight: 600 }}>
href={`/${target_type}s/${target_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/${target_type}s/${target_id}`);
}}
style={{ fontWeight: 600 }}
>
{target_id} {target_id}
</a> </a>
) : ( ) : ('?')}{' '}
'?'
)}{' '}
{reason ? `(${reason})` : ''} {reason ? `(${reason})` : ''}
</span> </span>
), ),
@@ -84,19 +75,10 @@ const AdminLayout: React.FC = () => {
<span> <span>
Тикет{' '} Тикет{' '}
{ticket_id ? ( {ticket_id ? (
<a <a href={`/tickets/${ticket_id}`} onClick={(e) => { e.preventDefault(); navigate(`/tickets/${ticket_id}`); }} style={{ fontWeight: 600 }}>
href={`/tickets/${ticket_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/tickets/${ticket_id}`);
}}
style={{ fontWeight: 600 }}
>
#{ticket_id} #{ticket_id}
</a> </a>
) : ( ) : ('без ID')}{' '}
'без ID'
)}{' '}
создан создан
</span> </span>
), ),
@@ -104,27 +86,52 @@ const AdminLayout: React.FC = () => {
}); });
} }
}; };
window.addEventListener('admin-ws-message', handler as EventListener); window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener); return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate]); }, [navigate]);
const menuItems = [ const onlineNodes = React.useMemo(() => {
{ key: '/dashboard', icon: <DashboardOutlined />, label: 'Дашборд' }, const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');
{ key: '/users', icon: <UserOutlined />, label: 'Пользователи', roles: ['superadmin', 'admin'] }, const recentMetrics = allHistory.filter(m => dayjs(m.timestamp).isAfter(cutoff));
{ key: '/events', icon: <CalendarOutlined />, label: 'События', roles: ['superadmin', 'admin'] }, const nodes = new Set(recentMetrics.map(m => m.node));
{ key: '/reports', icon: <WarningOutlined />, label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] }, return Array.from(nodes);
{ key: '/reviews', icon: <StarOutlined />, label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] }, }, [allHistory]);
{ key: '/banned-words', icon: <StopOutlined />, label: 'Бан-слова', roles: ['superadmin', 'admin'] },
{ key: '/tickets', icon: <BugOutlined />, label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] },
{ key: '/subscriptions', icon: <DollarOutlined />, label: 'Подписки', roles: ['superadmin', 'admin'] },
{ key: '/admins', icon: <TeamOutlined />, label: 'Администраторы', roles: ['superadmin'] },
{ key: '/audit', icon: <AuditOutlined />, label: 'Аудит', roles: ['superadmin'] },
];
const filteredMenu = menuItems.filter( const latestByNode = React.useMemo(() => {
(item) => !item.roles || (user && item.roles.includes(user.role)) const map = new Map<string, typeof current>();
); 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<string, React.ReactNode> = {
'/dashboard': <DashboardOutlined />,
'/users': <UserOutlined />,
'/calendars': <CalendarOutlined />,
'/events': <CalendarOutlined />,
'/reports': <WarningOutlined />,
'/reviews': <StarOutlined />,
'/banned-words': <StopOutlined />,
'/tickets': <BugOutlined />,
'/subscriptions': <DollarOutlined />,
'/admins': <TeamOutlined />,
'/audit': <AuditOutlined />,
'/monitoring': <CloudServerOutlined />,
};
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 () => { const handleLogout = async () => {
await logout(); await logout();
@@ -139,6 +146,60 @@ const AdminLayout: React.FC = () => {
return user?.id ?? '—'; 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 (
<Tooltip
title={
<div>
<div>{node}</div>
<div>CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}</div>
<div>Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%)
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}</div>
</div>
}
>
<div style={{ position: 'relative', width: 48, height: 48, marginRight: 12 }}>
<Progress
type="circle"
percent={memoryPercent}
format={() => ''}
size={48}
strokeColor={memColor}
railColor="#f0f0f0"
strokeWidth={6}
style={{ position: 'absolute', top: 0, left: 0 }}
/>
<Progress
type="circle"
percent={cpu}
format={() => ''}
size={32}
strokeColor={cpuColor}
railColor="#f0f0f0"
strokeWidth={6}
style={{ position: 'absolute', top: 8, left: 8 }}
/>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: 10, fontWeight: 600 }}>
{cpu.toFixed(0)}%
</div>
</div>
</Tooltip>
);
};
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Sider <Sider
@@ -147,11 +208,10 @@ const AdminLayout: React.FC = () => {
onCollapse={setCollapsed} onCollapse={setCollapsed}
trigger={null} trigger={null}
style={{ style={{
position: 'relative', // чтобы абсолютный блок считался от него
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
height: '100vh', height: '100vh',
// position: 'sticky', position: 'sticky',
top: 0, top: 0,
left: 0, left: 0,
}} }}
@@ -169,21 +229,17 @@ const AdminLayout: React.FC = () => {
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
overflowX: 'hidden', overflowX: 'hidden',
marginBottom: 60, // отступ, чтобы меню не заходило под нижний блок
}} }}
/> />
<div <div
style={{ style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between', justifyContent: collapsed ? 'center' : 'space-between',
padding: '12px 24px', padding: '12px 24px',
borderTop: '1px solid rgba(255,255,255,0.1)', borderTop: '1px solid rgba(255,255,255,0.1)',
background: '#001529', // цвет сайдбара Ant Design background: 'rgba(0,0,0,0.1)',
marginTop: 'auto',
flexShrink: 0, flexShrink: 0,
}} }}
> >
@@ -236,7 +292,6 @@ const AdminLayout: React.FC = () => {
)} )}
</Button> </Button>
</Dropdown> </Dropdown>
<Button <Button
type="text" type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
@@ -264,9 +319,17 @@ const AdminLayout: React.FC = () => {
}} }}
> >
<Space size="large"> <Space size="large">
<Badge status="success" text="Онлайн: 12" /> <Tooltip title={`Онлайн-ноды: ${onlineNodes.join(', ')}`}>
<Badge status="processing" text="Заказов сегодня: 5" /> <Badge
<Badge status="warning" text="Тикетов: 2" /> status="processing"
text={`Ноды: ${onlineNodes.length}`}
/>
</Tooltip>
{onlineNodes.map(node => {
const stats = latestByNode.get(node);
const prevStats = previous?.node === node ? previous : undefined;
return <NodeIndicator key={node} node={node} stats={stats} prevStats={prevStats} />;
})}
</Space> </Space>
<div>{/* Резерв */}</div> <div>{/* Резерв */}</div>
</Header> </Header>
+21
View File
@@ -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 (
<Result
status="403"
title="403"
subTitle="У вас нет доступа к этому разделу."
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
На дашборд
</Button>
}
/>
);
};
export default ForbiddenPage;
+15 -5
View File
@@ -116,26 +116,36 @@ const TicketListPage: React.FC = () => {
<h2>Тикеты</h2> <h2>Тикеты</h2>
{stats && ( {stats && (
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6} md={4}>
<Card> <Card>
<Statistic title="Открыто" value={stats.open} prefix={<BugOutlined />} styles={{ content: { color: 'red' } }} /> <Statistic title="Всего тикетов" value={stats.total_tickets} prefix={<BugOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Открыто" value={stats.open} styles={{ content: { color: 'red' } }} />
</Card>
</Col>
<Col xs={12} sm={6} md={4}>
<Card> <Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} /> <Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6} md={4}>
<Card> <Card>
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} /> <Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6} md={4}>
<Card> <Card>
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} /> <Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6} md={4}>
<Card>
<Statistic title="Всего ошибок" value={stats.total_errors} />
</Card>
</Col>
</Row> </Row>
)} )}
<Table <Table
+2
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { Admin } from '../types/api'; import { Admin } from '../types/api';
import { authApi } from '../api/authApi'; import { authApi } from '../api/authApi';
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/constants'; import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/constants';
import { useMetricsStore } from './metricsStore';
interface AuthState { interface AuthState {
user: Admin | null; user: Admin | null;
@@ -37,6 +38,7 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: async () => { logout: async () => {
localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY);
useMetricsStore.getState().reset();
set({ set({
user: null, user: null,
accessToken: null, accessToken: null,
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import dayjs from 'dayjs';
export interface NodeMetric {
active_sessions: number;
io_in: number;
io_out: number;
memory_atom: number;
memory_binary: number;
memory_ets: number;
memory_processes: number;
memory_total: number;
mnesia_commits: number;
mnesia_failures: number;
node: string;
process_count: number;
run_queue: number;
table_sizes: Record<string, number>;
timestamp: string;
ws_connections: number;
cpu_utilization: number;
memory_available: number;
}
interface MetricsState {
current: NodeMetric | null;
previous: NodeMetric | null;
allHistory: NodeMetric[];
visibleHistory: NodeMetric[];
setCurrent: (metric: NodeMetric) => void;
setAllHistory: (history: NodeMetric[]) => void;
addToAllHistory: (metric: NodeMetric) => void;
filterVisibleHistory: (minutes: number) => void;
reset: () => void;
}
const MAX_HISTORY = 500; // жесткий лимит, чтобы избежать ошибок памяти
export const useMetricsStore = create<MetricsState>((set, get) => ({
current: null,
previous: null,
allHistory: [],
visibleHistory: [],
setCurrent: (metric) =>
set((state) => ({
previous: state.current,
current: metric,
})),
setAllHistory: (history) =>
set({
allHistory: history.slice(-MAX_HISTORY),
visibleHistory: history.slice(-MAX_HISTORY),
}),
addToAllHistory: (metric) =>
set((state) => {
const newAll = [...state.allHistory, metric].slice(-MAX_HISTORY);
return { allHistory: newAll };
}),
filterVisibleHistory: (minutes) => {
const { allHistory } = get();
if (allHistory.length === 0) return;
const now = dayjs();
const from = now.subtract(minutes, 'minute');
const filtered = allHistory.filter((m) => {
const t = dayjs(m.timestamp);
return t.isAfter(from);
});
let step = 1;
if (filtered.length > 300) step = Math.ceil(filtered.length / 300);
const thinned = filtered.filter((_, i) => i % step === 0);
set({ visibleHistory: thinned });
},
reset: () =>
set({
current: null,
previous: null,
allHistory: [],
visibleHistory: [],
}),
}));
+34 -1
View File
@@ -1,3 +1,35 @@
// ===================== Calendar =====================
export interface Calendar {
id: string;
title: string;
description: string;
type: 'personal' | 'commercial';
category: string;
owner_id: string;
status: 'active' | 'frozen' | 'deleted';
reason: string | null;
tags: string[];
color: string;
image_url: string;
rating_avg: number;
rating_count: number;
short_name: string;
confirmation: string;
settings: object | null;
created_at: string;
updated_at: string;
}
export interface CalendarListParams {
q?: string;
status?: 'active' | 'frozen' | 'deleted';
type?: 'personal' | 'commercial';
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Event (Событие) ===================== // ===================== Event (Событие) =====================
export interface Event { export interface Event {
id: string; id: string;
@@ -156,7 +188,8 @@ export interface TicketStats {
in_progress: number; in_progress: number;
resolved: number; resolved: number;
closed: number; closed: number;
total: number; total_tickets: number;
total_errors: number;
} }
// ===================== Subscription (Подписка) ===================== // ===================== Subscription (Подписка) =====================