mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13: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:
@@ -815,7 +815,14 @@
|
|||||||
"statusDisabled": "Disabled",
|
"statusDisabled": "Disabled",
|
||||||
"customDates": "Custom dates",
|
"customDates": "Custom dates",
|
||||||
"dateFrom": "From",
|
"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": {
|
"emailTemplates": {
|
||||||
"title": "Email Templates",
|
"title": "Email Templates",
|
||||||
|
|||||||
@@ -696,7 +696,14 @@
|
|||||||
"statusDisabled": "غیرفعال",
|
"statusDisabled": "غیرفعال",
|
||||||
"customDates": "تاریخ دلخواه",
|
"customDates": "تاریخ دلخواه",
|
||||||
"dateFrom": "از",
|
"dateFrom": "از",
|
||||||
"dateTo": "تا"
|
"dateTo": "تا",
|
||||||
|
"totalThreshold": "کل GB/روز",
|
||||||
|
"nodeThreshold": "نود GB/روز",
|
||||||
|
"risk": "ریسک",
|
||||||
|
"riskLow": "کم",
|
||||||
|
"riskMedium": "متوسط",
|
||||||
|
"riskHigh": "بالا",
|
||||||
|
"riskCritical": "بحرانی"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "قالبهای ایمیل",
|
"title": "قالبهای ایمیل",
|
||||||
|
|||||||
@@ -836,7 +836,14 @@
|
|||||||
"statusDisabled": "Отключена",
|
"statusDisabled": "Отключена",
|
||||||
"customDates": "Выбрать даты",
|
"customDates": "Выбрать даты",
|
||||||
"dateFrom": "От",
|
"dateFrom": "От",
|
||||||
"dateTo": "До"
|
"dateTo": "До",
|
||||||
|
"totalThreshold": "Всего ГБ/д",
|
||||||
|
"nodeThreshold": "Нода ГБ/д",
|
||||||
|
"risk": "Риск",
|
||||||
|
"riskLow": "Низкий",
|
||||||
|
"riskMedium": "Средний",
|
||||||
|
"riskHigh": "Высокий",
|
||||||
|
"riskCritical": "Критический"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email-шаблоны",
|
"title": "Email-шаблоны",
|
||||||
|
|||||||
@@ -696,7 +696,14 @@
|
|||||||
"statusDisabled": "已禁用",
|
"statusDisabled": "已禁用",
|
||||||
"customDates": "自定义日期",
|
"customDates": "自定义日期",
|
||||||
"dateFrom": "从",
|
"dateFrom": "从",
|
||||||
"dateTo": "到"
|
"dateTo": "到",
|
||||||
|
"totalThreshold": "总流量GB/天",
|
||||||
|
"nodeThreshold": "节点GB/天",
|
||||||
|
"risk": "风险",
|
||||||
|
"riskLow": "低",
|
||||||
|
"riskMedium": "中",
|
||||||
|
"riskHigh": "高",
|
||||||
|
"riskCritical": "严重"
|
||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "支付方法",
|
"title": "支付方法",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ declare module '@tanstack/react-table' {
|
|||||||
// ============ Utils ============
|
// ============ Utils ============
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes <= 0) return '0 B';
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
@@ -53,6 +53,71 @@ const toBackendSortField = (columnId: string): string => {
|
|||||||
return columnId;
|
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 ============
|
// ============ Icons ============
|
||||||
|
|
||||||
const SearchIcon = () => (
|
const SearchIcon = () => (
|
||||||
@@ -171,6 +236,38 @@ const StatusIcon = () => (
|
|||||||
</svg>
|
</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 ============
|
// ============ Progress Bar ============
|
||||||
|
|
||||||
function ProgressBar({ loading }: { loading: boolean }) {
|
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 ============
|
// ============ Main Page ============
|
||||||
|
|
||||||
export default function AdminTrafficUsage() {
|
export default function AdminTrafficUsage() {
|
||||||
@@ -740,6 +854,9 @@ export default function AdminTrafficUsage() {
|
|||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
||||||
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
|
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
|
||||||
|
const [totalThreshold, setTotalThreshold] = useState('');
|
||||||
|
const [nodeThreshold, setNodeThreshold] = useState('');
|
||||||
|
const [periodDays, setPeriodDays] = useState(30);
|
||||||
|
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const hasData = items.length > 0 || nodes.length > 0;
|
const hasData = items.length > 0 || nodes.length > 0;
|
||||||
@@ -788,6 +905,7 @@ export default function AdminTrafficUsage() {
|
|||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setAvailableTariffs(data.available_tariffs);
|
setAvailableTariffs(data.available_tariffs);
|
||||||
setAvailableStatuses(data.available_statuses);
|
setAvailableStatuses(data.available_statuses);
|
||||||
|
setPeriodDays(data.period_days);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
@@ -948,6 +1066,12 @@ export default function AdminTrafficUsage() {
|
|||||||
[nodes, selectedNodes],
|
[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 columns = useMemo<ColumnDef<UserTrafficItem>[]>(() => {
|
||||||
const cols: ColumnDef<UserTrafficItem>[] = [
|
const cols: ColumnDef<UserTrafficItem>[] = [
|
||||||
{
|
{
|
||||||
@@ -955,19 +1079,22 @@ export default function AdminTrafficUsage() {
|
|||||||
accessorFn: (row) => row.full_name,
|
accessorFn: (row) => row.full_name,
|
||||||
header: t('admin.trafficUsage.user'),
|
header: t('admin.trafficUsage.user'),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
size: 200,
|
size: 120,
|
||||||
minSize: 140,
|
minSize: 80,
|
||||||
|
maxSize: 200,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original;
|
const item = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<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 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] || '?'}
|
{item.full_name?.[0] || '?'}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1021,33 +1148,75 @@ export default function AdminTrafficUsage() {
|
|||||||
meta: { align: 'center' as const },
|
meta: { align: 'center' as const },
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const bytes = getValue() as number;
|
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 (
|
return (
|
||||||
<span className="text-xs text-dark-300">
|
<span
|
||||||
{bytes > 0 ? formatBytes(bytes) : '\u2014'}
|
className="text-xs text-dark-300"
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: nodeRatio > 0.8 ? 600 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatBytes(bytes)}
|
||||||
</span>
|
</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;
|
return cols;
|
||||||
}, [displayNodes, t]);
|
}, [
|
||||||
|
displayNodes,
|
||||||
|
t,
|
||||||
|
hasAnyThreshold,
|
||||||
|
hasTotalThreshold,
|
||||||
|
hasNodeThreshold,
|
||||||
|
totalThresholdNum,
|
||||||
|
nodeThresholdNum,
|
||||||
|
periodDays,
|
||||||
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: items,
|
data: items,
|
||||||
@@ -1133,6 +1302,51 @@ export default function AdminTrafficUsage() {
|
|||||||
selected={selectedStatuses}
|
selected={selectedStatuses}
|
||||||
onChange={handleStatusChange}
|
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
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
@@ -1213,31 +1427,44 @@ export default function AdminTrafficUsage() {
|
|||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => {
|
||||||
<tr
|
const compositeRatio = hasAnyThreshold
|
||||||
key={row.id}
|
? getCompositeRatio(
|
||||||
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
row.original,
|
||||||
onClick={() => navigate(`/admin/users/${row.original.user_id}`)}
|
totalThresholdNum,
|
||||||
>
|
nodeThresholdNum,
|
||||||
{row.getVisibleCells().map((cell) => {
|
periodDays,
|
||||||
const meta = cell.column.columnDef.meta;
|
)
|
||||||
const isSticky = meta?.sticky;
|
: 0;
|
||||||
const align = meta?.align === 'center' ? 'text-center' : 'text-left';
|
const rowBg = hasAnyThreshold ? getRowBgColor(compositeRatio) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<tr
|
||||||
key={cell.id}
|
key={row.id}
|
||||||
className={`px-3 py-2 ${align} ${
|
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
||||||
isSticky ? 'sticky left-0 z-10 bg-dark-900' : ''
|
style={{ backgroundColor: rowBg }}
|
||||||
}`}
|
onClick={() => navigate(`/admin/users/${row.original.user_id}`)}
|
||||||
style={{ width: cell.column.getSize() }}
|
>
|
||||||
>
|
{row.getVisibleCells().map((cell) => {
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
const meta = cell.column.columnDef.meta;
|
||||||
</td>
|
const isSticky = meta?.sticky;
|
||||||
);
|
const align = meta?.align === 'center' ? 'text-center' : 'text-left';
|
||||||
})}
|
|
||||||
</tr>
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user