Route guards, WS lifecycle, ticket stats. Refs EventHub/EventHubFrontAdmin#3
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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]);
|
||||||
|
};
|
||||||
|
|||||||
+343
-280
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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,
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user