From e1d2f8cee403d895508f538ffbc6c9c528f92b05 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 06:11:12 +0300 Subject: [PATCH] feat: show VPN connection info and subscription request history on admin user detail - Load panelInfo eagerly on page load (not just on subscription tab) - Add VPN connection card to info tab: last/first connection, online status indicator, last node - Rename "Last activity" to "Bot activity" to distinguish from VPN connection - Show cabinet_last_login field that was never rendered - Add collapsible subscription request history section in subscription tab with paginated table - Add subscription request history API types and method - Add i18n keys for all 4 locales (ru, en, zh, fa) --- src/api/adminUsers.ts | 29 ++++ src/locales/en.json | 13 ++ src/locales/fa.json | 13 ++ src/locales/ru.json | 13 ++ src/locales/zh.json | 13 ++ src/pages/AdminUserDetail.tsx | 263 +++++++++++++++++++++++++++++++++- 6 files changed, 342 insertions(+), 2 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 8697196..604bf94 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -158,6 +158,19 @@ export interface UserPanelInfo { last_connected_node_name: string | null; } +export interface SubscriptionRequestRecord { + id: number; + userUuid: string; + requestAt: string; + requestIp: string | null; + userAgent: string | null; +} + +export interface SubscriptionRequestHistory { + total: number; + records: SubscriptionRequestRecord[]; +} + export interface UserNodeUsageItem { node_uuid: string; node_name: string; @@ -684,6 +697,22 @@ export const adminUsersApi = { return response.data; }, + // Get subscription request history from RemnaWave panel + getSubscriptionRequestHistory: async ( + userId: number, + subscriptionId?: number, + offset = 0, + limit = 20, + ): Promise => { + const params: Record = { offset, limit }; + if (subscriptionId != null) params.subscription_id = subscriptionId; + const response = await apiClient.get( + `/cabinet/admin/users/${userId}/subscription-request-history`, + { params }, + ); + return response.data; + }, + // Get user devices getUserDevices: async ( userId: number, diff --git a/src/locales/en.json b/src/locales/en.json index 4263969..6108f2d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3075,6 +3075,19 @@ "language": "Language", "registration": "Registration", "lastActivity": "Last activity", + "botActivity": "Bot activity", + "cabinetLastLogin": "Last cabinet login", + "vpnConnection": "VPN connection", + "lastConnection": "Last connection", + "firstConnection": "First connection", + "online": "Online", + "requestHistory": "Subscription request history", + "requestHistoryTotal": "Total requests", + "requestAt": "Date", + "requestIp": "IP address", + "requestUserAgent": "Device", + "loadMore": "Show more", + "noRequests": "No requests", "totalSpent": "Total spent", "purchases": "Purchases", "campaign": "Advertising campaign", diff --git a/src/locales/fa.json b/src/locales/fa.json index 3c29a4a..63d5f36 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2579,6 +2579,19 @@ "language": "زبان", "registration": "ثبت‌نام", "lastActivity": "آخرین فعالیت", + "botActivity": "فعالیت در ربات", + "cabinetLastLogin": "آخرین ورود به کابینت", + "vpnConnection": "اتصال VPN", + "lastConnection": "آخرین اتصال", + "firstConnection": "اولین اتصال", + "online": "آنلاین", + "requestHistory": "تاریخچه درخواست‌های اشتراک", + "requestHistoryTotal": "کل درخواست‌ها", + "requestAt": "تاریخ", + "requestIp": "آدرس IP", + "requestUserAgent": "دستگاه", + "loadMore": "نمایش بیشتر", + "noRequests": "بدون درخواست", "totalSpent": "کل هزینه", "purchases": "خریدها", "campaign": "کمپین تبلیغاتی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6de3b43..16a5261 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3482,6 +3482,19 @@ "language": "Язык", "registration": "Регистрация", "lastActivity": "Последняя активность", + "botActivity": "Активность в боте", + "cabinetLastLogin": "Последний вход в кабинет", + "vpnConnection": "VPN подключение", + "lastConnection": "Последнее подключение", + "firstConnection": "Первое подключение", + "online": "Онлайн", + "requestHistory": "История запросов подписки", + "requestHistoryTotal": "Всего запросов", + "requestAt": "Дата", + "requestIp": "IP адрес", + "requestUserAgent": "Устройство", + "loadMore": "Показать ещё", + "noRequests": "Нет запросов", "totalSpent": "Всего потрачено", "purchases": "Покупок", "campaign": "Рекламная кампания", diff --git a/src/locales/zh.json b/src/locales/zh.json index 8661527..6c04819 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2578,6 +2578,19 @@ "language": "语言", "registration": "注册时间", "lastActivity": "最后活跃", + "botActivity": "机器人活动", + "cabinetLastLogin": "最后登录面板", + "vpnConnection": "VPN 连接", + "lastConnection": "最后连接", + "firstConnection": "首次连接", + "online": "在线", + "requestHistory": "订阅请求历史", + "requestHistoryTotal": "总请求数", + "requestAt": "日期", + "requestIp": "IP 地址", + "requestUserAgent": "设备", + "loadMore": "显示更多", + "noRequests": "没有请求", "totalSpent": "总消费", "purchases": "购买次数", "campaign": "广告活动", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index a5a80ae..20bacb3 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -14,6 +14,7 @@ import { type PanelSyncStatusResponse, type UpdateSubscriptionRequest, type AdminUserGiftsResponse, + type SubscriptionRequestRecord, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; @@ -383,6 +384,13 @@ export default function AdminUserDetail() { const [giftsData, setGiftsData] = useState(null); const [giftsLoading, setGiftsLoading] = useState(false); + // Subscription request history + const [requestHistory, setRequestHistory] = useState([]); + const [requestHistoryLoading, setRequestHistoryLoading] = useState(false); + const [requestHistoryOffset, setRequestHistoryOffset] = useState(0); + const [requestHistoryTotal, setRequestHistoryTotal] = useState(0); + const [requestHistoryExpanded, setRequestHistoryExpanded] = useState(false); + const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { @@ -483,6 +491,29 @@ export default function AdminUserDetail() { } }, [userId, activeSubscriptionId]); + const loadRequestHistory = useCallback( + async (offset = 0, append = false) => { + if (!userId) return; + try { + setRequestHistoryLoading(true); + const data = await adminUsersApi.getSubscriptionRequestHistory( + userId, + activeSubscriptionId ?? undefined, + offset, + 20, + ); + setRequestHistory((prev) => (append ? [...prev, ...data.records] : data.records)); + setRequestHistoryTotal(data.total); + setRequestHistoryOffset(offset + data.records.length); + } catch { + // silent + } finally { + setRequestHistoryLoading(false); + } + }, + [userId, activeSubscriptionId], + ); + const loadNodeUsage = useCallback(async () => { if (!userId) return; try { @@ -575,7 +606,8 @@ export default function AdminUserDetail() { return; } loadUser(); - }, [userId, loadUser, navigate]); + loadPanelInfo(); + }, [userId, loadUser, loadPanelInfo, navigate]); useEffect(() => { if (activeTab === 'info') { @@ -1301,10 +1333,16 @@ export default function AdminUserDetail() {
- {t('admin.users.detail.lastActivity')} + {t('admin.users.detail.botActivity')}
{formatDate(user.last_activity)}
+
+
+ {t('admin.users.detail.cabinetLastLogin')} +
+
{formatDate(user.cabinet_last_login)}
+
{t('admin.users.detail.totalSpent')} @@ -1321,6 +1359,87 @@ export default function AdminUserDetail() {
+ {/* VPN Connection Info */} + {panelInfo && panelInfo.found && ( +
+
+ {t('admin.users.detail.vpnConnection')} +
+
+
+
+ {t('admin.users.detail.lastConnection')} +
+
+ {panelInfo.online_at && + (() => { + const onlineDate = new Date(panelInfo.online_at); + const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000; + return ( + <> + + + {isRecent + ? t('admin.users.detail.online') + : formatDate(panelInfo.online_at)} + + + ); + })()} + {!panelInfo.online_at && -} +
+
+
+
+ {t('admin.users.detail.firstConnection')} +
+
+ {panelInfo.first_connected_at + ? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : '-'} +
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ + + + {panelInfo.last_connected_node_name} +
+
+ )} +
+
+ )} + {panelInfoLoading && !panelInfo && ( +
+
+
+ )} + {/* Campaign */} {user.campaign_name && (
@@ -2348,6 +2467,146 @@ export default function AdminUserDetail() {
)}
+ + {/* Subscription Request History */} +
+ + + {requestHistoryExpanded && ( +
+ {/* Subscription selector for multi-tariff */} + {userSubscriptions.length > 1 && ( +
+ +
+ )} + + {requestHistoryLoading && requestHistory.length === 0 ? ( +
+
+
+ ) : requestHistory.length === 0 && !requestHistoryLoading ? ( +
+ {t('admin.users.detail.noRequests')} +
+ ) : ( + <> +
+ {t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal} +
+ + {/* Table */} +
+ + + + + + + + + + {requestHistory.map((record, idx) => ( + + + + + + ))} + +
+ {t('admin.users.detail.requestAt')} + + {t('admin.users.detail.requestIp')} + + {t('admin.users.detail.requestUserAgent')} +
+ {formatDate(record.requestAt)} + + {record.requestIp || '\u2014'} + + {record.userAgent + ? record.userAgent.length > 60 + ? `${record.userAgent.slice(0, 60)}...` + : record.userAgent + : '\u2014'} +
+
+ + {/* Load more */} + {requestHistory.length < requestHistoryTotal && ( + + )} + + )} +
+ )} +
)}