diff --git a/src/App.tsx b/src/App.tsx index 0e5031a..121f49b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,6 +70,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage')); +const AdminUpdates = lazy(() => import('./pages/AdminUpdates')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); @@ -656,6 +657,16 @@ function App() { } /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/admin/stats/system-info'); + return response.data; + }, + // Get complete dashboard stats getDashboardStats: async (): Promise => { const response = await apiClient.get('/cabinet/admin/stats/dashboard'); diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 8af4dcd..437e126 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -10,6 +10,7 @@ export interface UserTrafficItem { user_id: number; telegram_id: number | null; username: string | null; + email: string | null; full_name: string; tariff_name: string | null; subscription_status: string | null; @@ -35,6 +36,18 @@ export interface ExportCsvResponse { message: string; } +export interface TrafficEnrichmentData { + devices_connected: number; + total_spent_kopeks: number; + subscription_start_date: string | null; + subscription_end_date: string | null; + last_node_name: string | null; +} + +export interface TrafficEnrichmentResponse { + data: Record; +} + export type TrafficParams = { period?: number; limit?: number; @@ -69,6 +82,11 @@ function buildCacheKey(params: TrafficParams): string { }); } +const enrichmentCache: { data: TrafficEnrichmentResponse | null; timestamp: number } = { + data: null, + timestamp: 0, +}; + export const adminTrafficApi = { getTrafficUsage: async ( params: TrafficParams, @@ -104,6 +122,22 @@ export const adminTrafficApi = { trafficCache.clear(); }, + getEnrichment: async (options?: { skipCache?: boolean }): Promise => { + if ( + !options?.skipCache && + enrichmentCache.data && + Date.now() - enrichmentCache.timestamp < CACHE_TTL + ) { + return enrichmentCache.data; + } + + const response = await apiClient.get('/cabinet/admin/traffic/enrichment'); + const data: TrafficEnrichmentResponse = response.data; + enrichmentCache.data = data; + enrichmentCache.timestamp = Date.now(); + return data; + }, + exportCsv: async (data: { period: number; start_date?: string; diff --git a/src/api/adminUpdates.ts b/src/api/adminUpdates.ts new file mode 100644 index 0000000..d8f157c --- /dev/null +++ b/src/api/adminUpdates.ts @@ -0,0 +1,32 @@ +import apiClient from './client'; + +// ============ Types ============ + +export interface ReleaseItem { + tag_name: string; + name: string; + body: string; + published_at: string; + prerelease: boolean; +} + +export interface ProjectReleasesInfo { + current_version: string; + has_updates: boolean; + releases: ReleaseItem[]; + repo_url: string; +} + +export interface ReleasesResponse { + bot: ProjectReleasesInfo; + cabinet: ProjectReleasesInfo; +} + +// ============ API ============ + +export const adminUpdatesApi = { + getReleases: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/updates/releases'); + return response.data; + }, +}; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index e8f90b6..6b811a3 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -2,6 +2,15 @@ import apiClient from './client'; // ============ Types ============ +export interface TrafficPurchaseInfo { + id: number; + traffic_gb: number; + expires_at: string; + created_at: string; + days_remaining: number; + is_expired: boolean; +} + export interface UserSubscriptionInfo { id: number; status: string; @@ -16,6 +25,8 @@ export interface UserSubscriptionInfo { autopay_enabled: boolean; is_active: boolean; days_remaining: number; + purchased_traffic_gb: number; + traffic_purchases: TrafficPurchaseInfo[]; } export interface UserPromoGroupInfo { @@ -187,6 +198,11 @@ export interface UserAvailableTariff { price_per_day_kopeks: number; min_days: number; max_days: number; + device_price_kopeks: number | null; + max_device_limit: number | null; + traffic_topup_enabled: boolean; + traffic_topup_packages: Record; + max_topup_traffic_gb: number; is_available: boolean; requires_promo_group: boolean; } @@ -277,7 +293,10 @@ export interface UpdateSubscriptionRequest { | 'toggle_autopay' | 'cancel' | 'activate' - | 'create'; + | 'create' + | 'add_traffic' + | 'remove_traffic' + | 'set_device_limit'; days?: number; end_date?: string; tariff_id?: number; @@ -286,6 +305,8 @@ export interface UpdateSubscriptionRequest { autopay_enabled?: boolean; is_trial?: boolean; device_limit?: number; + traffic_gb?: number; + traffic_purchase_id?: number; } export interface UpdateSubscriptionResponse { @@ -447,6 +468,17 @@ export const adminUsersApi = { return response.data; }, + // Update referral commission + updateReferralCommission: async ( + userId: number, + commissionPercent: number | null, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/referral-commission`, { + commission_percent: commissionPercent, + }); + return response.data; + }, + // Delete user (soft delete, does NOT remove from Remnawave) deleteUser: async ( userId: number, @@ -552,4 +584,33 @@ export const adminUsersApi = { const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`); return response.data; }, + + // Get user devices + getUserDevices: async ( + userId: number, + ): Promise<{ + devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[]; + total: number; + device_limit: number; + }> => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/devices`); + return response.data; + }, + + // Delete single device + deleteUserDevice: async ( + userId: number, + hwid: string, + ): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => { + const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`); + return response.data; + }, + + // Reset all devices + resetUserDevices: async ( + userId: number, + ): Promise<{ success: boolean; message: string; deleted_count: number }> => { + const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index f8e5d23..df1bd61 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -762,7 +762,8 @@ "promoOffers": "Promo Offers", "promocodes": "Promo Codes", "promoGroups": "Discount Groups", - "trafficUsage": "Traffic Usage" + "trafficUsage": "Traffic Usage", + "updates": "Updates" }, "panel": { "title": "Admin Panel", @@ -785,7 +786,8 @@ "promoOffersDesc": "Personal discounts", "promocodesDesc": "Manage promo codes", "promoGroupsDesc": "Discount groups for users", - "trafficUsageDesc": "Per-user traffic by nodes" + "trafficUsageDesc": "Per-user traffic by nodes", + "updatesDesc": "Bot & cabinet versions" }, "trafficUsage": { "title": "Traffic Usage", @@ -822,7 +824,12 @@ "riskLow": "Low", "riskMedium": "Medium", "riskHigh": "High", - "riskCritical": "Critical" + "riskCritical": "Critical", + "connected": "Conn.", + "totalSpent": "Spent", + "subStart": "Start", + "subEnd": "End", + "lastNode": "Last Node" }, "emailTemplates": { "title": "Email Templates", @@ -1909,11 +1916,20 @@ "nodeUsage": "Node usage", "copied": "Copied", "panelNotFound": "User not found in panel", + "devices": { + "title": "Devices", + "none": "No connected devices", + "resetAll": "Reset all", + "deleted": "Device removed", + "allDeleted": "All devices reset" + }, "referral": { "title": "Referral program", "referrals": "Referrals", "earned": "Earned", - "commission": "Commission" + "commission": "Commission", + "default": "Default", + "invalidPercent": "Commission must be between 0 and 100" }, "restrictions": { "title": "Restrictions", @@ -1958,7 +1974,17 @@ "unavailable": "(unavailable)", "noActive": "No active subscription", "create": "Create subscription", - "creating": "Creating..." + "creating": "Creating...", + "trafficPackages": "Traffic packages", + "addTraffic": "Add traffic", + "selectPackage": "Select package", + "addButton": "Add", + "addTrafficNote": "Admin: free, valid 30 days", + "trafficRemoved": "Traffic package removed", + "trafficAdded": "Traffic package added", + "expired": "expired", + "daysLeft": "d left", + "deviceLimitUpdated": "Device limit updated" }, "balance": { "current": "Current balance", @@ -2089,6 +2115,19 @@ } } }, + "adminUpdates": { + "title": "Updates", + "subtitle": "Bot and cabinet release history", + "bot": "Bot", + "cabinet": "Cabinet", + "currentVersion": "Current version", + "upToDate": "Up to date", + "updateAvailable": "Update available", + "published": "Published", + "prerelease": "Pre-release", + "refresh": "Check for updates", + "noReleases": "No release data" + }, "adminDashboard": { "title": "Statistics Dashboard", "subtitle": "Real-time system overview", @@ -2181,6 +2220,15 @@ "purchasedWeek": "Week", "purchasedMonth": "Month", "noTariffs": "No tariffs" + }, + "systemInfo": { + "title": "System", + "cabinet": "Cabinet", + "bot": "Bot", + "python": "Python", + "uptime": "Uptime", + "users": "Users", + "activeSubs": "Active Subs" } }, "banSystem": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 8d2eb68..c1be32b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "در حال بارگذاری...", "all": "همه", @@ -643,7 +643,8 @@ "promoGroups": "گروه‌های تخفیف", "remnawave": "RemnaWave", "users": "کاربران", - "trafficUsage": "مصرف ترافیک" + "trafficUsage": "مصرف ترافیک", + "updates": "به‌روزرسانی‌ها" }, "panel": { "title": "پنل مدیریت", @@ -666,7 +667,8 @@ "promoGroupsDesc": "گروه‌های تخفیف برای کاربران", "remnawaveDesc": "مدیریت پنل و آمار", "usersDesc": "مدیریت کاربران ربات", - "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود" + "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود", + "updatesDesc": "نسخه‌های ربات و کابینت" }, "trafficUsage": { "title": "مصرف ترافیک", @@ -703,7 +705,12 @@ "riskLow": "کم", "riskMedium": "متوسط", "riskHigh": "بالا", - "riskCritical": "بحرانی" + "riskCritical": "بحرانی", + "connected": "متصل", + "totalSpent": "هزینه", + "subStart": "شروع", + "subEnd": "پایان", + "lastNode": "آخرین نود" }, "emailTemplates": { "title": "قالب‌های ایمیل", @@ -1612,11 +1619,20 @@ "nodeUsage": "مصرف نودها", "copied": "کپی شد", "panelNotFound": "کاربر در پنل یافت نشد", + "devices": { + "title": "دستگاه‌ها", + "none": "دستگاه متصلی وجود ندارد", + "resetAll": "بازنشانی همه", + "deleted": "دستگاه حذف شد", + "allDeleted": "همه دستگاه‌ها بازنشانی شدند" + }, "referral": { "title": "برنامه ارجاع", "referrals": "ارجاعات", "earned": "درآمد", - "commission": "کمیسیون" + "commission": "کمیسیون", + "default": "پیش‌فرض", + "invalidPercent": "کمیسیون باید بین ۰ تا ۱۰۰ باشد" }, "restrictions": { "title": "محدودیت‌ها", @@ -1657,7 +1673,17 @@ "unavailable": "(غیرقابل دسترس)", "noActive": "اشتراک فعالی وجود ندارد", "create": "ایجاد اشتراک", - "creating": "در حال ایجاد..." + "creating": "در حال ایجاد...", + "trafficPackages": "بسته‌های ترافیک", + "addTraffic": "افزودن ترافیک", + "selectPackage": "انتخاب بسته", + "addButton": "افزودن", + "addTrafficNote": "مدیر: رایگان، ۳۰ روز اعتبار", + "trafficRemoved": "بسته ترافیک حذف شد", + "trafficAdded": "بسته ترافیک اضافه شد", + "expired": "منقضی شده", + "daysLeft": "روز باقیمانده", + "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد" }, "balance": { "current": "موجودی فعلی", @@ -1904,6 +1930,19 @@ "saturation": "اشباع" } }, + "adminUpdates": { + "title": "به‌روزرسانی‌ها", + "subtitle": "تاریخچه انتشار ربات و کابینت", + "bot": "ربات", + "cabinet": "کابینت", + "currentVersion": "نسخه فعلی", + "upToDate": "به‌روز است", + "updateAvailable": "به‌روزرسانی موجود", + "published": "منتشر شده", + "prerelease": "پیش‌انتشار", + "refresh": "بررسی به‌روزرسانی", + "noReleases": "داده‌ای موجود نیست" + }, "adminDashboard": { "title": "پنل آمار", "subtitle": "نمای کلی سیستم در لحظه", @@ -1984,6 +2023,15 @@ "amount": "مبلغ", "method": "روش", "date": "تاریخ" + }, + "systemInfo": { + "title": "سیستم", + "cabinet": "کابینت", + "bot": "ربات", + "python": "Python", + "uptime": "آپتایم", + "users": "کاربران", + "activeSubs": "اشتراک‌ها" } }, "profile": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 91fb441..bd2f537 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,4 +1,4 @@ -{ +{ "common": { "all": "Все", "loading": "Загрузка...", @@ -783,7 +783,8 @@ "promoOffers": "Промопредложения", "promocodes": "Промокоды", "promoGroups": "Группы скидок", - "trafficUsage": "Расход трафика" + "trafficUsage": "Расход трафика", + "updates": "Обновления" }, "panel": { "title": "Панель администратора", @@ -806,7 +807,8 @@ "promoOffersDesc": "Персональные скидки", "promocodesDesc": "Управление промокодами", "promoGroupsDesc": "Группы скидок для пользователей", - "trafficUsageDesc": "Трафик пользователей по нодам" + "trafficUsageDesc": "Трафик пользователей по нодам", + "updatesDesc": "Версии бота и кабинета" }, "trafficUsage": { "title": "Расход трафика", @@ -843,7 +845,12 @@ "riskLow": "Низкий", "riskMedium": "Средний", "riskHigh": "Высокий", - "riskCritical": "Критический" + "riskCritical": "Критический", + "connected": "Подкл.", + "totalSpent": "Траты", + "subStart": "Начало", + "subEnd": "Конец", + "lastNode": "Посл. нода" }, "emailTemplates": { "title": "Email-шаблоны", @@ -2435,11 +2442,20 @@ "nodeUsage": "Расход по нодам", "copied": "Скопировано", "panelNotFound": "Пользователь не найден в панели", + "devices": { + "title": "Устройства", + "none": "Нет подключённых устройств", + "resetAll": "Сбросить все", + "deleted": "Устройство удалено", + "allDeleted": "Все устройства сброшены" + }, "referral": { "title": "Реферальная программа", "referrals": "Рефералов", "earned": "Заработано", - "commission": "Комиссия" + "commission": "Комиссия", + "default": "По умолч.", + "invalidPercent": "Процент должен быть от 0 до 100" }, "restrictions": { "title": "Ограничения", @@ -2484,7 +2500,17 @@ "unavailable": "(недоступен)", "noActive": "Нет активной подписки", "create": "Создать подписку", - "creating": "Создание..." + "creating": "Создание...", + "trafficPackages": "Пакеты трафика", + "addTraffic": "Добавить трафик", + "selectPackage": "Выберите пакет", + "addButton": "Добавить", + "addTrafficNote": "Админ: бесплатно, действует 30 дней", + "trafficRemoved": "Пакет трафика удалён", + "trafficAdded": "Пакет трафика добавлен", + "expired": "истёк", + "daysLeft": "дн. осталось", + "deviceLimitUpdated": "Лимит устройств обновлён" }, "balance": { "current": "Текущий баланс", @@ -2635,6 +2661,19 @@ "saturation": "Насыщенность" } }, + "adminUpdates": { + "title": "Обновления", + "subtitle": "История релизов бота и кабинета", + "bot": "Бот", + "cabinet": "Кабинет", + "currentVersion": "Текущая версия", + "upToDate": "Актуальная версия", + "updateAvailable": "Доступно обновление", + "published": "Опубликовано", + "prerelease": "Предрелиз", + "refresh": "Проверить обновления", + "noReleases": "Нет данных о релизах" + }, "adminDashboard": { "title": "Панель статистики", "subtitle": "Обзор системы в реальном времени", @@ -2732,6 +2771,15 @@ "amount": "Сумма", "method": "Метод", "date": "Дата" + }, + "systemInfo": { + "title": "Система", + "cabinet": "Кабинет", + "bot": "Бот", + "python": "Python", + "uptime": "Аптайм", + "users": "Юзеров", + "activeSubs": "Подписок" } }, "banSystem": { diff --git a/src/locales/zh.json b/src/locales/zh.json index a5c9c11..3fc8ca5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "加载中...", "error": "错误", @@ -643,7 +643,8 @@ "promoGroups": "折扣组", "remnawave": "RemnaWave", "users": "用户", - "trafficUsage": "流量使用" + "trafficUsage": "流量使用", + "updates": "更新" }, "panel": { "title": "管理面板", @@ -666,7 +667,8 @@ "promoGroupsDesc": "用户折扣组", "remnawaveDesc": "面板管理和统计", "usersDesc": "管理机器人用户", - "trafficUsageDesc": "按节点统计用户流量" + "trafficUsageDesc": "按节点统计用户流量", + "updatesDesc": "机器人和面板版本" }, "trafficUsage": { "title": "流量使用", @@ -703,7 +705,12 @@ "riskLow": "低", "riskMedium": "中", "riskHigh": "高", - "riskCritical": "严重" + "riskCritical": "严重", + "connected": "连接", + "totalSpent": "消费", + "subStart": "开始", + "subEnd": "结束", + "lastNode": "最近节点" }, "paymentMethods": { "title": "支付方法", @@ -1611,11 +1618,20 @@ "nodeUsage": "节点用量", "copied": "已复制", "panelNotFound": "面板中未找到用户", + "devices": { + "title": "设备", + "none": "没有已连接的设备", + "resetAll": "重置全部", + "deleted": "设备已删除", + "allDeleted": "所有设备已重置" + }, "referral": { "title": "推荐计划", "referrals": "推荐人数", "earned": "已赚取", - "commission": "佣金" + "commission": "佣金", + "default": "默认", + "invalidPercent": "佣金必须在0到100之间" }, "restrictions": { "title": "限制", @@ -1656,7 +1672,17 @@ "unavailable": "(不可用)", "noActive": "无活跃订阅", "create": "创建订阅", - "creating": "创建中..." + "creating": "创建中...", + "trafficPackages": "流量包", + "addTraffic": "添加流量", + "selectPackage": "选择套餐", + "addButton": "添加", + "addTrafficNote": "管理员:免费,有效期30天", + "trafficRemoved": "流量包已删除", + "trafficAdded": "流量包已添加", + "expired": "已过期", + "daysLeft": "天剩余", + "deviceLimitUpdated": "设备限制已更新" }, "balance": { "current": "当前余额", @@ -1903,6 +1929,19 @@ } } }, + "adminUpdates": { + "title": "更新", + "subtitle": "机器人和面板发布历史", + "bot": "机器人", + "cabinet": "面板", + "currentVersion": "当前版本", + "upToDate": "已是最新", + "updateAvailable": "有可用更新", + "published": "发布于", + "prerelease": "预发布", + "refresh": "检查更新", + "noReleases": "没有发布数据" + }, "adminDashboard": { "title": "统计面板", "subtitle": "实时系统概览", @@ -1983,6 +2022,15 @@ "amount": "金额", "method": "方法", "date": "日期" + }, + "systemInfo": { + "title": "系统", + "cabinet": "面板", + "bot": "机器人", + "python": "Python", + "uptime": "运行时间", + "users": "用户", + "activeSubs": "订阅" } }, "profile": { diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index f9e523a..304edc5 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -5,10 +5,13 @@ import { statsApi, type DashboardStats, type NodeStatus, + type SystemInfo, type TopReferrersResponse, type TopCampaignsResponse, type RecentPaymentsResponse, } from '../api/admin'; + +const CABINET_VERSION = __APP_VERSION__; import { useCurrency } from '../hooks/useCurrency'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -375,6 +378,7 @@ export default function AdminDashboard() { const [campaigns, setCampaigns] = useState(null); const [payments, setPayments] = useState(null); const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings'); + const [systemInfo, setSystemInfo] = useState(null); const fetchStats = useCallback(async () => { try { @@ -392,14 +396,16 @@ export default function AdminDashboard() { const fetchExtendedStats = useCallback(async () => { try { - const [referrersData, campaignsData, paymentsData] = await Promise.all([ + const [referrersData, campaignsData, paymentsData, sysInfoData] = await Promise.all([ statsApi.getTopReferrers(10), statsApi.getTopCampaigns(10), statsApi.getRecentPayments(20), + statsApi.getSystemInfo(), ]); setReferrers(referrersData); setCampaigns(campaignsData); setPayments(paymentsData); + setSystemInfo(sysInfoData); } catch (err) { console.error('Failed to load extended stats:', err); } @@ -1137,6 +1143,49 @@ export default function AdminDashboard() { )} + + {/* System Info */} + {systemInfo && ( +
+

+ {t('adminDashboard.systemInfo.title')} +

+
+
+ {t('adminDashboard.systemInfo.cabinet')}: + v{CABINET_VERSION} +
+
+ {t('adminDashboard.systemInfo.bot')}: + v{systemInfo.bot_version} +
+
+ {t('adminDashboard.systemInfo.python')}: + {systemInfo.python_version} +
+
+ {t('adminDashboard.systemInfo.uptime')}: + + {(() => { + const s = systemInfo.uptime_seconds; + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + return [d > 0 && `${d}d`, h > 0 && `${h}h`, `${m}m`].filter(Boolean).join(' '); + })()} + +
+
+ {t('adminDashboard.systemInfo.users')}: + {systemInfo.users_total} +
+
+ {t('adminDashboard.systemInfo.activeSubs')}: + {systemInfo.subscriptions_active} +
+
+
+ )} ); } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index ff86da8..5cbd872 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { RemnawaveIcon } from '../components/icons'; +import { RemnawaveIcon, ArrowPathIcon } from '../components/icons'; // Group header icons const AnalyticsGroupIcon = () => ( @@ -473,6 +473,12 @@ export default function AdminPanel() { title: t('admin.nav.emailTemplates'), description: t('admin.panel.emailTemplatesDesc'), }, + { + to: '/admin/updates', + icon: , + title: t('admin.nav.updates'), + description: t('admin.panel.updatesDesc'), + }, ], }, ]; diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 33ff729..b123ddb 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -15,6 +15,7 @@ import { type TrafficNodeInfo, type TrafficUsageResponse, type TrafficParams, + type TrafficEnrichmentData, } from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -48,6 +49,21 @@ const getFlagEmoji = (countryCode: string): string => { return String.fromCodePoint(...codePoints); }; +const formatCurrency = (kopeks: number): string => { + const rubles = kopeks / 100; + if (rubles === 0) return '0'; + if (rubles < 10) return rubles.toFixed(2); + if (rubles < 1000) return Math.round(rubles).toString(); + return `${(rubles / 1000).toFixed(1)}k`; +}; + +const formatShortDate = (iso: string | null): string => { + if (!iso) return '\u2014'; + const d = new Date(iso); + if (isNaN(d.getTime())) return '\u2014'; + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}`; +}; + const toBackendSortField = (columnId: string): string => { if (columnId === 'user') return 'full_name'; return columnId; @@ -1049,6 +1065,8 @@ export default function AdminTrafficUsage() { const [selectedCountries, setSelectedCountries] = useState>(new Set()); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); + const [enrichment, setEnrichment] = useState | null>(null); + const [enrichmentLoading, setEnrichmentLoading] = useState(false); const [exporting, setExporting] = useState(false); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [sorting, setSorting] = useState([{ id: 'total_bytes', desc: true }]); @@ -1158,6 +1176,27 @@ export default function AdminTrafficUsage() { loadData(); }, [loadData]); + // Load enrichment after main data arrives + useEffect(() => { + if (initialLoading || items.length === 0) return; + let cancelled = false; + const load = async () => { + setEnrichmentLoading(true); + try { + const res = await adminTrafficApi.getEnrichment(); + if (!cancelled) setEnrichment(res.data); + } catch { + // silently fail — enrichment is optional + } finally { + if (!cancelled) setEnrichmentLoading(false); + } + }; + load(); + return () => { + cancelled = true; + }; + }, [initialLoading, items.length]); + // Prefetch adjacent periods in background (only in period mode) useEffect(() => { if (dateMode) return; @@ -1286,6 +1325,13 @@ export default function AdminTrafficUsage() { const handleRefresh = () => { loadData(true); + setEnrichment(null); + setEnrichmentLoading(true); + adminTrafficApi + .getEnrichment({ skipCache: true }) + .then((res) => setEnrichment(res.data)) + .catch(() => {}) + .finally(() => setEnrichmentLoading(false)); }; const availableCountries = useMemo(() => { @@ -1335,11 +1381,15 @@ export default function AdminTrafficUsage() {
{item.full_name}
- {item.username && ( + {item.username ? (
@{item.username}
- )} + ) : item.email ? ( +
+ {item.email} +
+ ) : null}
); @@ -1381,6 +1431,90 @@ export default function AdminTrafficUsage() { return {gb > 0 ? `${gb} GB` : '\u221E'}; }, }, + // ---- Enrichment columns ---- + { + id: 'connected', + header: t('admin.trafficUsage.connected'), + size: 65, + minSize: 50, + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ row }) => { + const e = enrichment?.[row.original.user_id]; + if (enrichmentLoading && !enrichment) + return
; + return {e?.devices_connected ?? '\u2014'}; + }, + }, + { + id: 'total_spent', + header: t('admin.trafficUsage.totalSpent'), + size: 75, + minSize: 55, + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ row }) => { + const e = enrichment?.[row.original.user_id]; + if (enrichmentLoading && !enrichment) + return
; + if (!e || e.total_spent_kopeks === 0) + return {'\u2014'}; + return ( + {formatCurrency(e.total_spent_kopeks)} + ); + }, + }, + { + id: 'sub_start', + header: t('admin.trafficUsage.subStart'), + size: 80, + minSize: 65, + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ row }) => { + const e = enrichment?.[row.original.user_id]; + if (enrichmentLoading && !enrichment) + return
; + return ( + + {formatShortDate(e?.subscription_start_date ?? null)} + + ); + }, + }, + { + id: 'sub_end', + header: t('admin.trafficUsage.subEnd'), + size: 80, + minSize: 65, + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ row }) => { + const e = enrichment?.[row.original.user_id]; + if (enrichmentLoading && !enrichment) + return
; + return ( + + {formatShortDate(e?.subscription_end_date ?? null)} + + ); + }, + }, + { + id: 'last_node', + header: t('admin.trafficUsage.lastNode'), + size: 100, + minSize: 70, + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ row }) => { + const e = enrichment?.[row.original.user_id]; + if (enrichmentLoading && !enrichment) + return
; + return {e?.last_node_name ?? '\u2014'}; + }, + }, + // ---- Dynamic node columns ---- ...displayNodes.map( (node): ColumnDef => ({ id: `node_${node.node_uuid}`, @@ -1486,6 +1620,8 @@ export default function AdminTrafficUsage() { totalThresholdNum, nodeThresholdNum, periodDays, + enrichment, + enrichmentLoading, ]); const table = useReactTable({ diff --git a/src/pages/AdminUpdates.tsx b/src/pages/AdminUpdates.tsx new file mode 100644 index 0000000..283831f --- /dev/null +++ b/src/pages/AdminUpdates.tsx @@ -0,0 +1,377 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import DOMPurify from 'dompurify'; +import { adminUpdatesApi, ReleaseItem, ProjectReleasesInfo } from '../api/adminUpdates'; + +declare const __APP_VERSION__: string; + +// ============ Icons ============ + +const BackIcon = () => ( + + + +); + +const RefreshIcon = () => ( + + + +); + +const BotIcon = () => ( + + + +); + +const CabinetIcon = () => ( + + + +); + +const TagIcon = () => ( + + + + +); + +const CalendarIcon = () => ( + + + +); + +const ExternalLinkIcon = () => ( + + + +); + +// ============ Helpers ============ + +function formatDate(iso: string): string { + try { + const date = new Date(iso); + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch { + return iso; + } +} + +function stripVPrefix(tag: string): string { + return tag.replace(/^v/, ''); +} + +function renderMarkdown(md: string): string { + const html = md + // Headers: ### Title ->

Title

+ .replace(/^#### (.+)$/gm, '

$1

') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold: **text** -> text + .replace(/\*\*(.+?)\*\*/g, '$1') + // Inline code: `text` -> text + .replace(/`([^`]+)`/g, '$1') + // Links: [text](url) -> text + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1', + ); + + // Process lines into blocks + const lines = html.split('\n'); + const blocks: string[] = []; + let listItems: string[] = []; + + const flushList = () => { + if (listItems.length > 0) { + blocks.push(`
    ${listItems.join('')}
`); + listItems = []; + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + flushList(); + continue; + } + // List item: * text or - text + const listMatch = trimmed.match(/^[*-]\s+(.+)$/); + if (listMatch) { + listItems.push(`
  • ${listMatch[1]}
  • `); + continue; + } + // Already an HTML block element from header replacement + if (/^/.test(trimmed)) { + flushList(); + blocks.push(trimmed); + continue; + } + // Regular line + flushList(); + blocks.push(`

    ${trimmed}

    `); + } + flushList(); + + return DOMPurify.sanitize(blocks.join(''), { + ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'ul', 'li', 'strong', 'em', 'code', 'a', 'br'], + ALLOWED_ATTR: ['href', 'target', 'rel'], + }); +} + +// ============ Components ============ + +function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) { + const { t } = useTranslation(); + + if (hasUpdate) { + return ( + + + {t('adminUpdates.updateAvailable')} + + ); + } + + return ( + + + {t('adminUpdates.upToDate')} + + ); +} + +function ReleaseCard({ release }: { release: ReleaseItem }) { + const { t } = useTranslation(); + // Content is sanitized via DOMPurify in renderMarkdown — safe for innerHTML + const bodyHtml = useMemo( + () => (release.body ? renderMarkdown(release.body) : ''), + [release.body], + ); + + return ( +
    +
    +
    + + {release.tag_name} +
    + {release.name !== release.tag_name && ( + {release.name} + )} + {release.prerelease && ( + + {t('adminUpdates.prerelease')} + + )} +
    + + {formatDate(release.published_at)} +
    +
    + {bodyHtml ? ( +
    + ) : null} +
    + ); +} + +function ProjectSection({ + icon, + title, + info, + currentVersion, + hasUpdate, + repoUrl, +}: { + icon: React.ReactNode; + title: string; + info: ProjectReleasesInfo; + currentVersion: string; + hasUpdate: boolean; + repoUrl: string; +}) { + const { t } = useTranslation(); + + return ( +
    + {/* Header */} +
    +
    + {icon} +
    +
    +
    +

    {title}

    + +
    +

    + {t('adminUpdates.currentVersion')}:{' '} + {currentVersion || '—'} +

    +
    + + GitHub + + +
    + + {/* Releases list */} + {info.releases.length > 0 ? ( +
    + {info.releases.map((release) => ( + + ))} +
    + ) : ( +
    + {t('adminUpdates.noReleases')} +
    + )} +
    + ); +} + +// ============ Page ============ + +export default function AdminUpdates() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ['admin', 'releases'], + queryFn: adminUpdatesApi.getReleases, + staleTime: 60_000, + }); + + // Cabinet has_updates: compare __APP_VERSION__ with latest release + const cabinetHasUpdate = (() => { + if (!data?.cabinet.releases.length) return false; + try { + const latestTag = data.cabinet.releases.find((r) => !r.prerelease)?.tag_name; + if (!latestTag) return false; + return stripVPrefix(latestTag) !== stripVPrefix(__APP_VERSION__); + } catch { + return false; + } + })(); + + return ( +
    + {/* Top bar */} +
    + +
    +

    {t('adminUpdates.title')}

    +

    {t('adminUpdates.subtitle')}

    +
    + +
    + + {/* Loading skeleton */} + {isLoading && ( +
    + {[0, 1].map((i) => ( +
    + ))} +
    + )} + + {/* Content */} + {data && ( +
    + } + title={t('adminUpdates.bot')} + info={data.bot} + currentVersion={data.bot.current_version} + hasUpdate={data.bot.has_updates} + repoUrl={data.bot.repo_url} + /> + } + title={t('adminUpdates.cabinet')} + info={data.cabinet} + currentVersion={__APP_VERSION__} + hasUpdate={cabinetHasUpdate} + repoUrl={data.cabinet.repo_url} + /> +
    + )} +
    + ); +} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 9e9fbdc..0d690ba 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -186,11 +186,26 @@ export default function AdminUserDetail() { const [promoGroups, setPromoGroups] = useState([]); const [editingPromoGroup, setEditingPromoGroup] = useState(false); + // Referral commission + const [editingReferralCommission, setEditingReferralCommission] = useState(false); + const [referralCommissionValue, setReferralCommissionValue] = useState(''); + // Send promo offer const [offerDiscountPercent, setOfferDiscountPercent] = useState(''); const [offerValidHours, setOfferValidHours] = useState(24); const [offerSending, setOfferSending] = useState(false); + // Traffic packages + const [selectedTrafficGb, setSelectedTrafficGb] = useState(''); + + // Devices + const [devices, setDevices] = useState< + { hwid: string; platform: string; device_model: string; created_at: string | null }[] + >([]); + const [devicesTotal, setDevicesTotal] = useState(0); + const [deviceLimit, setDeviceLimit] = useState(0); + const [devicesLoading, setDevicesLoading] = useState(false); + const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { @@ -289,9 +304,24 @@ export default function AdminUserDetail() { } }, [userId]); + const loadDevices = useCallback(async () => { + if (!userId) return; + try { + setDevicesLoading(true); + const data = await adminUsersApi.getUserDevices(userId); + setDevices(data.devices); + setDevicesTotal(data.total); + setDeviceLimit(data.device_limit); + } catch { + // ignore + } finally { + setDevicesLoading(false); + } + }, [userId]); + const loadSubscriptionData = useCallback(async () => { - await Promise.all([loadPanelInfo(), loadNodeUsage()]); - }, [loadPanelInfo, loadNodeUsage]); + await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]); + }, [loadPanelInfo, loadNodeUsage, loadDevices]); const loadPromoGroups = useCallback(async () => { try { @@ -489,6 +519,85 @@ export default function AdminUserDetail() { } }; + const handleDeleteDevice = async (hwid: string) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.deleteUserDevice(userId, hwid); + notify.success(t('admin.users.detail.devices.deleted')); + await loadDevices(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleResetDevices = async () => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.resetUserDevices(userId); + notify.success(t('admin.users.detail.devices.allDeleted')); + await loadDevices(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleAddTraffic = async (gb: number) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.updateSubscription(userId, { action: 'add_traffic', traffic_gb: gb }); + notify.success(t('admin.users.detail.subscription.trafficAdded')); + setSelectedTrafficGb(''); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleRemoveTraffic = async (purchaseId: number) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.updateSubscription(userId, { + action: 'remove_traffic', + traffic_purchase_id: purchaseId, + }); + notify.success(t('admin.users.detail.subscription.trafficRemoved')); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleSetDeviceLimit = async (newLimit: number) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.updateSubscription(userId, { + action: 'set_device_limit', + device_limit: newLimit, + }); + notify.success(t('admin.users.detail.subscription.deviceLimitUpdated')); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const currentTariff = tariffs.find((t) => t.id === user?.subscription?.tariff_id) || null; + const handleChangePromoGroup = async (groupId: number | null) => { if (!userId) return; setActionLoading(true); @@ -503,6 +612,25 @@ export default function AdminUserDetail() { } }; + const handleUpdateReferralCommission = async () => { + if (!userId) return; + setActionLoading(true); + try { + const value = referralCommissionValue === '' ? null : toNumber(referralCommissionValue); + if (value !== null && (value < 0 || value > 100)) { + notify.error(t('admin.users.detail.referral.invalidPercent'), t('common.error')); + return; + } + await adminUsersApi.updateReferralCommission(userId, value); + await loadUser(); + setEditingReferralCommission(false); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const handleDeactivateOffer = async () => { if (!userId) return; setActionLoading(true); @@ -850,8 +978,21 @@ export default function AdminUserDetail() { {/* Referral */}
    -
    - {t('admin.users.detail.referral.title')} +
    + + {t('admin.users.detail.referral.title')} + +
    @@ -871,12 +1012,38 @@ export default function AdminUserDetail() {
    -
    - {user.referral.commission_percent || 0}% -
    -
    - {t('admin.users.detail.referral.commission')} -
    + {editingReferralCommission ? ( +
    + + +
    + ) : ( + <> +
    + {user.referral.commission_percent != null + ? `${user.referral.commission_percent}%` + : t('admin.users.detail.referral.default')} +
    +
    + {t('admin.users.detail.referral.commission')} +
    + + )}
    @@ -1053,11 +1220,136 @@ export default function AdminUserDetail() {
    {t('admin.users.detail.subscription.devices')}
    -
    {user.subscription.device_limit}
    +
    + + + {user.subscription.device_limit} + + +
    + {/* Traffic Packages */} + {user.subscription.traffic_purchases && + user.subscription.traffic_purchases.length > 0 && ( +
    +
    + + {t('admin.users.detail.subscription.trafficPackages')} + {user.subscription.purchased_traffic_gb > 0 && ( + + ({user.subscription.purchased_traffic_gb} {t('common.units.gb')}) + + )} + +
    +
    + {user.subscription.traffic_purchases.map((tp) => ( +
    +
    +
    + + {tp.traffic_gb} {t('common.units.gb')} + + {tp.is_expired ? ( + + {t('admin.users.detail.subscription.expired')} + + ) : ( + + {tp.days_remaining}{' '} + {t('admin.users.detail.subscription.daysLeft')} + + )} +
    +
    + {!tp.is_expired && ( + + )} +
    + ))} +
    +
    + )} + + {/* Add Traffic */} + {currentTariff && + currentTariff.traffic_topup_enabled && + Object.keys(currentTariff.traffic_topup_packages).length > 0 && ( +
    +
    + {t('admin.users.detail.subscription.addTraffic')} +
    +
    + + +
    +
    + {t('admin.users.detail.subscription.addTrafficNote')} +
    +
    + )} + {/* Actions */}
    @@ -1391,6 +1683,87 @@ export default function AdminUserDetail() {
    ) : null} + + {/* Devices */} +
    +
    + + {t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit}) + +
    + + {devices.length > 0 && ( + + )} +
    +
    + {devicesLoading ? ( +
    +
    +
    + ) : devices.length > 0 ? ( +
    + {devices.map((device) => ( +
    +
    +
    + {device.platform || device.device_model || device.hwid.slice(0, 12)} +
    +
    + {device.device_model && device.platform && ( + {device.device_model} + )} + {device.hwid.slice(0, 8)}... + {device.created_at && ( + {new Date(device.created_at).toLocaleDateString(locale)} + )} +
    +
    + +
    + ))} +
    + ) : ( +
    + {t('admin.users.detail.devices.none')} +
    + )} +
    )} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 47caca1..a5e4314 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string; + interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_TELEGRAM_BOT_USERNAME?: string; diff --git a/tsconfig.node.json b/tsconfig.node.json index b940375..ec78806 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,6 +5,7 @@ "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, "types": ["node"] }, "include": ["vite.config.ts"] diff --git a/vite.config.ts b/vite.config.ts index 97abefa..3c5012f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,14 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +import packageJson from './package.json'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, resolve: { alias: { '@': path.resolve(__dirname, 'src'),