mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -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');
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user