mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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).
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user