From a6507b2cfe73d3f9dafec9e87fd17e287c91067d Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 12:41:34 +0300 Subject: [PATCH] feat: add traffic abuse risk assessment with color gradation 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). --- src/locales/en.json | 9 +- src/locales/fa.json | 9 +- src/locales/ru.json | 9 +- src/locales/zh.json | 9 +- src/pages/AdminTrafficUsage.tsx | 327 +++++++++++++++++++++++++++----- 5 files changed, 309 insertions(+), 54 deletions(-) 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 && ( + + )} +
+