diff --git a/src/locales/en.json b/src/locales/en.json index d73e8b6..f8e5d23 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -815,7 +815,14 @@ "statusDisabled": "Disabled", "customDates": "Custom dates", "dateFrom": "From", - "dateTo": "To" + "dateTo": "To", + "totalThreshold": "Total GB/d", + "nodeThreshold": "Node GB/d", + "risk": "Risk", + "riskLow": "Low", + "riskMedium": "Medium", + "riskHigh": "High", + "riskCritical": "Critical" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 22be7de..8d2eb68 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -696,7 +696,14 @@ "statusDisabled": "غیرفعال", "customDates": "تاریخ دلخواه", "dateFrom": "از", - "dateTo": "تا" + "dateTo": "تا", + "totalThreshold": "کل GB/روز", + "nodeThreshold": "نود GB/روز", + "risk": "ریسک", + "riskLow": "کم", + "riskMedium": "متوسط", + "riskHigh": "بالا", + "riskCritical": "بحرانی" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index b535256..91fb441 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -836,7 +836,14 @@ "statusDisabled": "Отключена", "customDates": "Выбрать даты", "dateFrom": "От", - "dateTo": "До" + "dateTo": "До", + "totalThreshold": "Всего ГБ/д", + "nodeThreshold": "Нода ГБ/д", + "risk": "Риск", + "riskLow": "Низкий", + "riskMedium": "Средний", + "riskHigh": "Высокий", + "riskCritical": "Критический" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5a96fa1..a5c9c11 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -696,7 +696,14 @@ "statusDisabled": "已禁用", "customDates": "自定义日期", "dateFrom": "从", - "dateTo": "到" + "dateTo": "到", + "totalThreshold": "总流量GB/天", + "nodeThreshold": "节点GB/天", + "risk": "风险", + "riskLow": "低", + "riskMedium": "中", + "riskHigh": "高", + "riskCritical": "严重" }, "paymentMethods": { "title": "支付方法", diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 3636860..d84c568 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -32,7 +32,7 @@ declare module '@tanstack/react-table' { // ============ Utils ============ const formatBytes = (bytes: number): string => { - if (bytes <= 0) return '0 B'; + 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)); @@ -53,6 +53,71 @@ const toBackendSortField = (columnId: string): string => { 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 = { + 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 = () => ( @@ -171,6 +236,38 @@ const StatusIcon = () => ( ); +const ShieldIcon = () => ( + + + +); + +const ServerSmallIcon = () => ( + + + +); + // ============ Progress Bar ============ function ProgressBar({ loading }: { loading: boolean }) { @@ -712,6 +809,23 @@ function NodeFilter({ ); } +// ============ 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 ( +
+ + {pct}% + {t(labelKey)} +
+ ); +} + // ============ Main Page ============ export default function AdminTrafficUsage() { @@ -740,6 +854,9 @@ export default function AdminTrafficUsage() { const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [sorting, setSorting] = useState([{ id: 'total_bytes', desc: true }]); const [columnSizing, setColumnSizing] = useState>({}); + const [totalThreshold, setTotalThreshold] = useState(''); + const [nodeThreshold, setNodeThreshold] = useState(''); + const [periodDays, setPeriodDays] = useState(30); const limit = 50; const hasData = items.length > 0 || nodes.length > 0; @@ -788,6 +905,7 @@ export default function AdminTrafficUsage() { setTotal(data.total); setAvailableTariffs(data.available_tariffs); setAvailableStatuses(data.available_statuses); + setPeriodDays(data.period_days); }, []); const loadData = useCallback( @@ -948,6 +1066,12 @@ export default function AdminTrafficUsage() { [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[]>(() => { const cols: ColumnDef[] = [ { @@ -955,19 +1079,22 @@ export default function AdminTrafficUsage() { accessorFn: (row) => row.full_name, header: t('admin.trafficUsage.user'), enableSorting: true, - size: 200, - minSize: 140, + size: 120, + minSize: 80, + maxSize: 200, cell: ({ row }) => { const item = row.original; return ( -
-
+
+
{item.full_name?.[0] || '?'}
-
{item.full_name}
+
{item.full_name}
{item.username && ( -
@{item.username}
+
+ @{item.username} +
)}
@@ -1021,33 +1148,75 @@ export default function AdminTrafficUsage() { meta: { align: 'center' as const }, cell: ({ getValue }) => { const bytes = getValue() as number; + if (bytes <= 0) { + return {'\u2014'}; + } + const dailyNode = bytesToGbPerDay(bytes, periodDays); + const nodeRatio = hasNodeThreshold ? getRatio(dailyNode, nodeThresholdNum) : 0; + const textColor = hasNodeThreshold ? getNodeTextColor(nodeRatio) : undefined; return ( - - {bytes > 0 ? formatBytes(bytes) : '\u2014'} + 0.8 ? 600 : undefined, + }} + > + {formatBytes(bytes)} ); }, }), ), - { - 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 ( - - {bytes > 0 ? formatBytes(bytes) : '\u2014'} - - ); - }, - }, ]; + + // 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 ; + }, + }); + } + + 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 ( + + {bytes > 0 ? formatBytes(bytes) : '\u2014'} + + ); + }, + }); + return cols; - }, [displayNodes, t]); + }, [ + displayNodes, + t, + hasAnyThreshold, + hasTotalThreshold, + hasNodeThreshold, + totalThresholdNum, + nodeThresholdNum, + periodDays, + ]); const table = useReactTable({ data: items, @@ -1133,6 +1302,51 @@ export default function AdminTrafficUsage() { selected={selectedStatuses} onChange={handleStatusChange} /> + + {/* Threshold inputs */} +
+ + 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 && ( + + )} +
+
+ + 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 && ( + + )} +
+