feat(admin-remnawave): enrich panel stats UI and fix nodes-online display

- Show nodes as "online / total" (fixes "56 online" when only 5 nodes exist)
- Add recap, devices (+top users by device count), top-consumers, panel
  health and subscription-request widgets, with profile-specific Phosphor
  metric icons (CPU/RAM/uptime/devices/event-loop)
- Add the matching adminRemnawave API types and methods
This commit is contained in:
c0mrade
2026-05-31 18:51:12 +03:00
parent d0e0b6b7e3
commit b8be98939a
2 changed files with 368 additions and 45 deletions

View File

@@ -21,12 +21,57 @@ export interface SystemSummary {
total_users: number; total_users: number;
active_connections: number; active_connections: number;
nodes_online: number; nodes_online: number;
total_nodes: number;
users_last_day: number; users_last_day: number;
users_last_week: number; users_last_week: number;
users_never_online: number; users_never_online: number;
total_user_traffic: number; total_user_traffic: number;
} }
// Panel recap / devices / top consumers
export interface RecapResponse {
version: string | null;
init_date: string | null;
total: {
users: number;
nodes: number;
traffic_bytes: number;
nodes_ram_bytes: number;
nodes_cpu_cores: number;
distinct_countries: number;
};
this_month: { users: number; traffic_bytes: number };
}
export interface DevicesStatsResponse {
by_platform: { platform: string; count: number }[];
by_app: { app: string; count: number }[];
top_users: { username: string; devices_count: number }[];
total_unique_devices: number;
total_hwid_devices: number;
average_devices_per_user: number;
}
export interface TopConsumersResponse {
period_days: number;
users: { username: string; total_bytes: number }[];
}
export interface HealthResponse {
instances: number;
rss_bytes: number;
heap_used_bytes: number;
heap_total_bytes: number;
event_loop_delay_ms: number;
event_loop_p99_ms: number;
uptime_seconds: number;
instance_id: string | null;
}
export interface SubscriptionRequestStatsResponse {
by_app: { app: string; count: number }[];
}
export interface ServerInfo { export interface ServerInfo {
cpu_cores: number; cpu_cores: number;
memory_total: number; memory_total: number;
@@ -292,6 +337,34 @@ export const adminRemnawaveApi = {
return response.data; return response.data;
}, },
// Panel recap / devices / top consumers
getRecap: async (): Promise<RecapResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/recap');
return response.data;
},
getDevicesStats: async (): Promise<DevicesStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/devices-stats');
return response.data;
},
getTopConsumers: async (days = 7, limit = 10): Promise<TopConsumersResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/top-consumers', {
params: { days, limit },
});
return response.data;
},
getHealth: async (): Promise<HealthResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/health');
return response.data;
},
getSubscriptionRequests: async (): Promise<SubscriptionRequestStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/subscription-requests');
return response.data;
},
// Nodes // Nodes
getNodes: async (): Promise<NodesListResponse> => { getNodes: async (): Promise<NodesListResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/nodes'); const response = await apiClient.get('/cabinet/admin/remnawave/nodes');

View File

@@ -9,6 +9,11 @@ import {
SquadWithLocalInfo, SquadWithLocalInfo,
SystemStatsResponse, SystemStatsResponse,
AutoSyncStatus, AutoSyncStatus,
RecapResponse,
DevicesStatsResponse,
TopConsumersResponse,
HealthResponse,
SubscriptionRequestStatsResponse,
} from '../api/adminRemnawave'; } from '../api/adminRemnawave';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { formatUptime } from '../utils/format'; import { formatUptime } from '../utils/format';
@@ -25,19 +30,19 @@ import {
StopIcon, StopIcon,
ArrowPathIcon, ArrowPathIcon,
RemnawaveIcon, RemnawaveIcon,
DownloadIcon,
UploadIcon,
SubscriptionIcon,
BackIcon,
ChevronRightIcon,
} from '../components/icons'; } from '../components/icons';
import {
const BackIcon = () => ( PiCpuDuotone,
<svg PiMemoryDuotone,
className="h-5 w-5 text-dark-400" PiTimerDuotone,
fill="none" PiDevicesDuotone,
viewBox="0 0 24 24" PiPulseDuotone,
stroke="currentColor" } from 'react-icons/pi';
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
@@ -231,15 +236,7 @@ function SquadCard({ squad, onClick }: SquadCardProps) {
</div> </div>
</div> </div>
<svg <ChevronRightIcon className="h-5 w-5 shrink-0 text-dark-500" />
className="h-5 w-5 shrink-0 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div> </div>
</div> </div>
); );
@@ -285,13 +282,67 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC
); );
} }
const formatUptimeSince = (iso: string): string => {
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
if (Number.isNaN(days)) return '—';
if (days < 1) return '<1d';
if (days < 30) return `${days}d`;
if (days < 365) return `${Math.floor(days / 30)}mo`;
return `${Math.floor(days / 365)}y`;
};
function BreakdownCard({
title,
items,
}: {
title: string;
items: { label: string; count: number }[];
}) {
const max = Math.max(1, ...items.map((i) => i.count));
return (
<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>
<div className="space-y-2">
{items.slice(0, 8).map((it) => (
<div key={it.label}>
<div className="flex items-center justify-between text-xs">
<span className="truncate text-dark-300">{it.label}</span>
<span className="ml-2 shrink-0 text-dark-400">{it.count}</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-dark-700">
<div
className="h-1.5 rounded-full bg-accent-500"
style={{ width: `${(it.count / max) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}
interface OverviewTabProps { interface OverviewTabProps {
stats: SystemStatsResponse | undefined; stats: SystemStatsResponse | undefined;
recap?: RecapResponse;
devicesStats?: DevicesStatsResponse;
topConsumers?: TopConsumersResponse;
health?: HealthResponse;
subRequests?: SubscriptionRequestStatsResponse;
isLoading: boolean; isLoading: boolean;
onRefresh: () => void; onRefresh: () => void;
} }
function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { function OverviewTab({
stats,
recap,
devicesStats,
topConsumers,
health,
subRequests,
isLoading,
onRefresh,
}: OverviewTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (isLoading) { if (isLoading) {
@@ -341,13 +392,13 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.nodesOnline', 'Nodes Online')} label={t('admin.remnawave.overview.nodesOnline', 'Nodes Online')}
value={stats.system.nodes_online} value={`${stats.system.nodes_online} / ${stats.system.total_nodes}`}
icon={<GlobeIcon />} icon={<GlobeIcon />}
color="purple" color={stats.system.nodes_online < stats.system.total_nodes ? 'orange' : 'purple'}
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.connections', 'Connections')} label={t('admin.remnawave.overview.active24h', 'Активны за 24ч')}
value={stats.system.active_connections} value={stats.system.users_last_day}
icon={<ServerIcon className="h-5 w-5" />} icon={<ServerIcon className="h-5 w-5" />}
color="orange" color="orange"
/> />
@@ -356,26 +407,27 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
{/* Bandwidth */} {/* Bandwidth */}
<div> <div>
<h3 className="mb-3 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<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">
<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)}
icon={<span className="text-lg"></span>} icon={<DownloadIcon className="h-5 w-5" />}
color="green" color="green"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.upload', 'Upload')} label={t('admin.remnawave.overview.upload', 'Upload')}
value={formatBytes(stats.bandwidth.realtime_upload)} value={formatBytes(stats.bandwidth.realtime_upload)}
icon={<span className="text-lg"></span>} icon={<UploadIcon className="h-5 w-5" />}
color="blue" color="blue"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.total', 'Total')} label={t('admin.remnawave.overview.total', 'Total')}
value={formatBytes(stats.bandwidth.realtime_total)} value={formatBytes(stats.bandwidth.realtime_total)}
icon={<span className="text-lg"></span>} icon={<ChartIcon className="h-5 w-5" />}
color="purple" color="purple"
/> />
</div> </div>
@@ -383,27 +435,28 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
{/* Server Info */} {/* Server Info */}
<div> <div>
<h3 className="mb-3 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<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">
<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={<span className="text-lg"></span>} icon={<PiCpuDuotone 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={<span className="text-lg">💾</span>} icon={<PiMemoryDuotone 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={<span className="text-lg"></span>} icon={<PiTimerDuotone className="h-5 w-5" />}
color="blue" color="blue"
/> />
</div> </div>
@@ -411,38 +464,39 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
{/* Traffic Periods */} {/* Traffic Periods */}
<div> <div>
<h3 className="mb-3 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<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">
<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)}
icon={<span className="text-xs">📊</span>} icon={<ChartIcon className="h-5 w-5" />}
color="accent" color="accent"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.traffic7days', '7 days')} label={t('admin.remnawave.overview.traffic7days', '7 days')}
value={formatBytes(stats.traffic_periods.last_7_days.current)} value={formatBytes(stats.traffic_periods.last_7_days.current)}
icon={<span className="text-xs">📊</span>} icon={<ChartIcon className="h-5 w-5" />}
color="blue" color="blue"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.traffic30days', '30 days')} label={t('admin.remnawave.overview.traffic30days', '30 days')}
value={formatBytes(stats.traffic_periods.last_30_days.current)} value={formatBytes(stats.traffic_periods.last_30_days.current)}
icon={<span className="text-xs">📊</span>} icon={<ChartIcon className="h-5 w-5" />}
color="green" color="green"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.trafficMonth', 'Month')} label={t('admin.remnawave.overview.trafficMonth', 'Month')}
value={formatBytes(stats.traffic_periods.current_month.current)} value={formatBytes(stats.traffic_periods.current_month.current)}
icon={<span className="text-xs">📊</span>} icon={<ChartIcon className="h-5 w-5" />}
color="purple" color="purple"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.trafficYear', 'Year')} label={t('admin.remnawave.overview.trafficYear', 'Year')}
value={formatBytes(stats.traffic_periods.current_year.current)} value={formatBytes(stats.traffic_periods.current_year.current)}
icon={<span className="text-xs">📊</span>} icon={<ChartIcon className="h-5 w-5" />}
color="orange" color="orange"
/> />
</div> </div>
@@ -450,7 +504,8 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
{/* Users by Status */} {/* Users by Status */}
<div> <div>
<h3 className="mb-3 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<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">
@@ -465,6 +520,160 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
))} ))}
</div> </div>
</div> </div>
{/* Panel recap */}
{recap && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<RemnawaveIcon className="h-4 w-4" />
{t('admin.remnawave.overview.panel', 'Панель')}
</h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
label={t('admin.remnawave.overview.lifetimeTraffic', 'Трафик за всё время')}
value={formatBytes(recap.total.traffic_bytes)}
icon={<ChartIcon className="h-5 w-5" />}
color="purple"
/>
<StatCard
label={t('admin.remnawave.overview.thisMonthTraffic', 'Трафик за месяц')}
value={formatBytes(recap.this_month.traffic_bytes)}
icon={<ChartIcon className="h-5 w-5" />}
color="blue"
/>
<StatCard
label={t('admin.remnawave.overview.countries', 'Стран')}
value={recap.total.distinct_countries}
icon={<GlobeIcon className="h-5 w-5" />}
color="green"
/>
<StatCard
label={t('admin.remnawave.overview.panelVersion', 'Версия панели')}
value={recap.version || '—'}
subValue={
recap.init_date
? `${t('admin.remnawave.overview.uptime', 'аптайм')} ${formatUptimeSince(recap.init_date)}`
: undefined
}
icon={<ServerIcon className="h-5 w-5" />}
color="accent"
/>
</div>
</div>
)}
{/* Devices breakdown */}
{devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<PiDevicesDuotone className="h-4 w-4" />
{t('admin.remnawave.overview.devices', 'Устройства')} ·{' '}
{devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/
{t('admin.remnawave.overview.perUser', 'юзер')})
</h3>
<div className="grid gap-3 lg:grid-cols-3">
<BreakdownCard
title={t('admin.remnawave.overview.byPlatform', 'По платформам')}
items={devicesStats.by_platform.map((p) => ({ label: p.platform, count: p.count }))}
/>
<BreakdownCard
title={t('admin.remnawave.overview.byApp', 'По приложениям')}
items={devicesStats.by_app.map((a) => ({ label: a.app, count: a.count }))}
/>
{devicesStats.top_users.length > 0 && (
<BreakdownCard
title={t('admin.remnawave.overview.topByDevices', 'Топ по устройствам')}
items={devicesStats.top_users.map((u) => ({
label: u.username,
count: u.devices_count,
}))}
/>
)}
</div>
</div>
)}
{/* Top consumers */}
{topConsumers && topConsumers.users.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<ChartIcon className="h-4 w-4" />
{t('admin.remnawave.overview.topConsumers', 'Топ потребителей')} ·{' '}
{topConsumers.period_days}
{t('admin.remnawave.overview.daysShort', 'д')}
</h3>
<div className="divide-y divide-dark-700 rounded-xl border border-dark-700 bg-dark-800/50">
{topConsumers.users.map((u, i) => (
<div
key={u.username}
className="flex items-center justify-between px-4 py-2.5 text-sm"
>
<span className="flex min-w-0 items-center gap-2">
<span className="w-5 shrink-0 text-dark-500">{i + 1}</span>
<span className="truncate text-dark-100">{u.username}</span>
</span>
<span className="shrink-0 font-medium text-accent-400">
{formatBytes(u.total_bytes)}
</span>
</div>
))}
</div>
</div>
)}
{/* Panel health */}
{health && health.instances > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<ServerIcon className="h-4 w-4" />
{t('admin.remnawave.overview.panelHealth', 'Здоровье панели')}
{health.instances > 1 ? ` · ${health.instances}` : ''}
</h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
label={t('admin.remnawave.overview.panelRam', 'RAM процесса')}
value={formatBytes(health.rss_bytes)}
icon={<PiMemoryDuotone className="h-5 w-5" />}
color="blue"
/>
<StatCard
label={t('admin.remnawave.overview.heap', 'Heap')}
value={formatBytes(health.heap_used_bytes)}
subValue={`/ ${formatBytes(health.heap_total_bytes)}`}
icon={<ChartIcon className="h-5 w-5" />}
color="purple"
/>
<StatCard
label={t('admin.remnawave.overview.eventLoopP99', 'Event-loop p99')}
value={`${health.event_loop_p99_ms.toFixed(1)} ms`}
icon={<PiPulseDuotone className="h-5 w-5" />}
color={health.event_loop_p99_ms > 50 ? 'red' : 'green'}
/>
<StatCard
label={t('admin.remnawave.overview.panelUptime', 'Аптайм панели')}
value={formatUptime(health.uptime_seconds)}
icon={<PiTimerDuotone className="h-5 w-5" />}
color="accent"
/>
</div>
</div>
)}
{/* Subscription requests by app */}
{subRequests && subRequests.by_app.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<SubscriptionIcon className="h-4 w-4" />
{t('admin.remnawave.overview.subRequests', 'Запросы подписки (по клиентам)')}
</h3>
<div className="grid gap-3 lg:grid-cols-2">
<BreakdownCard
title={t('admin.remnawave.overview.byApp', 'По приложениям')}
items={subRequests.by_app.map((a) => ({ label: a.app, count: a.count }))}
/>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -857,19 +1066,19 @@ function TrafficTab({ data, isLoading, onRefresh }: TrafficTabProps) {
<StatCard <StatCard
label={t('admin.remnawave.traffic.totalDownload', 'Download')} label={t('admin.remnawave.traffic.totalDownload', 'Download')}
value={formatBytes(totalDownload)} value={formatBytes(totalDownload)}
icon={<span className="text-lg"></span>} icon={<DownloadIcon className="h-5 w-5" />}
color="green" color="green"
/> />
<StatCard <StatCard
label={t('admin.remnawave.traffic.totalUpload', 'Upload')} label={t('admin.remnawave.traffic.totalUpload', 'Upload')}
value={formatBytes(totalUpload)} value={formatBytes(totalUpload)}
icon={<span className="text-lg"></span>} icon={<UploadIcon className="h-5 w-5" />}
color="blue" color="blue"
/> />
<StatCard <StatCard
label={t('admin.remnawave.traffic.totalTraffic', 'Total')} label={t('admin.remnawave.traffic.totalTraffic', 'Total')}
value={formatBytes(totalDownload + totalUpload)} value={formatBytes(totalDownload + totalUpload)}
icon={<span className="text-lg"></span>} icon={<ChartIcon className="h-5 w-5" />}
color="purple" color="purple"
/> />
</div> </div>
@@ -980,6 +1189,42 @@ export default function AdminRemnawave() {
refetchInterval: 30000, refetchInterval: 30000,
}); });
const { data: recap } = useQuery({
queryKey: ['admin-remnawave-recap'],
queryFn: adminRemnawaveApi.getRecap,
enabled: activeTab === 'overview',
staleTime: 60000,
});
const { data: devicesStats } = useQuery({
queryKey: ['admin-remnawave-devices-stats'],
queryFn: adminRemnawaveApi.getDevicesStats,
enabled: activeTab === 'overview',
staleTime: 60000,
});
const { data: topConsumers } = useQuery({
queryKey: ['admin-remnawave-top-consumers'],
queryFn: () => adminRemnawaveApi.getTopConsumers(7, 10),
enabled: activeTab === 'overview',
staleTime: 60000,
});
const { data: health } = useQuery({
queryKey: ['admin-remnawave-health'],
queryFn: adminRemnawaveApi.getHealth,
enabled: activeTab === 'overview',
refetchInterval: 30000,
staleTime: 15000,
});
const { data: subRequests } = useQuery({
queryKey: ['admin-remnawave-sub-requests'],
queryFn: adminRemnawaveApi.getSubscriptionRequests,
enabled: activeTab === 'overview',
staleTime: 60000,
});
const { const {
data: nodesData, data: nodesData,
isLoading: isLoadingNodes, isLoading: isLoadingNodes,
@@ -1112,7 +1357,7 @@ export default function AdminRemnawave() {
onClick={() => navigate('/admin')} onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
> >
<BackIcon /> <BackIcon className="text-dark-400" />
</button> </button>
)} )}
<div className="rounded-lg bg-accent-500/20 p-2"> <div className="rounded-lg bg-accent-500/20 p-2">
@@ -1172,6 +1417,11 @@ export default function AdminRemnawave() {
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<OverviewTab <OverviewTab
stats={systemStats} stats={systemStats}
recap={recap}
devicesStats={devicesStats}
topConsumers={topConsumers}
health={health}
subRequests={subRequests}
isLoading={isLoadingStats} isLoading={isLoadingStats}
onRefresh={() => refetchStats()} onRefresh={() => refetchStats()}
/> />