Merge pull request #188 from BEDOLAGA-DEV/feat/traffic-risk-assessment

feat: traffic abuse risk assessment with color gradation
This commit is contained in:
Egor
2026-02-07 12:42:59 +03:00
committed by GitHub
5 changed files with 309 additions and 54 deletions

View File

@@ -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",

View File

@@ -696,7 +696,14 @@
"statusDisabled": "غیرفعال",
"customDates": "تاریخ دلخواه",
"dateFrom": "از",
"dateTo": "تا"
"dateTo": "تا",
"totalThreshold": "کل GB/روز",
"nodeThreshold": "نود GB/روز",
"risk": "ریسک",
"riskLow": "کم",
"riskMedium": "متوسط",
"riskHigh": "بالا",
"riskCritical": "بحرانی"
},
"emailTemplates": {
"title": "قالب‌های ایمیل",

View File

@@ -836,7 +836,14 @@
"statusDisabled": "Отключена",
"customDates": "Выбрать даты",
"dateFrom": "От",
"dateTo": "До"
"dateTo": "До",
"totalThreshold": "Всего ГБ/д",
"nodeThreshold": "Нода ГБ/д",
"risk": "Риск",
"riskLow": "Низкий",
"riskMedium": "Средний",
"riskHigh": "Высокий",
"riskCritical": "Критический"
},
"emailTemplates": {
"title": "Email-шаблоны",

View File

@@ -696,7 +696,14 @@
"statusDisabled": "已禁用",
"customDates": "自定义日期",
"dateFrom": "从",
"dateTo": "到"
"dateTo": "到",
"totalThreshold": "总流量GB/天",
"nodeThreshold": "节点GB/天",
"risk": "风险",
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"riskCritical": "严重"
},
"paymentMethods": {
"title": "支付方法",

View File

@@ -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<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 = () => (
@@ -171,6 +236,38 @@ const StatusIcon = () => (
</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 }) {
@@ -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 (
<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() {
@@ -740,6 +854,9 @@ export default function AdminTrafficUsage() {
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;
@@ -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<ColumnDef<UserTrafficItem>[]>(() => {
const cols: ColumnDef<UserTrafficItem>[] = [
{
@@ -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 (
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
<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-sm font-medium text-dark-100">{item.full_name}</div>
<div className="truncate text-xs font-medium text-dark-100">{item.full_name}</div>
{item.username && (
<div className="truncate text-xs text-dark-500">@{item.username}</div>
<div className="truncate text-[10px] leading-tight text-dark-500">
@{item.username}
</div>
)}
</div>
</div>
@@ -1021,33 +1148,75 @@ export default function AdminTrafficUsage() {
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">
{bytes > 0 ? formatBytes(bytes) : '\u2014'}
<span
className="text-xs text-dark-300"
style={{
color: textColor,
fontWeight: nodeRatio > 0.8 ? 600 : undefined,
}}
>
{formatBytes(bytes)}
</span>
);
},
}),
),
{
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>
);
},
},
];
// 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]);
}, [
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 */}
<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}
@@ -1213,31 +1427,44 @@ export default function AdminTrafficUsage() {
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
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';
{table.getRowModel().rows.map((row) => {
const compositeRatio = hasAnyThreshold
? getCompositeRatio(
row.original,
totalThresholdNum,
nodeThresholdNum,
periodDays,
)
: 0;
const rowBg = hasAnyThreshold ? getRowBgColor(compositeRatio) : undefined;
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>
))}
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>