From 34c128fd87a672fc8b70f05404897bc1703876e8 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 16:03:51 +0300 Subject: [PATCH 1/8] =?UTF-8?q?=20=D0=98=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 9 + src/api/banSystem.ts | 272 +++++++++++++ src/locales/en.json | 173 ++++++++- src/locales/ru.json | 173 ++++++++- src/pages/AdminBanSystem.tsx | 722 +++++++++++++++++++++++++++++++++++ src/pages/AdminPanel.tsx | 13 + 6 files changed, 1358 insertions(+), 4 deletions(-) create mode 100644 src/api/banSystem.ts create mode 100644 src/pages/AdminBanSystem.tsx diff --git a/src/App.tsx b/src/App.tsx index ae5c905..3c010d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,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' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -217,6 +218,14 @@ function App() { } /> + + + + } + /> {/* Catch all */} } /> diff --git a/src/api/banSystem.ts b/src/api/banSystem.ts new file mode 100644 index 0000000..18137fd --- /dev/null +++ b/src/api/banSystem.ts @@ -0,0 +1,272 @@ +import apiClient from './client' + +// === Types === + +export interface BanSystemStatus { + enabled: boolean + configured: boolean +} + +export interface BanSystemStats { + total_users: number + active_users: number + users_over_limit: number + total_requests: number + total_punishments: number + active_punishments: number + nodes_online: number + nodes_total: number + agents_online: number + agents_total: number + panel_connected: boolean + uptime_seconds: number | null +} + +export interface BanUserIPInfo { + ip: string + first_seen: string | null + last_seen: string | null + node: string | null + request_count: number + country_code: string | null + country_name: string | null + city: string | null +} + +export interface BanUserRequestLog { + timestamp: string + source_ip: string + destination: string | null + dest_port: number | null + protocol: string | null + action: string | null + node: string | null +} + +export interface BanUserListItem { + email: string + unique_ip_count: number + total_requests: number + limit: number | null + is_over_limit: boolean + blocked_count: number + last_seen: string | null +} + +export interface BanUsersListResponse { + users: BanUserListItem[] + total: number + offset: number + limit: number +} + +export interface BanUserDetailResponse { + email: string + unique_ip_count: number + total_requests: number + limit: number | null + is_over_limit: boolean + blocked_count: number + ips: BanUserIPInfo[] + recent_requests: BanUserRequestLog[] + network_type: string | null +} + +export interface BanPunishmentItem { + id: number | null + user_id: string + uuid: string | null + username: string + reason: string | null + punished_at: string + enable_at: string | null + ip_count: number + limit: number + enabled: boolean + enabled_at: string | null + node_name: string | null +} + +export interface BanPunishmentsListResponse { + punishments: BanPunishmentItem[] + total: number +} + +export interface BanHistoryResponse { + items: BanPunishmentItem[] + total: number +} + +export interface BanUserRequest { + username: string + minutes: number + reason?: string +} + +export interface UnbanResponse { + success: boolean + message: string +} + +export interface BanNodeItem { + name: string + address: string | null + is_connected: boolean + last_seen: string | null + users_count: number + agent_stats: Record | 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 +} + +// === 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 + }, +} diff --git a/src/locales/en.json b/src/locales/en.json index 3c1449a..cd9293d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -439,7 +439,8 @@ "apps": "Apps", "wheel": "Wheel", "tariffs": "Tariffs", - "servers": "Servers" + "servers": "Servers", + "banSystem": "Ban Monitoring" }, "panel": { "title": "Admin Panel", @@ -450,7 +451,8 @@ "appsDesc": "Manage connection apps", "wheelDesc": "Configure fortune wheel and prizes", "tariffsDesc": "Manage tariff plans", - "serversDesc": "Configure VPN servers" + "serversDesc": "Configure VPN servers", + "banSystemDesc": "Ban monitoring and violations" }, "wheel": { "title": "Fortune Wheel Settings", @@ -744,6 +746,173 @@ "noTariffs": "No tariffs" } }, + "banSystem": { + "title": "Ban Monitoring", + "subtitle": "BedolagaBan system management", + "notConfigured": "Ban system is not configured", + "notConfiguredDesc": "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" + }, + "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", + "requests": "Requests", + "limit": "Limit", + "status": "Status", + "lastSeen": "Last Seen", + "overLimit": "Over Limit", + "normal": "Normal", + "noLimit": "No Limit", + "noUsers": "No users found", + "viewDetails": "View Details", + "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", + "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", + "username": "User", + "reason": "Reason", + "punishedAt": "Banned At", + "enableAt": "Unban At", + "ipCount": "IPs", + "limit": "Limit", + "node": "Node", + "actions": "Actions", + "unban": "Unban", + "noPunishments": "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", + "online": "Online", + "offline": "Offline", + "noNodes": "No nodes found", + "total": "Total", + "onlineCount": "Online" + }, + "agents": { + "title": "Agents", + "nodeName": "Node", + "status": "Status", + "health": "Health", + "sent": "Sent", + "dropped": "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", + "username": "User", + "email": "Email", + "type": "Type", + "description": "Description", + "bytesUsed": "Used", + "bytesLimit": "Limit", + "detectedAt": "Detected At", + "resolved": "Resolved", + "noViolations": "No violations found", + "yes": "Yes", + "no": "No" + } + }, "profile": { "title": "Profile", "accountInfo": "Account Information", diff --git a/src/locales/ru.json b/src/locales/ru.json index c070a8b..6f07e37 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -439,7 +439,8 @@ "apps": "Приложения", "wheel": "Колесо", "tariffs": "Тарифы", - "servers": "Серверы" + "servers": "Серверы", + "banSystem": "Мониторинг банов" }, "panel": { "title": "Панель администратора", @@ -450,7 +451,8 @@ "appsDesc": "Управление приложениями для подключения", "wheelDesc": "Настройка колеса удачи и призов", "tariffsDesc": "Управление тарифными планами", - "serversDesc": "Настройка VPN серверов" + "serversDesc": "Настройка VPN серверов", + "banSystemDesc": "Мониторинг банов и нарушений" }, "wheel": { "title": "Настройки колеса удачи", @@ -744,6 +746,173 @@ "noTariffs": "Нет тарифов" } }, + "banSystem": { + "title": "Мониторинг банов", + "subtitle": "Управление системой банов BedolagaBan", + "notConfigured": "Ban система не настроена", + "notConfiguredDesc": "Укажите BAN_SYSTEM_API_URL и BAN_SYSTEM_API_TOKEN в конфигурации", + "loadError": "Не удалось загрузить данные", + "tabs": { + "dashboard": "Статистика", + "users": "Пользователи", + "punishments": "Баны", + "nodes": "Ноды", + "agents": "Агенты", + "violations": "Нарушения" + }, + "dashboard": { + "totalUsers": "Всего пользователей", + "activeUsers": "Активных пользователей", + "usersOverLimit": "Превысили лимит", + "totalRequests": "Всего запросов", + "totalPunishments": "Всего наказаний", + "activePunishments": "Активных банов", + "nodesOnline": "Нод онлайн", + "agentsOnline": "Агентов онлайн", + "panelConnected": "Панель подключена", + "panelDisconnected": "Панель отключена", + "uptime": "Аптайм" + }, + "users": { + "title": "Пользователи", + "searchPlaceholder": "Поиск по email...", + "email": "Email", + "uniqueIps": "Уникальных IP", + "requests": "Запросов", + "limit": "Лимит", + "status": "Статус", + "lastSeen": "Последняя активность", + "overLimit": "Превышен лимит", + "normal": "Норма", + "noLimit": "Без лимита", + "noUsers": "Пользователи не найдены", + "viewDetails": "Подробнее", + "filter": { + "all": "Все", + "overLimit": "Превысили лимит", + "normal": "В норме" + } + }, + "userDetail": { + "title": "Детали пользователя", + "email": "Email", + "uniqueIps": "Уникальных IP", + "totalRequests": "Всего запросов", + "limit": "Лимит устройств", + "status": "Статус", + "networkType": "Тип сети", + "ipHistory": "История IP", + "recentRequests": "Последние запросы", + "ip": "IP адрес", + "firstSeen": "Первое подключение", + "lastSeen": "Последнее подключение", + "node": "Нода", + "requestCount": "Запросов", + "country": "Страна", + "city": "Город", + "timestamp": "Время", + "sourceIp": "IP источника", + "destination": "Назначение", + "port": "Порт", + "protocol": "Протокол", + "action": "Действие", + "noIps": "Нет данных об IP", + "noRequests": "Нет данных о запросах", + "ban": "Забанить", + "close": "Закрыть" + }, + "punishments": { + "title": "Активные баны", + "username": "Пользователь", + "reason": "Причина", + "punishedAt": "Забанен", + "enableAt": "Разбан в", + "ipCount": "IP", + "limit": "Лимит", + "node": "Нода", + "actions": "Действия", + "unban": "Разбанить", + "noPunishments": "Активных банов нет", + "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": "Пользователей", + "online": "Онлайн", + "offline": "Оффлайн", + "noNodes": "Ноды не найдены", + "total": "Всего", + "onlineCount": "Онлайн" + }, + "agents": { + "title": "Агенты", + "nodeName": "Нода", + "status": "Статус", + "health": "Здоровье", + "sent": "Отправлено", + "dropped": "Отброшено", + "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": "Нарушения трафика", + "username": "Пользователь", + "email": "Email", + "type": "Тип", + "description": "Описание", + "bytesUsed": "Использовано", + "bytesLimit": "Лимит", + "detectedAt": "Обнаружено", + "resolved": "Решено", + "noViolations": "Нарушений не обнаружено", + "yes": "Да", + "no": "Нет" + } + }, "profile": { "title": "Профиль", "accountInfo": "Информация об аккаунте", diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx new file mode 100644 index 0000000..05869e4 --- /dev/null +++ b/src/pages/AdminBanSystem.tsx @@ -0,0 +1,722 @@ +import { useState, useEffect } 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, +} from '../api/banSystem' + +// Icons +const ShieldIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const BanIcon = () => ( + + + +) + +const ServerIcon = () => ( + + + +) + +const AgentIcon = () => ( + + + +) + +const WarningIcon = () => ( + + + +) + +const RefreshIcon = () => ( + + + +) + +const ChartIcon = () => ( + + + +) + +const SearchIcon = () => ( + + + +) + +type TabType = 'dashboard' | 'users' | 'punishments' | 'nodes' | 'agents' | 'violations' + +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 [loading, setLoading] = useState(true) + 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 + } + } 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 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: }, + ] + + 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')}
+ )} +
+ )} + + )} + + {/* 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/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 98fba07..4394ca1 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -51,6 +51,12 @@ const ChartIcon = () => ( ) +const BanSystemIcon = () => ( + + + +) + interface AdminCardProps { to: string icon: React.ReactNode @@ -129,6 +135,13 @@ export default function AdminPanel() { description: t('admin.panel.serversDesc'), color: 'purple' }, + { + to: '/admin/ban-system', + icon: , + title: t('admin.nav.banSystem'), + description: t('admin.panel.banSystemDesc'), + color: 'error' + }, ] return ( From 246c51d642682031e192fa0c3e853b80ece96177 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 18:20:28 +0300 Subject: [PATCH 2/8] =?UTF-8?q?=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE:=20=20=20-=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B5=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20banSystem.stats=20=D1=81=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20=20=20-=20=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=8E=D1=89=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B8:=20ipCount,=20bans,=20ok,=20networkTyp?= =?UTF-8?q?e,=20user,=20bannedAt,=20noBans,=20users,=20node,=20totalSent,?= =?UTF-8?q?=20totalDropped,=20status,=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.json | 30 +++++++++++++++++++++++++++++- src/locales/ru.json | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index cd9293d..bfa40cd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -750,7 +750,7 @@ "title": "Ban Monitoring", "subtitle": "BedolagaBan system management", "notConfigured": "Ban system is not configured", - "notConfiguredDesc": "Set BAN_SYSTEM_API_URL and BAN_SYSTEM_API_TOKEN in configuration", + "configureHint": "Set BAN_SYSTEM_API_URL and BAN_SYSTEM_API_TOKEN in configuration", "loadError": "Failed to load data", "tabs": { "dashboard": "Dashboard", @@ -760,6 +760,19 @@ "agents": "Agents", "violations": "Violations" }, + "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", @@ -778,15 +791,19 @@ "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", @@ -808,6 +825,7 @@ "lastSeen": "Last Seen", "node": "Node", "requestCount": "Requests", + "requests": "Requests", "country": "Country", "city": "City", "timestamp": "Timestamp", @@ -823,9 +841,11 @@ }, "punishments": { "title": "Active Bans", + "user": "User", "username": "User", "reason": "Reason", "punishedAt": "Banned At", + "bannedAt": "Banned At", "enableAt": "Unban At", "ipCount": "IPs", "limit": "Limit", @@ -833,6 +853,7 @@ "actions": "Actions", "unban": "Unban", "noPunishments": "No active bans", + "noBans": "No active bans", "unbanConfirm": "Unban user {{username}}?", "unbanSuccess": "User unbanned", "unbanError": "Unban failed", @@ -860,6 +881,7 @@ "status": "Status", "lastSeen": "Last Seen", "usersCount": "Users", + "users": "Users", "online": "Online", "offline": "Offline", "noNodes": "No nodes found", @@ -869,10 +891,13 @@ "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", @@ -900,6 +925,7 @@ }, "violations": { "title": "Traffic Violations", + "user": "User", "username": "User", "email": "Email", "type": "Type", @@ -907,7 +933,9 @@ "bytesUsed": "Used", "bytesLimit": "Limit", "detectedAt": "Detected At", + "status": "Status", "resolved": "Resolved", + "active": "Active", "noViolations": "No violations found", "yes": "Yes", "no": "No" diff --git a/src/locales/ru.json b/src/locales/ru.json index 6f07e37..4de17cd 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -750,7 +750,7 @@ "title": "Мониторинг банов", "subtitle": "Управление системой банов BedolagaBan", "notConfigured": "Ban система не настроена", - "notConfiguredDesc": "Укажите BAN_SYSTEM_API_URL и BAN_SYSTEM_API_TOKEN в конфигурации", + "configureHint": "Укажите BAN_SYSTEM_API_URL и BAN_SYSTEM_API_TOKEN в конфигурации", "loadError": "Не удалось загрузить данные", "tabs": { "dashboard": "Статистика", @@ -760,6 +760,19 @@ "agents": "Агенты", "violations": "Нарушения" }, + "stats": { + "activeUsers": "Активных пользователей", + "total": "Всего", + "usersOverLimit": "Превысили лимит", + "activeBans": "Активных банов", + "nodesOnline": "Нод онлайн", + "agentsOnline": "Агентов онлайн", + "totalRequests": "Всего запросов", + "panelStatus": "Статус панели", + "connected": "Подключена", + "disconnected": "Отключена", + "uptime": "Аптайм" + }, "dashboard": { "totalUsers": "Всего пользователей", "activeUsers": "Активных пользователей", @@ -778,15 +791,19 @@ "searchPlaceholder": "Поиск по email...", "email": "Email", "uniqueIps": "Уникальных IP", + "ipCount": "IP", "requests": "Запросов", "limit": "Лимит", "status": "Статус", + "bans": "Баны", "lastSeen": "Последняя активность", "overLimit": "Превышен лимит", + "ok": "OK", "normal": "Норма", "noLimit": "Без лимита", "noUsers": "Пользователи не найдены", "viewDetails": "Подробнее", + "networkType": "Тип сети", "filter": { "all": "Все", "overLimit": "Превысили лимит", @@ -808,6 +825,7 @@ "lastSeen": "Последнее подключение", "node": "Нода", "requestCount": "Запросов", + "requests": "Запросов", "country": "Страна", "city": "Город", "timestamp": "Время", @@ -823,9 +841,11 @@ }, "punishments": { "title": "Активные баны", + "user": "Пользователь", "username": "Пользователь", "reason": "Причина", "punishedAt": "Забанен", + "bannedAt": "Забанен", "enableAt": "Разбан в", "ipCount": "IP", "limit": "Лимит", @@ -833,6 +853,7 @@ "actions": "Действия", "unban": "Разбанить", "noPunishments": "Активных банов нет", + "noBans": "Активных банов нет", "unbanConfirm": "Разбанить пользователя {{username}}?", "unbanSuccess": "Пользователь разбанен", "unbanError": "Ошибка разбана", @@ -860,6 +881,7 @@ "status": "Статус", "lastSeen": "Последняя активность", "usersCount": "Пользователей", + "users": "Пользователей", "online": "Онлайн", "offline": "Оффлайн", "noNodes": "Ноды не найдены", @@ -869,10 +891,13 @@ "agents": { "title": "Агенты", "nodeName": "Нода", + "node": "Нода", "status": "Статус", "health": "Здоровье", "sent": "Отправлено", "dropped": "Отброшено", + "totalSent": "Всего отправлено", + "totalDropped": "Всего отброшено", "batches": "Пакетов", "reconnects": "Переподключений", "failures": "Ошибок", @@ -882,7 +907,7 @@ "lastReport": "Последний отчёт", "online": "Онлайн", "offline": "Оффлайн", - "healthy": "Здоров", + "healthy": "Здоровых", "warning": "Предупреждение", "critical": "Критический", "noAgents": "Агенты не найдены", @@ -900,6 +925,7 @@ }, "violations": { "title": "Нарушения трафика", + "user": "Пользователь", "username": "Пользователь", "email": "Email", "type": "Тип", @@ -907,7 +933,9 @@ "bytesUsed": "Использовано", "bytesLimit": "Лимит", "detectedAt": "Обнаружено", + "status": "Статус", "resolved": "Решено", + "active": "Активно", "noViolations": "Нарушений не обнаружено", "yes": "Да", "no": "Нет" From 5de8a2e576bbf5ba31df98a671d2e0f9e3c9b8cb Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 20:52:23 +0300 Subject: [PATCH 3/8] Add traffic, reports, settings, and health features to ban system admin panel - Introduced new interfaces for traffic, reports, settings, and health responses in the ban system API. - Implemented API calls for fetching traffic statistics, top users, settings management, and health status. - Enhanced the AdminBanSystem component to display traffic statistics, recent violations, report summaries, and system health. - Updated localization files to include new keys for traffic, reports, settings, and health sections. --- src/api/banSystem.ts | 173 ++++++++++++++++ src/locales/en.json | 48 ++++- src/locales/ru.json | 48 ++++- src/pages/AdminBanSystem.tsx | 390 ++++++++++++++++++++++++++++++++++- 4 files changed, 654 insertions(+), 5 deletions(-) diff --git a/src/api/banSystem.ts b/src/api/banSystem.ts index 18137fd..af6e98d 100644 --- a/src/api/banSystem.ts +++ b/src/api/banSystem.ts @@ -176,6 +176,97 @@ export interface BanTrafficViolationsResponse { 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 = { @@ -269,4 +360,86 @@ export const banSystemApi = { }) 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/locales/en.json b/src/locales/en.json index bfa40cd..1152287 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -18,7 +18,8 @@ "and": "and", "edit": "Edit", "delete": "Delete", - "currency": "$" + "currency": "$", + "refresh": "Refresh" }, "nav": { "dashboard": "Dashboard", @@ -758,7 +759,11 @@ "punishments": "Bans", "nodes": "Nodes", "agents": "Agents", - "violations": "Violations" + "violations": "Violations", + "traffic": "Traffic", + "reports": "Reports", + "settings": "Settings", + "health": "Health" }, "stats": { "activeUsers": "Active Users", @@ -939,6 +944,45 @@ "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" + }, + "health": { + "title": "System Health", + "systemStatus": "System Status", + "healthy": "Healthy", + "degraded": "Degraded", + "unhealthy": "Unhealthy", + "components": "Components", + "uptime": "Uptime" } }, "profile": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 4de17cd..ac4ce57 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -18,7 +18,8 @@ "and": "и", "edit": "Редактировать", "delete": "Удалить", - "currency": "₽" + "currency": "₽", + "refresh": "Обновить" }, "nav": { "dashboard": "Главная", @@ -758,7 +759,11 @@ "punishments": "Баны", "nodes": "Ноды", "agents": "Агенты", - "violations": "Нарушения" + "violations": "Нарушения", + "traffic": "Трафик", + "reports": "Отчёты", + "settings": "Настройки", + "health": "Здоровье" }, "stats": { "activeUsers": "Активных пользователей", @@ -939,6 +944,45 @@ "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": "Ошибка сохранения" + }, + "health": { + "title": "Состояние системы", + "systemStatus": "Статус системы", + "healthy": "Здорова", + "degraded": "Деградация", + "unhealthy": "Проблема", + "components": "Компоненты", + "uptime": "Аптайм" } }, "profile": { diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 05869e4..594834a 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -10,6 +10,11 @@ import { type BanNodesListResponse, type BanAgentsListResponse, type BanTrafficViolationsResponse, + type BanSettingsResponse, + type BanSettingDefinition, + type BanTrafficResponse, + type BanReportResponse, + type BanHealthResponse, } from '../api/banSystem' // Icons @@ -67,7 +72,32 @@ const SearchIcon = () => ( ) -type TabType = 'dashboard' | 'users' | 'punishments' | 'nodes' | 'agents' | 'violations' +const SettingsIcon = () => ( + + + + +) + +const TrafficIcon = () => ( + + + +) + +const ReportIcon = () => ( + + + +) + +const HealthIcon = () => ( + + + +) + +type TabType = 'dashboard' | 'users' | 'punishments' | 'nodes' | 'agents' | 'violations' | 'settings' | 'traffic' | 'reports' | 'health' interface StatCardProps { title: string @@ -111,6 +141,12 @@ export default function AdminBanSystem() { 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) const [error, setError] = useState(null) const [searchQuery, setSearchQuery] = useState('') @@ -171,6 +207,22 @@ export default function AdminBanSystem() { 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')) @@ -219,6 +271,48 @@ export default function AdminBanSystem() { } } + 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) @@ -242,6 +336,10 @@ export default function AdminBanSystem() { { 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) { @@ -648,6 +746,296 @@ export default function AdminBanSystem() { )}
)} + + {/* 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 = {} + settings.settings.forEach((s) => { + const cat = s.category || 'general' + if (!grouped[cat]) grouped[cat] = [] + grouped[cat].push(s) + }) + + return Object.entries(grouped).map(([category, items]) => ( +
+
+

{category}

+
+
+ {items.map((setting) => ( +
+
+
{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.join(', ') : String(setting.value)} +
+ ) : ( +
{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}
+ )} +
+ ))} +
+ )} +
+ )} )} From fd68d3ec7c454c4b3495e500320be382230d963f Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 21:05:39 +0300 Subject: [PATCH 4/8] Refactor CI workflow and enhance AdminBanSystem component - Updated CI workflow to use 'dev' branch instead of 'develop' and renamed job from 'lint-and-typecheck' to 'lint-and-build'. - Simplified linting and type checking steps in the CI process. - Refactored switch cases in AdminBanSystem component for better readability and structure, ensuring consistent handling of API calls for various tabs. --- .github/workflows/ci.yml | 43 ++++++------------------------------ src/pages/AdminBanSystem.tsx | 30 ++++++++++++++++--------- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7375c4..a593c22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,12 @@ 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: @@ -24,39 +24,10 @@ jobs: run: npm ci - name: Run ESLint - run: npm run lint -- --max-warnings 50 + run: npm run lint - - name: Type check - run: npm run type-check + - name: Run TypeScript check + run: npx tsc --noEmit - 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 + - 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: - name: dist - path: dist/ - retention-days: 7 diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 594834a..ec1c3d1 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -183,46 +183,56 @@ export default function AdminBanSystem() { setError(null) switch (tab) { - case 'dashboard': + case 'dashboard': { const statsData = await banSystemApi.getStats() setStats(statsData) break - case 'users': + } + case 'users': { const usersData = await banSystemApi.getUsers({ limit: 50 }) setUsers(usersData) break - case 'punishments': + } + case 'punishments': { const punishmentsData = await banSystemApi.getPunishments() setPunishments(punishmentsData) break - case 'nodes': + } + case 'nodes': { const nodesData = await banSystemApi.getNodes() setNodes(nodesData) break - case 'agents': + } + case 'agents': { const agentsData = await banSystemApi.getAgents() setAgents(agentsData) break - case 'violations': + } + case 'violations': { const violationsData = await banSystemApi.getTrafficViolations() setViolations(violationsData) break - case 'settings': + } + case 'settings': { const settingsData = await banSystemApi.getSettings() setSettings(settingsData) break - case 'traffic': + } + case 'traffic': { const trafficData = await banSystemApi.getTraffic() setTraffic(trafficData) break - case 'reports': + } + case 'reports': { const reportData = await banSystemApi.getReport(reportHours) setReport(reportData) break - case 'health': + } + case 'health': { const healthData = await banSystemApi.getHealth() setHealth(healthData) break + } } } catch { setError(t('banSystem.loadError')) From c6e99f4685e4f9a379f60edb066efbfbc2282def Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 17 Jan 2026 07:18:37 +0300 Subject: [PATCH 5/8] Remove Docker Hub CI workflow configuration --- .github/workflows/docker-hub.yml | 53 -------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 .github/workflows/docker-hub.yml 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 From 9ab53034df951ca098f8698f5bef5b73ce9270b6 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 17 Jan 2026 07:24:14 +0300 Subject: [PATCH 6/8] Enhance localization and AdminBanSystem component - Added new localization keys for settings related to punishment, traffic monitoring, and network detection in both English and Russian. - Implemented formatting functions in AdminBanSystem to improve the display of setting keys and categories, enhancing readability and user experience. --- src/locales/en.json | 41 +++++++++++++++++++++++++++++++++++- src/locales/ru.json | 41 +++++++++++++++++++++++++++++++++++- src/pages/AdminBanSystem.tsx | 28 +++++++++++++++++++++--- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 1152287..6436c47 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -973,7 +973,46 @@ "notifications": "Notifications", "whitelist": "Whitelist", "saved": "Setting saved", - "error": "Error saving" + "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", diff --git a/src/locales/ru.json b/src/locales/ru.json index ac4ce57..2fc81af 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -973,7 +973,46 @@ "notifications": "Уведомления", "whitelist": "Белый список", "saved": "Настройка сохранена", - "error": "Ошибка сохранения" + "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": "Состояние системы", diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index ec1c3d1..a135121 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { banSystemApi, @@ -148,6 +148,28 @@ export default function AdminBanSystem() { 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) @@ -925,13 +947,13 @@ export default function AdminBanSystem() { return Object.entries(grouped).map(([category, items]) => (
-

{category}

+

{formatCategory(category)}

{items.map((setting) => (
-
{setting.key}
+
{formatSettingKey(setting.key)}
{setting.description && (
{setting.description}
)} From f450f9c4aca502f127096b37fd289cf079660720 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 17 Jan 2026 07:32:35 +0300 Subject: [PATCH 7/8] Add localization keys for 'add' in English and Russian; enhance AdminBanSystem component with improved category inference and sorting --- src/locales/en.json | 1 + src/locales/ru.json | 1 + src/pages/AdminBanSystem.tsx | 71 ++++++++++++++++++++++++++++++++---- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 6436c47..86f0998 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -15,6 +15,7 @@ "yes": "Yes", "no": "No", "or": "or", + "add": "Add", "and": "and", "edit": "Edit", "delete": "Delete", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2fc81af..af72674 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -15,6 +15,7 @@ "yes": "Да", "no": "Нет", "or": "или", + "add": "Добавить", "and": "и", "edit": "Редактировать", "delete": "Удалить", diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index a135121..99a87c7 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -938,27 +938,50 @@ export default function AdminBanSystem() { {/* 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 = s.category || 'general' + const cat = inferCategory(s.key, s.category) if (!grouped[cat]) grouped[cat] = [] grouped[cat].push(s) }) - return Object.entries(grouped).map(([category, items]) => ( + // 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)}

- {items.map((setting) => ( -
+ {grouped[category].map((setting) => ( +
{formatSettingKey(setting.key)}
{setting.description && (
{setting.description}
)}
-
+
{setting.type === 'bool' ? (