diff --git a/src/api/calendarsApi.ts b/src/api/calendarsApi.ts index e69de29..ff64c6e 100644 --- a/src/api/calendarsApi.ts +++ b/src/api/calendarsApi.ts @@ -0,0 +1,33 @@ +import apiClient from './client'; +import { Calendar, CalendarListParams, CalendarStats, PaginatedResponse } from '../types/api'; + +const mapListParams = (params: CalendarListParams) => { + const { sort, order, ...rest } = params; + return { + ...rest, + ...(sort ? { sort_by: sort } : {}), + ...(order ? { sort_order: order } : {}), + }; +}; + +export const calendarsApi = { + getCalendars: async (params: CalendarListParams): Promise> => { + const { data, headers } = await apiClient.get('/v1/admin/calendars', { params: mapListParams(params) }); + const total = parseInt(headers['x-total-count'] || '0', 10); + return { data, total }; + }, + getCalendar: async (id: string): Promise => { + const { data } = await apiClient.get(`/v1/admin/calendars/${id}`); + return data; + }, + updateCalendar: async (id: string, payload: Partial): Promise => { + await apiClient.put(`/v1/admin/calendars/${id}`, payload); + }, + deleteCalendar: async (id: string): Promise => { + await apiClient.delete(`/v1/admin/calendars/${id}`); + }, + getCalendarStats: async (): Promise => { + const { data } = await apiClient.get('/v1/admin/calendars/stats'); + return data; + }, +}; diff --git a/src/api/client.ts b/src/api/client.ts index daf7204..056f63e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/constants'; const apiClient = axios.create({ @@ -8,6 +8,31 @@ const apiClient = axios.create({ }, }); +type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean }; + +let isRefreshing = false; +let pendingRequests: Array<{ + resolve: (token: string) => void; + reject: (error: unknown) => void; +}> = []; + +const processQueue = (error: unknown, token: string | null) => { + pendingRequests.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else if (token) { + resolve(token); + } + }); + pendingRequests = []; +}; + +const redirectToLogin = () => { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + window.location.href = '/login'; +}; + apiClient.interceptors.request.use((config) => { const token = localStorage.getItem(ACCESS_TOKEN_KEY); if (token) { @@ -18,15 +43,53 @@ apiClient.interceptors.request.use((config) => { apiClient.interceptors.response.use( (response) => response, - async (error) => { - // Временно отключаем рефреш и просто редиректим при 401 - if (error.response?.status === 401) { - localStorage.removeItem(ACCESS_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - window.location.href = '/login'; + async (error: AxiosError) => { + const originalRequest = error.config as RetryConfig | undefined; + if (!originalRequest || error.response?.status !== 401 || originalRequest._retry) { + return Promise.reject(error); + } + + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + if (!refreshToken) { + redirectToLogin(); + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingRequests.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return apiClient(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const { data } = await axios.post( + `${import.meta.env.VITE_API_BASE_URL || ''}/v1/admin/refresh`, + { refresh_token: refreshToken }, + { headers: { 'Content-Type': 'application/json' } } + ); + + const newAccess = data.token as string; + const newRefresh = data.refresh_token as string; + localStorage.setItem(ACCESS_TOKEN_KEY, newAccess); + localStorage.setItem(REFRESH_TOKEN_KEY, newRefresh); + + processQueue(null, newAccess); + originalRequest.headers.Authorization = `Bearer ${newAccess}`; + return apiClient(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + redirectToLogin(); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; } - return Promise.reject(error); } ); -export default apiClient; \ No newline at end of file +export default apiClient; diff --git a/src/api/eventsApi.ts b/src/api/eventsApi.ts index c35c704..3862035 100644 --- a/src/api/eventsApi.ts +++ b/src/api/eventsApi.ts @@ -17,4 +17,8 @@ export const eventsApi = { deleteEvent: async (id: string): Promise => { await apiClient.delete(`/v1/admin/events/${id}`); }, + getEventStats: async (): Promise => { + const { data } = await apiClient.get('/v1/admin/events/stats'); + return data; + }, }; \ No newline at end of file diff --git a/src/api/reportsApi.ts b/src/api/reportsApi.ts index 4cdc82d..55a9bb8 100644 --- a/src/api/reportsApi.ts +++ b/src/api/reportsApi.ts @@ -14,4 +14,8 @@ export const reportsApi = { updateReport: async (id: string, payload: { status: 'reviewed' | 'dismissed' }): Promise => { await apiClient.put(`/v1/admin/reports/${id}`, payload); }, + getReportStats: async (): Promise => { + const { data } = await apiClient.get('/v1/admin/reports/stats'); + return data; + }, }; \ No newline at end of file diff --git a/src/api/reviewsApi.ts b/src/api/reviewsApi.ts index 8072eb1..edc6e34 100644 --- a/src/api/reviewsApi.ts +++ b/src/api/reviewsApi.ts @@ -18,4 +18,8 @@ export const reviewsApi = { const { data } = await apiClient.patch('/v1/admin/reviews', updates); return data; }, + getReviewStats: async (): Promise => { + const { data } = await apiClient.get('/v1/admin/reviews/stats'); + return data; + }, }; \ No newline at end of file diff --git a/src/api/subscriptionsApi.ts b/src/api/subscriptionsApi.ts index a42c0cc..d86896c 100644 --- a/src/api/subscriptionsApi.ts +++ b/src/api/subscriptionsApi.ts @@ -19,4 +19,8 @@ export const subscriptionsApi = { const { data } = await apiClient.delete(`/v1/admin/subscriptions/${id}`); return data; }, + getSubscriptionStats: async (): Promise => { + const { data } = await apiClient.get('/v1/admin/subscriptions/stats'); + return data; + }, }; \ No newline at end of file diff --git a/src/api/usersApi.ts b/src/api/usersApi.ts index 69a3b55..eb50baa 100644 --- a/src/api/usersApi.ts +++ b/src/api/usersApi.ts @@ -17,4 +17,8 @@ export const usersApi = { deleteUser: async (id: string): Promise => { await apiClient.delete(`/v1/admin/users/${id}`); }, + getUserStats: async (): Promise => { + const { data } = await apiClient.get('/v1/admin/users/stats'); + return data; + }, }; \ No newline at end of file diff --git a/src/components/MetricIndicator.tsx b/src/components/MetricIndicator.tsx index e69de29..2a81fe3 100644 --- a/src/components/MetricIndicator.tsx +++ b/src/components/MetricIndicator.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Tooltip, Progress } from 'antd'; +import { NodeMetric } from '../store/metricsStore'; + +interface Props { + node: string; + stats: NodeMetric; + prevStats?: NodeMetric | null; +} + +const MetricIndicator: React.FC = ({ node, stats, prevStats }) => { + const cpu = stats.cpu_utilization ?? 0; + const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0); + const usedMemory = stats.memory_total ?? 0; + const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0; + + const prevCpu = prevStats?.cpu_utilization; + const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0); + const prevUsed = prevStats?.memory_total ?? 0; + const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null; + + const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a'; + const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff'; + + return ( + +
{node}
+
CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}
+
+ Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%) + {prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''} +
+ + } + > +
+ ''} + size={48} + strokeColor={memColor} + railColor="#f0f0f0" + strokeWidth={6} + style={{ position: 'absolute', top: 0, left: 0 }} + /> + ''} + size={32} + strokeColor={cpuColor} + railColor="#f0f0f0" + strokeWidth={6} + style={{ position: 'absolute', top: 8, left: 8 }} + /> +
+ {cpu.toFixed(0)}% +
+
+
+ ); +}; + +export default MetricIndicator; diff --git a/src/hooks/useCalendars.ts b/src/hooks/useCalendars.ts index e69de29..d0c370a 100644 --- a/src/hooks/useCalendars.ts +++ b/src/hooks/useCalendars.ts @@ -0,0 +1,55 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { calendarsApi } from '../api/calendarsApi'; +import { Calendar, CalendarListParams } from '../types/api'; +import { message } from 'antd'; +import { normalizeData } from '../utils/normalize'; + +export const useCalendars = (params: CalendarListParams) => { + return useQuery({ + queryKey: ['calendars', params], + queryFn: async () => { + const res = await calendarsApi.getCalendars(params); + return { data: normalizeData(res.data), total: res.total }; + }, + }); +}; + +export const useCalendar = (id: string) => { + return useQuery({ + queryKey: ['calendars', id], + queryFn: () => calendarsApi.getCalendar(id), + enabled: !!id && id !== '-', + }); +}; + +export const useUpdateCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + calendarsApi.updateCalendar(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + message.success('Календарь обновлён'); + }, + onError: () => message.error('Ошибка обновления'), + }); +}; + +export const useDeleteCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => calendarsApi.deleteCalendar(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + message.success('Календарь удалён'); + }, + onError: () => message.error('Ошибка удаления'), + }); +}; + +export const useCalendarStats = () => { + return useQuery({ + queryKey: ['calendar-stats'], + queryFn: async () => normalizeData(await calendarsApi.getCalendarStats()), + }); +}; diff --git a/src/hooks/useEvents.ts b/src/hooks/useEvents.ts index 853bee8..86da1a2 100644 --- a/src/hooks/useEvents.ts +++ b/src/hooks/useEvents.ts @@ -45,4 +45,11 @@ export const useDeleteEvent = () => { }, onError: () => message.error('Ошибка удаления'), }); +}; + +export const useEventStats = () => { + return useQuery({ + queryKey: ['event-stats'], + queryFn: async () => normalizeData(await eventsApi.getEventStats()), + }); }; \ No newline at end of file diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 7316189..529ad72 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -33,4 +33,11 @@ export const useUpdateReport = () => { }, onError: () => message.error('Ошибка обновления'), }); +}; + +export const useReportStats = () => { + return useQuery({ + queryKey: ['report-stats'], + queryFn: async () => normalizeData(await reportsApi.getReportStats()), + }); }; \ No newline at end of file diff --git a/src/hooks/useReviews.ts b/src/hooks/useReviews.ts index a1ee6ba..0685d8b 100644 --- a/src/hooks/useReviews.ts +++ b/src/hooks/useReviews.ts @@ -51,4 +51,11 @@ export const useBulkUpdateReviews = () => { console.error(error); }, }); +}; + +export const useReviewStats = () => { + return useQuery({ + queryKey: ['review-stats'], + queryFn: async () => normalizeData(await reviewsApi.getReviewStats()), + }); }; \ No newline at end of file diff --git a/src/hooks/useSubscriptions.ts b/src/hooks/useSubscriptions.ts index 63e3452..f6e34b5 100644 --- a/src/hooks/useSubscriptions.ts +++ b/src/hooks/useSubscriptions.ts @@ -45,4 +45,11 @@ export const useDeleteSubscription = () => { }, onError: () => message.error('Ошибка удаления'), }); +}; + +export const useSubscriptionStats = () => { + return useQuery({ + queryKey: ['subscription-stats'], + queryFn: async () => normalizeData(await subscriptionsApi.getSubscriptionStats()), + }); }; \ No newline at end of file diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index 0674fde..1f597cc 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -44,4 +44,11 @@ export const useDeleteUser = () => { }, onError: () => message.error('Ошибка удаления'), }); +}; + +export const useUserStats = () => { + return useQuery({ + queryKey: ['user-stats'], + queryFn: async () => normalizeData(await usersApi.getUserStats()), + }); }; \ No newline at end of file diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 893adc7..ddc782c 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip, Progress } from 'antd'; +import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip } from 'antd'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { useAdminWebSocket } from '../hooks/useAdminWebSocket'; import { useMetricsStore } from '../store/metricsStore'; +import MetricIndicator from '../components/MetricIndicator'; import { DashboardOutlined, UserOutlined, @@ -146,59 +147,11 @@ const AdminLayout: React.FC = () => { return user?.id ?? '—'; }; - const NodeIndicator: React.FC<{ node: string; stats: any; prevStats?: any }> = ({ node, stats, prevStats }) => { - const cpu = stats?.cpu_utilization ?? 0; - const totalMemory = (stats?.memory_total ?? 0) + (stats?.memory_available ?? 0); - const usedMemory = stats?.memory_total ?? 0; - const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0; - - const prevCpu = prevStats?.cpu_utilization; - const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0); - const prevUsed = prevStats?.memory_total ?? 0; - const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null; - - const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a'; - const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff'; - - return ( - -
{node}
-
CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}
-
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%) - {prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
- - } - > -
- ''} - size={48} - strokeColor={memColor} - railColor="#f0f0f0" - strokeWidth={6} - style={{ position: 'absolute', top: 0, left: 0 }} - /> - ''} - size={32} - strokeColor={cpuColor} - railColor="#f0f0f0" - strokeWidth={6} - style={{ position: 'absolute', top: 8, left: 8 }} - /> -
- {cpu.toFixed(0)}% -
-
-
- ); - }; + const NodeIndicator: React.FC<{ node: string; stats: NonNullable; prevStats?: typeof current }> = ({ + node, + stats, + prevStats, + }) => ; return ( @@ -327,6 +280,7 @@ const AdminLayout: React.FC = () => { {onlineNodes.map(node => { const stats = latestByNode.get(node); + if (!stats) return null; const prevStats = previous?.node === node ? previous : undefined; return ; })} diff --git a/src/pages/calendars/CalendarDetailPage.tsx b/src/pages/calendars/CalendarDetailPage.tsx index e69de29..aac771e 100644 --- a/src/pages/calendars/CalendarDetailPage.tsx +++ b/src/pages/calendars/CalendarDetailPage.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { Card, Descriptions, Spin, Button, Tag } from 'antd'; +import { useCalendar } from '../../hooks/useCalendars'; +import dayjs from 'dayjs'; + +const CalendarDetailPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: calendar, isLoading } = useCalendar(id || ''); + + const isBadValue = (val: unknown) => + val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; + + const displayValue = (val: unknown) => (isBadValue(val) ? '-' : String(val)); + + const formatDate = (dateStr: string) => { + if (isBadValue(dateStr)) return '-'; + const d = dayjs(dateStr); + return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr; + }; + + if (isLoading) return ; + if (!calendar) return

Календарь не найден

; + + const tagsStr = Array.isArray(calendar.tags) && calendar.tags.length > 0 ? calendar.tags.join(', ') : '-'; + + return ( + + + {calendar.id} + {calendar.title} + {displayValue(calendar.short_name)} + {displayValue(calendar.description)} + + {calendar.type} + + + + {calendar.status} + + + {displayValue(calendar.reason)} + {displayValue(calendar.category)} + + {!isBadValue(calendar.owner_id) ? ( + {calendar.owner_id} + ) : '-'} + + {displayValue(calendar.color)} + {displayValue(calendar.image_url)} + {displayValue(calendar.confirmation)} + {tagsStr} + {calendar.rating_avg} ({calendar.rating_count}) + + {calendar.settings ? JSON.stringify(calendar.settings) : '-'} + + {formatDate(calendar.created_at)} + {formatDate(calendar.updated_at)} + + + + ); +}; + +export default CalendarDetailPage; diff --git a/src/pages/calendars/CalendarListPage.tsx b/src/pages/calendars/CalendarListPage.tsx index e69de29..b28e803 100644 --- a/src/pages/calendars/CalendarListPage.tsx +++ b/src/pages/calendars/CalendarListPage.tsx @@ -0,0 +1,298 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd'; +import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons'; +import { Link, useNavigate } from 'react-router-dom'; +import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars'; +import { Calendar, CalendarListParams } from '../../types/api'; +import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; + +const CalendarListPage: React.FC = () => { + const navigate = useNavigate(); + const [params, setParams] = useState({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' }); + const { data, isLoading } = useCalendars(params); + const { data: stats } = useCalendarStats(); + const updateCalendar = useUpdateCalendar(); + const deleteCalendar = useDeleteCalendar(); + + const [editModal, setEditModal] = useState<{ open: boolean; calendarId: string | null }>({ + open: false, + calendarId: null, + }); + const { data: editingCalendar, isLoading: loadingCalendar } = useCalendar(editModal.calendarId || ''); + const [editForm] = Form.useForm(); + const [editHasErrors, setEditHasErrors] = useState(true); + const originalCalendarRef = useRef(null); + + const validateEditForm = useCallback(() => { + const title = editForm.getFieldValue('title'); + const status = editForm.getFieldValue('status'); + setEditHasErrors(!title || !status); + }, [editForm]); + + const handleEdit = (id: string) => { + setEditModal({ open: true, calendarId: id }); + }; + + const handleSaveEdit = () => { + editForm.validateFields().then((values) => { + if (!editModal.calendarId || !originalCalendarRef.current) return; + const original = originalCalendarRef.current; + const cleanedValues: Record = {}; + + const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]); + allKeys.forEach((key) => { + const newVal = values[key]; + if (newVal === '' || newVal === undefined || newVal === null) { + const origVal = (original as unknown as Record)[key]; + if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) { + cleanedValues[key] = null; + } + return; + } + if (newVal === '-') return; + cleanedValues[key] = newVal; + }); + + const payload = Object.fromEntries( + Object.entries(cleanedValues).filter(([key, v]) => { + const origVal = (original as unknown as Record)[key]; + return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal); + }) + ); + + updateCalendar.mutate( + { id: editModal.calendarId, data: payload }, + { onSuccess: () => setEditModal({ open: false, calendarId: null }) } + ); + }); + }; + + useEffect(() => { + if (editingCalendar && editModal.open) { + originalCalendarRef.current = editingCalendar; + const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val); + setTimeout(() => { + editForm.setFieldsValue({ + title: clean(editingCalendar.title), + description: clean(editingCalendar.description), + type: clean(editingCalendar.type), + status: clean(editingCalendar.status), + category: clean(editingCalendar.category), + reason: clean(editingCalendar.reason), + }); + validateEditForm(); + }, 0); + } + }, [editingCalendar, editModal.open, editForm, validateEditForm]); + + const handleDelete = (id: string) => { + Modal.confirm({ + title: 'Удалить календарь?', + okText: 'Удалить', + okType: 'danger', + cancelText: 'Отмена', + onOk: () => deleteCalendar.mutate(id), + }); + }; + + const handleTableChange = ( + pagination: { current?: number; pageSize?: number }, + _filters: unknown, + sorter: SorterResult | SorterResult[] + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + setParams(prev => ({ + ...prev, + sort: s.field as string, + order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, + offset: ((pagination.current || 1) - 1) * (pagination.pageSize || prev.limit || 20), + limit: pagination.pageSize || prev.limit, + })); + }; + + const columns: ColumnsType = [ + { + title: , + key: 'detail', + width: 48, + align: 'center', + render: (_, record) => ( + +