diff --git a/src/components/admin/trafficUsage/RiskBadge.tsx b/src/components/admin/trafficUsage/RiskBadge.tsx new file mode 100644 index 0000000..e8c6983 --- /dev/null +++ b/src/components/admin/trafficUsage/RiskBadge.tsx @@ -0,0 +1,36 @@ +import { RISK_STYLES, formatGbPerDay, type RiskLevel } from './trafficUsageHelpers'; + +// ────────────────────────────────────────────────────────────────── +// RiskBadge — small dot + GB/d value + mini progress bar showing the +// ratio of observed GB/d to configured threshold. Used inside the +// AdminTrafficUsage column defs to colour rows by risk level. +// ────────────────────────────────────────────────────────────────── + +export interface RiskBadgeProps { + level: RiskLevel; + ratio: number; + gbPerDay: number; +} + +export function RiskBadge({ level, ratio, gbPerDay }: RiskBadgeProps) { + const style = RISK_STYLES[level]; + const barWidth = Math.min(ratio * 100, 100); + + return ( +
+
+ + + {formatGbPerDay(gbPerDay)} + + GB/d +
+
+
+
+
+ ); +} diff --git a/src/components/admin/trafficUsage/trafficUsageHelpers.ts b/src/components/admin/trafficUsage/trafficUsageHelpers.ts new file mode 100644 index 0000000..c084d17 --- /dev/null +++ b/src/components/admin/trafficUsage/trafficUsageHelpers.ts @@ -0,0 +1,166 @@ +import type { RowData } from '@tanstack/react-table'; +import { getFlagEmoji as _sharedGetFlagEmoji } from '../../../utils/subscriptionHelpers'; +import type { UserTrafficItem } from '../../../api/adminTraffic'; + +// ────────────────────────────────────────────────────────────────── +// TanStack Table module augmentation — shared by the AdminTrafficUsage +// column defs (sticky / align / bold per-column meta). +// ────────────────────────────────────────────────────────────────── + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + sticky?: boolean; + align?: 'left' | 'center'; + bold?: boolean; + } +} + +// ────────────────────────────────────────────────────────────────── +// Formatters +// ────────────────────────────────────────────────────────────────── + +export const formatBytes = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +// Local wrapper over the shared helper so internal call sites keep +// the (string) signature. +export const getFlagEmoji = (countryCode: string): string => _sharedGetFlagEmoji(countryCode); + +export const formatCurrency = (kopeks: number): string => { + const rubles = kopeks / 100; + if (rubles === 0) return '0'; + if (rubles < 10) return rubles.toFixed(2); + if (rubles < 1000) return Math.round(rubles).toString(); + return `${(rubles / 1000).toFixed(1)}k`; +}; + +export const formatShortDate = (iso: string | null): string => { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return '—'; + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}`; +}; + +export const toBackendSortField = (columnId: string): string => { + if (columnId === 'user') return 'full_name'; + return columnId; +}; + +// ────────────────────────────────────────────────────────────────── +// Risk assessment +// ────────────────────────────────────────────────────────────────── + +export const bytesToGbPerDay = (bytes: number, days: number): number => + days > 0 ? bytes / days / 1024 ** 3 : 0; + +export const getRatio = (gbPerDay: number, threshold: number): number => + threshold > 0 ? gbPerDay / threshold : 0; + +export 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})`; +}; + +export 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}%)`; +}; + +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +export 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'; +}; + +export interface RiskResult { + ratio: number; + gbPerDay: number; // the dominant daily value (total or worst node) + totalRatio: number; + maxNodeRatio: number; +} + +export const getCompositeRisk = ( + row: UserTrafficItem, + totalThreshold: number, + nodeThreshold: number, + days: number, +): RiskResult => { + const dailyTotal = bytesToGbPerDay(row.total_bytes, days); + const totalR = totalThreshold > 0 ? getRatio(dailyTotal, totalThreshold) : 0; + + let maxNodeR = 0; + let worstNodeGbPerDay = 0; + if (nodeThreshold > 0) { + for (const b of Object.values(row.node_traffic)) { + const daily = bytesToGbPerDay(b || 0, days); + const r = getRatio(daily, nodeThreshold); + if (r > maxNodeR) { + maxNodeR = r; + worstNodeGbPerDay = daily; + } + } + } + + // The dominant metric determines what GB/d we show + const ratio = Math.max(totalR, maxNodeR); + const gbPerDay = totalR >= maxNodeR ? dailyTotal : worstNodeGbPerDay; + + return { ratio, gbPerDay, totalRatio: totalR, maxNodeRatio: maxNodeR }; +}; + +export const RISK_STYLES: Record< + RiskLevel, + { dot: string; text: string; bar: string; bg: string } +> = { + low: { + dot: 'bg-success-400', + text: 'text-success-400', + bar: 'bg-success-400', + bg: 'bg-success-400/10', + }, + medium: { + dot: 'bg-warning-400', + text: 'text-warning-400', + bar: 'bg-warning-400', + bg: 'bg-warning-400/10', + }, + high: { + dot: 'bg-warning-400', + text: 'text-warning-400', + bar: 'bg-warning-400', + bg: 'bg-warning-400/10', + }, + critical: { + dot: 'bg-error-400 animate-pulse', + text: 'text-error-400', + bar: 'bg-error-400', + bg: 'bg-error-400/10', + }, +}; + +export const formatGbPerDay = (gbPerDay: number): string => { + if (gbPerDay < 0.01) return '<0.01'; + if (gbPerDay < 10) return gbPerDay.toFixed(2); + if (gbPerDay < 100) return gbPerDay.toFixed(1); + return Math.round(gbPerDay).toString(); +}; diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 0a0546c..18c1963 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -8,7 +8,6 @@ import { flexRender, type ColumnDef, type SortingState, - type RowData, } from '@tanstack/react-table'; import { adminTrafficApi, @@ -18,160 +17,23 @@ import { type TrafficEnrichmentData, } from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; -import { getFlagEmoji as _sharedGetFlagEmoji } from '../utils/subscriptionHelpers'; +import { + formatBytes, + getFlagEmoji, + formatCurrency, + formatShortDate, + toBackendSortField, + bytesToGbPerDay, + getRatio, + getRowBgColor, + getNodeTextColor, + getRiskLevel, + getCompositeRisk, + formatGbPerDay, +} from '../components/admin/trafficUsage/trafficUsageHelpers'; +import { RiskBadge } from '../components/admin/trafficUsage/RiskBadge'; -// ============ TanStack Table module augmentation ============ - -declare module '@tanstack/react-table' { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface ColumnMeta { - sticky?: boolean; - align?: 'left' | 'center'; - bold?: boolean; - } -} - -// ============ Utils ============ - -const formatBytes = (bytes: number): string => { - if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -}; - -// Локальная обёртка над общим helper'ом, чтобы внутренние сигнатуры (string) -// оставались как были и call-sites не меняли. -const getFlagEmoji = (countryCode: string): string => _sharedGetFlagEmoji(countryCode); - -const formatCurrency = (kopeks: number): string => { - const rubles = kopeks / 100; - if (rubles === 0) return '0'; - if (rubles < 10) return rubles.toFixed(2); - if (rubles < 1000) return Math.round(rubles).toString(); - return `${(rubles / 1000).toFixed(1)}k`; -}; - -const formatShortDate = (iso: string | null): string => { - if (!iso) return '\u2014'; - const d = new Date(iso); - if (isNaN(d.getTime())) return '\u2014'; - return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}`; -}; - -const toBackendSortField = (columnId: string): string => { - if (columnId === 'user') return 'full_name'; - 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'; -}; - -interface RiskResult { - ratio: number; - gbPerDay: number; // the dominant daily value (total or worst node) - totalRatio: number; - maxNodeRatio: number; -} - -const getCompositeRisk = ( - row: UserTrafficItem, - totalThreshold: number, - nodeThreshold: number, - days: number, -): RiskResult => { - const dailyTotal = bytesToGbPerDay(row.total_bytes, days); - const totalR = totalThreshold > 0 ? getRatio(dailyTotal, totalThreshold) : 0; - - let maxNodeR = 0; - let worstNodeGbPerDay = 0; - if (nodeThreshold > 0) { - for (const b of Object.values(row.node_traffic)) { - const daily = bytesToGbPerDay(b || 0, days); - const r = getRatio(daily, nodeThreshold); - if (r > maxNodeR) { - maxNodeR = r; - worstNodeGbPerDay = daily; - } - } - } - - // The dominant metric determines what GB/d we show - const ratio = Math.max(totalR, maxNodeR); - const gbPerDay = totalR >= maxNodeR ? dailyTotal : worstNodeGbPerDay; - - return { ratio, gbPerDay, totalRatio: totalR, maxNodeRatio: maxNodeR }; -}; - -const RISK_STYLES: Record = { - low: { - dot: 'bg-success-400', - text: 'text-success-400', - bar: 'bg-success-400', - bg: 'bg-success-400/10', - }, - medium: { - dot: 'bg-warning-400', - text: 'text-warning-400', - bar: 'bg-warning-400', - bg: 'bg-warning-400/10', - }, - high: { - dot: 'bg-warning-400', - text: 'text-warning-400', - bar: 'bg-warning-400', - bg: 'bg-warning-400/10', - }, - critical: { - dot: 'bg-error-400 animate-pulse', - text: 'text-error-400', - bar: 'bg-error-400', - bg: 'bg-error-400/10', - }, -}; - -const formatGbPerDay = (gbPerDay: number): string => { - if (gbPerDay < 0.01) return '<0.01'; - if (gbPerDay < 10) return gbPerDay.toFixed(2); - if (gbPerDay < 100) return gbPerDay.toFixed(1); - return Math.round(gbPerDay).toString(); -}; +// (TanStack Table augmentation + utils + risk helpers moved into ./trafficUsage/trafficUsageHelpers.ts) // ============ Icons ============ @@ -1002,39 +864,7 @@ function CountryFilter({ ); } -// ============ Risk Badge ============ - -function RiskBadge({ - level, - ratio, - gbPerDay, -}: { - level: RiskLevel; - ratio: number; - gbPerDay: number; -}) { - const style = RISK_STYLES[level]; - const barWidth = Math.min(ratio * 100, 100); - - return ( -
-
- - - {formatGbPerDay(gbPerDay)} - - GB/d -
- {/* Mini progress bar showing ratio to threshold */} -
-
-
-
- ); -} +// (RiskBadge moved into ./trafficUsage/RiskBadge.tsx) // ============ Main Page ============