mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
5a8722ff07
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
'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 (
|
||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||
<p className='text-xs text-muted-foreground'>
|
||
{t('page', { page: page + 1 })}
|
||
{typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}
|
||
</p>
|
||
<div className='flex gap-2'>
|
||
<Button
|
||
variant='outline'
|
||
size='sm'
|
||
disabled={loading || page <= 0}
|
||
onClick={onPrev}
|
||
>
|
||
{t('prev')}
|
||
</Button>
|
||
<Button
|
||
variant='outline'
|
||
size='sm'
|
||
disabled={loading || !hasMore}
|
||
onClick={onNext}
|
||
>
|
||
{t('next')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<AccessLogIPSummaryItem | null>(null);
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [draftSince, setDraftSince] = useState(customSince);
|
||
const [draftUntil, setDraftUntil] = useState(customUntil);
|
||
const [customError, setCustomError] = useState<string | null>(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 (
|
||
<>
|
||
<div className='space-y-4'>
|
||
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
|
||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||
<div className='space-y-1'>
|
||
<p className='text-sm font-medium'>{t('ip.timeRange')}</p>
|
||
<p className='text-xs text-muted-foreground'>
|
||
{t('ip.current', { range: rangeHint })}
|
||
{data?.total_ip != null
|
||
? t('ip.total', { count: data.total_ip })
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<ToggleGroup
|
||
type='single'
|
||
value={timeMode === 'preset' ? String(hours) : ''}
|
||
onValueChange={(value) => {
|
||
if (!value) return;
|
||
onTimeModeChange('preset');
|
||
onHoursChange(
|
||
Number.parseInt(value, 10) as OverviewRangeHours,
|
||
);
|
||
}}
|
||
variant='outline'
|
||
size='sm'
|
||
>
|
||
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||
<ToggleGroupItem
|
||
key={option.value}
|
||
value={String(option.value)}
|
||
className='px-2.5 text-xs'
|
||
>
|
||
{t(option.labelKey)}
|
||
</ToggleGroupItem>
|
||
))}
|
||
</ToggleGroup>
|
||
<Button
|
||
size='sm'
|
||
variant={timeMode === 'custom' ? 'default' : 'outline'}
|
||
className='h-8 text-xs'
|
||
onClick={() => onTimeModeChange('custom')}
|
||
>
|
||
{t('ip.custom')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{timeMode === 'custom' ? (
|
||
<div className='space-y-2'>
|
||
<div className='flex flex-wrap items-end gap-3'>
|
||
<div className='space-y-1'>
|
||
<p className='text-xs text-muted-foreground'>{t('ip.start')}</p>
|
||
<Input
|
||
type='datetime-local'
|
||
className='h-9 w-52 text-xs'
|
||
value={draftSince || defaultCustomRange.since}
|
||
onChange={(e) => {
|
||
setDraftSince(e.target.value);
|
||
setCustomError(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className='space-y-1'>
|
||
<p className='text-xs text-muted-foreground'>{t('ip.end')}</p>
|
||
<Input
|
||
type='datetime-local'
|
||
className='h-9 w-52 text-xs'
|
||
value={draftUntil || defaultCustomRange.until}
|
||
onChange={(e) => {
|
||
setDraftUntil(e.target.value);
|
||
setCustomError(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
<Button
|
||
size='sm'
|
||
className='h-9 text-xs'
|
||
onClick={handleApplyCustom}
|
||
>
|
||
{t('ip.apply')}
|
||
</Button>
|
||
</div>
|
||
{customError ? (
|
||
<p className='text-xs text-destructive'>{customError}</p>
|
||
) : (
|
||
<p className='text-xs text-muted-foreground'>
|
||
{t('ip.applyHint')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className='flex flex-wrap items-center gap-3'>
|
||
<div className='space-y-1'>
|
||
<p className='text-xs text-muted-foreground'>{t('ip.sort')}</p>
|
||
<Select value={sort} onValueChange={onSortChange}>
|
||
<SelectTrigger className='h-8 w-48 text-xs'>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{IP_SORT_OPTIONS.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{t(option.labelKey)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className='space-y-1'>
|
||
<p className='text-xs text-muted-foreground'>{t('ip.pageSize')}</p>
|
||
<Select
|
||
value={String(pageSize)}
|
||
onValueChange={(value) =>
|
||
onPageSizeChange(Number.parseInt(value, 10))
|
||
}
|
||
>
|
||
<SelectTrigger className='h-8 w-24 text-xs'>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||
<SelectItem key={size} value={String(size)}>
|
||
{size}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||
<p className='text-sm font-medium'>{t('ip.title')}</p>
|
||
</div>
|
||
{error ? (
|
||
<div className='p-4'>
|
||
<ErrorInline
|
||
message={error.message || t('loadFailed')}
|
||
onRetry={onRetry}
|
||
/>
|
||
</div>
|
||
) : loading ? (
|
||
<LoadingStateWithBorder />
|
||
) : (data?.items ?? []).length === 0 ? (
|
||
<EmptyStateWithBorder title={t('ip.empty')} />
|
||
) : (
|
||
<Table>
|
||
<TableHeader className='bg-muted/40'>
|
||
<TableRow className='border-dashed hover:bg-transparent'>
|
||
<TableHead className='text-xs'>IP</TableHead>
|
||
<TableHead className='text-xs'>{t('ip.region')}</TableHead>
|
||
<TableHead className='text-xs text-right'>
|
||
{t('ip.requests')}
|
||
</TableHead>
|
||
<TableHead className='text-xs text-right'>
|
||
{t('ip.successRatio')}
|
||
</TableHead>
|
||
<TableHead className='text-xs text-right'>
|
||
{t('ip.inbound')}
|
||
</TableHead>
|
||
<TableHead className='text-xs text-right'>
|
||
{t('ip.outbound')}
|
||
</TableHead>
|
||
<TableHead className='text-xs'>{t('ip.lastSeen')}</TableHead>
|
||
<TableHead className='w-12 text-center text-xs' />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{(data?.items ?? []).map((item) => (
|
||
<TableRow key={item.remote_addr} className='border-dashed'>
|
||
<TableCell className='font-mono text-xs'>
|
||
{item.remote_addr}
|
||
</TableCell>
|
||
<TableCell className='text-xs text-muted-foreground'>
|
||
{item.region || '—'}
|
||
</TableCell>
|
||
<TableCell className='text-xs text-right tabular-nums'>
|
||
{formatCompactNumber(item.total_requests)}
|
||
</TableCell>
|
||
<TableCell className='text-xs text-right tabular-nums'>
|
||
<span
|
||
title={`${item.success_2xx_count}/${item.total_requests}`}
|
||
>
|
||
{formatRatio(item.success_ratio)}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className='text-xs text-right tabular-nums'>
|
||
{formatBytes(item.bytes_received)}
|
||
</TableCell>
|
||
<TableCell className='text-xs text-right tabular-nums'>
|
||
{formatBytes(item.bytes_sent)}
|
||
</TableCell>
|
||
<TableCell className='text-xs'>
|
||
{formatDateTime(item.last_seen_at)}
|
||
</TableCell>
|
||
<TableCell className='text-center'>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant='ghost'
|
||
size='icon'
|
||
className='h-6 w-6 text-muted-foreground hover:text-foreground'
|
||
onClick={() => {
|
||
setSelected(item);
|
||
setDetailOpen(true);
|
||
}}
|
||
>
|
||
<Eye className='size-3' />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side='top' className='text-xs'>
|
||
{t('ip.view')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
<PaginationBar
|
||
page={page}
|
||
hasMore={data?.has_more ?? false}
|
||
loading={isFetching}
|
||
totalIp={data?.total_ip}
|
||
onPrev={onPrevPage}
|
||
onNext={onNextPage}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<IpDetailDialog
|
||
open={detailOpen}
|
||
remoteAddr={selected?.remote_addr ?? null}
|
||
region={selected?.region}
|
||
initialHours={analysisHours}
|
||
onOpenChange={setDetailOpen}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 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 };
|
||
}
|