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
+41 -19
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>
@@ -74,4 +96,4 @@ const App: React.FC = () => {
); );
}; };
export default App; export default App;
View File
View File
+33 -32
View File
@@ -1,32 +1,33 @@
import React from 'react'; 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 {
allowedRoles?: string[]; interface Props {
} allowedRoles?: string[];
}
const ProtectedRoute: React.FC<Props> = ({ allowedRoles }) => {
const { isAuthenticated, isInitialized, user } = useAuthStore(); const ProtectedRoute: React.FC<Props> = ({ allowedRoles }) => {
const { isAuthenticated, isInitialized, user } = useAuthStore();
if (!isInitialized) {
return ( if (!isInitialized) {
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> return (
<Spin size="large" /> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
</div> <Spin size="large" />
); </div>
} );
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />; if (!isAuthenticated) {
} return <Navigate to="/login" replace />;
}
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />; if (allowedRoles && user && !allowedRoles.includes(user.role)) {
} return <ForbiddenPage />;
}
return <Outlet />;
}; return <Outlet />;
};
export default ProtectedRoute;
export default ProtectedRoute;
+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)));
}
+143 -100
View File
@@ -1,100 +1,143 @@
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: 'report_created' | 'ticket_created'; type WsMessage = {
data?: { type: 'report_created' | 'ticket_created' | 'node_metric';
report_id?: string; data?: any;
ticket_id?: string; status?: string;
target_type?: string; channel?: string;
target_id?: string; timestamp?: number;
reason?: string; };
};
status?: string; export const useAdminWebSocket = () => {
channel?: string; const queryClient = useQueryClient();
timestamp?: number; const accessToken = useAuthStore((s) => s.accessToken);
}; const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
export const useAdminWebSocket = () => { const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken); const wsRef = useRef<WebSocket | null>(null);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); 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 shouldReconnectRef = useRef(true);
const wsRef = useRef<WebSocket | null>(null);
const clearTimers = () => {
useEffect(() => { if (reconnectTimeoutRef.current) {
if (!isAuthenticated || !accessToken) return; clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
let isMounted = true; }
const connect = () => { if (pingIntervalRef.current) {
const wsUrl = import.meta.env.DEV clearInterval(pingIntervalRef.current);
? `ws://localhost:5173/admin/ws?token=${accessToken}` pingIntervalRef.current = null;
: `wss://${window.location.host}/admin/ws?token=${accessToken}`; }
const ws = new WebSocket(wsUrl); };
wsRef.current = ws;
const closeSocket = () => {
ws.onopen = () => { clearTimers();
if (!isMounted) return; if (wsRef.current) {
console.log('[WS] Connected'); wsRef.current.onclose = null;
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' })); wsRef.current.onerror = null;
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' })); wsRef.current.onmessage = null;
wsRef.current.onopen = null;
pingIntervalRef.current = setInterval(() => { if (wsRef.current.readyState === WebSocket.OPEN ||
if (ws.readyState === WebSocket.OPEN) { wsRef.current.readyState === WebSocket.CONNECTING) {
ws.send(JSON.stringify({ action: 'ping' })); wsRef.current.close();
} }
}, 30_000); wsRef.current = null;
}; }
};
ws.onmessage = (event) => {
if (!isMounted) return; useEffect(() => {
try { if (!isAuthenticated || !accessToken) {
const msg: WsMessage = JSON.parse(event.data); shouldReconnectRef.current = false;
console.log('[WS] Message:', msg); closeSocket();
return;
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) { }
return;
} shouldReconnectRef.current = true;
if (msg.timestamp) { let isMounted = true;
lastMessageTimestamp.current = msg.timestamp;
} const connect = () => {
if (!isMounted || !shouldReconnectRef.current) return;
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
window.dispatchEvent( closeSocket();
new CustomEvent('admin-ws-message', { detail: msg })
); const wsUrl = import.meta.env.DEV
if (msg.type === 'report_created') { ? `ws://localhost:5173/admin/ws?token=${accessToken}`
queryClient.invalidateQueries({ queryKey: ['reports'] }); : `wss://${window.location.host}/admin/ws?token=${accessToken}`;
} else if (msg.type === 'ticket_created') {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); const ws = new WebSocket(wsUrl);
queryClient.invalidateQueries({ queryKey: ['ticket-stats'] }); wsRef.current = ws;
}
} ws.onopen = () => {
} catch (e) { if (!isMounted || !shouldReconnectRef.current) return;
console.warn('[WS] Failed to parse message:', e); console.log('[WS] Connected');
} ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
}; ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
ws.onclose = () => { pingIntervalRef.current = setInterval(() => {
if (!isMounted) return; if (ws.readyState === WebSocket.OPEN) {
console.log('[WS] Disconnected, will reconnect in 5s'); ws.send(JSON.stringify({ action: 'ping' }));
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); }
reconnectTimeoutRef.current = setTimeout(connect, 5000); }, 30_000);
}; };
// Не вызываем ws.close() при ошибке, браузер закроет сокет сам ws.onmessage = (event) => {
}; if (!isMounted) return;
try {
connect(); const msg: WsMessage = JSON.parse(event.data);
console.log('[WS] Message:', msg);
return () => {
isMounted = false; if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); return;
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); }
// Никакого принудительного закрытия wsRef.current – браузер сам управится if (msg.timestamp) {
}; lastMessageTimestamp.current = msg.timestamp;
}, [isAuthenticated, accessToken, queryClient]); }
};
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]);
};
View File
+343 -280
View File
@@ -1,281 +1,344 @@
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 { import { useMetricsStore } from '../store/metricsStore';
DashboardOutlined, import {
UserOutlined, DashboardOutlined,
CalendarOutlined, UserOutlined,
WarningOutlined, CalendarOutlined,
StarOutlined, WarningOutlined,
StopOutlined, StarOutlined,
BugOutlined, StopOutlined,
DollarOutlined, BugOutlined,
TeamOutlined, DollarOutlined,
AuditOutlined, TeamOutlined,
LogoutOutlined, AuditOutlined,
SettingOutlined, LogoutOutlined,
MenuFoldOutlined, SettingOutlined,
MenuUnfoldOutlined, MenuFoldOutlined,
} from '@ant-design/icons'; MenuUnfoldOutlined,
CloudServerOutlined,
const { Header, Sider, Content } = Layout; } from '@ant-design/icons';
const { Text } = Typography; import dayjs from 'dayjs';
import { MENU_ITEMS, filterMenuByRole } from '../config/adminAccess';
const AdminLayout: React.FC = () => {
useAdminWebSocket(); const { Header, Sider, Content } = Layout;
const navigate = useNavigate(); const { Text } = Typography;
const location = useLocation();
const { user, logout } = useAuthStore(); const NODE_STATS_THRESHOLD = 5;
const { token: { colorBgContainer } } = theme.useToken();
const [collapsed, setCollapsed] = useState(false); const AdminLayout: React.FC = () => {
useAdminWebSocket();
// Обработка WebSocket-уведомлений const navigate = useNavigate();
useEffect(() => { const location = useLocation();
const handler = (event: CustomEvent) => { const { user, logout } = useAuthStore();
const msg = event.detail; const { token: { colorBgContainer } } = theme.useToken();
if (msg.type === 'report_created') { const [collapsed, setCollapsed] = useState(false);
const { report_id, target_type, target_id, reason } = msg.data || {};
notification.info({ const allHistory = useMetricsStore((s) => s.allHistory);
title: 'Новая жалоба', const current = useMetricsStore((s) => s.current);
description: ( const previous = useMetricsStore((s) => s.previous);
<span>
Жалоба{' '} useEffect(() => {
{report_id ? ( const handler = (event: CustomEvent) => {
<a const msg = event.detail;
href={`/reports/${report_id}`} if (msg.type === 'report_created') {
onClick={(e) => { const { report_id, target_type, target_id, reason } = msg.data || {};
e.preventDefault(); notification.info({
navigate(`/reports/${report_id}`); title: 'Новая жалоба',
}} description: (
style={{ fontWeight: 600 }} <span>
> Жалоба{' '}
#{report_id} {report_id ? (
</a> <a href={`/reports/${report_id}`} onClick={(e) => { e.preventDefault(); navigate(`/reports/${report_id}`); }} style={{ fontWeight: 600 }}>
) : ( #{report_id}
'#?' </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}`} {target_id}
onClick={(e) => { </a>
e.preventDefault(); ) : ('?')}{' '}
navigate(`/${target_type}s/${target_id}`); {reason ? `(${reason})` : ''}
}} </span>
style={{ fontWeight: 600 }} ),
> placement: 'topRight',
{target_id} });
</a> } else if (msg.type === 'ticket_created') {
) : ( const { ticket_id } = msg.data || {};
'?' notification.info({
)}{' '} title: 'Новый тикет',
{reason ? `(${reason})` : ''} description: (
</span> <span>
), Тикет{' '}
placement: 'topRight', {ticket_id ? (
}); <a href={`/tickets/${ticket_id}`} onClick={(e) => { e.preventDefault(); navigate(`/tickets/${ticket_id}`); }} style={{ fontWeight: 600 }}>
} else if (msg.type === 'ticket_created') { #{ticket_id}
const { ticket_id } = msg.data || {}; </a>
notification.info({ ) : ('без ID')}{' '}
title: 'Новый тикет', создан
description: ( </span>
<span> ),
Тикет{' '} placement: 'topRight',
{ticket_id ? ( });
<a }
href={`/tickets/${ticket_id}`} };
onClick={(e) => { window.addEventListener('admin-ws-message', handler as EventListener);
e.preventDefault(); return () => window.removeEventListener('admin-ws-message', handler as EventListener);
navigate(`/tickets/${ticket_id}`); }, [navigate]);
}}
style={{ fontWeight: 600 }} const onlineNodes = React.useMemo(() => {
> const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');
#{ticket_id} const recentMetrics = allHistory.filter(m => dayjs(m.timestamp).isAfter(cutoff));
</a> const nodes = new Set(recentMetrics.map(m => m.node));
) : ( return Array.from(nodes);
'без ID' }, [allHistory]);
)}{' '}
создан const latestByNode = React.useMemo(() => {
</span> const map = new Map<string, typeof current>();
), const recent = allHistory.slice(-1000);
placement: 'topRight', for (let i = recent.length - 1; i >= 0; i--) {
}); const m = recent[i];
} if (!map.has(m.node)) {
}; map.set(m.node, m);
}
window.addEventListener('admin-ws-message', handler as EventListener); }
return () => window.removeEventListener('admin-ws-message', handler as EventListener); return map;
}, [navigate]); }, [allHistory]);
const menuItems = [ const menuIconByKey: Record<string, React.ReactNode> = {
{ key: '/dashboard', icon: <DashboardOutlined />, label: 'Дашборд' }, '/dashboard': <DashboardOutlined />,
{ key: '/users', icon: <UserOutlined />, label: 'Пользователи', roles: ['superadmin', 'admin'] }, '/users': <UserOutlined />,
{ key: '/events', icon: <CalendarOutlined />, label: 'События', roles: ['superadmin', 'admin'] }, '/calendars': <CalendarOutlined />,
{ key: '/reports', icon: <WarningOutlined />, label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] }, '/events': <CalendarOutlined />,
{ key: '/reviews', icon: <StarOutlined />, label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] }, '/reports': <WarningOutlined />,
{ key: '/banned-words', icon: <StopOutlined />, label: 'Бан-слова', roles: ['superadmin', 'admin'] }, '/reviews': <StarOutlined />,
{ key: '/tickets', icon: <BugOutlined />, label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] }, '/banned-words': <StopOutlined />,
{ key: '/subscriptions', icon: <DollarOutlined />, label: 'Подписки', roles: ['superadmin', 'admin'] }, '/tickets': <BugOutlined />,
{ key: '/admins', icon: <TeamOutlined />, label: 'Администраторы', roles: ['superadmin'] }, '/subscriptions': <DollarOutlined />,
{ key: '/audit', icon: <AuditOutlined />, label: 'Аудит', roles: ['superadmin'] }, '/admins': <TeamOutlined />,
]; '/audit': <AuditOutlined />,
'/monitoring': <CloudServerOutlined />,
const filteredMenu = menuItems.filter( };
(item) => !item.roles || (user && item.roles.includes(user.role))
); const menuItems = MENU_ITEMS.map((item) => ({
key: item.key,
const handleLogout = async () => { icon: menuIconByKey[item.key],
await logout(); label: item.label,
navigate('/login'); roles: item.roles,
}; }));
const getDisplayName = () => { const filteredMenu = filterMenuByRole(menuItems, user?.role);
const nick = user?.nickname;
const email = user?.email; const handleLogout = async () => {
if (nick && nick !== '-' && nick !== 'undefined') return nick; await logout();
if (email && email !== '-' && email !== 'undefined') return email; navigate('/login');
return user?.id ?? '—'; };
};
const getDisplayName = () => {
return ( const nick = user?.nickname;
<Layout style={{ minHeight: '100vh' }}> const email = user?.email;
<Sider if (nick && nick !== '-' && nick !== 'undefined') return nick;
collapsible if (email && email !== '-' && email !== 'undefined') return email;
collapsed={collapsed} return user?.id ?? '—';
onCollapse={setCollapsed} };
trigger={null}
style={{ const NodeIndicator: React.FC<{ node: string; stats: any; prevStats?: any }> = ({ node, stats, prevStats }) => {
position: 'relative', // чтобы абсолютный блок считался от него const cpu = stats?.cpu_utilization ?? 0;
display: 'flex', const totalMemory = (stats?.memory_total ?? 0) + (stats?.memory_available ?? 0);
flexDirection: 'column', const usedMemory = stats?.memory_total ?? 0;
height: '100vh', const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0;
// position: 'sticky',
top: 0, const prevCpu = prevStats?.cpu_utilization;
left: 0, const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0);
}} const prevUsed = prevStats?.memory_total ?? 0;
> const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
<div style={{ height: 32, margin: 16, color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
{collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE} const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a';
</div> const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff';
<Menu
theme="dark" return (
mode="inline" <Tooltip
selectedKeys={[location.pathname]} title={
items={filteredMenu} <div>
onClick={({ key }) => navigate(key)} <div>{node}</div>
style={{ <div>CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}</div>
flex: 1, <div>Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%)
overflowY: 'auto', {prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}</div>
overflowX: 'hidden', </div>
marginBottom: 60, // отступ, чтобы меню не заходило под нижний блок }
}} >
/> <div style={{ position: 'relative', width: 48, height: 48, marginRight: 12 }}>
<div <Progress
style={{ type="circle"
position: 'absolute', percent={memoryPercent}
bottom: 0, format={() => ''}
left: 0, size={48}
right: 0, strokeColor={memColor}
display: 'flex', railColor="#f0f0f0"
alignItems: 'center', strokeWidth={6}
justifyContent: collapsed ? 'center' : 'space-between', style={{ position: 'absolute', top: 0, left: 0 }}
padding: '12px 24px', />
borderTop: '1px solid rgba(255,255,255,0.1)', <Progress
background: '#001529', // цвет сайдбара Ant Design type="circle"
flexShrink: 0, percent={cpu}
}} format={() => ''}
> size={32}
<Dropdown strokeColor={cpuColor}
menu={{ railColor="#f0f0f0"
items: [ strokeWidth={6}
{ style={{ position: 'absolute', top: 8, left: 8 }}
key: 'profile', />
icon: <SettingOutlined />, <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: 10, fontWeight: 600 }}>
label: 'Мой профиль', {cpu.toFixed(0)}%
onClick: () => navigate('/profile'), </div>
}, </div>
{ type: 'divider' }, </Tooltip>
{ );
key: 'logout', };
icon: <LogoutOutlined />,
label: 'Выйти', return (
onClick: handleLogout, <Layout style={{ minHeight: '100vh' }}>
}, <Sider
], collapsible
}} collapsed={collapsed}
trigger={['click']} onCollapse={setCollapsed}
placement="topLeft" trigger={null}
> style={{
<Button display: 'flex',
type="text" flexDirection: 'column',
style={{ height: '100vh',
color: 'white', position: 'sticky',
display: 'flex', top: 0,
alignItems: 'center', left: 0,
padding: '4px 0', }}
border: 'none', >
width: collapsed ? 'auto' : '100%', <div style={{ height: 32, margin: 16, color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
}} {collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE}
> </div>
<Avatar icon={<UserOutlined />} src={user?.avatar_url} size="small" /> <Menu
{!collapsed && ( theme="dark"
<Text mode="inline"
style={{ selectedKeys={[location.pathname]}
color: 'white', items={filteredMenu}
marginLeft: 8, onClick={({ key }) => navigate(key)}
maxWidth: 100, style={{
overflow: 'hidden', flex: 1,
textOverflow: 'ellipsis', overflowY: 'auto',
}} overflowX: 'hidden',
ellipsis }}
> />
{getDisplayName()} <div
</Text> style={{
)} display: 'flex',
</Button> alignItems: 'center',
</Dropdown> justifyContent: collapsed ? 'center' : 'space-between',
padding: '12px 24px',
<Button borderTop: '1px solid rgba(255,255,255,0.1)',
type="text" background: 'rgba(0,0,0,0.1)',
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} marginTop: 'auto',
onClick={() => setCollapsed(!collapsed)} flexShrink: 0,
style={{ }}
color: 'white', >
fontSize: '16px', <Dropdown
width: 32, menu={{
height: 32, items: [
display: 'flex', {
alignItems: 'center', key: 'profile',
justifyContent: 'center', icon: <SettingOutlined />,
}} label: 'Мой профиль',
/> onClick: () => navigate('/profile'),
</div> },
</Sider> { type: 'divider' },
<Layout> {
<Header key: 'logout',
style={{ icon: <LogoutOutlined />,
padding: '0 24px', label: 'Выйти',
background: colorBgContainer, onClick: handleLogout,
display: 'flex', },
alignItems: 'center', ],
justifyContent: 'space-between', }}
}} trigger={['click']}
> placement="topLeft"
<Space size="large"> >
<Badge status="success" text="Онлайн: 12" /> <Button
<Badge status="processing" text="Заказов сегодня: 5" /> type="text"
<Badge status="warning" text="Тикетов: 2" /> style={{
</Space> color: 'white',
<div>{/* Резерв */}</div> display: 'flex',
</Header> alignItems: 'center',
<Content style={{ margin: 24 }}> padding: '4px 0',
<Outlet /> border: 'none',
</Content> width: collapsed ? 'auto' : '100%',
</Layout> }}
</Layout> >
); <Avatar icon={<UserOutlined />} src={user?.avatar_url} size="small" />
}; {!collapsed && (
<Text
style={{
color: 'white',
marginLeft: 8,
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
ellipsis
>
{getDisplayName()}
</Text>
)}
</Button>
</Dropdown>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
color: 'white',
fontSize: '16px',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</div>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space size="large">
<Tooltip title={`Онлайн-ноды: ${onlineNodes.join(', ')}`}>
<Badge
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>
<div>{/* Резерв */}</div>
</Header>
<Content style={{ margin: 24 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout; export default AdminLayout;
+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;
+167 -157
View File
@@ -1,158 +1,168 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd'; 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 { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets'; import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets';
import { useAdmin } from '../../hooks/useAdmins'; import { useAdmin } from '../../hooks/useAdmins';
import { Ticket, TicketListParams } from '../../types/api'; import { Ticket, TicketListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const TicketListPage: React.FC = () => { const TicketListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: 20, offset: 0, sort: 'last_seen', order: 'desc' }); const [params, setParams] = useState<TicketListParams>({ limit: 20, offset: 0, sort: 'last_seen', order: 'desc' });
const { data, isLoading } = useTickets(params); const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket(); const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats(); const { data: stats } = useTicketStats();
const isBadValue = (val: any) => const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
// Резолвер администратора для колонки "Назначен" // Резолвер администратора для колонки "Назначен"
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => { const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || ''); const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>; if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Spin size="small" />; if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>; if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email; const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>; return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
}; };
const handleTableChange = ( const handleTableChange = (
pagination: any, pagination: any,
_filters: any, _filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[] sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => ({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, offset: ((pagination.current - 1) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit, limit: pagination.pageSize || prev.limit,
})); }));
}; };
const columns: ColumnsType<Ticket> = [ const columns: ColumnsType<Ticket> = [
{ {
title: <InfoCircleOutlined />, title: <InfoCircleOutlined />,
key: 'detail', key: 'detail',
width: 48, width: 48,
align: 'center', align: 'center',
render: (_, record) => ( render: (_, record) => (
<Tooltip title="Открыть страницу тикета"> <Tooltip title="Открыть страницу тикета">
<Button <Button
icon={<InfoCircleOutlined />} icon={<InfoCircleOutlined />}
size="small" size="small"
type="link" type="link"
onClick={() => navigate(`/tickets/${record.id}`)} onClick={() => navigate(`/tickets/${record.id}`)}
/> />
</Tooltip> </Tooltip>
), ),
}, },
{ {
title: 'Ошибка', title: 'Ошибка',
dataIndex: 'error_message', dataIndex: 'error_message',
key: 'error_message', key: 'error_message',
ellipsis: true, ellipsis: true,
sorter: true, sorter: true,
render: (text: string) => isBadValue(text) ? '-' : text, render: (text: string) => isBadValue(text) ? '-' : text,
}, },
{ {
title: 'Статус', title: 'Статус',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 100, width: 100,
sorter: true, sorter: true,
render: (status: string) => { render: (status: string) => {
const color = const color =
status === 'open' ? 'red' : status === 'open' ? 'red' :
status === 'in_progress' ? 'blue' : status === 'in_progress' ? 'blue' :
status === 'resolved' ? 'green' : 'default'; status === 'resolved' ? 'green' : 'default';
return <Tag color={color}>{status}</Tag>; return <Tag color={color}>{status}</Tag>;
}, },
}, },
{ {
title: 'Назначен', title: 'Назначен',
dataIndex: 'assigned_to', dataIndex: 'assigned_to',
key: 'assigned_to', key: 'assigned_to',
render: (assigned: string) => <AssignedCell adminId={assigned} />, render: (assigned: string) => <AssignedCell adminId={assigned} />,
}, },
{ title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true }, { title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true },
{ title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true }, { title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true },
{ title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true }, { title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true },
{ {
title: 'Действия', title: 'Действия',
key: 'actions', key: 'actions',
width: 80, width: 80,
render: (_, record) => ( render: (_, record) => (
<Popconfirm <Popconfirm
title="Удалить тикет?" title="Удалить тикет?"
onConfirm={() => deleteTicket.mutate(record.id)} onConfirm={() => deleteTicket.mutate(record.id)}
> >
<Tooltip title="Удалить"> <Tooltip title="Удалить">
<Button <Button
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
size="small" size="small"
danger danger
/> />
</Tooltip> </Tooltip>
</Popconfirm> </Popconfirm>
), ),
}, },
]; ];
return ( return (
<div> <div>
<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> <Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} /> <Statistic title="Открыто" value={stats.open} styles={{ content: { color: 'red' } }} />
</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.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.closed} prefix={<CloseCircleOutlined />} /> <Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card> </Card>
</Col> </Col>
</Row> <Col xs={12} sm={6} md={4}>
)} <Card>
<Table <Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
columns={columns} </Card>
dataSource={data?.data} </Col>
rowKey="id" <Col xs={12} sm={6} md={4}>
loading={isLoading} <Card>
onChange={handleTableChange} <Statistic title="Всего ошибок" value={stats.total_errors} />
pagination={{ </Card>
total: data?.total, </Col>
current: (params.offset || 0) / (params.limit || 20) + 1, </Row>
pageSize: params.limit || 20, )}
showSizeChanger: false, <Table
}} columns={columns}
/> dataSource={data?.data}
</div> rowKey="id"
); loading={isLoading}
}; onChange={handleTableChange}
pagination={{
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/>
</div>
);
};
export default TicketListPage; export default TicketListPage;
+71 -69
View File
@@ -1,70 +1,72 @@
import { create } from 'zustand'; 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 {
user: Admin | null; interface AuthState {
accessToken: string | null; user: Admin | null;
refreshToken: string | null; accessToken: string | null;
isAuthenticated: boolean; refreshToken: string | null;
isInitialized: boolean; isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>; isInitialized: boolean;
logout: () => Promise<void>; login: (email: string, password: string) => Promise<void>;
checkAuth: () => Promise<void>; logout: () => Promise<void>;
} checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null, export const useAuthStore = create<AuthState>((set) => ({
accessToken: localStorage.getItem(ACCESS_TOKEN_KEY), user: null,
refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY), accessToken: localStorage.getItem(ACCESS_TOKEN_KEY),
isAuthenticated: false, refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY),
isInitialized: false, isAuthenticated: false,
isInitialized: false,
login: async (email: string, password: string) => {
const { token, refresh_token, user } = await authApi.login(email, password); login: async (email: string, password: string) => {
localStorage.setItem(ACCESS_TOKEN_KEY, token); const { token, refresh_token, user } = await authApi.login(email, password);
localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token); localStorage.setItem(ACCESS_TOKEN_KEY, token);
set({ localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token);
accessToken: token, set({
refreshToken: refresh_token, accessToken: token,
user, refreshToken: refresh_token,
isAuthenticated: true, user,
isInitialized: true, isAuthenticated: true,
}); isInitialized: true,
}, });
},
logout: async () => {
localStorage.removeItem(ACCESS_TOKEN_KEY); logout: async () => {
localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(ACCESS_TOKEN_KEY);
set({ localStorage.removeItem(REFRESH_TOKEN_KEY);
user: null, useMetricsStore.getState().reset();
accessToken: null, set({
refreshToken: null, user: null,
isAuthenticated: false, accessToken: null,
isInitialized: true, refreshToken: null,
}); isAuthenticated: false,
}, isInitialized: true,
});
checkAuth: async () => { },
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (!token) { checkAuth: async () => {
set({ isInitialized: true }); const token = localStorage.getItem(ACCESS_TOKEN_KEY);
return; if (!token) {
} set({ isInitialized: true });
try { return;
const user = await authApi.getMe(); }
set({ user, isAuthenticated: true, isInitialized: true }); try {
} catch { const user = await authApi.getMe();
localStorage.removeItem(ACCESS_TOKEN_KEY); set({ user, isAuthenticated: true, isInitialized: true });
localStorage.removeItem(REFRESH_TOKEN_KEY); } catch {
set({ localStorage.removeItem(ACCESS_TOKEN_KEY);
user: null, localStorage.removeItem(REFRESH_TOKEN_KEY);
accessToken: null, set({
refreshToken: null, user: null,
isAuthenticated: false, accessToken: null,
isInitialized: true, refreshToken: null,
}); isAuthenticated: false,
} isInitialized: true,
}, });
}
},
})); }));
+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: [],
}),
}));
+331 -298
View File
@@ -1,299 +1,332 @@
// ===================== Event (Событие) ===================== // ===================== Calendar =====================
export interface Event { export interface Calendar {
id: string; id: string;
calendar_id: string; title: string;
title: string; description: string;
description: string; type: 'personal' | 'commercial';
event_type: 'single' | 'recurring'; category: string;
start_time: string; // ISO8601 owner_id: string;
duration: number; status: 'active' | 'frozen' | 'deleted';
recurrence: object | null; reason: string | null;
master_id: string | null; tags: string[];
is_instance: boolean; color: string;
specialist_id: string | null; image_url: string;
location: object | null; rating_avg: number;
tags: string[]; rating_count: number;
capacity: number | null; short_name: string;
online_link: string | null; confirmation: string;
status: 'active' | 'cancelled' | 'completed'; settings: object | null;
reason: string | null; created_at: string;
rating_avg: number; updated_at: string;
rating_count: number; }
attachments: string[] | null;
edit_history: object[] | null; export interface CalendarListParams {
created_at: string; // ISO8601 q?: string;
updated_at: string; // ISO8601 status?: 'active' | 'frozen' | 'deleted';
} type?: 'personal' | 'commercial';
limit?: number;
export interface EventListParams { offset?: number;
from?: string; sort?: string;
to?: string; order?: 'asc' | 'desc';
status?: 'active' | 'cancelled' | 'completed'; }
calendar_id?: string;
title?: string; // ===================== Event (Событие) =====================
q?: string; export interface Event {
limit?: number; id: string;
offset?: number; calendar_id: string;
sort?: string; title: string;
order?: 'asc' | 'desc'; description: string;
} event_type: 'single' | 'recurring';
start_time: string; // ISO8601
// ===================== User (Пользователь) ===================== duration: number;
export interface User { recurrence: object | null;
id: string; master_id: string | null;
email: string; is_instance: boolean;
role: 'user' | 'bot'; specialist_id: string | null;
status: 'active' | 'frozen' | 'deleted'; location: object | null;
reason: string | null; tags: string[];
nickname: string | null; capacity: number | null;
avatar_url: string | null; online_link: string | null;
timezone: string | null; status: 'active' | 'cancelled' | 'completed';
language: string | null; reason: string | null;
social_links: string[] | null; rating_avg: number;
phone: string | null; rating_count: number;
preferences: object | null; attachments: string[] | null;
last_login: string; // ISO8601 edit_history: object[] | null;
created_at: string; // ISO8601 created_at: string; // ISO8601
updated_at: string; // ISO8601 updated_at: string; // ISO8601
} }
export interface UserListParams { export interface EventListParams {
role?: 'user' | 'bot'; from?: string;
status?: 'active' | 'frozen' | 'deleted'; to?: string;
q?: string; status?: 'active' | 'cancelled' | 'completed';
limit?: number; calendar_id?: string;
offset?: number; title?: string;
sort?: string; q?: string;
order?: 'asc' | 'desc'; limit?: number;
} offset?: number;
sort?: string;
// ===================== Report (Жалоба) ===================== order?: 'asc' | 'desc';
export interface Report { }
id: string;
reporter_id: string; // ===================== User (Пользователь) =====================
target_type: 'calendar' | 'event' | 'review'; export interface User {
target_id: string; id: string;
reason: string; email: string;
status: 'pending' | 'reviewed' | 'dismissed'; role: 'user' | 'bot';
created_at: string; status: 'active' | 'frozen' | 'deleted';
resolved_at: string | null; reason: string | null;
resolved_by: string | null; nickname: string | null;
} avatar_url: string | null;
timezone: string | null;
export interface ReportListParams { language: string | null;
status?: 'pending' | 'reviewed' | 'dismissed'; social_links: string[] | null;
target_type?: 'calendar' | 'event' | 'review'; phone: string | null;
q?: string; preferences: object | null;
limit?: number; last_login: string; // ISO8601
offset?: number; created_at: string; // ISO8601
sort?: string; updated_at: string; // ISO8601
order?: 'asc' | 'desc'; }
}
export interface UserListParams {
// ===================== Review (Отзыв) ===================== role?: 'user' | 'bot';
export interface Review { status?: 'active' | 'frozen' | 'deleted';
id: string; q?: string;
user_id: string; limit?: number;
target_type: 'calendar' | 'event'; offset?: number;
target_id: string; sort?: string;
rating: number; // 1-5 order?: 'asc' | 'desc';
comment: string; }
status: 'visible' | 'hidden' | 'deleted';
reason: string | null; // ===================== Report (Жалоба) =====================
likes: number; export interface Report {
dislikes: number; id: string;
created_at: string; reporter_id: string;
updated_at: string; target_type: 'calendar' | 'event' | 'review';
} target_id: string;
reason: string;
export interface ReviewListParams { status: 'pending' | 'reviewed' | 'dismissed';
target_type?: 'calendar' | 'event'; created_at: string;
target_id?: string; resolved_at: string | null;
user_id?: string; resolved_by: string | null;
status?: 'visible' | 'hidden' | 'deleted'; }
limit?: number;
offset?: number; export interface ReportListParams {
sort?: string; status?: 'pending' | 'reviewed' | 'dismissed';
order?: 'asc' | 'desc'; target_type?: 'calendar' | 'event' | 'review';
} q?: string;
limit?: number;
// ===================== Banned Word (Бан-слово) ===================== offset?: number;
export interface BannedWord { sort?: string;
id: string; order?: 'asc' | 'desc';
word: string; }
added_by: string | null;
added_at: string | null; // ===================== Review (Отзыв) =====================
} export interface Review {
id: string;
// ===================== Ticket (Тикет баг-трекера) ===================== user_id: string;
export interface Ticket { target_type: 'calendar' | 'event';
id: string; target_id: string;
reporter_id: string; rating: number; // 1-5
error_hash: string; comment: string;
error_message: string; status: 'visible' | 'hidden' | 'deleted';
stacktrace: string; reason: string | null;
context: string; likes: number;
count: number; dislikes: number;
first_seen: string; created_at: string;
last_seen: string; updated_at: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed'; }
assigned_to: string | null;
resolution_note: string | null; export interface ReviewListParams {
} target_type?: 'calendar' | 'event';
target_id?: string;
export interface TicketListParams { user_id?: string;
status?: 'open' | 'in_progress' | 'resolved' | 'closed'; status?: 'visible' | 'hidden' | 'deleted';
assigned_to?: string; limit?: number;
q?: string; offset?: number;
limit?: number; sort?: string;
offset?: number; order?: 'asc' | 'desc';
sort?: string; }
order?: 'asc' | 'desc';
} // ===================== Banned Word (Бан-слово) =====================
export interface BannedWord {
export interface TicketStats { id: string;
open: number; word: string;
in_progress: number; added_by: string | null;
resolved: number; added_at: string | null;
closed: number; }
total: number;
} // ===================== Ticket (Тикет баг-трекера) =====================
export interface Ticket {
// ===================== Subscription (Подписка) ===================== id: string;
export interface Subscription { reporter_id: string;
id: string; error_hash: string;
user_id: string; error_message: string;
plan: 'monthly' | 'quarterly' | 'biannual' | 'annual'; stacktrace: string;
status: 'active' | 'expired' | 'cancelled'; context: string;
trial_used: boolean; count: number;
started_at: string; first_seen: string;
expires_at: string; last_seen: string;
created_at: string; status: 'open' | 'in_progress' | 'resolved' | 'closed';
updated_at: string; assigned_to: string | null;
} resolution_note: string | null;
}
export interface SubscriptionListParams {
plan?: 'monthly' | 'quarterly' | 'biannual' | 'annual'; export interface TicketListParams {
status?: 'active' | 'expired' | 'cancelled'; status?: 'open' | 'in_progress' | 'resolved' | 'closed';
limit?: number; assigned_to?: string;
offset?: number; q?: string;
sort?: string; limit?: number;
order?: 'asc' | 'desc'; offset?: number;
} sort?: string;
order?: 'asc' | 'desc';
// ===================== Admin (Администратор) ===================== }
export interface Admin {
id: string; export interface TicketStats {
email: string; open: number;
role: 'superadmin' | 'admin' | 'moderator' | 'support'; in_progress: number;
status: 'active' | 'blocked'; resolved: number;
nickname: string | null; closed: number;
avatar_url: string | null; total_tickets: number;
timezone: string | null; total_errors: number;
language: string | null; }
phone: string | null;
preferences: object | null; // ===================== Subscription (Подписка) =====================
last_login: string; export interface Subscription {
created_at: string; id: string;
updated_at: string; user_id: string;
} plan: 'monthly' | 'quarterly' | 'biannual' | 'annual';
status: 'active' | 'expired' | 'cancelled';
export interface AdminListParams { trial_used: boolean;
role?: 'superadmin' | 'admin' | 'moderator' | 'support'; started_at: string;
status?: 'active' | 'blocked'; expires_at: string;
limit?: number; created_at: string;
offset?: number; updated_at: string;
sort?: string; }
order?: 'asc' | 'desc';
} export interface SubscriptionListParams {
plan?: 'monthly' | 'quarterly' | 'biannual' | 'annual';
// ===================== Audit (Запись аудита) ===================== status?: 'active' | 'expired' | 'cancelled';
export interface AuditRecord { limit?: number;
id: string; offset?: number;
admin_id: string; sort?: string;
email: string; order?: 'asc' | 'desc';
role: string; }
action: string;
entity_type: string; // ===================== Admin (Администратор) =====================
entity_id: string; export interface Admin {
timestamp: string; id: string;
ip: string; email: string;
reason: string | null; role: 'superadmin' | 'admin' | 'moderator' | 'support';
} status: 'active' | 'blocked';
nickname: string | null;
export interface AuditListParams { avatar_url: string | null;
admin_id?: string; timezone: string | null;
action?: string; language: string | null;
date_from?: string; phone: string | null;
date_to?: string; preferences: object | null;
limit?: number; last_login: string;
offset?: number; created_at: string;
sort?: string; updated_at: string;
order?: 'asc' | 'desc'; }
}
export interface AdminListParams {
// ===================== Dashboard Stats ===================== role?: 'superadmin' | 'admin' | 'moderator' | 'support';
export interface DashboardStats { status?: 'active' | 'blocked';
users_total: number; limit?: number;
events_total: number; offset?: number;
reviews_total: number; sort?: string;
calendars_total: number; order?: 'asc' | 'desc';
reports_total: number; }
tickets_total: number;
tickets_open: number; // ===================== Audit (Запись аудита) =====================
avg_ticket_resolution_h: number; export interface AuditRecord {
admin_activity: AdminActivity[]; id: string;
events_by_day: DayCount[]; admin_id: string;
registrations_by_day: DayCount[]; email: string;
} role: string;
action: string;
export interface AdminActivity { entity_type: string;
admin_id: string; entity_id: string;
email: string; timestamp: string;
role: string; ip: string;
last_login: string; reason: string | null;
nickname: string; }
actions: number;
} export interface AuditListParams {
admin_id?: string;
export interface DayCount { action?: string;
date: string; date_from?: string;
count: number; date_to?: string;
} limit?: number;
offset?: number;
// ===================== Moderation ===================== sort?: string;
export type TargetType = 'calendar' | 'event' | 'review' | 'user'; order?: 'asc' | 'desc';
}
export type ModerationAction = {
calendar: 'freeze' | 'unfreeze'; // ===================== Dashboard Stats =====================
event: 'freeze' | 'unfreeze'; export interface DashboardStats {
review: 'hide' | 'unhide'; users_total: number;
user: 'block' | 'unblock'; events_total: number;
}; reviews_total: number;
calendars_total: number;
export interface ModerationPayload { reports_total: number;
action: string; tickets_total: number;
reason?: string; tickets_open: number;
} avg_ticket_resolution_h: number;
admin_activity: AdminActivity[];
// ===================== Auth ===================== events_by_day: DayCount[];
export interface LoginRequest { registrations_by_day: DayCount[];
email: string; }
password: string;
} export interface AdminActivity {
admin_id: string;
export interface LoginResponse { email: string;
access_token: string; role: string;
refresh_token: string; last_login: string;
} nickname: string;
actions: number;
export interface RefreshResponse { }
access_token: string;
refresh_token: string; export interface DayCount {
} date: string;
count: number;
// ===================== Pagination ===================== }
export interface PaginatedResponse<T> {
data: T[]; // ===================== Moderation =====================
total: number; export type TargetType = 'calendar' | 'event' | 'review' | 'user';
export type ModerationAction = {
calendar: 'freeze' | 'unfreeze';
event: 'freeze' | 'unfreeze';
review: 'hide' | 'unhide';
user: 'block' | 'unblock';
};
export interface ModerationPayload {
action: string;
reason?: string;
}
// ===================== Auth =====================
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
}
export interface RefreshResponse {
access_token: string;
refresh_token: string;
}
// ===================== Pagination =====================
export interface PaginatedResponse<T> {
data: T[];
total: number;
} }