'use client'; import { useEffect, useMemo, useState } from 'react'; import { Eye } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import type { AccessLogIPSummaryItem, AccessLogIPSummaryList, } from '@/lib/services/openflare'; import { formatDateTime } from '@/lib/utils'; import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; import { formatOverviewRangeHint, IP_SORT_OPTIONS, OVERVIEW_RANGE_OPTIONS, PAGE_SIZE_OPTIONS, type OverviewRangeHours, } from './access-log-utils'; import { IpDetailDialog } from './ip-detail-dialog'; export function toLocalInputValue(date: Date) { const pad = (n: number) => `${n}`.padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** Default local range ending now, spanning `hours`. */ export function defaultLocalRangeForHours(hours: number) { const until = new Date(); const since = new Date(until.getTime() - hours * 3_600_000); return { since: toLocalInputValue(since), until: toLocalInputValue(until), }; } function localInputToRFC3339(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return date.toISOString(); } const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000; /** Validate custom local datetime-local range; returns error message or null. */ export function validateCustomTimeRange( sinceLocal: string, untilLocal: string, t?: (key: 'ip.needRange' | 'ip.invalidTime' | 'ip.endAfterStart' | 'ip.maxRange') => string, ): string | null { if (!sinceLocal.trim() || !untilLocal.trim()) { return t ? t('ip.needRange') : 'needRange'; } const sinceMs = new Date(sinceLocal).getTime(); const untilMs = new Date(untilLocal).getTime(); if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) { return t ? t('ip.invalidTime') : 'invalidTime'; } if (untilMs <= sinceMs) { return t ? t('ip.endAfterStart') : 'endAfterStart'; } if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) { return t ? t('ip.maxRange') : 'maxRange'; } return null; } function formatRatio(ratio: number) { if (!Number.isFinite(ratio) || ratio < 0) return '0%'; return `${(ratio * 100).toFixed(1)}%`; } /** Exact hours for analysis API (1–720), matching list window duration. */ export function resolveAnalysisHours(input: { timeMode: IpTabTimeMode; hours: OverviewRangeHours; customSince: string; customUntil: string; }): number { if (input.timeMode === 'custom' && input.customSince && input.customUntil) { const ms = new Date(input.customUntil).getTime() - new Date(input.customSince).getTime(); if (Number.isFinite(ms) && ms > 0) { return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000))); } } return input.hours; } function PaginationBar({ page, hasMore, loading, totalIp, onPrev, onNext, }: { page: number; hasMore: boolean; loading: boolean; totalIp?: number; onPrev: () => void; onNext: () => void; }) { const t = useTranslations('accessLogs.ip'); return (

{t('page', { page: page + 1 })} {typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}

); } export type IpTabTimeMode = 'preset' | 'custom'; export function IpTab({ data, loading, error, page, pageSize, sort, hours, timeMode, customSince, customUntil, onHoursChange, onTimeModeChange, onApplyCustomRange, onPageSizeChange, onSortChange, onRetry, onPrevPage, onNextPage, isFetching, }: { data?: AccessLogIPSummaryList; loading: boolean; error: Error | null; page: number; pageSize: number; sort: string; hours: OverviewRangeHours; timeMode: IpTabTimeMode; /** Applied custom range (local datetime-local strings). */ customSince: string; customUntil: string; onHoursChange: (hours: OverviewRangeHours) => void; onTimeModeChange: (mode: IpTabTimeMode) => void; /** Commit validated custom range for list query. */ onApplyCustomRange: (since: string, until: string) => void; onPageSizeChange: (size: number) => void; onSortChange: (value: string) => void; onRetry: () => void; onPrevPage: () => void; onNextPage: () => void; isFetching: boolean; }) { const t = useTranslations('accessLogs'); const [selected, setSelected] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [draftSince, setDraftSince] = useState(customSince); const [draftUntil, setDraftUntil] = useState(customUntil); const [customError, setCustomError] = useState(null); const defaultCustomRange = useMemo( () => defaultLocalRangeForHours(hours), [hours], ); useEffect(() => { if (timeMode !== 'custom') { setCustomError(null); return; } // Sync draft from applied range when entering custom or after apply. setDraftSince(customSince || defaultCustomRange.since); setDraftUntil(customUntil || defaultCustomRange.until); setCustomError(null); }, [timeMode, customSince, customUntil, defaultCustomRange]); const analysisHours = resolveAnalysisHours({ timeMode, hours, customSince, customUntil, }); const rangeHint = timeMode === 'custom' && customSince && customUntil ? t('ip.customRange') : timeMode === 'custom' ? t('ip.customPending') : formatOverviewRangeHint(hours, (key, values) => t(key, values)); const handleApplyCustom = () => { const message = validateCustomTimeRange(draftSince, draftUntil, (key) => t(key), ); if (message) { setCustomError(message); return; } setCustomError(null); onApplyCustomRange(draftSince, draftUntil); }; return ( <>

{t('ip.timeRange')}

{t('ip.current', { range: rangeHint })} {data?.total_ip != null ? t('ip.total', { count: data.total_ip }) : ''}

{ if (!value) return; onTimeModeChange('preset'); onHoursChange( Number.parseInt(value, 10) as OverviewRangeHours, ); }} variant='outline' size='sm' > {OVERVIEW_RANGE_OPTIONS.map((option) => ( {t(option.labelKey)} ))}
{timeMode === 'custom' ? (

{t('ip.start')}

{ setDraftSince(e.target.value); setCustomError(null); }} />

{t('ip.end')}

{ setDraftUntil(e.target.value); setCustomError(null); }} />
{customError ? (

{customError}

) : (

{t('ip.applyHint')}

)}
) : null}

{t('ip.sort')}

{t('ip.pageSize')}

{t('ip.title')}

{error ? (
) : loading ? ( ) : (data?.items ?? []).length === 0 ? ( ) : ( IP {t('ip.region')} {t('ip.requests')} {t('ip.successRatio')} {t('ip.inbound')} {t('ip.outbound')} {t('ip.lastSeen')} {(data?.items ?? []).map((item) => ( {item.remote_addr} {item.region || '—'} {formatCompactNumber(item.total_requests)} {formatRatio(item.success_ratio)} {formatBytes(item.bytes_received)} {formatBytes(item.bytes_sent)} {formatDateTime(item.last_seen_at)} {t('ip.view')} ))}
)}
); } /** Build list API time params from IP tab state (applied values only). */ export function buildIpSummaryTimeParams(input: { timeMode: IpTabTimeMode; hours: OverviewRangeHours; customSince: string; customUntil: string; }): { hours?: number; since?: string; until?: string } | null { if (input.timeMode === 'custom') { if (validateCustomTimeRange(input.customSince, input.customUntil)) { return null; } const since = localInputToRFC3339(input.customSince); const until = localInputToRFC3339(input.customUntil); if (!since || !until) { return null; } return { since, until }; } return { hours: input.hours }; }