Files
bedolaga-cabinet/src/pages/AdminRemnawave.tsx
c0mrade bc90ba3779 refactor: migrate to eslint flat config and format codebase with prettier
- Remove legacy .eslintrc.cjs and .eslintignore
- Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url)
- Add .prettierrc and .prettierignore
- Format entire codebase with prettier
2026-01-27 17:37:31 +03:00

1327 lines
45 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
adminRemnawaveApi,
NodeInfo,
SquadWithLocalInfo,
SystemStatsResponse,
AutoSyncStatus,
} from '../api/adminRemnawave';
// ============ Icons ============
const ServerIcon = ({ className = 'w-6 h-6' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<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>
);
const ChartIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
const GlobeIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
);
const UsersIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const RefreshIcon = ({
className = 'w-4 h-4',
spinning = false,
}: {
className?: string;
spinning?: boolean;
}) => (
<svg
className={`${className} ${spinning ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
const PlayIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
);
const StopIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
/>
</svg>
);
const ArrowPathIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const ChevronLeftIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
// ============ Helpers ============
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const getCountryFlag = (code: string | null | undefined): string => {
if (!code) return '🌍';
const codeMap: Record<string, string> = {
RU: '🇷🇺',
US: '🇺🇸',
DE: '🇩🇪',
NL: '🇳🇱',
GB: '🇬🇧',
UK: '🇬🇧',
FR: '🇫🇷',
FI: '🇫🇮',
SE: '🇸🇪',
NO: '🇳🇴',
PL: '🇵🇱',
TR: '🇹🇷',
JP: '🇯🇵',
SG: '🇸🇬',
HK: '🇭🇰',
KR: '🇰🇷',
AU: '🇦🇺',
CA: '🇨🇦',
CH: '🇨🇭',
AT: '🇦🇹',
IT: '🇮🇹',
ES: '🇪🇸',
BR: '🇧🇷',
IN: '🇮🇳',
AE: '🇦🇪',
IL: '🇮🇱',
KZ: '🇰🇿',
UA: '🇺🇦',
CZ: '🇨🇿',
RO: '🇷🇴',
LV: '🇱🇻',
LT: '🇱🇹',
EE: '🇪🇪',
BG: '🇧🇬',
HU: '🇭🇺',
MD: '🇲🇩',
};
return codeMap[code.toUpperCase()] || code;
};
// ============ Sub-Components ============
interface StatCardProps {
label: string;
value: string | number;
icon: React.ReactNode;
color?: string;
subValue?: string;
}
function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) {
const colorClasses: Record<string, string> = {
accent: 'bg-accent-500/20 text-accent-400',
green: 'bg-emerald-500/20 text-emerald-400',
blue: 'bg-blue-500/20 text-blue-400',
orange: 'bg-orange-500/20 text-orange-400',
red: 'bg-red-500/20 text-red-400',
purple: 'bg-purple-500/20 text-purple-400',
};
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${colorClasses[color]}`}>{icon}</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-dark-400">{label}</p>
<p className="text-lg font-semibold text-dark-100">{value}</p>
{subValue && <p className="text-xs text-dark-500">{subValue}</p>}
</div>
</div>
</div>
);
}
interface NodeCardProps {
node: NodeInfo;
onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void;
isLoading?: boolean;
}
function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
const { t } = useTranslation();
const statusColor = node.is_disabled
? 'text-dark-500'
: node.is_connected && node.is_node_online
? 'text-emerald-400'
: 'text-red-400';
const statusText = node.is_disabled
? t('admin.remnawave.nodes.disabled', 'Disabled')
: node.is_connected && node.is_node_online
? t('admin.remnawave.nodes.online', 'Online')
: t('admin.remnawave.nodes.offline', 'Offline');
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-colors hover:border-dark-600">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-lg">{getCountryFlag(node.country_code)}</span>
<h3 className="truncate font-medium text-dark-100">{node.name}</h3>
<span className={`rounded-full px-2 py-0.5 text-xs ${statusColor} bg-current/10`}>
{statusText}
</span>
</div>
<p className="mt-1 truncate text-xs text-dark-500">{node.address}</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-dark-400">
<span className="flex items-center gap-1">
<UsersIcon className="h-3.5 w-3.5" />
{node.users_online ?? 0} online
</span>
{node.traffic_used_bytes !== undefined && (
<span>{formatBytes(node.traffic_used_bytes)} used</span>
)}
{node.xray_uptime && <span>Uptime: {node.xray_uptime}</span>}
</div>
</div>
<div className="flex shrink-0 gap-1.5">
<button
onClick={() => onAction(node.uuid, 'restart')}
disabled={isLoading || node.is_disabled}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
title={t('admin.remnawave.nodes.restart', 'Restart')}
>
<ArrowPathIcon className="h-4 w-4" />
</button>
<button
onClick={() => onAction(node.uuid, node.is_disabled ? 'enable' : 'disable')}
disabled={isLoading}
className={`rounded-lg p-2 transition-colors disabled:opacity-50 ${
node.is_disabled
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
}`}
title={
node.is_disabled
? t('admin.remnawave.nodes.enable', 'Enable')
: t('admin.remnawave.nodes.disable', 'Disable')
}
>
{node.is_disabled ? <PlayIcon className="h-4 w-4" /> : <StopIcon className="h-4 w-4" />}
</button>
</div>
</div>
</div>
);
}
interface SquadCardProps {
squad: SquadWithLocalInfo;
onSelect: (squad: SquadWithLocalInfo) => void;
}
function SquadCard({ squad, onSelect }: SquadCardProps) {
const { t } = useTranslation();
return (
<div
onClick={() => onSelect(squad)}
className="cursor-pointer rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-colors hover:border-dark-600"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-lg">{getCountryFlag(squad.country_code)}</span>
<h3 className="truncate font-medium text-dark-100">
{squad.display_name || squad.name}
</h3>
{squad.is_synced ? (
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
{t('admin.remnawave.squads.synced', 'Synced')}
</span>
) : (
<span className="rounded-full bg-orange-500/20 px-2 py-0.5 text-xs text-orange-400">
{t('admin.remnawave.squads.notSynced', 'Not synced')}
</span>
)}
</div>
<p className="mt-1 truncate text-xs text-dark-500">{squad.name}</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-dark-400">
<span className="flex items-center gap-1">
<UsersIcon className="h-3.5 w-3.5" />
{squad.members_count} members
</span>
{squad.current_users !== undefined && (
<span>
{squad.current_users} / {squad.max_users ?? '∞'}
</span>
)}
<span>{squad.inbounds_count} inbounds</span>
{squad.is_available !== undefined && (
<span className={squad.is_available ? 'text-emerald-400' : 'text-red-400'}>
{squad.is_available ? '✓ Available' : '✗ Unavailable'}
</span>
)}
</div>
</div>
<svg
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>
);
}
interface SyncCardProps {
title: string;
description: string;
onAction: () => void;
isLoading?: boolean;
lastResult?: { success: boolean; message?: string } | null;
}
function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncCardProps) {
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="font-medium text-dark-100">{title}</h3>
<p className="mt-1 text-xs text-dark-400">{description}</p>
{lastResult && (
<p
className={`mt-2 text-xs ${lastResult.success ? 'text-emerald-400' : 'text-red-400'}`}
>
{lastResult.message}
</p>
)}
</div>
<button
onClick={onAction}
disabled={isLoading}
className="flex shrink-0 items-center gap-2 rounded-lg bg-accent-500/20 px-3 py-1.5 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
>
<RefreshIcon spinning={isLoading} />
{isLoading ? 'Running...' : 'Run'}
</button>
</div>
</div>
);
}
// ============ Tab Components ============
interface OverviewTabProps {
stats: SystemStatsResponse | undefined;
isLoading: boolean;
onRefresh: () => void;
}
function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
if (!stats) {
return (
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.remnawave.noData', 'Failed to load data')}</p>
<button onClick={onRefresh} className="btn-primary mt-4">
{t('common.retry', 'Retry')}
</button>
</div>
);
}
const memoryUsedPercent =
stats.server_info.memory_total > 0
? Math.round((stats.server_info.memory_used / stats.server_info.memory_total) * 100)
: 0;
return (
<div className="space-y-6">
{/* System Stats */}
<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.system', 'System')}
</h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
label={t('admin.remnawave.overview.usersOnline', 'Users Online')}
value={stats.system.users_online}
icon={<UsersIcon />}
color="green"
/>
<StatCard
label={t('admin.remnawave.overview.totalUsers', 'Total Users')}
value={stats.system.total_users}
icon={<UsersIcon />}
color="blue"
/>
<StatCard
label={t('admin.remnawave.overview.nodesOnline', 'Nodes Online')}
value={stats.system.nodes_online}
icon={<GlobeIcon />}
color="purple"
/>
<StatCard
label={t('admin.remnawave.overview.connections', 'Connections')}
value={stats.system.active_connections}
icon={<ServerIcon className="h-5 w-5" />}
color="orange"
/>
</div>
</div>
{/* Bandwidth */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.overview.bandwidth', 'Realtime Bandwidth')}
</h3>
<div className="grid grid-cols-3 gap-3">
<StatCard
label={t('admin.remnawave.overview.download', 'Download')}
value={formatBytes(stats.bandwidth.realtime_download) + '/s'}
icon={<span className="text-lg"></span>}
color="green"
/>
<StatCard
label={t('admin.remnawave.overview.upload', 'Upload')}
value={formatBytes(stats.bandwidth.realtime_upload) + '/s'}
icon={<span className="text-lg"></span>}
color="blue"
/>
<StatCard
label={t('admin.remnawave.overview.total', 'Total')}
value={formatBytes(stats.bandwidth.realtime_total) + '/s'}
icon={<span className="text-lg"></span>}
color="purple"
/>
</div>
</div>
{/* Server Info */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.overview.server', 'Server')}
</h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatCard
label={t('admin.remnawave.overview.cpu', 'CPU Cores')}
value={`${stats.server_info.cpu_cores} (${stats.server_info.cpu_physical_cores} physical)`}
icon={<span className="text-lg"></span>}
color="accent"
/>
<StatCard
label={t('admin.remnawave.overview.memory', 'Memory')}
value={`${memoryUsedPercent}%`}
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`}
icon={<span className="text-lg">💾</span>}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/>
<StatCard
label={t('admin.remnawave.overview.uptime', 'Uptime')}
value={formatUptime(stats.server_info.uptime_seconds)}
icon={<span className="text-lg"></span>}
color="blue"
/>
</div>
</div>
{/* Traffic Periods */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.overview.traffic', 'Traffic Statistics')}
</h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<StatCard
label="2 days"
value={formatBytes(stats.traffic_periods.last_2_days.current)}
icon={<span className="text-xs">📊</span>}
color="accent"
/>
<StatCard
label="7 days"
value={formatBytes(stats.traffic_periods.last_7_days.current)}
icon={<span className="text-xs">📊</span>}
color="blue"
/>
<StatCard
label="30 days"
value={formatBytes(stats.traffic_periods.last_30_days.current)}
icon={<span className="text-xs">📊</span>}
color="green"
/>
<StatCard
label="Month"
value={formatBytes(stats.traffic_periods.current_month.current)}
icon={<span className="text-xs">📊</span>}
color="purple"
/>
<StatCard
label="Year"
value={formatBytes(stats.traffic_periods.current_year.current)}
icon={<span className="text-xs">📊</span>}
color="orange"
/>
</div>
</div>
{/* Users by Status */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.overview.usersByStatus', 'Users by Status')}
</h3>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{Object.entries(stats.users_by_status).map(([status, count]) => (
<StatCard
key={status}
label={status}
value={count}
icon={<UsersIcon className="h-4 w-4" />}
color={status === 'ACTIVE' ? 'green' : status === 'DISABLED' ? 'red' : 'accent'}
/>
))}
</div>
</div>
</div>
);
}
interface NodesTabProps {
nodes: NodeInfo[];
isLoading: boolean;
onRefresh: () => void;
onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void;
onRestartAll: () => void;
isActionLoading: boolean;
}
function NodesTab({
nodes,
isLoading,
onRefresh,
onAction,
onRestartAll,
isActionLoading,
}: NodesTabProps) {
const { t } = useTranslation();
const stats = useMemo(() => {
const total = nodes.length;
const online = nodes.filter((n) => n.is_connected && n.is_node_online && !n.is_disabled).length;
const offline = nodes.filter(
(n) => (!n.is_connected || !n.is_node_online) && !n.is_disabled,
).length;
const disabled = nodes.filter((n) => n.is_disabled).length;
const totalUsers = nodes.reduce((acc, n) => acc + (n.users_online ?? 0), 0);
return { total, online, offline, disabled, totalUsers };
}, [nodes]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<StatCard label="Total" value={stats.total} icon={<GlobeIcon />} color="accent" />
<StatCard label="Online" value={stats.online} icon={<GlobeIcon />} color="green" />
<StatCard label="Offline" value={stats.offline} icon={<GlobeIcon />} color="red" />
<StatCard label="Disabled" value={stats.disabled} icon={<GlobeIcon />} color="accent" />
<StatCard label="Users" value={stats.totalUsers} icon={<UsersIcon />} color="blue" />
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onRefresh}
className="flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
>
<RefreshIcon />
{t('common.refresh', 'Refresh')}
</button>
<button
onClick={onRestartAll}
disabled={isActionLoading}
className="flex items-center gap-2 rounded-lg bg-orange-500/20 px-3 py-1.5 text-orange-400 transition-colors hover:bg-orange-500/30 disabled:opacity-50"
>
<ArrowPathIcon />
{t('admin.remnawave.nodes.restartAll', 'Restart All')}
</button>
</div>
{/* Nodes List */}
<div className="space-y-3">
{nodes.length === 0 ? (
<p className="py-8 text-center text-dark-400">
{t('admin.remnawave.nodes.noNodes', 'No nodes found')}
</p>
) : (
nodes.map((node) => (
<NodeCard key={node.uuid} node={node} onAction={onAction} isLoading={isActionLoading} />
))
)}
</div>
</div>
);
}
interface SquadsTabProps {
squads: SquadWithLocalInfo[];
isLoading: boolean;
onRefresh: () => void;
onSelect: (squad: SquadWithLocalInfo) => void;
onSync: () => void;
isSyncing: boolean;
}
function SquadsTab({ squads, isLoading, onRefresh, onSelect, onSync, isSyncing }: SquadsTabProps) {
const { t } = useTranslation();
const stats = useMemo(() => {
const total = squads.length;
const synced = squads.filter((s) => s.is_synced).length;
const available = squads.filter((s) => s.is_available).length;
const totalMembers = squads.reduce((acc, s) => acc + s.members_count, 0);
return { total, synced, available, totalMembers };
}, [squads]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
label="Total"
value={stats.total}
icon={<ServerIcon className="h-5 w-5" />}
color="accent"
/>
<StatCard label="Synced" value={stats.synced} icon={<SyncIcon />} color="green" />
<StatCard
label="Available"
value={stats.available}
icon={<ServerIcon className="h-5 w-5" />}
color="blue"
/>
<StatCard label="Members" value={stats.totalMembers} icon={<UsersIcon />} color="purple" />
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onRefresh}
className="flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
>
<RefreshIcon />
{t('common.refresh', 'Refresh')}
</button>
<button
onClick={onSync}
disabled={isSyncing}
className="flex items-center gap-2 rounded-lg bg-accent-500/20 px-3 py-1.5 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
>
<RefreshIcon spinning={isSyncing} />
{t('admin.remnawave.squads.syncServers', 'Sync Servers')}
</button>
</div>
{/* Squads List */}
<div className="space-y-3">
{squads.length === 0 ? (
<p className="py-8 text-center text-dark-400">
{t('admin.remnawave.squads.noSquads', 'No squads found')}
</p>
) : (
squads.map((squad) => <SquadCard key={squad.uuid} squad={squad} onSelect={onSelect} />)
)}
</div>
</div>
);
}
interface SyncTabProps {
autoSyncStatus: AutoSyncStatus | undefined;
isLoading: boolean;
onRunAutoSync: () => void;
onSyncFromPanel: () => void;
onSyncToPanel: () => void;
onValidate: () => void;
onCleanup: () => void;
onSyncStatuses: () => void;
syncResults: Record<string, { success: boolean; message?: string } | null>;
loadingStates: Record<string, boolean>;
}
function SyncTab({
autoSyncStatus,
isLoading,
onRunAutoSync,
onSyncFromPanel,
onSyncToPanel,
onValidate,
onCleanup,
onSyncStatuses,
syncResults,
loadingStates,
}: SyncTabProps) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="space-y-6">
{/* Auto Sync Status */}
{autoSyncStatus && (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="flex items-center gap-2 font-medium text-dark-100">
<SyncIcon />
{t('admin.remnawave.sync.autoSync', 'Auto Sync')}
</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
autoSyncStatus.enabled
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-dark-600 text-dark-400'
}`}
>
{autoSyncStatus.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm lg:grid-cols-4">
<div>
<p className="text-dark-500">Schedule</p>
<p className="text-dark-200">
{autoSyncStatus.times.length > 0 ? autoSyncStatus.times.join(', ') : '—'}
</p>
</div>
<div>
<p className="text-dark-500">Next Run</p>
<p className="text-dark-200">
{autoSyncStatus.next_run ? new Date(autoSyncStatus.next_run).toLocaleString() : '—'}
</p>
</div>
<div>
<p className="text-dark-500">Last Run</p>
<p className="text-dark-200">
{autoSyncStatus.last_run_finished_at
? new Date(autoSyncStatus.last_run_finished_at).toLocaleString()
: '—'}
</p>
</div>
<div>
<p className="text-dark-500">Status</p>
<p
className={
autoSyncStatus.is_running
? 'text-orange-400'
: autoSyncStatus.last_run_success
? 'text-emerald-400'
: 'text-dark-200'
}
>
{autoSyncStatus.is_running
? 'Running...'
: autoSyncStatus.last_run_success
? 'Success'
: autoSyncStatus.last_run_error || '—'}
</p>
</div>
</div>
<button
onClick={onRunAutoSync}
disabled={loadingStates.autoSync || autoSyncStatus.is_running}
className="mt-4 flex items-center gap-2 rounded-lg bg-accent-500/20 px-4 py-2 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
>
<RefreshIcon spinning={loadingStates.autoSync || autoSyncStatus.is_running} />
{t('admin.remnawave.sync.runNow', 'Run Now')}
</button>
</div>
)}
{/* Manual Sync Options */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.sync.manual', 'Manual Sync')}
</h3>
<div className="space-y-3">
<SyncCard
title={t('admin.remnawave.sync.fromPanel', 'Sync from Panel')}
description={t(
'admin.remnawave.sync.fromPanelDesc',
'Import users from RemnaWave panel to bot database',
)}
onAction={onSyncFromPanel}
isLoading={loadingStates.fromPanel}
lastResult={syncResults.fromPanel}
/>
<SyncCard
title={t('admin.remnawave.sync.toPanel', 'Sync to Panel')}
description={t(
'admin.remnawave.sync.toPanelDesc',
'Export users from bot database to RemnaWave panel',
)}
onAction={onSyncToPanel}
isLoading={loadingStates.toPanel}
lastResult={syncResults.toPanel}
/>
</div>
</div>
{/* Subscriptions */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.sync.subscriptions', 'Subscriptions')}
</h3>
<div className="space-y-3">
<SyncCard
title={t('admin.remnawave.sync.validate', 'Validate')}
description={t(
'admin.remnawave.sync.validateDesc',
'Check and fix subscription inconsistencies',
)}
onAction={onValidate}
isLoading={loadingStates.validate}
lastResult={syncResults.validate}
/>
<SyncCard
title={t('admin.remnawave.sync.cleanup', 'Cleanup')}
description={t(
'admin.remnawave.sync.cleanupDesc',
'Remove orphaned subscriptions without users',
)}
onAction={onCleanup}
isLoading={loadingStates.cleanup}
lastResult={syncResults.cleanup}
/>
<SyncCard
title={t('admin.remnawave.sync.statuses', 'Sync Statuses')}
description={t(
'admin.remnawave.sync.statusesDesc',
'Synchronize subscription statuses with panel',
)}
onAction={onSyncStatuses}
isLoading={loadingStates.statuses}
lastResult={syncResults.statuses}
/>
</div>
</div>
</div>
);
}
// ============ Main Component ============
type TabType = 'overview' | 'nodes' | 'squads' | 'sync';
export default function AdminRemnawave() {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [selectedSquad, setSelectedSquad] = useState<SquadWithLocalInfo | null>(null);
const [syncResults, setSyncResults] = useState<
Record<string, { success: boolean; message?: string } | null>
>({});
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
// Queries
const { data: status } = useQuery({
queryKey: ['admin-remnawave-status'],
queryFn: adminRemnawaveApi.getStatus,
});
const {
data: systemStats,
isLoading: isLoadingStats,
refetch: refetchStats,
} = useQuery({
queryKey: ['admin-remnawave-system'],
queryFn: adminRemnawaveApi.getSystemStats,
enabled: activeTab === 'overview',
refetchInterval: 30000,
});
const {
data: nodesData,
isLoading: isLoadingNodes,
refetch: refetchNodes,
} = useQuery({
queryKey: ['admin-remnawave-nodes'],
queryFn: adminRemnawaveApi.getNodes,
enabled: activeTab === 'nodes',
refetchInterval: 15000,
});
const {
data: squadsData,
isLoading: isLoadingSquads,
refetch: refetchSquads,
} = useQuery({
queryKey: ['admin-remnawave-squads'],
queryFn: adminRemnawaveApi.getSquads,
enabled: activeTab === 'squads',
});
const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({
queryKey: ['admin-remnawave-autosync'],
queryFn: adminRemnawaveApi.getAutoSyncStatus,
enabled: activeTab === 'sync',
refetchInterval: 10000,
});
// Mutations
const nodeActionMutation = useMutation({
mutationFn: ({ uuid, action }: { uuid: string; action: 'enable' | 'disable' | 'restart' }) =>
adminRemnawaveApi.nodeAction(uuid, action),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-remnawave-nodes'] });
},
});
const restartAllMutation = useMutation({
mutationFn: adminRemnawaveApi.restartAllNodes,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-remnawave-nodes'] });
},
});
const syncServersMutation = useMutation({
mutationFn: adminRemnawaveApi.syncServers,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-remnawave-squads'] });
},
});
// Handlers
const handleNodeAction = (uuid: string, action: 'enable' | 'disable' | 'restart') => {
nodeActionMutation.mutate({ uuid, action });
};
const handleRestartAll = () => {
if (
confirm(
t('admin.remnawave.nodes.confirmRestartAll', 'Are you sure you want to restart all nodes?'),
)
) {
restartAllMutation.mutate();
}
};
const handleSyncServers = () => {
syncServersMutation.mutate();
};
const handleSyncAction = async (
key: string,
action: () => Promise<{ success?: boolean; started?: boolean; message?: string }>,
) => {
setLoadingStates((prev) => ({ ...prev, [key]: true }));
try {
const result = await action();
setSyncResults((prev) => ({
...prev,
[key]: { success: result.success ?? result.started ?? false, message: result.message },
}));
} catch {
setSyncResults((prev) => ({ ...prev, [key]: { success: false, message: 'Failed' } }));
} finally {
setLoadingStates((prev) => ({ ...prev, [key]: false }));
}
};
const tabs = [
{
id: 'overview' as const,
label: t('admin.remnawave.tabs.overview', 'Overview'),
icon: <ChartIcon />,
},
{ id: 'nodes' as const, label: t('admin.remnawave.tabs.nodes', 'Nodes'), icon: <GlobeIcon /> },
{
id: 'squads' as const,
label: t('admin.remnawave.tabs.squads', 'Squads'),
icon: <ServerIcon className="h-5 w-5" />,
},
{ id: 'sync' as const, label: t('admin.remnawave.tabs.sync', 'Sync'), icon: <SyncIcon /> },
];
const isConfigured = status?.is_configured;
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/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"
>
<ChevronLeftIcon />
</Link>
<div className="rounded-lg bg-purple-500/20 p-2">
<ServerIcon className="h-6 w-6 text-purple-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.remnawave.title', 'RemnaWave')}
</h1>
<p className="text-sm text-dark-400">
{t('admin.remnawave.subtitle', 'Panel management and statistics')}
</p>
</div>
</div>
{/* Connection Status Badge */}
<div
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs ${
isConfigured ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'
}`}
>
<span
className={`h-2 w-2 rounded-full ${isConfigured ? 'bg-emerald-400' : 'bg-red-400'}`}
/>
{isConfigured
? t('admin.remnawave.connected', 'Connected')
: t('admin.remnawave.disconnected', 'Not configured')}
</div>
</div>
{/* Configuration Error */}
{status?.configuration_error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{status.configuration_error}</p>
</div>
)}
{/* Tabs */}
<div className="mb-6 flex gap-1 overflow-x-auto rounded-xl bg-dark-800/50 p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex min-w-[80px] flex-1 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-accent-500/20 text-accent-400'
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200'
}`}
>
{tab.icon}
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<OverviewTab
stats={systemStats}
isLoading={isLoadingStats}
onRefresh={() => refetchStats()}
/>
)}
{activeTab === 'nodes' && (
<NodesTab
nodes={nodesData?.items || []}
isLoading={isLoadingNodes}
onRefresh={() => refetchNodes()}
onAction={handleNodeAction}
onRestartAll={handleRestartAll}
isActionLoading={nodeActionMutation.isPending || restartAllMutation.isPending}
/>
)}
{activeTab === 'squads' && (
<SquadsTab
squads={squadsData?.items || []}
isLoading={isLoadingSquads}
onRefresh={() => refetchSquads()}
onSelect={setSelectedSquad}
onSync={handleSyncServers}
isSyncing={syncServersMutation.isPending}
/>
)}
{activeTab === 'sync' && (
<SyncTab
autoSyncStatus={autoSyncStatus}
isLoading={isLoadingAutoSync}
onRunAutoSync={() => handleSyncAction('autoSync', adminRemnawaveApi.runAutoSync)}
onSyncFromPanel={() =>
handleSyncAction('fromPanel', () => adminRemnawaveApi.syncFromPanel('all'))
}
onSyncToPanel={() => handleSyncAction('toPanel', adminRemnawaveApi.syncToPanel)}
onValidate={() => handleSyncAction('validate', adminRemnawaveApi.validateSubscriptions)}
onCleanup={() => handleSyncAction('cleanup', adminRemnawaveApi.cleanupSubscriptions)}
onSyncStatuses={() =>
handleSyncAction('statuses', adminRemnawaveApi.syncSubscriptionStatuses)
}
syncResults={syncResults}
loadingStates={loadingStates}
/>
)}
{/* Squad Detail Modal */}
{selectedSquad && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedSquad(null)}
>
<div
className="max-h-[80vh] w-full max-w-lg overflow-auto rounded-xl bg-dark-800"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 flex items-center justify-between border-b border-dark-700 bg-dark-800 p-4">
<div className="flex items-center gap-2">
<span className="text-xl">{getCountryFlag(selectedSquad.country_code)}</span>
<h2 className="text-lg font-semibold text-dark-100">
{selectedSquad.display_name || selectedSquad.name}
</h2>
</div>
<button
onClick={() => setSelectedSquad(null)}
className="rounded-lg p-1 text-dark-400 hover:bg-dark-700"
>
</button>
</div>
{/* Content */}
<div className="space-y-4 p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-dark-500">UUID</p>
<p className="break-all font-mono text-xs text-dark-200">{selectedSquad.uuid}</p>
</div>
<div>
<p className="text-dark-500">Original Name</p>
<p className="text-dark-200">{selectedSquad.name}</p>
</div>
<div>
<p className="text-dark-500">Members</p>
<p className="text-dark-200">{selectedSquad.members_count}</p>
</div>
<div>
<p className="text-dark-500">Inbounds</p>
<p className="text-dark-200">{selectedSquad.inbounds_count}</p>
</div>
{selectedSquad.is_synced && (
<>
<div>
<p className="text-dark-500">Price</p>
<p className="text-dark-200">
{((selectedSquad.price_kopeks ?? 0) / 100).toFixed(2)}
</p>
</div>
<div>
<p className="text-dark-500">Users</p>
<p className="text-dark-200">
{selectedSquad.current_users ?? 0} / {selectedSquad.max_users ?? '∞'}
</p>
</div>
<div>
<p className="text-dark-500">Available</p>
<p
className={selectedSquad.is_available ? 'text-emerald-400' : 'text-red-400'}
>
{selectedSquad.is_available ? 'Yes' : 'No'}
</p>
</div>
<div>
<p className="text-dark-500">Trial Eligible</p>
<p
className={
selectedSquad.is_trial_eligible ? 'text-emerald-400' : 'text-dark-400'
}
>
{selectedSquad.is_trial_eligible ? 'Yes' : 'No'}
</p>
</div>
</>
)}
</div>
{/* Inbounds */}
{selectedSquad.inbounds.length > 0 && (
<div>
<p className="mb-2 text-sm text-dark-500">Inbounds</p>
<div className="space-y-1">
{selectedSquad.inbounds.map((inbound: Record<string, unknown>, idx) => (
<div
key={idx}
className="rounded bg-dark-700/50 px-2 py-1 text-xs text-dark-300"
>
{String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)}
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-dark-700 p-4">
<button onClick={() => setSelectedSquad(null)} className="btn-secondary">
{t('common.close', 'Close')}
</button>
</div>
</div>
</div>
)}
</div>
);
}