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)
This commit is contained in:
Fringg
2026-04-29 06:11:12 +03:00
parent 5b1892ddbb
commit e1d2f8cee4
6 changed files with 342 additions and 2 deletions

View File

@@ -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<SubscriptionRequestHistory> => {
const params: Record<string, unknown> = { 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,

View File

@@ -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",

View File

@@ -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": "کمپین تبلیغاتی",

View File

@@ -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": "Рекламная кампания",

View File

@@ -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": "广告活动",

View File

@@ -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<AdminUserGiftsResponse | null>(null);
const [giftsLoading, setGiftsLoading] = useState(false);
// Subscription request history
const [requestHistory, setRequestHistory] = useState<SubscriptionRequestRecord[]>([]);
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() {
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastActivity')}
{t('admin.users.detail.botActivity')}
</div>
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.cabinetLastLogin')}
</div>
<div className="text-dark-100">{formatDate(user.cabinet_last_login)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.totalSpent')}
@@ -1321,6 +1359,87 @@ export default function AdminUserDetail() {
</div>
</div>
{/* VPN Connection Info */}
{panelInfo && panelInfo.found && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.vpnConnection')}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastConnection')}
</div>
<div className="flex items-center gap-2">
{panelInfo.online_at &&
(() => {
const onlineDate = new Date(panelInfo.online_at);
const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000;
return (
<>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${isRecent ? 'bg-success-400 shadow-[0_0_6px_rgba(74,222,128,0.5)]' : 'bg-dark-500'}`}
title={isRecent ? t('admin.users.detail.online') : ''}
/>
<span
className={`text-sm ${isRecent ? 'font-medium text-success-400' : 'text-dark-100'}`}
>
{isRecent
? t('admin.users.detail.online')
: formatDate(panelInfo.online_at)}
</span>
</>
);
})()}
{!panelInfo.online_at && <span className="text-sm text-dark-100">-</span>}
</div>
</div>
<div className="rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.firstConnection')}
</div>
<div className="text-sm text-dark-100">
{panelInfo.first_connected_at
? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '-'}
</div>
</div>
{panelInfo.last_connected_node_name && (
<div className="col-span-2 rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastNode')}
</div>
<div className="flex items-center gap-2 text-sm text-dark-100">
<svg
className="h-4 w-4 shrink-0 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
{panelInfo.last_connected_node_name}
</div>
</div>
)}
</div>
</div>
)}
{panelInfoLoading && !panelInfo && (
<div className="flex justify-center rounded-xl bg-dark-800/50 py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{/* Campaign */}
{user.campaign_name && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
@@ -2348,6 +2467,146 @@ export default function AdminUserDetail() {
</div>
)}
</div>
{/* Subscription Request History */}
<div className="rounded-xl bg-dark-800/50">
<button
onClick={() => {
const next = !requestHistoryExpanded;
setRequestHistoryExpanded(next);
if (next && requestHistory.length === 0) {
loadRequestHistory(0);
}
}}
className="flex w-full items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.requestHistory')}
</span>
{requestHistoryTotal > 0 && (
<span className="rounded-full bg-accent-500/15 px-2 py-0.5 text-xs font-medium text-accent-400">
{requestHistoryTotal}
</span>
)}
</div>
<svg
className={`h-4 w-4 text-dark-500 transition-transform ${requestHistoryExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
{requestHistoryExpanded && (
<div className="border-t border-dark-700/50 px-4 pb-4 pt-3">
{/* Subscription selector for multi-tariff */}
{userSubscriptions.length > 1 && (
<div className="mb-3">
<select
value={activeSubscriptionId || ''}
onChange={(e) => {
const subId = Number(e.target.value) || null;
setActiveSubscriptionId(subId);
setRequestHistory([]);
setRequestHistoryOffset(0);
setRequestHistoryTotal(0);
}}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100"
>
{userSubscriptions.map((sub) => (
<option key={sub.id} value={sub.id}>
{sub.tariff_name || `#${sub.id}`} {sub.status}
</option>
))}
</select>
</div>
)}
{requestHistoryLoading && requestHistory.length === 0 ? (
<div className="flex justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : requestHistory.length === 0 && !requestHistoryLoading ? (
<div className="py-6 text-center text-sm text-dark-500">
{t('admin.users.detail.noRequests')}
</div>
) : (
<>
<div className="mb-2 text-xs text-dark-500">
{t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal}
</div>
{/* Table */}
<div className="-mx-4 overflow-x-auto px-4">
<table className="w-full min-w-[480px] text-left text-sm">
<thead>
<tr className="border-b border-dark-700/50 text-xs text-dark-500">
<th className="pb-2 pr-3 font-medium">
{t('admin.users.detail.requestAt')}
</th>
<th className="pb-2 pr-3 font-medium">
{t('admin.users.detail.requestIp')}
</th>
<th className="pb-2 font-medium">
{t('admin.users.detail.requestUserAgent')}
</th>
</tr>
</thead>
<tbody>
{requestHistory.map((record, idx) => (
<tr
key={record.id}
className={`border-b border-dark-700/30 ${idx % 2 === 0 ? 'bg-dark-800/30' : ''}`}
>
<td className="whitespace-nowrap py-2.5 pr-3 text-dark-200">
{formatDate(record.requestAt)}
</td>
<td className="whitespace-nowrap py-2.5 pr-3 font-mono text-xs text-dark-300">
{record.requestIp || '\u2014'}
</td>
<td
className="max-w-[200px] truncate py-2.5 text-xs text-dark-400"
title={record.userAgent || ''}
>
{record.userAgent
? record.userAgent.length > 60
? `${record.userAgent.slice(0, 60)}...`
: record.userAgent
: '\u2014'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Load more */}
{requestHistory.length < requestHistoryTotal && (
<button
onClick={() => loadRequestHistory(requestHistoryOffset, true)}
disabled={requestHistoryLoading}
className="mt-3 flex min-h-[44px] w-full items-center justify-center rounded-lg bg-dark-700/50 py-2.5 text-sm text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
>
{requestHistoryLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
) : (
t('admin.users.detail.loadMore')
)}
</button>
)}
</>
)}
</div>
)}
</div>
</>
)}
</div>