mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Add threshold inputs (total GB/day, per-node GB/day) to admin traffic usage page. When set, rows get background color gradation (green to red) and node cells get text color (blue to red) based on threshold ratios. Conditional Risk column shows composite risk badge with level indicator. 7 i18n keys added across all 4 locales (ru, en, zh, fa).
1506 lines
51 KiB
TypeScript
1506 lines
51 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
flexRender,
|
|
type ColumnDef,
|
|
type SortingState,
|
|
type RowData,
|
|
} from '@tanstack/react-table';
|
|
import {
|
|
adminTrafficApi,
|
|
type UserTrafficItem,
|
|
type TrafficNodeInfo,
|
|
type TrafficUsageResponse,
|
|
type TrafficParams,
|
|
} from '../api/adminTraffic';
|
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
|
|
|
// ============ TanStack Table module augmentation ============
|
|
|
|
declare module '@tanstack/react-table' {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
interface ColumnMeta<TData extends RowData, TValue> {
|
|
sticky?: boolean;
|
|
align?: 'left' | 'center';
|
|
bold?: boolean;
|
|
}
|
|
}
|
|
|
|
// ============ Utils ============
|
|
|
|
const formatBytes = (bytes: number): string => {
|
|
if (!Number.isFinite(bytes) || 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 getFlagEmoji = (countryCode: string): string => {
|
|
if (!countryCode || countryCode.length !== 2) return '';
|
|
const codePoints = countryCode
|
|
.toUpperCase()
|
|
.split('')
|
|
.map((char) => 127397 + char.charCodeAt(0));
|
|
return String.fromCodePoint(...codePoints);
|
|
};
|
|
|
|
const toBackendSortField = (columnId: string): string => {
|
|
if (columnId === 'user') return 'full_name';
|
|
return columnId;
|
|
};
|
|
|
|
// ============ Risk assessment helpers ============
|
|
|
|
const bytesToGbPerDay = (bytes: number, days: number): number =>
|
|
days > 0 ? bytes / days / 1024 ** 3 : 0;
|
|
|
|
const getRatio = (gbPerDay: number, threshold: number): number =>
|
|
threshold > 0 ? gbPerDay / threshold : 0;
|
|
|
|
const getRowBgColor = (ratio: number): string | undefined => {
|
|
if (ratio <= 0) return undefined;
|
|
const clamped = Math.min(ratio, 1.5);
|
|
const hue = 120 - Math.min(clamped, 1) * 120;
|
|
const opacity = clamped <= 1 ? 0.06 + clamped * 0.07 : 0.13 + (clamped - 1) * 0.14;
|
|
return `hsla(${hue}, 70%, 45%, ${opacity})`;
|
|
};
|
|
|
|
const getNodeTextColor = (ratio: number): string => {
|
|
const clamped = Math.min(Math.max(ratio, 0), 1.5);
|
|
let hue: number;
|
|
if (clamped <= 0.7) {
|
|
hue = 210 - (clamped / 0.7) * 180; // 210 (blue) → 30 (amber)
|
|
} else {
|
|
hue = Math.max(0, 30 - ((clamped - 0.7) / 0.8) * 30); // 30 (amber) → 0 (red)
|
|
}
|
|
const saturation = 70 + clamped * 15;
|
|
const lightness = 65 - clamped * 10;
|
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
};
|
|
|
|
type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
|
|
|
const getRiskLevel = (ratio: number): RiskLevel => {
|
|
if (ratio < 0.5) return 'low';
|
|
if (ratio < 0.8) return 'medium';
|
|
if (ratio < 1.2) return 'high';
|
|
return 'critical';
|
|
};
|
|
|
|
const getCompositeRatio = (
|
|
row: UserTrafficItem,
|
|
totalThreshold: number,
|
|
nodeThreshold: number,
|
|
days: number,
|
|
): number => {
|
|
const dailyTotal = bytesToGbPerDay(row.total_bytes, days);
|
|
const totalR = totalThreshold > 0 ? getRatio(dailyTotal, totalThreshold) : 0;
|
|
const nodeR =
|
|
nodeThreshold > 0
|
|
? Math.max(
|
|
0,
|
|
...Object.values(row.node_traffic).map((b) =>
|
|
getRatio(bytesToGbPerDay(b || 0, days), nodeThreshold),
|
|
),
|
|
)
|
|
: 0;
|
|
return Math.max(totalR, nodeR);
|
|
};
|
|
|
|
const RISK_STYLES: Record<RiskLevel, { dot: string; text: string }> = {
|
|
low: { dot: 'bg-success-400', text: 'text-success-400' },
|
|
medium: { dot: 'bg-warning-400', text: 'text-warning-400' },
|
|
high: { dot: 'bg-orange-400', text: 'text-orange-400' },
|
|
critical: { dot: 'bg-error-400 animate-pulse', text: 'text-error-400' },
|
|
};
|
|
|
|
// ============ Icons ============
|
|
|
|
const SearchIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ChevronLeftIcon = () => (
|
|
<svg className="h-5 w-5" 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>
|
|
);
|
|
|
|
const ChevronRightIcon = () => (
|
|
<svg className="h-5 w-5" 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>
|
|
);
|
|
|
|
const RefreshIcon = ({ className = 'w-5 h-5' }: { 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 DownloadIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => (
|
|
<svg
|
|
className="ml-1 inline h-3 w-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
{direction === 'asc' ? (
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
|
) : direction === 'desc' ? (
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
) : (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
|
|
/>
|
|
)}
|
|
</svg>
|
|
);
|
|
|
|
const FilterIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ChevronDownIcon = () => (
|
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const ServerIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const CalendarIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const XIcon = () => (
|
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
);
|
|
|
|
const StatusIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ShieldIcon = () => (
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ServerSmallIcon = () => (
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<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>
|
|
);
|
|
|
|
// ============ Progress Bar ============
|
|
|
|
function ProgressBar({ loading }: { loading: boolean }) {
|
|
const [progress, setProgress] = useState(0);
|
|
const [visible, setVisible] = useState(false);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
|
|
useEffect(() => {
|
|
if (loading) {
|
|
setProgress(0);
|
|
setVisible(true);
|
|
// Fast initial progress, then slow down
|
|
intervalRef.current = setInterval(() => {
|
|
setProgress((prev) => {
|
|
if (prev < 30) return prev + 8;
|
|
if (prev < 60) return prev + 3;
|
|
if (prev < 85) return prev + 1;
|
|
if (prev < 95) return prev + 0.3;
|
|
return prev;
|
|
});
|
|
}, 100);
|
|
} else {
|
|
if (visible) {
|
|
setProgress(100);
|
|
clearInterval(intervalRef.current);
|
|
const timer = setTimeout(() => {
|
|
setVisible(false);
|
|
setProgress(0);
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}
|
|
return () => clearInterval(intervalRef.current);
|
|
}, [loading, visible]);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<div className="absolute left-0 right-0 top-0 z-50 h-0.5 overflow-hidden rounded-full bg-dark-700/50">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-200 ease-out"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Components ============
|
|
|
|
const PERIODS = [1, 3, 7, 14, 30] as const;
|
|
|
|
function PeriodSelector({
|
|
value,
|
|
onChange,
|
|
label,
|
|
dateMode,
|
|
customStart,
|
|
customEnd,
|
|
onToggleDateMode,
|
|
onCustomStartChange,
|
|
onCustomEndChange,
|
|
}: {
|
|
value: number;
|
|
onChange: (v: number) => void;
|
|
label: string;
|
|
dateMode: boolean;
|
|
customStart: string;
|
|
customEnd: string;
|
|
onToggleDateMode: () => void;
|
|
onCustomStartChange: (v: string) => void;
|
|
onCustomEndChange: (v: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
// Limit: last 31 days
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const minDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
|
|
if (dateMode) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<CalendarIcon />
|
|
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span>
|
|
<input
|
|
type="date"
|
|
value={customStart}
|
|
min={minDate}
|
|
max={customEnd || today}
|
|
onChange={(e) => onCustomStartChange(e.target.value)}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none"
|
|
/>
|
|
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateTo')}</span>
|
|
<input
|
|
type="date"
|
|
value={customEnd}
|
|
min={customStart || minDate}
|
|
max={today}
|
|
onChange={(e) => onCustomEndChange(e.target.value)}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none"
|
|
/>
|
|
<button
|
|
onClick={onToggleDateMode}
|
|
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
|
title={t('admin.trafficUsage.period')}
|
|
>
|
|
<XIcon />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-dark-400">{label}</span>
|
|
<div className="flex gap-1">
|
|
{PERIODS.map((p) => (
|
|
<button
|
|
key={p}
|
|
onClick={() => onChange(p)}
|
|
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
value === p
|
|
? 'bg-accent-500 text-white'
|
|
: 'bg-dark-800 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
|
}`}
|
|
>
|
|
{p}
|
|
{t('admin.trafficUsage.days')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={onToggleDateMode}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-1.5 text-dark-400 transition-colors hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
|
|
title={t('admin.trafficUsage.customDates')}
|
|
>
|
|
<CalendarIcon />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TariffFilter({
|
|
available,
|
|
selected,
|
|
onChange,
|
|
}: {
|
|
available: string[];
|
|
selected: Set<string>;
|
|
onChange: (next: Set<string>) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
if (available.length === 0) return null;
|
|
|
|
const allSelected = selected.size === 0;
|
|
const activeCount = selected.size;
|
|
|
|
const toggle = (tariff: string) => {
|
|
const next = new Set(selected);
|
|
if (next.has(tariff)) {
|
|
next.delete(tariff);
|
|
} else {
|
|
next.add(tariff);
|
|
}
|
|
onChange(next);
|
|
};
|
|
|
|
const selectAll = () => onChange(new Set());
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
activeCount > 0
|
|
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
|
|
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
|
|
}`}
|
|
>
|
|
<FilterIcon />
|
|
{t('admin.trafficUsage.tariff')}
|
|
{activeCount > 0 && (
|
|
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
|
{activeCount}
|
|
</span>
|
|
)}
|
|
<ChevronDownIcon />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 top-full z-30 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
|
<button
|
|
onClick={selectAll}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
|
|
allSelected ? 'text-accent-400' : 'text-dark-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
|
|
}`}
|
|
>
|
|
{allSelected && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{t('admin.trafficUsage.allTariffs')}
|
|
</button>
|
|
|
|
<div className="mx-2 border-t border-dark-700" />
|
|
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{available.map((tariff) => {
|
|
const checked = selected.has(tariff);
|
|
return (
|
|
<button
|
|
key={tariff}
|
|
onClick={() => toggle(tariff)}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
|
|
}`}
|
|
>
|
|
{checked && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M4.5 12.75l6 6 9-13.5"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{tariff}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
active: 'bg-success-500',
|
|
trial: 'bg-warning-500',
|
|
expired: 'bg-error-500',
|
|
disabled: 'bg-dark-500',
|
|
};
|
|
|
|
function StatusFilter({
|
|
available,
|
|
selected,
|
|
onChange,
|
|
}: {
|
|
available: string[];
|
|
selected: Set<string>;
|
|
onChange: (next: Set<string>) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
if (available.length === 0) return null;
|
|
|
|
const allSelected = selected.size === 0;
|
|
const activeCount = selected.size;
|
|
|
|
const toggle = (status: string) => {
|
|
const next = new Set(selected);
|
|
if (next.has(status)) {
|
|
next.delete(status);
|
|
} else {
|
|
next.add(status);
|
|
}
|
|
onChange(next);
|
|
};
|
|
|
|
const selectAll = () => onChange(new Set());
|
|
|
|
const statusLabel = (s: string) => {
|
|
const key = `admin.trafficUsage.status${s.charAt(0).toUpperCase() + s.slice(1)}`;
|
|
return t(key, s);
|
|
};
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
activeCount > 0
|
|
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
|
|
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
|
|
}`}
|
|
>
|
|
<StatusIcon />
|
|
{t('admin.trafficUsage.status')}
|
|
{activeCount > 0 && (
|
|
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
|
{activeCount}
|
|
</span>
|
|
)}
|
|
<ChevronDownIcon />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 top-full z-30 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
|
<button
|
|
onClick={selectAll}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
|
|
allSelected ? 'text-accent-400' : 'text-dark-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
|
|
}`}
|
|
>
|
|
{allSelected && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{t('admin.trafficUsage.allStatuses')}
|
|
</button>
|
|
|
|
<div className="mx-2 border-t border-dark-700" />
|
|
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{available.map((s) => {
|
|
const checked = selected.has(s);
|
|
return (
|
|
<button
|
|
key={s}
|
|
onClick={() => toggle(s)}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
|
|
}`}
|
|
>
|
|
{checked && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M4.5 12.75l6 6 9-13.5"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</span>
|
|
<span className={`h-2 w-2 rounded-full ${STATUS_COLORS[s] || 'bg-dark-500'}`} />
|
|
{statusLabel(s)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NodeFilter({
|
|
available,
|
|
selected,
|
|
onChange,
|
|
}: {
|
|
available: TrafficNodeInfo[];
|
|
selected: Set<string>;
|
|
onChange: (next: Set<string>) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
if (available.length === 0) return null;
|
|
|
|
const allSelected = selected.size === 0;
|
|
const activeCount = selected.size;
|
|
|
|
const toggle = (uuid: string) => {
|
|
const next = new Set(selected);
|
|
if (next.has(uuid)) {
|
|
next.delete(uuid);
|
|
} else {
|
|
next.add(uuid);
|
|
}
|
|
onChange(next);
|
|
};
|
|
|
|
const selectAll = () => onChange(new Set());
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
activeCount > 0
|
|
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
|
|
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
|
|
}`}
|
|
>
|
|
<ServerIcon />
|
|
{t('admin.trafficUsage.nodes')}
|
|
{activeCount > 0 && (
|
|
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
|
{activeCount}
|
|
</span>
|
|
)}
|
|
<ChevronDownIcon />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 top-full z-30 mt-1 w-64 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
|
<button
|
|
onClick={selectAll}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
|
|
allSelected ? 'text-accent-400' : 'text-dark-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
|
|
}`}
|
|
>
|
|
{allSelected && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{t('admin.trafficUsage.allNodes')}
|
|
</button>
|
|
|
|
<div className="mx-2 border-t border-dark-700" />
|
|
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{available.map((node) => {
|
|
const checked = selected.has(node.node_uuid);
|
|
return (
|
|
<button
|
|
key={node.node_uuid}
|
|
onClick={() => toggle(node.node_uuid)}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
|
|
>
|
|
<span
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
|
|
}`}
|
|
>
|
|
{checked && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M4.5 12.75l6 6 9-13.5"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{getFlagEmoji(node.country_code)} {node.node_name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Risk Badge ============
|
|
|
|
function RiskBadge({ level, ratio }: { level: RiskLevel; ratio: number }) {
|
|
const { t } = useTranslation();
|
|
const style = RISK_STYLES[level];
|
|
const labelKey = `admin.trafficUsage.risk${level.charAt(0).toUpperCase() + level.slice(1)}`;
|
|
const pct = Math.round(ratio * 100);
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-1.5">
|
|
<span className={`inline-block h-2 w-2 rounded-full ${style.dot}`} />
|
|
<span className={`text-[11px] font-medium ${style.text}`}>{pct}%</span>
|
|
<span className={`text-[10px] ${style.text} opacity-70`}>{t(labelKey)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Main Page ============
|
|
|
|
export default function AdminTrafficUsage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { capabilities } = usePlatform();
|
|
|
|
const [items, setItems] = useState<UserTrafficItem[]>([]);
|
|
const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]);
|
|
const [availableTariffs, setAvailableTariffs] = useState<string[]>([]);
|
|
const [availableStatuses, setAvailableStatuses] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
const [period, setPeriod] = useState(30);
|
|
const [dateMode, setDateMode] = useState(false);
|
|
const [customStart, setCustomStart] = useState('');
|
|
const [customEnd, setCustomEnd] = useState('');
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [committedSearch, setCommittedSearch] = useState('');
|
|
const [selectedTariffs, setSelectedTariffs] = useState<Set<string>>(new Set());
|
|
const [selectedStatuses, setSelectedStatuses] = useState<Set<string>>(new Set());
|
|
const [selectedNodes, setSelectedNodes] = useState<Set<string>>(new Set());
|
|
const [offset, setOffset] = useState(0);
|
|
const [total, setTotal] = useState(0);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
|
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
|
|
const [totalThreshold, setTotalThreshold] = useState('');
|
|
const [nodeThreshold, setNodeThreshold] = useState('');
|
|
const [periodDays, setPeriodDays] = useState(30);
|
|
|
|
const limit = 50;
|
|
const hasData = items.length > 0 || nodes.length > 0;
|
|
|
|
const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes';
|
|
const sortDesc = sorting[0]?.desc ?? true;
|
|
const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined;
|
|
const statusesParam = selectedStatuses.size > 0 ? [...selectedStatuses].join(',') : undefined;
|
|
const nodesParam = selectedNodes.size > 0 ? [...selectedNodes].join(',') : undefined;
|
|
|
|
const buildParams = useCallback((): TrafficParams => {
|
|
const params: TrafficParams = {
|
|
limit,
|
|
offset,
|
|
search: committedSearch || undefined,
|
|
sort_by: sortBy,
|
|
sort_desc: sortDesc,
|
|
tariffs: tariffsParam,
|
|
statuses: statusesParam,
|
|
nodes: nodesParam,
|
|
};
|
|
if (dateMode && customStart && customEnd) {
|
|
params.start_date = customStart;
|
|
params.end_date = customEnd;
|
|
} else {
|
|
params.period = period;
|
|
}
|
|
return params;
|
|
}, [
|
|
period,
|
|
offset,
|
|
committedSearch,
|
|
sortBy,
|
|
sortDesc,
|
|
tariffsParam,
|
|
statusesParam,
|
|
nodesParam,
|
|
dateMode,
|
|
customStart,
|
|
customEnd,
|
|
]);
|
|
|
|
const applyData = useCallback((data: TrafficUsageResponse) => {
|
|
setItems(data.items);
|
|
setNodes(data.nodes);
|
|
setTotal(data.total);
|
|
setAvailableTariffs(data.available_tariffs);
|
|
setAvailableStatuses(data.available_statuses);
|
|
setPeriodDays(data.period_days);
|
|
}, []);
|
|
|
|
const loadData = useCallback(
|
|
async (skipCache = false) => {
|
|
const params = buildParams();
|
|
|
|
// Check cache first — apply instantly without any loading state
|
|
if (!skipCache) {
|
|
const cached = adminTrafficApi.getCached(params);
|
|
if (cached) {
|
|
applyData(cached);
|
|
setInitialLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
const data = await adminTrafficApi.getTrafficUsage(params, { skipCache });
|
|
applyData(data);
|
|
} catch {
|
|
// silently fail — keep stale data visible
|
|
} finally {
|
|
setLoading(false);
|
|
setInitialLoading(false);
|
|
}
|
|
},
|
|
[buildParams, applyData],
|
|
);
|
|
|
|
// Load on param change
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// Prefetch adjacent periods in background (only in period mode)
|
|
useEffect(() => {
|
|
if (dateMode) return;
|
|
const prefetchPeriods = PERIODS.filter((p) => p !== period);
|
|
const timer = setTimeout(() => {
|
|
prefetchPeriods.forEach((p) => {
|
|
const params: TrafficParams = {
|
|
period: p,
|
|
limit,
|
|
offset: 0,
|
|
sort_by: 'total_bytes',
|
|
sort_desc: true,
|
|
};
|
|
if (!adminTrafficApi.getCached(params)) {
|
|
adminTrafficApi.getTrafficUsage(params).catch(() => {});
|
|
}
|
|
});
|
|
}, 500);
|
|
return () => clearTimeout(timer);
|
|
}, [period, dateMode]);
|
|
|
|
useEffect(() => {
|
|
if (toast) {
|
|
const timer = setTimeout(() => setToast(null), 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [toast]);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setOffset(0);
|
|
setCommittedSearch(searchInput);
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
try {
|
|
setExporting(true);
|
|
const exportData: {
|
|
period: number;
|
|
start_date?: string;
|
|
end_date?: string;
|
|
tariffs?: string;
|
|
statuses?: string;
|
|
nodes?: string;
|
|
} = { period };
|
|
if (dateMode && customStart && customEnd) {
|
|
exportData.start_date = customStart;
|
|
exportData.end_date = customEnd;
|
|
}
|
|
if (tariffsParam) exportData.tariffs = tariffsParam;
|
|
if (statusesParam) exportData.statuses = statusesParam;
|
|
if (nodesParam) exportData.nodes = nodesParam;
|
|
await adminTrafficApi.exportCsv(exportData);
|
|
setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' });
|
|
} catch {
|
|
setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' });
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const handlePeriodChange = (p: number) => {
|
|
setPeriod(p);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleToggleDateMode = () => {
|
|
if (dateMode) {
|
|
// Switch back to period mode
|
|
setDateMode(false);
|
|
setCustomStart('');
|
|
setCustomEnd('');
|
|
setOffset(0);
|
|
} else {
|
|
// Switch to date mode — pre-fill with last N days
|
|
const end = new Date();
|
|
const start = new Date(end.getTime() - period * 24 * 60 * 60 * 1000);
|
|
setCustomStart(start.toISOString().split('T')[0]);
|
|
setCustomEnd(end.toISOString().split('T')[0]);
|
|
setDateMode(true);
|
|
setOffset(0);
|
|
}
|
|
};
|
|
|
|
const handleCustomStartChange = (v: string) => {
|
|
setCustomStart(v);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleCustomEndChange = (v: string) => {
|
|
setCustomEnd(v);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleSortingChange = (updater: SortingState | ((old: SortingState) => SortingState)) => {
|
|
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
|
setSorting(next);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleTariffChange = (next: Set<string>) => {
|
|
setSelectedTariffs(next);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleStatusChange = (next: Set<string>) => {
|
|
setSelectedStatuses(next);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleNodeChange = (next: Set<string>) => {
|
|
setSelectedNodes(next);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
loadData(true);
|
|
};
|
|
|
|
// When node filter is active, show only selected node columns
|
|
const displayNodes = useMemo(
|
|
() => (selectedNodes.size > 0 ? nodes.filter((n) => selectedNodes.has(n.node_uuid)) : nodes),
|
|
[nodes, selectedNodes],
|
|
);
|
|
|
|
const totalThresholdNum = Math.max(0, parseFloat(totalThreshold) || 0);
|
|
const hasTotalThreshold = totalThresholdNum > 0;
|
|
const nodeThresholdNum = Math.max(0, parseFloat(nodeThreshold) || 0);
|
|
const hasNodeThreshold = nodeThresholdNum > 0;
|
|
const hasAnyThreshold = hasTotalThreshold || hasNodeThreshold;
|
|
|
|
const columns = useMemo<ColumnDef<UserTrafficItem>[]>(() => {
|
|
const cols: ColumnDef<UserTrafficItem>[] = [
|
|
{
|
|
id: 'user',
|
|
accessorFn: (row) => row.full_name,
|
|
header: t('admin.trafficUsage.user'),
|
|
enableSorting: true,
|
|
size: 120,
|
|
minSize: 80,
|
|
maxSize: 200,
|
|
cell: ({ row }) => {
|
|
const item = row.original;
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-[10px] font-medium text-white">
|
|
{item.full_name?.[0] || '?'}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-xs font-medium text-dark-100">{item.full_name}</div>
|
|
{item.username && (
|
|
<div className="truncate text-[10px] leading-tight text-dark-500">
|
|
@{item.username}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
meta: { sticky: true },
|
|
},
|
|
{
|
|
accessorKey: 'tariff_name',
|
|
header: t('admin.trafficUsage.tariff'),
|
|
enableSorting: true,
|
|
size: 120,
|
|
minSize: 80,
|
|
cell: ({ getValue }) => (
|
|
<span className="text-xs text-dark-300">
|
|
{(getValue() as string | null) || t('admin.trafficUsage.noTariff')}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'device_limit',
|
|
header: t('admin.trafficUsage.devices'),
|
|
enableSorting: true,
|
|
size: 80,
|
|
minSize: 60,
|
|
meta: { align: 'center' as const },
|
|
cell: ({ getValue }) => (
|
|
<span className="text-xs text-dark-300">{getValue() as number}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'traffic_limit_gb',
|
|
header: t('admin.trafficUsage.trafficLimit'),
|
|
enableSorting: true,
|
|
size: 80,
|
|
minSize: 60,
|
|
meta: { align: 'center' as const },
|
|
cell: ({ getValue }) => {
|
|
const gb = getValue() as number;
|
|
return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>;
|
|
},
|
|
},
|
|
...displayNodes.map(
|
|
(node): ColumnDef<UserTrafficItem> => ({
|
|
id: `node_${node.node_uuid}`,
|
|
accessorFn: (row) => row.node_traffic[node.node_uuid] || 0,
|
|
header: `${getFlagEmoji(node.country_code)} ${node.node_name}`,
|
|
enableSorting: true,
|
|
size: 110,
|
|
minSize: 80,
|
|
meta: { align: 'center' as const },
|
|
cell: ({ getValue }) => {
|
|
const bytes = getValue() as number;
|
|
if (bytes <= 0) {
|
|
return <span className="text-xs text-dark-300">{'\u2014'}</span>;
|
|
}
|
|
const dailyNode = bytesToGbPerDay(bytes, periodDays);
|
|
const nodeRatio = hasNodeThreshold ? getRatio(dailyNode, nodeThresholdNum) : 0;
|
|
const textColor = hasNodeThreshold ? getNodeTextColor(nodeRatio) : undefined;
|
|
return (
|
|
<span
|
|
className="text-xs text-dark-300"
|
|
style={{
|
|
color: textColor,
|
|
fontWeight: nodeRatio > 0.8 ? 600 : undefined,
|
|
}}
|
|
>
|
|
{formatBytes(bytes)}
|
|
</span>
|
|
);
|
|
},
|
|
}),
|
|
),
|
|
];
|
|
|
|
// Risk column — insert before total when any threshold is set
|
|
if (hasAnyThreshold) {
|
|
cols.push({
|
|
id: 'risk',
|
|
header: t('admin.trafficUsage.risk'),
|
|
size: 100,
|
|
minSize: 80,
|
|
meta: { align: 'center' as const },
|
|
accessorFn: (row) =>
|
|
getCompositeRatio(row, totalThresholdNum, nodeThresholdNum, periodDays),
|
|
enableSorting: false,
|
|
cell: ({ getValue }) => {
|
|
const maxRatio = getValue() as number;
|
|
const level = getRiskLevel(maxRatio);
|
|
return <RiskBadge level={level} ratio={maxRatio} />;
|
|
},
|
|
});
|
|
}
|
|
|
|
cols.push({
|
|
accessorKey: 'total_bytes',
|
|
header: t('admin.trafficUsage.total'),
|
|
enableSorting: true,
|
|
size: 110,
|
|
minSize: 80,
|
|
meta: { align: 'center' as const, bold: true },
|
|
cell: ({ getValue }) => {
|
|
const bytes = getValue() as number;
|
|
return (
|
|
<span className="text-xs font-semibold text-dark-100">
|
|
{bytes > 0 ? formatBytes(bytes) : '\u2014'}
|
|
</span>
|
|
);
|
|
},
|
|
});
|
|
|
|
return cols;
|
|
}, [
|
|
displayNodes,
|
|
t,
|
|
hasAnyThreshold,
|
|
hasTotalThreshold,
|
|
hasNodeThreshold,
|
|
totalThresholdNum,
|
|
nodeThresholdNum,
|
|
periodDays,
|
|
]);
|
|
|
|
const table = useReactTable({
|
|
data: items,
|
|
columns,
|
|
state: { sorting, columnSizing },
|
|
onSortingChange: handleSortingChange,
|
|
onColumnSizingChange: setColumnSizing,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
manualSorting: true,
|
|
enableSortingRemoval: false,
|
|
enableColumnResizing: true,
|
|
columnResizeMode: 'onChange',
|
|
});
|
|
|
|
const totalPages = Math.ceil(total / limit);
|
|
const currentPage = Math.floor(offset / limit) + 1;
|
|
|
|
return (
|
|
<div className="relative animate-fade-in">
|
|
{/* Progress bar — shown during background refresh */}
|
|
<ProgressBar loading={loading} />
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div
|
|
className={`fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-xl border px-4 py-2 text-sm shadow-lg ${
|
|
toast.type === 'success'
|
|
? 'border-success-500/30 bg-success-500/20 text-success-400'
|
|
: 'border-error-500/30 bg-error-500/20 text-error-400'
|
|
}`}
|
|
>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{!capabilities.hasBackButton && (
|
|
<button
|
|
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"
|
|
>
|
|
<ChevronLeftIcon />
|
|
</button>
|
|
)}
|
|
<div>
|
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.trafficUsage.title')}</h1>
|
|
<p className="text-sm text-dark-400">{t('admin.trafficUsage.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="rounded-lg p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
>
|
|
<RefreshIcon className={loading ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="mb-4 flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<PeriodSelector
|
|
value={period}
|
|
onChange={handlePeriodChange}
|
|
label={t('admin.trafficUsage.period')}
|
|
dateMode={dateMode}
|
|
customStart={customStart}
|
|
customEnd={customEnd}
|
|
onToggleDateMode={handleToggleDateMode}
|
|
onCustomStartChange={handleCustomStartChange}
|
|
onCustomEndChange={handleCustomEndChange}
|
|
/>
|
|
<TariffFilter
|
|
available={availableTariffs}
|
|
selected={selectedTariffs}
|
|
onChange={handleTariffChange}
|
|
/>
|
|
<NodeFilter available={nodes} selected={selectedNodes} onChange={handleNodeChange} />
|
|
<StatusFilter
|
|
available={availableStatuses}
|
|
selected={selectedStatuses}
|
|
onChange={handleStatusChange}
|
|
/>
|
|
|
|
{/* Threshold inputs */}
|
|
<div className="flex items-center gap-1.5 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1">
|
|
<ShieldIcon />
|
|
<input
|
|
type="number"
|
|
value={totalThreshold}
|
|
onChange={(e) => setTotalThreshold(e.target.value)}
|
|
placeholder={t('admin.trafficUsage.totalThreshold')}
|
|
step="0.1"
|
|
min="0"
|
|
max="9999"
|
|
className="w-20 bg-transparent text-xs text-dark-200 placeholder-dark-500 [appearance:textfield] focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
/>
|
|
{totalThreshold && (
|
|
<button
|
|
onClick={() => setTotalThreshold('')}
|
|
className="text-dark-500 hover:text-dark-300"
|
|
>
|
|
<XIcon />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1">
|
|
<ServerSmallIcon />
|
|
<input
|
|
type="number"
|
|
value={nodeThreshold}
|
|
onChange={(e) => setNodeThreshold(e.target.value)}
|
|
placeholder={t('admin.trafficUsage.nodeThreshold')}
|
|
step="0.1"
|
|
min="0"
|
|
max="9999"
|
|
className="w-20 bg-transparent text-xs text-dark-200 placeholder-dark-500 [appearance:textfield] focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
/>
|
|
{nodeThreshold && (
|
|
<button
|
|
onClick={() => setNodeThreshold('')}
|
|
className="text-dark-500 hover:text-dark-300"
|
|
>
|
|
<XIcon />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={exporting}
|
|
className="flex items-center gap-1.5 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-xs font-medium text-dark-200 transition-colors hover:border-dark-600 hover:bg-dark-700 disabled:opacity-50"
|
|
>
|
|
<DownloadIcon />
|
|
{t('admin.trafficUsage.exportCsv')}
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSearch}>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
placeholder={t('admin.trafficUsage.search')}
|
|
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none"
|
|
/>
|
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
|
<SearchIcon />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{initialLoading && !hasData ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
) : !hasData && !loading ? (
|
|
<div className="py-12 text-center text-dark-400">{t('admin.trafficUsage.noData')}</div>
|
|
) : (
|
|
<div
|
|
className={`transition-opacity duration-200 ${loading && hasData ? 'opacity-70' : 'opacity-100'}`}
|
|
>
|
|
<div className="overflow-x-auto rounded-xl border border-dark-700">
|
|
<table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
|
|
<thead>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
|
{headerGroup.headers.map((header) => {
|
|
const meta = header.column.columnDef.meta;
|
|
const isSticky = meta?.sticky;
|
|
const align = meta?.align === 'center' ? 'text-center' : 'text-left';
|
|
const isBold = meta?.bold;
|
|
|
|
return (
|
|
<th
|
|
key={header.id}
|
|
className={`relative px-3 py-2 text-xs font-medium ${
|
|
isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
|
|
} ${align} ${
|
|
isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
|
|
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
|
|
style={{ width: header.getSize() }}
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
>
|
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
{header.column.getCanSort() && (
|
|
<SortIcon direction={header.column.getIsSorted()} />
|
|
)}
|
|
<div
|
|
onMouseDown={header.getResizeHandler()}
|
|
onTouchStart={header.getResizeHandler()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${
|
|
header.column.getIsResizing()
|
|
? 'bg-accent-500'
|
|
: 'bg-transparent hover:bg-dark-500'
|
|
}`}
|
|
/>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{table.getRowModel().rows.map((row) => {
|
|
const compositeRatio = hasAnyThreshold
|
|
? getCompositeRatio(
|
|
row.original,
|
|
totalThresholdNum,
|
|
nodeThresholdNum,
|
|
periodDays,
|
|
)
|
|
: 0;
|
|
const rowBg = hasAnyThreshold ? getRowBgColor(compositeRatio) : undefined;
|
|
|
|
return (
|
|
<tr
|
|
key={row.id}
|
|
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
|
style={{ backgroundColor: rowBg }}
|
|
onClick={() => navigate(`/admin/users/${row.original.user_id}`)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => {
|
|
const meta = cell.column.columnDef.meta;
|
|
const isSticky = meta?.sticky;
|
|
const align = meta?.align === 'center' ? 'text-center' : 'text-left';
|
|
|
|
return (
|
|
<td
|
|
key={cell.id}
|
|
className={`px-3 py-2 ${align} ${
|
|
isSticky ? 'sticky left-0 z-10 bg-dark-900' : ''
|
|
}`}
|
|
style={{ width: cell.column.getSize() }}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<div className="text-sm text-dark-400">
|
|
{offset + 1}
|
|
{'\u2013'}
|
|
{Math.min(offset + limit, total)} / {total}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setOffset(Math.max(0, offset - limit))}
|
|
disabled={offset === 0}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
>
|
|
<ChevronLeftIcon />
|
|
</button>
|
|
<span className="px-3 py-2 text-dark-300">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setOffset(offset + limit)}
|
|
disabled={offset + limit >= total}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
>
|
|
<ChevronRightIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|