From 92d206f5b655cca2cceff172305f07d5edc551b7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 20:29:56 +0300 Subject: [PATCH 1/9] feat: add inline referral commission editing in admin user card Admins can now edit individual referral commission percent directly in the user detail card. Shows "Default" when no custom value is set. --- src/api/adminUsers.ts | 11 +++++ src/locales/en.json | 4 +- src/locales/fa.json | 4 +- src/locales/ru.json | 4 +- src/locales/zh.json | 4 +- src/pages/AdminUserDetail.tsx | 78 +++++++++++++++++++++++++++++++---- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index e8f90b6..60f4cfe 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -447,6 +447,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, diff --git a/src/locales/en.json b/src/locales/en.json index f8e5d23..cb4d7dd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1913,7 +1913,9 @@ "title": "Referral program", "referrals": "Referrals", "earned": "Earned", - "commission": "Commission" + "commission": "Commission", + "default": "Default", + "invalidPercent": "Commission must be between 0 and 100" }, "restrictions": { "title": "Restrictions", diff --git a/src/locales/fa.json b/src/locales/fa.json index 8d2eb68..a6cfb9f 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1616,7 +1616,9 @@ "title": "برنامه ارجاع", "referrals": "ارجاعات", "earned": "درآمد", - "commission": "کمیسیون" + "commission": "کمیسیون", + "default": "پیش‌فرض", + "invalidPercent": "کمیسیون باید بین ۰ تا ۱۰۰ باشد" }, "restrictions": { "title": "محدودیت‌ها", diff --git a/src/locales/ru.json b/src/locales/ru.json index 91fb441..edc6d87 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2439,7 +2439,9 @@ "title": "Реферальная программа", "referrals": "Рефералов", "earned": "Заработано", - "commission": "Комиссия" + "commission": "Комиссия", + "default": "По умолч.", + "invalidPercent": "Процент должен быть от 0 до 100" }, "restrictions": { "title": "Ограничения", diff --git a/src/locales/zh.json b/src/locales/zh.json index a5c9c11..38e20ce 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1615,7 +1615,9 @@ "title": "推荐计划", "referrals": "推荐人数", "earned": "已赚取", - "commission": "佣金" + "commission": "佣金", + "default": "默认", + "invalidPercent": "佣金必须在0到100之间" }, "restrictions": { "title": "限制", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 9e9fbdc..a93611d 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -186,6 +186,10 @@ 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); @@ -503,6 +507,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 +873,21 @@ export default function AdminUserDetail() { {/* Referral */}
-
- {t('admin.users.detail.referral.title')} +
+ + {t('admin.users.detail.referral.title')} + +
@@ -871,12 +907,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')} +
+ + )}
From 6f31fbe6b5638e400db2ea16af65ab69979dca97 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 20:49:07 +0300 Subject: [PATCH 2/9] feat: add device management UI in admin user card Show connected devices in subscription tab with ability to: - View device platform, model, HWID and connection date - Delete individual devices - Reset all devices at once --- src/api/adminUsers.ts | 29 ++++++++ src/locales/en.json | 7 ++ src/locales/fa.json | 7 ++ src/locales/ru.json | 7 ++ src/locales/zh.json | 7 ++ src/pages/AdminUserDetail.tsx | 136 +++++++++++++++++++++++++++++++++- 6 files changed, 191 insertions(+), 2 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 60f4cfe..aeb4a96 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -563,4 +563,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 cb4d7dd..b8b8761 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1909,6 +1909,13 @@ "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", diff --git a/src/locales/fa.json b/src/locales/fa.json index a6cfb9f..0004a65 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1612,6 +1612,13 @@ "nodeUsage": "مصرف نودها", "copied": "کپی شد", "panelNotFound": "کاربر در پنل یافت نشد", + "devices": { + "title": "دستگاه‌ها", + "none": "دستگاه متصلی وجود ندارد", + "resetAll": "بازنشانی همه", + "deleted": "دستگاه حذف شد", + "allDeleted": "همه دستگاه‌ها بازنشانی شدند" + }, "referral": { "title": "برنامه ارجاع", "referrals": "ارجاعات", diff --git a/src/locales/ru.json b/src/locales/ru.json index edc6d87..7962936 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2435,6 +2435,13 @@ "nodeUsage": "Расход по нодам", "copied": "Скопировано", "panelNotFound": "Пользователь не найден в панели", + "devices": { + "title": "Устройства", + "none": "Нет подключённых устройств", + "resetAll": "Сбросить все", + "deleted": "Устройство удалено", + "allDeleted": "Все устройства сброшены" + }, "referral": { "title": "Реферальная программа", "referrals": "Рефералов", diff --git a/src/locales/zh.json b/src/locales/zh.json index 38e20ce..5f0fe56 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1611,6 +1611,13 @@ "nodeUsage": "节点用量", "copied": "已复制", "panelNotFound": "面板中未找到用户", + "devices": { + "title": "设备", + "none": "没有已连接的设备", + "resetAll": "重置全部", + "deleted": "设备已删除", + "allDeleted": "所有设备已重置" + }, "referral": { "title": "推荐计划", "referrals": "推荐人数", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index a93611d..94afc93 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -195,6 +195,14 @@ export default function AdminUserDetail() { const [offerValidHours, setOfferValidHours] = useState(24); const [offerSending, setOfferSending] = useState(false); + // 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 () => { @@ -293,9 +301,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 { @@ -493,6 +516,34 @@ 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 handleChangePromoGroup = async (groupId: number | null) => { if (!userId) return; setActionLoading(true); @@ -1453,6 +1504,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')} +
+ )} +
)} From 2dfa5206046b50f4bc22793dfb448f684286adef Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 21:13:46 +0300 Subject: [PATCH 3/9] feat: add admin traffic packages and device limit management UI Add device limit +/- stepper, traffic packages display with inline delete, add traffic dropdown from tariff config, translations for ru/en/zh/fa. --- src/api/adminUsers.ts | 23 ++++- src/locales/en.json | 12 ++- src/locales/fa.json | 12 ++- src/locales/ru.json | 12 ++- src/locales/zh.json | 12 ++- src/pages/AdminUserDetail.tsx | 181 +++++++++++++++++++++++++++++++++- 6 files changed, 246 insertions(+), 6 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index aeb4a96..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 { diff --git a/src/locales/en.json b/src/locales/en.json index b8b8761..7ccfa11 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1967,7 +1967,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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 0004a65..0e56e32 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1666,7 +1666,17 @@ "unavailable": "(غیرقابل دسترس)", "noActive": "اشتراک فعالی وجود ندارد", "create": "ایجاد اشتراک", - "creating": "در حال ایجاد..." + "creating": "در حال ایجاد...", + "trafficPackages": "بسته‌های ترافیک", + "addTraffic": "افزودن ترافیک", + "selectPackage": "انتخاب بسته", + "addButton": "افزودن", + "addTrafficNote": "مدیر: رایگان، ۳۰ روز اعتبار", + "trafficRemoved": "بسته ترافیک حذف شد", + "trafficAdded": "بسته ترافیک اضافه شد", + "expired": "منقضی شده", + "daysLeft": "روز باقیمانده", + "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد" }, "balance": { "current": "موجودی فعلی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7962936..f69ed6e 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2493,7 +2493,17 @@ "unavailable": "(недоступен)", "noActive": "Нет активной подписки", "create": "Создать подписку", - "creating": "Создание..." + "creating": "Создание...", + "trafficPackages": "Пакеты трафика", + "addTraffic": "Добавить трафик", + "selectPackage": "Выберите пакет", + "addButton": "Добавить", + "addTrafficNote": "Админ: бесплатно, действует 30 дней", + "trafficRemoved": "Пакет трафика удалён", + "trafficAdded": "Пакет трафика добавлен", + "expired": "истёк", + "daysLeft": "дн. осталось", + "deviceLimitUpdated": "Лимит устройств обновлён" }, "balance": { "current": "Текущий баланс", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5f0fe56..a05d235 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1665,7 +1665,17 @@ "unavailable": "(不可用)", "noActive": "无活跃订阅", "create": "创建订阅", - "creating": "创建中..." + "creating": "创建中...", + "trafficPackages": "流量包", + "addTraffic": "添加流量", + "selectPackage": "选择套餐", + "addButton": "添加", + "addTrafficNote": "管理员:免费,有效期30天", + "trafficRemoved": "流量包已删除", + "trafficAdded": "流量包已添加", + "expired": "已过期", + "daysLeft": "天剩余", + "deviceLimitUpdated": "设备限制已更新" }, "balance": { "current": "当前余额", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 94afc93..0d690ba 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -195,6 +195,9 @@ export default function AdminUserDetail() { 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 }[] @@ -544,6 +547,57 @@ export default function AdminUserDetail() { } }; + 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); @@ -1166,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 */}
From 893c69ab6fc05ddc4bb64d229ae20376471a4f07 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 21:49:45 +0300 Subject: [PATCH 4/9] feat: add enrichment columns to admin traffic usage table Add 5 new columns (connected devices, spending, start/end dates, last node) with progressive loading shimmer placeholders and 4-language translations. --- src/api/adminTraffic.ts | 33 ++++++++ src/locales/en.json | 7 +- src/locales/fa.json | 9 ++- src/locales/ru.json | 9 ++- src/locales/zh.json | 9 ++- src/pages/AdminTrafficUsage.tsx | 132 ++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 7 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 8af4dcd..c8e4f7e 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -35,6 +35,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 +81,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 +121,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/locales/en.json b/src/locales/en.json index 7ccfa11..bef4532 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -822,7 +822,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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 0e56e32..a010f2c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "در حال بارگذاری...", "all": "همه", @@ -703,7 +703,12 @@ "riskLow": "کم", "riskMedium": "متوسط", "riskHigh": "بالا", - "riskCritical": "بحرانی" + "riskCritical": "بحرانی", + "connected": "متصل", + "totalSpent": "هزینه", + "subStart": "شروع", + "subEnd": "پایان", + "lastNode": "آخرین نود" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index f69ed6e..490a61b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,4 +1,4 @@ -{ +{ "common": { "all": "Все", "loading": "Загрузка...", @@ -843,7 +843,12 @@ "riskLow": "Низкий", "riskMedium": "Средний", "riskHigh": "Высокий", - "riskCritical": "Критический" + "riskCritical": "Критический", + "connected": "Подкл.", + "totalSpent": "Траты", + "subStart": "Начало", + "subEnd": "Конец", + "lastNode": "Посл. нода" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index a05d235..5a33f00 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "加载中...", "error": "错误", @@ -703,7 +703,12 @@ "riskLow": "低", "riskMedium": "中", "riskHigh": "高", - "riskCritical": "严重" + "riskCritical": "严重", + "connected": "连接", + "totalSpent": "消费", + "subStart": "开始", + "subEnd": "结束", + "lastNode": "最近节点" }, "paymentMethods": { "title": "支付方法", diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 33ff729..e8cb26b 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(() => { @@ -1381,6 +1427,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: false, + 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: false, + 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: false, + 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: false, + 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: false, + 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 +1616,8 @@ export default function AdminTrafficUsage() { totalThresholdNum, nodeThresholdNum, periodDays, + enrichment, + enrichmentLoading, ]); const table = useReactTable({ From a8ea5c958f846d84945ebbca2e30f002421786ff Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 22:04:57 +0300 Subject: [PATCH 5/9] fix: show email for OAuth/email users in traffic table Add email field to UserTrafficItem type and display it below user name when no Telegram username is present. --- src/api/adminTraffic.ts | 1 + src/pages/AdminTrafficUsage.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index c8e4f7e..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; diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index e8cb26b..bbeeab6 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -1381,11 +1381,15 @@ export default function AdminTrafficUsage() {
{item.full_name}
- {item.username && ( + {item.username ? (
@{item.username}
- )} + ) : item.email ? ( +
+ {item.email} +
+ ) : null}
); From 5678dfd55854d884220a02075fcc0f025752c189 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 22:39:31 +0300 Subject: [PATCH 6/9] feat: enable sorting on enrichment columns --- src/pages/AdminTrafficUsage.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index bbeeab6..b123ddb 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -1437,7 +1437,7 @@ export default function AdminTrafficUsage() { header: t('admin.trafficUsage.connected'), size: 65, minSize: 50, - enableSorting: false, + enableSorting: true, meta: { align: 'center' as const }, cell: ({ row }) => { const e = enrichment?.[row.original.user_id]; @@ -1451,7 +1451,7 @@ export default function AdminTrafficUsage() { header: t('admin.trafficUsage.totalSpent'), size: 75, minSize: 55, - enableSorting: false, + enableSorting: true, meta: { align: 'center' as const }, cell: ({ row }) => { const e = enrichment?.[row.original.user_id]; @@ -1469,7 +1469,7 @@ export default function AdminTrafficUsage() { header: t('admin.trafficUsage.subStart'), size: 80, minSize: 65, - enableSorting: false, + enableSorting: true, meta: { align: 'center' as const }, cell: ({ row }) => { const e = enrichment?.[row.original.user_id]; @@ -1487,7 +1487,7 @@ export default function AdminTrafficUsage() { header: t('admin.trafficUsage.subEnd'), size: 80, minSize: 65, - enableSorting: false, + enableSorting: true, meta: { align: 'center' as const }, cell: ({ row }) => { const e = enrichment?.[row.original.user_id]; @@ -1505,7 +1505,7 @@ export default function AdminTrafficUsage() { header: t('admin.trafficUsage.lastNode'), size: 100, minSize: 70, - enableSorting: false, + enableSorting: true, meta: { align: 'center' as const }, cell: ({ row }) => { const e = enrichment?.[row.original.user_id]; From ab0270ac58565f883722f7b04aa300b644e7973b Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 22:52:14 +0300 Subject: [PATCH 7/9] feat: add system info card to admin dashboard Shows cabinet version, bot version, Python version, uptime, users and active subscriptions at the bottom of the dashboard page. --- src/api/admin.ts | 14 ++++++++++ src/locales/en.json | 9 +++++++ src/locales/fa.json | 9 +++++++ src/locales/ru.json | 9 +++++++ src/locales/zh.json | 9 +++++++ src/pages/AdminDashboard.tsx | 51 +++++++++++++++++++++++++++++++++++- src/vite-env.d.ts | 2 ++ tsconfig.node.json | 1 + vite.config.ts | 4 +++ 9 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/api/admin.ts b/src/api/admin.ts index 843f969..608581d 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -308,9 +308,23 @@ export interface RecentPaymentsResponse { total_week_kopeks: number; } +export interface SystemInfo { + bot_version: string; + python_version: string; + uptime_seconds: number; + users_total: number; + subscriptions_active: number; +} + // ============ Dashboard Stats API ============ export const statsApi = { + // Get system info + getSystemInfo: async (): Promise => { + 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/locales/en.json b/src/locales/en.json index bef4532..afd36c6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2205,6 +2205,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 a010f2c..f5e4d25 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2008,6 +2008,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 490a61b..ce8de31 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2756,6 +2756,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 5a33f00..9a56aec 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2007,6 +2007,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/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'), From a15b3d410157f916c6008f7dbbe24b1284d3d595 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 23:20:49 +0300 Subject: [PATCH 8/9] feat: add admin updates page with release history Shows bot and cabinet releases with version badges, changelogs, and update detection via __APP_VERSION__. --- src/App.tsx | 11 ++ src/api/adminUpdates.ts | 32 ++++ src/locales/en.json | 19 ++- src/locales/fa.json | 19 ++- src/locales/ru.json | 19 ++- src/locales/zh.json | 19 ++- src/pages/AdminPanel.tsx | 8 +- src/pages/AdminUpdates.tsx | 310 +++++++++++++++++++++++++++++++++++++ 8 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 src/api/adminUpdates.ts create mode 100644 src/pages/AdminUpdates.tsx 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/updates/releases'); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index afd36c6..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", @@ -2113,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", diff --git a/src/locales/fa.json b/src/locales/fa.json index f5e4d25..c1be32b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "مصرف ترافیک", @@ -1928,6 +1930,19 @@ "saturation": "اشباع" } }, + "adminUpdates": { + "title": "به‌روزرسانی‌ها", + "subtitle": "تاریخچه انتشار ربات و کابینت", + "bot": "ربات", + "cabinet": "کابینت", + "currentVersion": "نسخه فعلی", + "upToDate": "به‌روز است", + "updateAvailable": "به‌روزرسانی موجود", + "published": "منتشر شده", + "prerelease": "پیش‌انتشار", + "refresh": "بررسی به‌روزرسانی", + "noReleases": "داده‌ای موجود نیست" + }, "adminDashboard": { "title": "پنل آمار", "subtitle": "نمای کلی سیستم در لحظه", diff --git a/src/locales/ru.json b/src/locales/ru.json index ce8de31..bd2f537 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -783,7 +783,8 @@ "promoOffers": "Промопредложения", "promocodes": "Промокоды", "promoGroups": "Группы скидок", - "trafficUsage": "Расход трафика" + "trafficUsage": "Расход трафика", + "updates": "Обновления" }, "panel": { "title": "Панель администратора", @@ -806,7 +807,8 @@ "promoOffersDesc": "Персональные скидки", "promocodesDesc": "Управление промокодами", "promoGroupsDesc": "Группы скидок для пользователей", - "trafficUsageDesc": "Трафик пользователей по нодам" + "trafficUsageDesc": "Трафик пользователей по нодам", + "updatesDesc": "Версии бота и кабинета" }, "trafficUsage": { "title": "Расход трафика", @@ -2659,6 +2661,19 @@ "saturation": "Насыщенность" } }, + "adminUpdates": { + "title": "Обновления", + "subtitle": "История релизов бота и кабинета", + "bot": "Бот", + "cabinet": "Кабинет", + "currentVersion": "Текущая версия", + "upToDate": "Актуальная версия", + "updateAvailable": "Доступно обновление", + "published": "Опубликовано", + "prerelease": "Предрелиз", + "refresh": "Проверить обновления", + "noReleases": "Нет данных о релизах" + }, "adminDashboard": { "title": "Панель статистики", "subtitle": "Обзор системы в реальном времени", diff --git a/src/locales/zh.json b/src/locales/zh.json index 9a56aec..3fc8ca5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "流量使用", @@ -1927,6 +1929,19 @@ } } }, + "adminUpdates": { + "title": "更新", + "subtitle": "机器人和面板发布历史", + "bot": "机器人", + "cabinet": "面板", + "currentVersion": "当前版本", + "upToDate": "已是最新", + "updateAvailable": "有可用更新", + "published": "发布于", + "prerelease": "预发布", + "refresh": "检查更新", + "noReleases": "没有发布数据" + }, "adminDashboard": { "title": "统计面板", "subtitle": "实时系统概览", 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/AdminUpdates.tsx b/src/pages/AdminUpdates.tsx new file mode 100644 index 0000000..e598096 --- /dev/null +++ b/src/pages/AdminUpdates.tsx @@ -0,0 +1,310 @@ +import { useNavigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +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/, ''); +} + +// ============ 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(); + + return ( +
+
+
+ + {release.tag_name} +
+ {release.name !== release.tag_name && ( + {release.name} + )} + {release.prerelease && ( + + {t('adminUpdates.prerelease')} + + )} +
+ + {formatDate(release.published_at)} +
+
+ {release.body ? ( +
+          {release.body}
+        
+ ) : 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} + /> +
+ )} +
+ ); +} From 0c34668e40d9d4eb7037da7d6f5c2c40c87b208f Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 23:28:07 +0300 Subject: [PATCH 9/9] feat: render GitHub markdown in release changelogs Replace raw preformatted text with rendered markdown: headers, bold, links, lists, inline code. Sanitized with DOMPurify. Styled with Tailwind child selectors. --- src/pages/AdminUpdates.tsx | 75 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/src/pages/AdminUpdates.tsx b/src/pages/AdminUpdates.tsx index e598096..283831f 100644 --- a/src/pages/AdminUpdates.tsx +++ b/src/pages/AdminUpdates.tsx @@ -1,6 +1,8 @@ +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; @@ -107,6 +109,65 @@ 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 }) { @@ -131,6 +192,11 @@ function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) { 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 (
    @@ -152,10 +218,11 @@ function ReleaseCard({ release }: { release: ReleaseItem }) { {formatDate(release.published_at)}
    - {release.body ? ( -
    -          {release.body}
    -        
    + {bodyHtml ? ( +
    ) : null}
    );