feat(admin-remnawave): auto-refresh overview, responsive mobile grids, wide sub-requests

- Auto-refresh the recap/devices/top-consumers/subscription-requests widgets
  on a 60s timer (system + health already refreshed) so the page updates itself
- Mobile: a lone trailing stat card now spans the full row (no empty gap when
  the 2-column grid reflows)
- Subscription requests: full-width card with a two-column breakdown and a total
  count, instead of a half-empty narrow card
This commit is contained in:
c0mrade
2026-05-31 19:24:44 +03:00
parent e18db02e42
commit ff3dbfaa25

View File

@@ -36,13 +36,7 @@ import {
BackIcon, BackIcon,
ChevronRightIcon, ChevronRightIcon,
} from '../components/icons'; } from '../components/icons';
import { import { PiCpu, PiMemory, PiTimer, PiDevices, PiPulse } from 'react-icons/pi';
PiCpuDuotone,
PiMemoryDuotone,
PiTimerDuotone,
PiDevicesDuotone,
PiPulseDuotone,
} from 'react-icons/pi';
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
@@ -294,16 +288,18 @@ const formatUptimeSince = (iso: string): string => {
function BreakdownCard({ function BreakdownCard({
title, title,
items, items,
wide = false,
}: { }: {
title: string; title: string;
items: { label: string; count: number }[]; items: { label: string; count: number }[];
wide?: boolean;
}) { }) {
const max = Math.max(1, ...items.map((i) => i.count)); const max = Math.max(1, ...items.map((i) => i.count));
return ( return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4"> <div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<h4 className="mb-3 text-sm font-medium text-dark-200">{title}</h4> <h4 className="mb-3 text-sm font-medium text-dark-200">{title}</h4>
<div className="space-y-2"> <div className={wide ? 'grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2' : 'space-y-2'}>
{items.slice(0, 8).map((it) => ( {items.slice(0, wide ? 16 : 8).map((it) => (
<div key={it.label}> <div key={it.label}>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="truncate text-dark-300">{it.label}</span> <span className="truncate text-dark-300">{it.label}</span>
@@ -377,7 +373,7 @@ function OverviewTab({
<ChartIcon className="h-4 w-4" /> <ChartIcon className="h-4 w-4" />
{t('admin.remnawave.overview.system', 'System')} {t('admin.remnawave.overview.system', 'System')}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.overview.usersOnline', 'Users Online')} label={t('admin.remnawave.overview.usersOnline', 'Users Online')}
value={stats.system.users_online} value={stats.system.users_online}
@@ -411,7 +407,7 @@ function OverviewTab({
<ChartIcon className="h-4 w-4" /> <ChartIcon className="h-4 w-4" />
{t('admin.remnawave.overview.bandwidth', 'Inbound Traffic')} {t('admin.remnawave.overview.bandwidth', 'Inbound Traffic')}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 [&>*:last-child:nth-child(odd)]:col-span-2 sm:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.overview.download', 'Download')} label={t('admin.remnawave.overview.download', 'Download')}
value={formatBytes(stats.bandwidth.realtime_download)} value={formatBytes(stats.bandwidth.realtime_download)}
@@ -439,24 +435,24 @@ function OverviewTab({
<ServerIcon className="h-4 w-4" /> <ServerIcon className="h-4 w-4" />
{t('admin.remnawave.overview.server', 'Server')} {t('admin.remnawave.overview.server', 'Server')}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.overview.cpu', 'CPU Cores')} label={t('admin.remnawave.overview.cpu', 'CPU Cores')}
value={stats.server_info.cpu_cores} value={stats.server_info.cpu_cores}
icon={<PiCpuDuotone className="h-5 w-5" />} icon={<PiCpu className="h-5 w-5" />}
color="accent" color="accent"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.memory', 'Memory')} label={t('admin.remnawave.overview.memory', 'Memory')}
value={`${memoryUsedPercent}%`} value={`${memoryUsedPercent}%`}
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`} subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`}
icon={<PiMemoryDuotone className="h-5 w-5" />} icon={<PiMemory className="h-5 w-5" />}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.uptime', 'Uptime')} label={t('admin.remnawave.overview.uptime', 'Uptime')}
value={formatUptime(stats.server_info.uptime_seconds)} value={formatUptime(stats.server_info.uptime_seconds)}
icon={<PiTimerDuotone className="h-5 w-5" />} icon={<PiTimer className="h-5 w-5" />}
color="blue" color="blue"
/> />
</div> </div>
@@ -468,7 +464,7 @@ function OverviewTab({
<ChartIcon className="h-4 w-4" /> <ChartIcon className="h-4 w-4" />
{t('admin.remnawave.overview.traffic', 'Traffic Statistics')} {t('admin.remnawave.overview.traffic', 'Traffic Statistics')}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-5 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.overview.traffic2days', '2 days')} label={t('admin.remnawave.overview.traffic2days', '2 days')}
value={formatBytes(stats.traffic_periods.last_2_days.current)} value={formatBytes(stats.traffic_periods.last_2_days.current)}
@@ -508,7 +504,7 @@ function OverviewTab({
<UsersIcon className="h-4 w-4" /> <UsersIcon className="h-4 w-4" />
{t('admin.remnawave.overview.usersByStatus', 'Users by Status')} {t('admin.remnawave.overview.usersByStatus', 'Users by Status')}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
{Object.entries(stats.users_by_status).map(([status, count]) => ( {Object.entries(stats.users_by_status).map(([status, count]) => (
<StatCard <StatCard
key={status} key={status}
@@ -528,7 +524,7 @@ function OverviewTab({
<RemnawaveIcon className="h-4 w-4" /> <RemnawaveIcon className="h-4 w-4" />
{t('admin.remnawave.overview.panel', 'Панель')} {t('admin.remnawave.overview.panel', 'Панель')}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.overview.lifetimeTraffic', 'Трафик за всё время')} label={t('admin.remnawave.overview.lifetimeTraffic', 'Трафик за всё время')}
value={formatBytes(recap.total.traffic_bytes)} value={formatBytes(recap.total.traffic_bytes)}
@@ -566,7 +562,7 @@ function OverviewTab({
{devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && ( {devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && (
<div> <div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<PiDevicesDuotone className="h-4 w-4" /> <PiDevices className="h-4 w-4" />
{t('admin.remnawave.overview.devices', 'Устройства')} ·{' '} {t('admin.remnawave.overview.devices', 'Устройства')} ·{' '}
{devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/ {devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/
{t('admin.remnawave.overview.perUser', 'юзер')}) {t('admin.remnawave.overview.perUser', 'юзер')})
@@ -629,11 +625,11 @@ function OverviewTab({
{t('admin.remnawave.overview.panelHealth', 'Здоровье панели')} {t('admin.remnawave.overview.panelHealth', 'Здоровье панели')}
{health.instances > 1 ? ` · ${health.instances}` : ''} {health.instances > 1 ? ` · ${health.instances}` : ''}
</h3> </h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.overview.panelRam', 'RAM процесса')} label={t('admin.remnawave.overview.panelRam', 'RAM процесса')}
value={formatBytes(health.rss_bytes)} value={formatBytes(health.rss_bytes)}
icon={<PiMemoryDuotone className="h-5 w-5" />} icon={<PiMemory className="h-5 w-5" />}
color="blue" color="blue"
/> />
<StatCard <StatCard
@@ -646,13 +642,13 @@ function OverviewTab({
<StatCard <StatCard
label={t('admin.remnawave.overview.eventLoopP99', 'Event-loop p99')} label={t('admin.remnawave.overview.eventLoopP99', 'Event-loop p99')}
value={`${health.event_loop_p99_ms.toFixed(1)} ms`} value={`${health.event_loop_p99_ms.toFixed(1)} ms`}
icon={<PiPulseDuotone className="h-5 w-5" />} icon={<PiPulse className="h-5 w-5" />}
color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} color={health.event_loop_p99_ms > 50 ? 'red' : 'green'}
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.panelUptime', 'Аптайм панели')} label={t('admin.remnawave.overview.panelUptime', 'Аптайм панели')}
value={formatUptime(health.uptime_seconds)} value={formatUptime(health.uptime_seconds)}
icon={<PiTimerDuotone className="h-5 w-5" />} icon={<PiTimer className="h-5 w-5" />}
color="accent" color="accent"
/> />
</div> </div>
@@ -664,14 +660,14 @@ function OverviewTab({
<div> <div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<SubscriptionIcon className="h-4 w-4" /> <SubscriptionIcon className="h-4 w-4" />
{t('admin.remnawave.overview.subRequests', 'Запросы подписки (по клиентам)')} {t('admin.remnawave.overview.subRequests', 'Запросы подписки (по клиентам)')} ·{' '}
{subRequests.by_app.reduce((acc, a) => acc + a.count, 0)}
</h3> </h3>
<div className="grid gap-3 lg:grid-cols-2"> <BreakdownCard
<BreakdownCard wide
title={t('admin.remnawave.overview.byApp', 'По приложениям')} title={t('admin.remnawave.overview.byApp', 'По приложениям')}
items={subRequests.by_app.map((a) => ({ label: a.app, count: a.count }))} items={subRequests.by_app.map((a) => ({ label: a.app, count: a.count }))}
/> />
</div>
</div> </div>
)} )}
</div> </div>
@@ -719,7 +715,7 @@ function NodesTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-5 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.nodes.stats.total', 'Total')} label={t('admin.remnawave.nodes.stats.total', 'Total')}
value={stats.total} value={stats.total}
@@ -825,7 +821,7 @@ function SquadsTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4 [&>*:last-child:nth-child(odd)]:col-span-2 lg:[&>*:last-child]:col-span-1">
<StatCard <StatCard
label={t('admin.remnawave.squads.stats.total', 'Total')} label={t('admin.remnawave.squads.stats.total', 'Total')}
value={stats.total} value={stats.total}
@@ -1193,6 +1189,7 @@ export default function AdminRemnawave() {
queryKey: ['admin-remnawave-recap'], queryKey: ['admin-remnawave-recap'],
queryFn: adminRemnawaveApi.getRecap, queryFn: adminRemnawaveApi.getRecap,
enabled: activeTab === 'overview', enabled: activeTab === 'overview',
refetchInterval: 60000,
staleTime: 60000, staleTime: 60000,
}); });
@@ -1200,6 +1197,7 @@ export default function AdminRemnawave() {
queryKey: ['admin-remnawave-devices-stats'], queryKey: ['admin-remnawave-devices-stats'],
queryFn: adminRemnawaveApi.getDevicesStats, queryFn: adminRemnawaveApi.getDevicesStats,
enabled: activeTab === 'overview', enabled: activeTab === 'overview',
refetchInterval: 60000,
staleTime: 60000, staleTime: 60000,
}); });
@@ -1207,6 +1205,7 @@ export default function AdminRemnawave() {
queryKey: ['admin-remnawave-top-consumers'], queryKey: ['admin-remnawave-top-consumers'],
queryFn: () => adminRemnawaveApi.getTopConsumers(7, 10), queryFn: () => adminRemnawaveApi.getTopConsumers(7, 10),
enabled: activeTab === 'overview', enabled: activeTab === 'overview',
refetchInterval: 60000,
staleTime: 60000, staleTime: 60000,
}); });
@@ -1222,6 +1221,7 @@ export default function AdminRemnawave() {
queryKey: ['admin-remnawave-sub-requests'], queryKey: ['admin-remnawave-sub-requests'],
queryFn: adminRemnawaveApi.getSubscriptionRequests, queryFn: adminRemnawaveApi.getSubscriptionRequests,
enabled: activeTab === 'overview', enabled: activeTab === 'overview',
refetchInterval: 60000,
staleTime: 60000, staleTime: 60000,
}); });