diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7375c4..33e57b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,59 +1,32 @@ -name: CI - on: push: - branches: [main, develop] + branches: [main, dev] pull_request: - branches: [main, develop] + branches: [main, dev] jobs: - lint-and-typecheck: + lint-and-build: runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - + - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - - name: Install dependencies run: npm ci - - name: Run ESLint - run: npm run lint -- --max-warnings 50 - - - name: Type check - run: npm run type-check - - build: - runs-on: ubuntu-latest - needs: lint-and-typecheck - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build application + run: npm run lint + - name: Run TypeScript check + run: npx tsc --noEmit + - name: Build run: npm run build env: VITE_API_URL: /api VITE_TELEGRAM_BOT_USERNAME: test_bot VITE_APP_NAME: Cabinet VITE_APP_LOGO: V - - name: Upload build artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml deleted file mode 100644 index 70a9c07..0000000 --- a/.github/workflows/docker-hub.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Docker Hub Build - -on: - push: - branches: [main] - tags: - - 'v*' - -jobs: - build-and-push-dockerhub: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/bedolaga-cabinet - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push to Docker Hub - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - VITE_API_URL=/api - VITE_TELEGRAM_BOT_USERNAME= - VITE_APP_NAME=Cabinet - VITE_APP_LOGO=V - platforms: linux/amd64,linux/arm64 diff --git a/src/App.tsx b/src/App.tsx index 71fec00..48f2c17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' +import { saveReturnUrl } from './utils/token' import Login from './pages/Login' import TelegramCallback from './pages/TelegramCallback' import TelegramRedirect from './pages/TelegramRedirect' @@ -25,6 +26,7 @@ import AdminTariffs from './pages/AdminTariffs' import AdminServers from './pages/AdminServers' import AdminPanel from './pages/AdminPanel' import AdminDashboard from './pages/AdminDashboard' +import AdminBanSystem from './pages/AdminBanSystem' import AdminBroadcasts from './pages/AdminBroadcasts' import AdminPromocodes from './pages/AdminPromocodes' import AdminCampaigns from './pages/AdminCampaigns' @@ -35,13 +37,16 @@ import AdminRemnawave from './pages/AdminRemnawave' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() + const location = useLocation() if (isLoading) { return } if (!isAuthenticated) { - return + // Сохраняем текущий URL для возврата после авторизации + saveReturnUrl() + return } return {children} @@ -49,13 +54,16 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading, isAdmin } = useAuthStore() + const location = useLocation() if (isLoading) { return } if (!isAuthenticated) { - return + // Сохраняем текущий URL для возврата после авторизации + saveReturnUrl() + return } if (!isAdmin) { @@ -224,6 +232,14 @@ function App() { } /> + + + + } + /> | null +} + +export interface BanNodesListResponse { + nodes: BanNodeItem[] + total: number + online: number +} + +export interface BanAgentItem { + node_name: string + sent_total: number + dropped_total: number + batches_total: number + reconnects: number + failures: number + queue_size: number + queue_max: number + dedup_checked: number + dedup_skipped: number + filter_checked: number + filter_filtered: number + health: string + is_online: boolean + last_report: string | null +} + +export interface BanAgentsSummary { + total_agents: number + online_agents: number + total_sent: number + total_dropped: number + avg_queue_size: number + healthy_count: number + warning_count: number + critical_count: number +} + +export interface BanAgentsListResponse { + agents: BanAgentItem[] + summary: BanAgentsSummary | null + total: number + online: number +} + +export interface BanTrafficViolationItem { + id: number | null + username: string + email: string | null + violation_type: string + description: string | null + bytes_used: number + bytes_limit: number + detected_at: string + resolved: boolean +} + +export interface BanTrafficViolationsResponse { + violations: BanTrafficViolationItem[] + total: number +} + +export interface BanTrafficTopItem { + username: string + bytes_total: number + bytes_limit: number | null + over_limit: boolean +} + +export interface BanTrafficResponse { + enabled: boolean + stats: Record | null + top_users: BanTrafficTopItem[] + recent_violations: BanTrafficViolationItem[] +} + +// === Settings Types === + +export interface BanSettingDefinition { + key: string + value: unknown + type: string + min_value: number | null + max_value: number | null + editable: boolean + description: string | null + category: string | null +} + +export interface BanSettingsResponse { + settings: BanSettingDefinition[] +} + +export interface BanWhitelistRequest { + username: string +} + +// === Report Types === + +export interface BanReportTopViolator { + username: string + count: number +} + +export interface BanReportResponse { + period_hours: number + current_users: number + current_ips: number + punishment_stats: Record | null + top_violators: BanReportTopViolator[] +} + +// === Health Types === + +export interface BanHealthComponent { + name: string + status: string + message: string | null + details: Record | null +} + +export interface BanHealthResponse { + status: string + uptime: number | null + components: BanHealthComponent[] +} + +export interface BanHealthDetailedResponse { + status: string + uptime: number | null + components: Record +} + +// === Agent History Types === + +export interface BanAgentHistoryItem { + timestamp: string + sent_total: number + dropped_total: number + queue_size: number + batches_total: number +} + +export interface BanAgentHistoryResponse { + node: string + hours: number + records: number + delta: Record | null + first: Record | null + last: Record | null + history: BanAgentHistoryItem[] +} + +// === API === + +export const banSystemApi = { + // Status + getStatus: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/status') + return response.data + }, + + // Stats + getStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/stats') + return response.data + }, + + // Users + getUsers: async (params: { + offset?: number + limit?: number + status?: string + } = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/users', { params }) + return response.data + }, + + getUsersOverLimit: async (limit: number = 50): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', { + params: { limit } + }) + return response.data + }, + + searchUsers: async (query: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`) + return response.data + }, + + getUser: async (email: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`) + return response.data + }, + + // Punishments + getPunishments: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/punishments') + return response.data + }, + + unbanUser: async (userId: string): Promise => { + const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`) + return response.data + }, + + banUser: async (data: BanUserRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/ban-system/ban', data) + return response.data + }, + + getPunishmentHistory: async (query: string, limit: number = 20): Promise => { + const response = await apiClient.get(`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, { + params: { limit } + }) + return response.data + }, + + // Nodes + getNodes: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/nodes') + return response.data + }, + + // Agents + getAgents: async (params: { + search?: string + health?: string + status?: string + } = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params }) + return response.data + }, + + getAgentsSummary: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary') + return response.data + }, + + // Traffic violations + getTrafficViolations: async (limit: number = 50): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', { + params: { limit } + }) + return response.data + }, + + // Full Traffic + getTraffic: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/traffic') + return response.data + }, + + getTrafficTop: async (limit: number = 20): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', { + params: { limit } + }) + return response.data + }, + + // Settings + getSettings: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/settings') + return response.data + }, + + getSetting: async (key: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`) + return response.data + }, + + setSetting: async (key: string, value: string): Promise => { + const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, { + params: { value } + }) + return response.data + }, + + toggleSetting: async (key: string): Promise => { + const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`) + return response.data + }, + + // Whitelist + whitelistAdd: async (username: string): Promise => { + const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { username }) + return response.data + }, + + whitelistRemove: async (username: string): Promise => { + const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { username }) + return response.data + }, + + // Reports + getReport: async (hours: number = 24): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/report', { + params: { hours } + }) + return response.data + }, + + // Health + getHealth: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/health') + return response.data + }, + + getHealthDetailed: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed') + return response.data + }, + + // Agent History + getAgentHistory: async (nodeName: string, hours: number = 24): Promise => { + const response = await apiClient.get(`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, { + params: { hours } + }) + return response.data + }, + + // User Punishment History + getUserHistory: async (email: string, limit: number = 20): Promise => { + const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, { + params: { limit } + }) + return response.data + }, +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index b450eac..c1d2d98 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -259,7 +259,7 @@ export default function Layout({ children }: LayoutProps) {
{/* Header */}
-
+
{/* Logo */} diff --git a/src/locales/en.json b/src/locales/en.json index 1b3ec48..4e85c02 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -15,10 +15,12 @@ "yes": "Yes", "no": "No", "or": "or", + "add": "Add", "and": "and", "edit": "Edit", "delete": "Delete", - "currency": "$" + "currency": "$", + "refresh": "Refresh" }, "nav": { "dashboard": "Dashboard", @@ -449,6 +451,7 @@ "wheel": "Wheel", "tariffs": "Tariffs", "servers": "Servers", + "banSystem": "Ban Monitoring", "broadcasts": "Broadcasts", "users": "Users", "payments": "Payments", @@ -464,6 +467,7 @@ "wheelDesc": "Configure fortune wheel and prizes", "tariffsDesc": "Manage tariff plans", "serversDesc": "Configure VPN servers", + "banSystemDesc": "Ban monitoring and violations", "broadcastsDesc": "Mass messaging to users", "usersDesc": "Manage bot users", "paymentsDesc": "Payment verification", @@ -863,6 +867,283 @@ "noTariffs": "No tariffs" } }, + "banSystem": { + "title": "Ban Monitoring", + "subtitle": "BedolagaBan system management", + "notConfigured": "Ban system is not configured", + "configureHint": "Set BAN_SYSTEM_API_URL and BAN_SYSTEM_API_TOKEN in configuration", + "loadError": "Failed to load data", + "tabs": { + "dashboard": "Dashboard", + "users": "Users", + "punishments": "Bans", + "nodes": "Nodes", + "agents": "Agents", + "violations": "Violations", + "traffic": "Traffic", + "reports": "Reports", + "settings": "Settings", + "health": "Health" + }, + "stats": { + "activeUsers": "Active Users", + "total": "Total", + "usersOverLimit": "Over Limit", + "activeBans": "Active Bans", + "nodesOnline": "Nodes Online", + "agentsOnline": "Agents Online", + "totalRequests": "Total Requests", + "panelStatus": "Panel Status", + "connected": "Connected", + "disconnected": "Disconnected", + "uptime": "Uptime" + }, + "dashboard": { + "totalUsers": "Total Users", + "activeUsers": "Active Users", + "usersOverLimit": "Over Limit", + "totalRequests": "Total Requests", + "totalPunishments": "Total Punishments", + "activePunishments": "Active Bans", + "nodesOnline": "Nodes Online", + "agentsOnline": "Agents Online", + "panelConnected": "Panel Connected", + "panelDisconnected": "Panel Disconnected", + "uptime": "Uptime" + }, + "users": { + "title": "Users", + "searchPlaceholder": "Search by email...", + "email": "Email", + "uniqueIps": "Unique IPs", + "ipCount": "IPs", + "requests": "Requests", + "limit": "Limit", + "status": "Status", + "bans": "Bans", + "lastSeen": "Last Seen", + "overLimit": "Over Limit", + "ok": "OK", + "normal": "Normal", + "noLimit": "No Limit", + "noUsers": "No users found", + "viewDetails": "View Details", + "networkType": "Network Type", + "filter": { + "all": "All", + "overLimit": "Over Limit", + "normal": "Normal" + } + }, + "userDetail": { + "title": "User Details", + "email": "Email", + "uniqueIps": "Unique IPs", + "totalRequests": "Total Requests", + "limit": "Device Limit", + "status": "Status", + "networkType": "Network Type", + "ipHistory": "IP History", + "recentRequests": "Recent Requests", + "ip": "IP Address", + "firstSeen": "First Seen", + "lastSeen": "Last Seen", + "node": "Node", + "requestCount": "Requests", + "requests": "Requests", + "country": "Country", + "city": "City", + "timestamp": "Timestamp", + "sourceIp": "Source IP", + "destination": "Destination", + "port": "Port", + "protocol": "Protocol", + "action": "Action", + "noIps": "No IP data", + "noRequests": "No request data", + "ban": "Ban", + "close": "Close" + }, + "punishments": { + "title": "Active Bans", + "user": "User", + "username": "User", + "reason": "Reason", + "punishedAt": "Banned At", + "bannedAt": "Banned At", + "enableAt": "Unban At", + "ipCount": "IPs", + "limit": "Limit", + "node": "Node", + "actions": "Actions", + "unban": "Unban", + "noPunishments": "No active bans", + "noBans": "No active bans", + "unbanConfirm": "Unban user {{username}}?", + "unbanSuccess": "User unbanned", + "unbanError": "Unban failed", + "history": "Ban History", + "searchHistory": "Search history...", + "noHistory": "No history found" + }, + "banModal": { + "title": "Ban User", + "username": "Username", + "usernamePlaceholder": "Enter username", + "duration": "Duration (minutes)", + "durationPlaceholder": "Enter duration in minutes", + "reason": "Reason", + "reasonPlaceholder": "Enter ban reason (optional)", + "cancel": "Cancel", + "ban": "Ban", + "success": "User banned", + "error": "Ban failed" + }, + "nodes": { + "title": "Nodes", + "name": "Name", + "address": "Address", + "status": "Status", + "lastSeen": "Last Seen", + "usersCount": "Users", + "users": "Users", + "online": "Online", + "offline": "Offline", + "noNodes": "No nodes found", + "total": "Total", + "onlineCount": "Online" + }, + "agents": { + "title": "Agents", + "nodeName": "Node", + "node": "Node", + "status": "Status", + "health": "Health", + "sent": "Sent", + "dropped": "Dropped", + "totalSent": "Total Sent", + "totalDropped": "Total Dropped", + "batches": "Batches", + "reconnects": "Reconnects", + "failures": "Failures", + "queue": "Queue", + "dedup": "Deduplication", + "filter": "Filtering", + "lastReport": "Last Report", + "online": "Online", + "offline": "Offline", + "healthy": "Healthy", + "warning": "Warning", + "critical": "Critical", + "noAgents": "No agents found", + "summary": { + "title": "Summary", + "totalAgents": "Total Agents", + "onlineAgents": "Online", + "totalSent": "Total Sent", + "totalDropped": "Total Dropped", + "avgQueueSize": "Avg Queue Size", + "healthyCount": "Healthy", + "warningCount": "Warning", + "criticalCount": "Critical" + } + }, + "violations": { + "title": "Traffic Violations", + "user": "User", + "username": "User", + "email": "Email", + "type": "Type", + "description": "Description", + "bytesUsed": "Used", + "bytesLimit": "Limit", + "detectedAt": "Detected At", + "status": "Status", + "resolved": "Resolved", + "active": "Active", + "noViolations": "No violations found", + "yes": "Yes", + "no": "No" + }, + "traffic": { + "title": "Traffic Statistics", + "enabled": "Traffic Monitoring", + "topUsers": "Top by Traffic", + "username": "User", + "bytesTotal": "Total", + "bytesLimit": "Limit", + "status": "Status", + "overLimit": "Over Limit", + "ok": "OK", + "recentViolations": "Recent Violations" + }, + "reports": { + "title": "Period Reports", + "period": "Period", + "currentUsers": "Active Users", + "currentIps": "Unique IPs", + "topViolators": "Top Violators", + "username": "User", + "count": "Violations" + }, + "settings": { + "title": "System Settings", + "general": "General", + "limits": "Limits", + "notifications": "Notifications", + "whitelist": "Whitelist", + "saved": "Setting saved", + "error": "Error saving", + "categories": { + "general": "General Settings", + "punishment": "Punishments", + "progressive_bans": "Progressive Bans", + "traffic": "Traffic Monitoring", + "network": "Network Detection", + "notifications": "Notifications", + "rate_limit": "Rate Limits" + }, + "punishment_enabled": "Enable auto-ban", + "punishment_minutes": "Ban duration (minutes)", + "ip_window_seconds": "IP counting window (seconds)", + "notify_on_punishment": "Notify on bans", + "notify_on_node_status": "Notify on node status", + "daily_report_enabled": "Daily report", + "daily_report_hour": "Report hour", + "rate_limit_max": "Max requests", + "rate_limit_window": "Rate limit window (sec)", + "heartbeat_timeout": "Heartbeat timeout (sec)", + "progressive_bans_enabled": "Progressive bans", + "progressive_ban_1": "1st ban (minutes)", + "progressive_ban_2": "2nd ban (minutes)", + "progressive_ban_3": "3rd ban (minutes)", + "progressive_ban_window_hours": "Reset window (hours)", + "traffic_monitor_enabled": "Traffic monitoring", + "traffic_limit_gb": "Traffic limit (GB)", + "traffic_window_minutes": "Check window (min)", + "traffic_check_interval": "Check interval (min)", + "traffic_ban_minutes": "Traffic ban (min)", + "network_detection_enabled": "Network type detection", + "network_detection_nodes": "Detection nodes", + "network_detection_monitor_all": "Monitor all nodes", + "network_detection_collect_all": "Collect from all nodes", + "network_notify_mobile": "Notify on mobile", + "network_block_mobile": "Block mobile", + "network_block_mobile_minutes": "Block mobile (min)", + "network_notify_wifi": "Notify on WiFi", + "network_block_wifi": "Block WiFi", + "network_block_wifi_minutes": "Block WiFi (min)" + }, + "health": { + "title": "System Health", + "systemStatus": "System Status", + "healthy": "Healthy", + "degraded": "Degraded", + "unhealthy": "Unhealthy", + "components": "Components", + "uptime": "Uptime" + } + }, "profile": { "title": "Profile", "accountInfo": "Account Information", diff --git a/src/locales/ru.json b/src/locales/ru.json index 94422cc..e46a052 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -15,10 +15,12 @@ "yes": "Да", "no": "Нет", "or": "или", + "add": "Добавить", "and": "и", "edit": "Редактировать", "delete": "Удалить", - "currency": "₽" + "currency": "₽", + "refresh": "Обновить" }, "nav": { "dashboard": "Главная", @@ -449,6 +451,7 @@ "wheel": "Колесо", "tariffs": "Тарифы", "servers": "Серверы", + "banSystem": "Мониторинг банов", "broadcasts": "Рассылки", "users": "Пользователи", "payments": "Платежи", @@ -464,6 +467,7 @@ "wheelDesc": "Настройка колеса удачи и призов", "tariffsDesc": "Управление тарифными планами", "serversDesc": "Настройка VPN серверов", + "banSystemDesc": "Мониторинг банов и нарушений", "broadcastsDesc": "Массовая отправка сообщений", "usersDesc": "Управление пользователями бота", "paymentsDesc": "Проверка платежей", @@ -863,6 +867,283 @@ "noTariffs": "Нет тарифов" } }, + "banSystem": { + "title": "Мониторинг банов", + "subtitle": "Управление системой банов BedolagaBan", + "notConfigured": "Ban система не настроена", + "configureHint": "Укажите BAN_SYSTEM_API_URL и BAN_SYSTEM_API_TOKEN в конфигурации", + "loadError": "Не удалось загрузить данные", + "tabs": { + "dashboard": "Статистика", + "users": "Пользователи", + "punishments": "Баны", + "nodes": "Ноды", + "agents": "Агенты", + "violations": "Нарушения", + "traffic": "Трафик", + "reports": "Отчёты", + "settings": "Настройки", + "health": "Здоровье" + }, + "stats": { + "activeUsers": "Активных пользователей", + "total": "Всего", + "usersOverLimit": "Превысили лимит", + "activeBans": "Активных банов", + "nodesOnline": "Нод онлайн", + "agentsOnline": "Агентов онлайн", + "totalRequests": "Всего запросов", + "panelStatus": "Статус панели", + "connected": "Подключена", + "disconnected": "Отключена", + "uptime": "Аптайм" + }, + "dashboard": { + "totalUsers": "Всего пользователей", + "activeUsers": "Активных пользователей", + "usersOverLimit": "Превысили лимит", + "totalRequests": "Всего запросов", + "totalPunishments": "Всего наказаний", + "activePunishments": "Активных банов", + "nodesOnline": "Нод онлайн", + "agentsOnline": "Агентов онлайн", + "panelConnected": "Панель подключена", + "panelDisconnected": "Панель отключена", + "uptime": "Аптайм" + }, + "users": { + "title": "Пользователи", + "searchPlaceholder": "Поиск по email...", + "email": "Email", + "uniqueIps": "Уникальных IP", + "ipCount": "IP", + "requests": "Запросов", + "limit": "Лимит", + "status": "Статус", + "bans": "Баны", + "lastSeen": "Последняя активность", + "overLimit": "Превышен лимит", + "ok": "OK", + "normal": "Норма", + "noLimit": "Без лимита", + "noUsers": "Пользователи не найдены", + "viewDetails": "Подробнее", + "networkType": "Тип сети", + "filter": { + "all": "Все", + "overLimit": "Превысили лимит", + "normal": "В норме" + } + }, + "userDetail": { + "title": "Детали пользователя", + "email": "Email", + "uniqueIps": "Уникальных IP", + "totalRequests": "Всего запросов", + "limit": "Лимит устройств", + "status": "Статус", + "networkType": "Тип сети", + "ipHistory": "История IP", + "recentRequests": "Последние запросы", + "ip": "IP адрес", + "firstSeen": "Первое подключение", + "lastSeen": "Последнее подключение", + "node": "Нода", + "requestCount": "Запросов", + "requests": "Запросов", + "country": "Страна", + "city": "Город", + "timestamp": "Время", + "sourceIp": "IP источника", + "destination": "Назначение", + "port": "Порт", + "protocol": "Протокол", + "action": "Действие", + "noIps": "Нет данных об IP", + "noRequests": "Нет данных о запросах", + "ban": "Забанить", + "close": "Закрыть" + }, + "punishments": { + "title": "Активные баны", + "user": "Пользователь", + "username": "Пользователь", + "reason": "Причина", + "punishedAt": "Забанен", + "bannedAt": "Забанен", + "enableAt": "Разбан в", + "ipCount": "IP", + "limit": "Лимит", + "node": "Нода", + "actions": "Действия", + "unban": "Разбанить", + "noPunishments": "Активных банов нет", + "noBans": "Активных банов нет", + "unbanConfirm": "Разбанить пользователя {{username}}?", + "unbanSuccess": "Пользователь разбанен", + "unbanError": "Ошибка разбана", + "history": "История банов", + "searchHistory": "Поиск истории...", + "noHistory": "История не найдена" + }, + "banModal": { + "title": "Забанить пользователя", + "username": "Username", + "usernamePlaceholder": "Введите username", + "duration": "Длительность (минуты)", + "durationPlaceholder": "Введите длительность в минутах", + "reason": "Причина", + "reasonPlaceholder": "Введите причину бана (опционально)", + "cancel": "Отмена", + "ban": "Забанить", + "success": "Пользователь забанен", + "error": "Ошибка бана" + }, + "nodes": { + "title": "Ноды", + "name": "Название", + "address": "Адрес", + "status": "Статус", + "lastSeen": "Последняя активность", + "usersCount": "Пользователей", + "users": "Пользователей", + "online": "Онлайн", + "offline": "Оффлайн", + "noNodes": "Ноды не найдены", + "total": "Всего", + "onlineCount": "Онлайн" + }, + "agents": { + "title": "Агенты", + "nodeName": "Нода", + "node": "Нода", + "status": "Статус", + "health": "Здоровье", + "sent": "Отправлено", + "dropped": "Отброшено", + "totalSent": "Всего отправлено", + "totalDropped": "Всего отброшено", + "batches": "Пакетов", + "reconnects": "Переподключений", + "failures": "Ошибок", + "queue": "Очередь", + "dedup": "Дедупликация", + "filter": "Фильтрация", + "lastReport": "Последний отчёт", + "online": "Онлайн", + "offline": "Оффлайн", + "healthy": "Здоровых", + "warning": "Предупреждение", + "critical": "Критический", + "noAgents": "Агенты не найдены", + "summary": { + "title": "Сводка", + "totalAgents": "Всего агентов", + "onlineAgents": "Онлайн", + "totalSent": "Отправлено", + "totalDropped": "Отброшено", + "avgQueueSize": "Средний размер очереди", + "healthyCount": "Здоровых", + "warningCount": "С предупреждениями", + "criticalCount": "Критических" + } + }, + "violations": { + "title": "Нарушения трафика", + "user": "Пользователь", + "username": "Пользователь", + "email": "Email", + "type": "Тип", + "description": "Описание", + "bytesUsed": "Использовано", + "bytesLimit": "Лимит", + "detectedAt": "Обнаружено", + "status": "Статус", + "resolved": "Решено", + "active": "Активно", + "noViolations": "Нарушений не обнаружено", + "yes": "Да", + "no": "Нет" + }, + "traffic": { + "title": "Статистика трафика", + "enabled": "Мониторинг трафика", + "topUsers": "Топ по трафику", + "username": "Пользователь", + "bytesTotal": "Всего", + "bytesLimit": "Лимит", + "status": "Статус", + "overLimit": "Превышен", + "ok": "OK", + "recentViolations": "Последние нарушения" + }, + "reports": { + "title": "Отчёты за период", + "period": "Период", + "currentUsers": "Активных пользователей", + "currentIps": "Уникальных IP", + "topViolators": "Топ нарушителей", + "username": "Пользователь", + "count": "Нарушений" + }, + "settings": { + "title": "Настройки системы", + "general": "Общие", + "limits": "Лимиты", + "notifications": "Уведомления", + "whitelist": "Белый список", + "saved": "Настройка сохранена", + "error": "Ошибка сохранения", + "categories": { + "general": "Общие настройки", + "punishment": "Наказания", + "progressive_bans": "Прогрессивные баны", + "traffic": "Мониторинг трафика", + "network": "Детекция сети", + "notifications": "Уведомления", + "rate_limit": "Лимиты запросов" + }, + "punishment_enabled": "Включить автобан", + "punishment_minutes": "Длительность бана (минуты)", + "ip_window_seconds": "Окно подсчёта IP (секунды)", + "notify_on_punishment": "Уведомлять о банах", + "notify_on_node_status": "Уведомлять о статусе нод", + "daily_report_enabled": "Ежедневный отчёт", + "daily_report_hour": "Час отправки отчёта", + "rate_limit_max": "Макс. запросов", + "rate_limit_window": "Окно лимита (сек)", + "heartbeat_timeout": "Таймаут heartbeat (сек)", + "progressive_bans_enabled": "Прогрессивные баны", + "progressive_ban_1": "1-й бан (минуты)", + "progressive_ban_2": "2-й бан (минуты)", + "progressive_ban_3": "3-й бан (минуты)", + "progressive_ban_window_hours": "Окно сброса (часы)", + "traffic_monitor_enabled": "Мониторинг трафика", + "traffic_limit_gb": "Лимит трафика (ГБ)", + "traffic_window_minutes": "Окно проверки (мин)", + "traffic_check_interval": "Интервал проверки (мин)", + "traffic_ban_minutes": "Бан за трафик (мин)", + "network_detection_enabled": "Детекция типа сети", + "network_detection_nodes": "Ноды для детекции", + "network_detection_monitor_all": "Мониторить все ноды", + "network_detection_collect_all": "Собирать со всех нод", + "network_notify_mobile": "Уведомлять о мобильных", + "network_block_mobile": "Блокировать мобильные", + "network_block_mobile_minutes": "Блок мобильных (мин)", + "network_notify_wifi": "Уведомлять о WiFi", + "network_block_wifi": "Блокировать WiFi", + "network_block_wifi_minutes": "Блок WiFi (мин)" + }, + "health": { + "title": "Состояние системы", + "systemStatus": "Статус системы", + "healthy": "Здорова", + "degraded": "Деградация", + "unhealthy": "Проблема", + "components": "Компоненты", + "uptime": "Аптайм" + } + }, "adminUsers": { "title": "Пользователи", "subtitle": "Управление пользователями", diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 2ff0b12..0a25598 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { adminAppsApi, AppDefinition, @@ -14,6 +15,12 @@ import { } from '../api/adminApps' // Icons +const BackIcon = () => ( + + + +) + const AppsIcon = () => ( @@ -640,7 +647,12 @@ export default function AdminApps() {
- + + +

{t('admin.apps.title')}

diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx new file mode 100644 index 0000000..99a87c7 --- /dev/null +++ b/src/pages/AdminBanSystem.tsx @@ -0,0 +1,1199 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { + banSystemApi, + type BanSystemStatus, + type BanSystemStats, + type BanUsersListResponse, + type BanUserDetailResponse, + type BanPunishmentsListResponse, + type BanNodesListResponse, + type BanAgentsListResponse, + type BanTrafficViolationsResponse, + type BanSettingsResponse, + type BanSettingDefinition, + type BanTrafficResponse, + type BanReportResponse, + type BanHealthResponse, +} from '../api/banSystem' + +// Icons +const ShieldIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const BanIcon = () => ( + + + +) + +const ServerIcon = () => ( + + + +) + +const AgentIcon = () => ( + + + +) + +const WarningIcon = () => ( + + + +) + +const RefreshIcon = () => ( + + + +) + +const ChartIcon = () => ( + + + +) + +const SearchIcon = () => ( + + + +) + +const SettingsIcon = () => ( + + + + +) + +const TrafficIcon = () => ( + + + +) + +const ReportIcon = () => ( + + + +) + +const HealthIcon = () => ( + + + +) + +type TabType = 'dashboard' | 'users' | 'punishments' | 'nodes' | 'agents' | 'violations' | 'settings' | 'traffic' | 'reports' | 'health' + +interface StatCardProps { + title: string + value: string | number + subtitle?: string + icon: React.ReactNode + color: 'accent' | 'success' | 'warning' | 'error' | 'info' +} + +function StatCard({ title, value, subtitle, icon, color }: StatCardProps) { + const colorClasses = { + accent: 'bg-accent-500/20 text-accent-400', + success: 'bg-success-500/20 text-success-400', + warning: 'bg-warning-500/20 text-warning-400', + error: 'bg-error-500/20 text-error-400', + info: 'bg-info-500/20 text-info-400', + } + + return ( +
+
+
+ {icon} +
+
+
{value}
+
{title}
+ {subtitle &&
{subtitle}
} +
+ ) +} + +export default function AdminBanSystem() { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState('dashboard') + const [status, setStatus] = useState(null) + const [stats, setStats] = useState(null) + const [users, setUsers] = useState(null) + const [selectedUser, setSelectedUser] = useState(null) + const [punishments, setPunishments] = useState(null) + const [nodes, setNodes] = useState(null) + const [agents, setAgents] = useState(null) + const [violations, setViolations] = useState(null) + const [settings, setSettings] = useState(null) + const [traffic, setTraffic] = useState(null) + const [report, setReport] = useState(null) + const [health, setHealth] = useState(null) + const [reportHours, setReportHours] = useState(24) + const [settingLoading, setSettingLoading] = useState(null) + const [loading, setLoading] = useState(true) + + // Format snake_case to readable label + const formatSettingKey = useCallback((key: string): string => { + // Try translation first + const translated = t(`banSystem.settings.${key}`, { defaultValue: '' }) + if (translated && translated !== `banSystem.settings.${key}`) { + return translated + } + // Fallback: convert snake_case to Title Case + return key + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') + }, [t]) + + const formatCategory = useCallback((category: string): string => { + const translated = t(`banSystem.settings.categories.${category}`, { defaultValue: '' }) + if (translated && translated !== `banSystem.settings.categories.${category}`) { + return translated + } + return category.charAt(0).toUpperCase() + category.slice(1).replace(/_/g, ' ') + }, [t]) + const [error, setError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [actionLoading, setActionLoading] = useState(null) + + useEffect(() => { + loadStatus() + }, []) + + useEffect(() => { + if (status?.enabled && status?.configured) { + loadTabData(activeTab) + } + }, [activeTab, status]) + + const loadStatus = async () => { + try { + setLoading(true) + const data = await banSystemApi.getStatus() + setStatus(data) + if (!data.enabled || !data.configured) { + setError(t('banSystem.notConfigured')) + } + } catch { + setError(t('banSystem.loadError')) + } finally { + setLoading(false) + } + } + + const loadTabData = async (tab: TabType) => { + try { + setLoading(true) + setError(null) + + switch (tab) { + case 'dashboard': { + const statsData = await banSystemApi.getStats() + setStats(statsData) + break + } + case 'users': { + const usersData = await banSystemApi.getUsers({ limit: 50 }) + setUsers(usersData) + break + } + case 'punishments': { + const punishmentsData = await banSystemApi.getPunishments() + setPunishments(punishmentsData) + break + } + case 'nodes': { + const nodesData = await banSystemApi.getNodes() + setNodes(nodesData) + break + } + case 'agents': { + const agentsData = await banSystemApi.getAgents() + setAgents(agentsData) + break + } + case 'violations': { + const violationsData = await banSystemApi.getTrafficViolations() + setViolations(violationsData) + break + } + case 'settings': { + const settingsData = await banSystemApi.getSettings() + setSettings(settingsData) + break + } + case 'traffic': { + const trafficData = await banSystemApi.getTraffic() + setTraffic(trafficData) + break + } + case 'reports': { + const reportData = await banSystemApi.getReport(reportHours) + setReport(reportData) + break + } + case 'health': { + const healthData = await banSystemApi.getHealth() + setHealth(healthData) + break + } + } + } catch { + setError(t('banSystem.loadError')) + } finally { + setLoading(false) + } + } + + const handleSearch = async () => { + if (!searchQuery.trim()) { + loadTabData('users') + return + } + try { + setLoading(true) + const data = await banSystemApi.searchUsers(searchQuery) + setUsers(data) + } catch { + setError(t('banSystem.loadError')) + } finally { + setLoading(false) + } + } + + const handleViewUser = async (email: string) => { + try { + setActionLoading(email) + const data = await banSystemApi.getUser(email) + setSelectedUser(data) + } catch { + setError(t('banSystem.loadError')) + } finally { + setActionLoading(null) + } + } + + const handleUnban = async (userId: string) => { + try { + setActionLoading(userId) + await banSystemApi.unbanUser(userId) + loadTabData('punishments') + } catch { + setError(t('banSystem.loadError')) + } finally { + setActionLoading(null) + } + } + + const handleToggleSetting = async (key: string) => { + try { + setSettingLoading(key) + await banSystemApi.toggleSetting(key) + loadTabData('settings') + } catch { + setError(t('banSystem.loadError')) + } finally { + setSettingLoading(null) + } + } + + const handleSetSetting = async (key: string, value: string) => { + try { + setSettingLoading(key) + await banSystemApi.setSetting(key, value) + loadTabData('settings') + } catch { + setError(t('banSystem.loadError')) + } finally { + setSettingLoading(null) + } + } + + const handleReportPeriodChange = (hours: number) => { + setReportHours(hours) + } + + useEffect(() => { + if (activeTab === 'reports' && status?.enabled) { + loadTabData('reports') + } + }, [reportHours]) + + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` + } + + const formatUptime = (seconds: number | null) => { + if (!seconds) return '-' + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours > 24) { + const days = Math.floor(hours / 24) + return `${days}d ${hours % 24}h` + } + return `${hours}h ${minutes}m` + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString() + } + + const tabs = [ + { id: 'dashboard' as TabType, label: t('banSystem.tabs.dashboard'), icon: }, + { id: 'users' as TabType, label: t('banSystem.tabs.users'), icon: }, + { id: 'punishments' as TabType, label: t('banSystem.tabs.punishments'), icon: }, + { id: 'nodes' as TabType, label: t('banSystem.tabs.nodes'), icon: }, + { id: 'agents' as TabType, label: t('banSystem.tabs.agents'), icon: }, + { id: 'violations' as TabType, label: t('banSystem.tabs.violations'), icon: }, + { id: 'traffic' as TabType, label: t('banSystem.tabs.traffic'), icon: }, + { id: 'reports' as TabType, label: t('banSystem.tabs.reports'), icon: }, + { id: 'settings' as TabType, label: t('banSystem.tabs.settings'), icon: }, + { id: 'health' as TabType, label: t('banSystem.tabs.health'), icon: }, + ] + + if (loading && !status) { + return ( +
+
+
+ ) + } + + if (error && !status?.enabled) { + return ( +
+
{error}
+

{t('banSystem.configureHint')}

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{t('banSystem.title')}

+

{t('banSystem.subtitle')}

+
+
+ +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Content */} + {loading ? ( +
+
+
+ ) : error ? ( +
{error}
+ ) : ( + <> + {/* Dashboard Tab */} + {activeTab === 'dashboard' && stats && ( +
+ } + color="success" + /> + } + color="warning" + /> + } + color="error" + /> + } + color="accent" + /> + } + color="info" + /> + } + color="accent" + /> + } + color={stats.panel_connected ? 'success' : 'error'} + /> + } + color="info" + /> +
+ )} + + {/* Users Tab */} + {activeTab === 'users' && ( +
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder={t('banSystem.users.searchPlaceholder')} + className="w-full pl-10 pr-4 py-2 bg-dark-800 border border-dark-700 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500" + /> +
+ +
+ + {/* Users Table */} +
+ + + + + + + + + + + + + {users?.users.map((user) => ( + + + + + + + + + ))} + +
{t('banSystem.users.email')}{t('banSystem.users.ipCount')}{t('banSystem.users.limit')}{t('banSystem.users.status')}{t('banSystem.users.bans')}{t('common.actions')}
{user.email}{user.unique_ip_count}{user.limit ?? '-'} + + {user.is_over_limit ? t('banSystem.users.overLimit') : t('banSystem.users.ok')} + + {user.blocked_count} + +
+ {(!users?.users || users.users.length === 0) && ( +
{t('common.noData')}
+ )} +
+
+ )} + + {/* Punishments Tab */} + {activeTab === 'punishments' && ( +
+ + + + + + + + + + + + + + {punishments?.punishments.map((p) => ( + + + + + + + + + + ))} + +
{t('banSystem.punishments.user')}{t('banSystem.punishments.reason')}{t('banSystem.punishments.ipCount')}{t('banSystem.punishments.limit')}{t('banSystem.punishments.bannedAt')}{t('banSystem.punishments.enableAt')}{t('common.actions')}
+
{p.username}
+
{p.user_id}
+
{p.reason || '-'}{p.ip_count}{p.limit}{formatDate(p.punished_at)}{formatDate(p.enable_at)} + +
+ {(!punishments?.punishments || punishments.punishments.length === 0) && ( +
{t('banSystem.punishments.noBans')}
+ )} +
+ )} + + {/* Nodes Tab */} + {activeTab === 'nodes' && ( +
+ {nodes?.nodes.map((node) => ( +
+
+
+
+
{node.name}
+
{node.address || '-'}
+
+
+
+
+
{t('banSystem.nodes.status')}
+
+ {node.is_connected ? t('banSystem.nodes.online') : t('banSystem.nodes.offline')} +
+
+
+
{t('banSystem.nodes.users')}
+
{node.users_count}
+
+
+
+ ))} + {(!nodes?.nodes || nodes.nodes.length === 0) && ( +
{t('banSystem.nodes.noNodes')}
+ )} +
+ )} + + {/* Agents Tab */} + {activeTab === 'agents' && ( +
+ {/* Summary */} + {agents?.summary && ( +
+ } + color="success" + /> + } + color="accent" + /> + } + color="warning" + /> + } + color="info" + /> +
+ )} + + {/* Agents List */} +
+ + + + + + + + + + + + + {agents?.agents.map((agent) => ( + + + + + + + + + ))} + +
{t('banSystem.agents.node')}{t('banSystem.agents.status')}{t('banSystem.agents.health')}{t('banSystem.agents.sent')}{t('banSystem.agents.dropped')}{t('banSystem.agents.queue')}
{agent.node_name} + + {agent.is_online ? t('banSystem.agents.online') : t('banSystem.agents.offline')} + + + + {agent.health} + + {agent.sent_total.toLocaleString()}{agent.dropped_total.toLocaleString()}{agent.queue_size}/{agent.queue_max}
+ {(!agents?.agents || agents.agents.length === 0) && ( +
{t('banSystem.agents.noAgents')}
+ )} +
+
+ )} + + {/* Violations Tab */} + {activeTab === 'violations' && ( +
+ + + + + + + + + + + + {violations?.violations.map((v, idx) => ( + + + + + + + + ))} + +
{t('banSystem.violations.user')}{t('banSystem.violations.type')}{t('banSystem.violations.description')}{t('banSystem.violations.detectedAt')}{t('banSystem.violations.status')}
+
{v.username}
+
{v.email || '-'}
+
{v.violation_type}{v.description || '-'}{formatDate(v.detected_at)} + + {v.resolved ? t('banSystem.violations.resolved') : t('banSystem.violations.active')} + +
+ {(!violations?.violations || violations.violations.length === 0) && ( +
{t('banSystem.violations.noViolations')}
+ )} +
+ )} + + {/* Traffic Tab */} + {activeTab === 'traffic' && traffic && ( +
+ {/* Traffic Stats */} +
+ } + color={traffic.enabled ? 'success' : 'warning'} + /> +
+ + {/* Top Users by Traffic */} + {traffic.top_users && traffic.top_users.length > 0 && ( +
+
+

{t('banSystem.traffic.topUsers')}

+
+ + + + + + + + + + + {traffic.top_users.map((user, idx) => ( + + + + + + + ))} + +
{t('banSystem.traffic.username')}{t('banSystem.traffic.bytesTotal')}{t('banSystem.traffic.bytesLimit')}{t('banSystem.traffic.status')}
{user.username}{formatBytes(user.bytes_total)}{user.bytes_limit ? formatBytes(user.bytes_limit) : '-'} + + {user.over_limit ? t('banSystem.traffic.overLimit') : t('banSystem.traffic.ok')} + +
+
+ )} + + {/* Recent Violations */} + {traffic.recent_violations && traffic.recent_violations.length > 0 && ( +
+
+

{t('banSystem.traffic.recentViolations')}

+
+ + + + + + + + + + {traffic.recent_violations.map((v, idx) => ( + + + + + + ))} + +
{t('banSystem.violations.user')}{t('banSystem.violations.type')}{t('banSystem.violations.detectedAt')}
{v.username}{v.violation_type}{formatDate(v.detected_at)}
+
+ )} + + {(!traffic.top_users || traffic.top_users.length === 0) && (!traffic.recent_violations || traffic.recent_violations.length === 0) && ( +
{t('common.noData')}
+ )} +
+ )} + + {/* Reports Tab */} + {activeTab === 'reports' && ( +
+ {/* Period Selector */} +
+ {t('banSystem.reports.period')}: +
+ {[6, 12, 24, 48, 72].map((hours) => ( + + ))} +
+
+ + {report && ( + <> + {/* Report Stats */} +
+ } + color="accent" + /> + } + color="info" + /> +
+ + {/* Top Violators */} + {report.top_violators && report.top_violators.length > 0 && ( +
+
+

{t('banSystem.reports.topViolators')}

+
+ + + + + + + + + {report.top_violators.map((v, idx) => ( + + + + + ))} + +
{t('banSystem.reports.username')}{t('banSystem.reports.count')}
{v.username}{v.count}
+
+ )} + + )} +
+ )} + + {/* Settings Tab */} + {activeTab === 'settings' && settings && ( +
+ {/* Group settings by category */} + {(() => { + const grouped: Record = {} + + // Smart categorization: use API category or infer from key prefix + const inferCategory = (key: string, apiCategory: string | null): string => { + if (apiCategory) return apiCategory + if (key.startsWith('punishment_') || key.startsWith('progressive_ban')) return 'punishment' + if (key.startsWith('traffic_')) return 'traffic' + if (key.startsWith('network_')) return 'network' + if (key.startsWith('rate_limit_')) return 'rate_limit' + if (key.startsWith('notify_') || key.startsWith('daily_report')) return 'notifications' + return 'general' + } + + settings.settings.forEach((s) => { + const cat = inferCategory(s.key, s.category) + if (!grouped[cat]) grouped[cat] = [] + grouped[cat].push(s) + }) + + // Sort categories in logical order + const categoryOrder = ['general', 'punishment', 'progressive_bans', 'traffic', 'network', 'notifications', 'rate_limit'] + const sortedCategories = Object.keys(grouped).sort((a, b) => { + const aIdx = categoryOrder.indexOf(a) + const bIdx = categoryOrder.indexOf(b) + if (aIdx === -1 && bIdx === -1) return a.localeCompare(b) + if (aIdx === -1) return 1 + if (bIdx === -1) return -1 + return aIdx - bIdx + }) + + return sortedCategories.map((category) => ( +
+
+

{formatCategory(category)}

+
+
+ {grouped[category].map((setting) => ( +
+
+
{formatSettingKey(setting.key)}
+ {setting.description && ( +
{setting.description}
+ )} +
+
+ {setting.type === 'bool' ? ( + + ) : setting.type === 'int' ? ( + handleSetSetting(setting.key, e.target.value)} + min={setting.min_value ?? undefined} + max={setting.max_value ?? undefined} + disabled={!setting.editable || settingLoading === setting.key} + className="w-24 px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-dark-100 text-sm focus:outline-none focus:border-accent-500 disabled:opacity-50" + /> + ) : setting.type === 'list' ? ( +
+ {Array.isArray(setting.value) && setting.value.length > 0 ? ( + setting.value.map((item, idx) => ( + + {String(item)} + + )) + ) : ( + {t('common.noData')} + )} + {setting.editable && nodes && setting.key.includes('nodes') && ( + + )} +
+ ) : ( +
{String(setting.value)}
+ )} +
+
+ ))} +
+
+ )) + })()} +
+ )} + + {/* Health Tab */} + {activeTab === 'health' && health && ( +
+ {/* Overall Status */} +
+
+
+
+
+
{t('banSystem.health.systemStatus')}
+
+ {health.status.toUpperCase()} +
+
+
+ {health.uptime !== null && ( +
+
{t('banSystem.stats.uptime')}
+
{formatUptime(health.uptime)}
+
+ )} +
+
+ + {/* Components Status */} + {health.components && health.components.length > 0 && ( +
+ {health.components.map((comp, idx) => ( +
+
+
+
{comp.name}
+
+
+ {comp.status} +
+ {comp.message && ( +
{comp.message}
+ )} +
+ ))} +
+ )} +
+ )} + + )} + + {/* User Detail Modal */} + {selectedUser && ( +
setSelectedUser(null)}> +
e.stopPropagation()}> +
+

{t('banSystem.userDetail.title')}

+ +
+
+ {/* User Info */} +
+
+
{t('banSystem.users.email')}
+
{selectedUser.email}
+
+
+
{t('banSystem.users.limit')}
+
{selectedUser.limit ?? '-'}
+
+
+
{t('banSystem.users.ipCount')}
+
{selectedUser.unique_ip_count}
+
+
+
{t('banSystem.users.networkType')}
+
{selectedUser.network_type || '-'}
+
+
+ + {/* IP History */} +
+

{t('banSystem.userDetail.ipHistory')}

+
+ + + + + + + + + + + {selectedUser.ips.map((ip, idx) => ( + + + + + + + ))} + +
{t('banSystem.userDetail.ip')}{t('banSystem.userDetail.country')}{t('banSystem.userDetail.node')}{t('banSystem.userDetail.requests')}
{ip.ip}{ip.country_name || ip.country_code || '-'}{ip.node || '-'}{ip.request_count}
+ {selectedUser.ips.length === 0 && ( +
{t('common.noData')}
+ )} +
+
+
+
+
+ )} +
+ ) +} diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx index 1451d5f..9618bd6 100644 --- a/src/pages/AdminBroadcasts.tsx +++ b/src/pages/AdminBroadcasts.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { adminBroadcastsApi, Broadcast, @@ -10,6 +11,12 @@ import { } from '../api/adminBroadcasts' // Icons +const BackIcon = () => ( + + + +) + const BroadcastIcon = () => ( @@ -618,9 +625,12 @@ export default function AdminBroadcasts() { {/* Header */}
-
- -
+ + +

{t('admin.broadcasts.title')}

{t('admin.broadcasts.subtitle')}

diff --git a/src/pages/AdminCampaigns.tsx b/src/pages/AdminCampaigns.tsx index d3ab946..a7a7fe6 100644 --- a/src/pages/AdminCampaigns.tsx +++ b/src/pages/AdminCampaigns.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from 'react-router-dom' import { campaignsApi, CampaignListItem, @@ -14,9 +15,9 @@ import { } from '../api/campaigns' // Icons -const CampaignIcon = () => ( - - +const BackIcon = () => ( + + ) @@ -741,9 +742,12 @@ export default function AdminCampaigns() { {/* Header */}
-
- -
+ + +

Рекламные кампании

Управление рекламными ссылками

diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 97b0956..6a8e6c9 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { statsApi, type DashboardStats, type NodeStatus } from '../api/admin' import { useCurrency } from '../hooks/useCurrency' // Icons -const ChartIcon = () => ( - - +const BackIcon = () => ( + + ) @@ -292,9 +293,12 @@ export default function AdminDashboard() { {/* Header */}
-
- -
+ + +

{t('adminDashboard.title')}

{t('adminDashboard.subtitle')}

diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 0cb5fa7..ab10f97 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -51,6 +51,12 @@ const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( @@ -272,6 +278,16 @@ export default function AdminPanel() { bgColor: 'bg-indigo-500/20', textColor: 'text-indigo-400' }, + { + to: '/admin/ban-system', + icon: , + mobileIcon: , + title: t('admin.nav.banSystem'), + description: t('admin.panel.banSystemDesc'), + color: 'error', + bgColor: 'bg-red-500/20', + textColor: 'text-red-400' + }, { to: '/admin/payments', icon: , diff --git a/src/pages/AdminPromoOffers.tsx b/src/pages/AdminPromoOffers.tsx index 3e265b4..fe00d0f 100644 --- a/src/pages/AdminPromoOffers.tsx +++ b/src/pages/AdminPromoOffers.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from 'react-router-dom' import { promoOffersApi, PromoOfferTemplate, @@ -12,9 +13,9 @@ import { } from '../api/promoOffers' // Icons -const GiftIcon = () => ( - - +const BackIcon = () => ( + + ) @@ -576,9 +577,12 @@ export default function AdminPromoOffers() { {/* Header */}
-
- -
+ + +

Промопредложения

Шаблоны, рассылка и логи предложений

diff --git a/src/pages/AdminPromocodes.tsx b/src/pages/AdminPromocodes.tsx index 12575cd..ab7122d 100644 --- a/src/pages/AdminPromocodes.tsx +++ b/src/pages/AdminPromocodes.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from 'react-router-dom' import { promocodesApi, PromoCode, @@ -13,9 +14,9 @@ import { } from '../api/promocodes' // Icons -const TicketIcon = () => ( - - +const BackIcon = () => ( + + ) @@ -800,9 +801,12 @@ export default function AdminPromocodes() { {/* Header */}
-
- -
+ + +

Промокоды

Управление промокодами и группами скидок

diff --git a/src/pages/AdminServers.tsx b/src/pages/AdminServers.tsx index 3709ffa..c715d0d 100644 --- a/src/pages/AdminServers.tsx +++ b/src/pages/AdminServers.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { serversApi, ServerListItem, @@ -9,9 +10,9 @@ import { } from '../api/servers' // Icons -const ServerIcon = () => ( - - +const BackIcon = () => ( + + ) @@ -322,9 +323,12 @@ export default function AdminServers() { {/* Header */}
-
- -
+ + +

{t('admin.servers.title')}

{t('admin.servers.subtitle')}

diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 7dff564..e8cfa56 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings' import { brandingApi } from '../api/branding' import { themeColorsApi } from '../api/themeColors' @@ -10,6 +11,12 @@ import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' // Icons +const BackIcon = () => ( + + + +) + const CogIcon = () => ( @@ -1033,7 +1040,12 @@ export default function AdminSettings() {
{/* Header */}
- + + +

{t('admin.settings.title')}

diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index 8994e94..7c7d514 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { tariffsApi, TariffListItem, @@ -13,9 +14,9 @@ import { } from '../api/tariffs' // Icons -const TariffIcon = () => ( - - +const BackIcon = () => ( + + ) @@ -1187,9 +1188,12 @@ export default function AdminTariffs() { {/* Header */}

-
- -
+ + +

{t('admin.tariffs.title')}

{t('admin.tariffs.subtitle')}

diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 83f2d9c..9d6b622 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -1,9 +1,16 @@ import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin' import { ticketsApi } from '../api/tickets' +const BackIcon = () => ( + + + +) + function AdminMessageMedia({ message, t }: { message: AdminTicketMessage; t: (key: string) => string }) { const [imageLoaded, setImageLoaded] = useState(false) const [imageError, setImageError] = useState(false) @@ -175,7 +182,15 @@ export default function AdminTickets() { return (
-

{t('admin.tickets.title')}

+
+ + + +

{t('admin.tickets.title')}

+