mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
- 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
1327 lines
45 KiB
TypeScript
1327 lines
45 KiB
TypeScript
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>
|
||
);
|
||
}
|