feat: subscription selector in VPN connection block for multi-tariff

- Show VPN connection block even when panelInfo not yet loaded
- Add subscription dropdown when user has multiple subscriptions
- Show spinner while loading, "no data" when panel returns not found
- Changing subscription in VPN block reloads panel info for that sub
This commit is contained in:
Fringg
2026-04-29 06:34:41 +03:00
parent 853e1c9c84
commit bc37f31350
3 changed files with 101 additions and 74 deletions

View File

@@ -3081,6 +3081,7 @@
"lastConnection": "Last connection", "lastConnection": "Last connection",
"firstConnection": "First connection", "firstConnection": "First connection",
"online": "Online", "online": "Online",
"noVpnData": "No connection data available",
"requestHistory": "Subscription request history", "requestHistory": "Subscription request history",
"requestHistoryTotal": "Total requests", "requestHistoryTotal": "Total requests",
"requestAt": "Date", "requestAt": "Date",

View File

@@ -3488,6 +3488,7 @@
"lastConnection": "Последнее подключение", "lastConnection": "Последнее подключение",
"firstConnection": "Первое подключение", "firstConnection": "Первое подключение",
"online": "Онлайн", "online": "Онлайн",
"noVpnData": "Нет данных о подключении",
"requestHistory": "История запросов подписки", "requestHistory": "История запросов подписки",
"requestHistoryTotal": "Всего запросов", "requestHistoryTotal": "Всего запросов",
"requestAt": "Дата", "requestAt": "Дата",

View File

@@ -1376,83 +1376,108 @@ export default function AdminUserDetail() {
</div> </div>
{/* VPN Connection Info */} {/* VPN Connection Info */}
{panelInfo && panelInfo.found && ( {(panelInfo || userSubscriptions.length > 0) && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4"> <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"> <div className="mb-3 flex items-center justify-between">
{t('admin.users.detail.vpnConnection')} <span className="text-sm font-medium text-dark-200">
</div> {t('admin.users.detail.vpnConnection')}
<div className="grid grid-cols-2 gap-3"> </span>
<div className="rounded-lg bg-dark-700/30 p-3"> {userSubscriptions.length > 1 && (
<div className="mb-1 text-xs text-dark-500"> <select
{t('admin.users.detail.lastConnection')} value={activeSubscriptionId ?? ''}
</div> onChange={(e) => {
<div className="flex items-center gap-2"> const subId = Number(e.target.value);
{panelInfo.online_at && setActiveSubscriptionId(subId);
(() => { }}
const onlineDate = new Date(panelInfo.online_at); className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs text-dark-200"
const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000; >
return ( {userSubscriptions.map((s) => (
<> <option key={s.id} value={s.id}>
<span {s.tariff_name || `#${s.id}`} {s.status}
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'}`} </option>
title={isRecent ? t('admin.users.detail.online') : ''} ))}
/> </select>
<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>
</div> {panelInfoLoading && !panelInfo?.found && (
)} <div className="flex items-center justify-center py-4">
{panelInfoLoading && !panelInfo && ( <div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<div className="flex justify-center rounded-xl bg-dark-800/50 py-4"> </div>
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" /> )}
{panelInfo?.found && (
<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>
)}
{!panelInfoLoading && !panelInfo?.found && userSubscriptions.length > 0 && (
<div className="py-2 text-center text-xs text-dark-500">
{t('admin.users.detail.noVpnData')}
</div>
)}
</div> </div>
)} )}