feat(access-logs): 重构访问日志为概览与明细双 Tab

新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行,
明细列表保留检索;排行榜改为紧凑列表样式。
This commit is contained in:
ryan
2026-07-18 20:58:03 +08:00
parent 4c0c389122
commit 177578ef4e
24 changed files with 1546 additions and 1419 deletions
@@ -11,11 +11,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AccessLogTab, SearchDraft } from './access-log-utils';
import type { SearchDraft } from './access-log-utils';
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
interface AccessLogFiltersProps {
tab: AccessLogTab;
draft: SearchDraft;
pageSize: number;
onDraftChange: (draft: SearchDraft) => void;
@@ -25,7 +24,6 @@ interface AccessLogFiltersProps {
}
export function AccessLogFilters({
tab,
draft,
pageSize,
onDraftChange,
@@ -33,8 +31,6 @@ export function AccessLogFilters({
onSearch,
onReset,
}: AccessLogFiltersProps) {
const showPath = tab === 'list' || tab === 'folds';
return (
<div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
@@ -81,24 +77,18 @@ export function AccessLogFilters({
className='h-9 text-xs'
/>
</div>
{showPath ? (
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>
请求路径
</p>
<Input
value={draft.path}
onChange={(e) =>
onDraftChange({ ...draft, path: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
className='h-9 text-xs'
/>
</div>
) : null}
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
<Input
value={draft.path}
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
className='h-9 text-xs'
/>
</div>
</div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
@@ -1,4 +1,4 @@
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
export type AccessLogTab = 'overview' | 'list';
export type SearchDraft = {
nodeId: string;
@@ -7,8 +7,20 @@ export type SearchDraft = {
path: string;
};
export type OverviewRangeHours = 24 | 168 | 360 | 720;
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
export const OVERVIEW_RANGE_OPTIONS: {
value: OverviewRangeHours;
label: string;
}[] = [
{ value: 24, label: '24 小时' },
{ value: 168, label: '7 天' },
{ value: 360, label: '15 天' },
{ value: 720, label: '30 天' },
];
export const DETAIL_SORT_OPTIONS = [
{ value: 'logged_at:desc', label: '时间从新到旧' },
{ value: 'logged_at:asc', label: '时间从旧到新' },
@@ -18,20 +30,6 @@ export const DETAIL_SORT_OPTIONS = [
{ value: 'remote_addr:desc', label: 'IP 倒序' },
];
export const FOLD_SORT_OPTIONS = [
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
{ value: 'request_count:desc', label: '访问次数从高到低' },
{ value: 'request_count:asc', label: '访问次数从低到高' },
];
export const IP_SORT_OPTIONS = [
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
];
export function parseSortValue(value: string) {
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
return {
@@ -46,3 +44,23 @@ export function formatCompactNumber(value: number) {
maximumFractionDigits: 1,
}).format(value);
}
export function formatOverviewRangeHint(hours: number) {
if (hours <= 24) return '近 24 小时';
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
return `近 ${hours} 小时`;
}
export function formatOverviewTrendLabel(value: string, hours: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
if (hours <= 24) {
return `${hour}:00`;
}
return `${month}/${day} ${hour}:00`;
}
@@ -0,0 +1,164 @@
'use client';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { AccessLogList } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { DETAIL_SORT_OPTIONS } from './access-log-utils';
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
}
export function DetailTab({
data,
loading,
error,
page,
detailSort,
onDetailSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogList;
loading: boolean;
error: Error | null;
page: number;
detailSort: string;
onDetailSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
return (
<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'>日志明细</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
);
}
@@ -0,0 +1,348 @@
'use client';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { AccessLogOverview } from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
OVERVIEW_RANGE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
function SparklineMetricCard({
title,
value,
hint,
color,
fillColor,
labels,
values,
valueFormatter,
}: {
title: string;
value: string;
hint: string;
color: string;
fillColor: string;
labels: string[];
values: number[];
valueFormatter?: (value: number) => string;
}) {
const option = useMemo<EChartsOption>(
() => ({
animationDuration: 400,
grid: {
left: 0,
right: 0,
top: 8,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false,
data: labels,
},
yAxis: {
type: 'value',
show: false,
min: 0,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const items = Array.isArray(params) ? params : [];
const item = items[0] as
{ axisValueLabel?: string; value?: number } | undefined;
if (!item) return '';
const raw = typeof item.value === 'number' ? item.value : 0;
const formatted = valueFormatter
? valueFormatter(raw)
: formatCompactNumber(raw);
return `${item.axisValueLabel ?? ''}<br/>${formatted}`;
},
},
series: [
{
type: 'line',
data: values,
smooth: true,
showSymbol: false,
lineStyle: {
color,
width: 2,
},
areaStyle: {
color: fillColor,
},
},
],
}),
[color, fillColor, labels, valueFormatter, values],
);
return (
<Card className='border-dashed shadow-none'>
<CardContent className='p-4'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 space-y-1'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{title}
</p>
<p className='text-2xl font-semibold tracking-tight'>{value}</p>
<p className='text-[11px] text-muted-foreground'>{hint}</p>
</div>
<div className='h-16 w-28 shrink-0 sm:w-36'>
{labels.length > 0 ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: '100%', width: '100%' }}
/>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}
function RankCard({
title,
description,
items,
color,
}: {
title: string;
description: string;
items: { label: string; value: number }[];
color: string;
}) {
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
<RankChart
items={items}
color={color}
emptyMessage={`暂无 ${title} 数据`}
/>
</CardContent>
</Card>
);
}
function OverviewRangeSwitch({
hours,
onHoursChange,
}: {
hours: OverviewRangeHours;
onHoursChange: (hours: OverviewRangeHours) => void;
}) {
return (
<div className='flex justify-end'>
<ToggleGroup
type='single'
value={String(hours)}
onValueChange={(value) => {
if (!value) return;
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
}}
variant='outline'
size='sm'
className='justify-end'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export function OverviewTab({
data,
loading,
error,
hours,
onHoursChange,
onRetry,
}: {
data?: AccessLogOverview;
loading: boolean;
error: Error | null;
hours: OverviewRangeHours;
onHoursChange: (hours: OverviewRangeHours) => void;
onRetry: () => void;
}) {
return (
<div className='space-y-6'>
<OverviewRangeSwitch hours={hours} onHoursChange={onHoursChange} />
{loading ? (
<LoadingStateWithBorder
title='加载访问概览'
description='正在聚合请求量、访问量与带宽趋势...'
/>
) : error ? (
<ErrorInline
message={error.message || '加载访问概览失败'}
onRetry={onRetry}
/>
) : !data ? (
<EmptyStateWithBorder
title='暂无概览数据'
description='当前时间范围内没有可展示的访问统计。'
/>
) : (
<OverviewContent data={data} hours={hours} />
)}
</div>
);
}
function OverviewContent({
data,
hours,
}: {
data: AccessLogOverview;
hours: number;
}) {
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
);
const requestValues = data.trends.requests.map((point) => point.value);
const visitValues = data.trends.visits.map((point) => point.value);
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
const hint = formatOverviewRangeHint(hours);
return (
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title='Total Requests'
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
fillColor='rgba(245, 158, 11, 0.18)'
labels={requestLabels}
values={requestValues}
/>
<SparklineMetricCard
title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)}
hint={`${hint} · 独立访客`}
color='#38bdf8'
fillColor='rgba(56, 189, 248, 0.16)'
labels={requestLabels}
values={visitValues}
/>
<SparklineMetricCard
title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)}
hint={`${hint} · 已提供数据`}
color='#34d399'
fillColor='rgba(52, 211, 153, 0.16)'
labels={requestLabels}
values={bandwidthValues}
valueFormatter={formatBytes}
/>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
Requests over time
</CardTitle>
<CardDescription className='text-xs'>
观察请求量是否出现异常抬升或回落。
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={requestLabels}
height={280}
showSummary={false}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: requestValues,
},
]}
/>
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
description='访问量最高的请求路径。'
color='#a78bfa'
items={data.top_paths.map((item) => ({
label: item.key,
value: item.value,
}))}
/>
<RankCard
title='Top Hosts'
description='流量集中的访问域名。'
color='#34d399'
items={data.top_hosts.map((item) => ({
label: item.key,
value: item.value,
}))}
/>
<RankCard
title='Top IPs'
description='请求次数最多的来源 IP。'
color='#38bdf8'
items={data.top_ips.map((item) => ({
label: item.key,
value: item.value,
}))}
/>
</div>
</>
);
}
+52 -559
View File
@@ -1,56 +1,24 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogFilters } from './components/access-log-filters';
import {
type AccessLogTab,
DETAIL_SORT_OPTIONS,
FOLD_SORT_OPTIONS,
formatCompactNumber,
IP_SORT_OPTIONS,
type OverviewRangeHours,
parseSortValue,
type SearchDraft,
} from './components/access-log-utils';
import { CleanupDialog } from './components/cleanup-dialog';
const trendChartConfig = {
requests: { label: '请求数', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
import { DetailTab } from './components/detail-tab';
import { OverviewTab } from './components/overview-tab';
const emptyDraft: SearchDraft = {
nodeId: '',
@@ -59,62 +27,27 @@ const emptyDraft: SearchDraft = {
path: '',
};
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
}
export default function AccessLogsPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<AccessLogTab>('list');
const [tab, setTab] = useState<AccessLogTab>('overview');
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
const [foldSort, setFoldSort] = useState('bucket_started_at:desc');
const [ipSort, setIpSort] = useState('total_requests:desc');
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3);
const [trendIp, setTrendIp] = useState('');
const [appliedTrendIp, setAppliedTrendIp] = useState('');
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
const foldSortState = parseSortValue(foldSort);
const ipSortState = parseSortValue(ipSort);
const overviewQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
}),
enabled: tab === 'overview',
});
const listQuery = useQuery({
queryKey: [
@@ -140,68 +73,6 @@ export default function AccessLogsPage() {
enabled: tab === 'list',
});
const foldsQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'folds',
filters,
page,
pageSize,
foldSort,
foldMinutes,
],
queryFn: () =>
AccessLogService.listFolds({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: page,
page_size: pageSize,
sort_by: foldSortState.sortBy,
sort_order: foldSortState.sortOrder,
fold_minutes: foldMinutes,
}),
enabled: tab === 'folds',
});
const ipSummaryQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-summary',
filters,
page,
pageSize,
ipSort,
],
queryFn: () =>
AccessLogService.listIPSummaries({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
p: page,
page_size: pageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
enabled: tab === 'ip-summary',
});
const ipTrendQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp],
queryFn: () =>
AccessLogService.getIPTrend({
node_id: filters.nodeId || undefined,
remote_addr: appliedTrendIp,
host: filters.host || undefined,
hours: 24,
bucket_minutes: 30,
}),
enabled: tab === 'ip-trend' && appliedTrendIp !== '',
});
const cleanupMutation = useMutation({
mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }),
@@ -217,35 +88,6 @@ export default function AccessLogsPage() {
},
});
const activeSummary = useMemo(() => {
if (tab === 'list' && listQuery.data) {
return {
totalRecord: listQuery.data.total_record,
totalIp: listQuery.data.total_ip,
};
}
if (tab === 'folds' && foldsQuery.data) {
return {
totalRecord: foldsQuery.data.total_record,
totalIp: foldsQuery.data.total_ip,
};
}
if (tab === 'ip-summary' && ipSummaryQuery.data) {
return {
totalRecord: 0,
totalIp: ipSummaryQuery.data.total_ip,
};
}
return { totalRecord: 0, totalIp: 0 };
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data]);
const trendChartData = useMemo(() => {
return (ipTrendQuery.data?.points ?? []).map((point) => ({
label: formatDateTime(point.bucket_started_at).slice(5),
requests: point.request_count,
}));
}, [ipTrendQuery.data?.points]);
const handleSearch = useCallback(() => {
setFilters({
nodeId: draft.nodeId.trim(),
@@ -260,8 +102,6 @@ export default function AccessLogsPage() {
setDraft(emptyDraft);
setFilters(emptyDraft);
setPage(0);
setTrendIp('');
setAppliedTrendIp('');
};
useEffect(() => {
@@ -269,17 +109,11 @@ export default function AccessLogsPage() {
}, [tab, pageSize]);
const refreshActive = () => {
if (tab === 'overview') void overviewQuery.refetch();
if (tab === 'list') void listQuery.refetch();
if (tab === 'folds') void foldsQuery.refetch();
if (tab === 'ip-summary') void ipSummaryQuery.refetch();
if (tab === 'ip-trend') void ipTrendQuery.refetch();
};
const isFetching =
listQuery.isFetching ||
foldsQuery.isFetching ||
ipSummaryQuery.isFetching ||
ipTrendQuery.isFetching;
const isFetching = overviewQuery.isFetching || listQuery.isFetching;
return (
<div className='py-6 px-1 space-y-6'>
@@ -289,7 +123,7 @@ export default function AccessLogsPage() {
<div>
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
<p className='text-sm text-muted-foreground'>
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
查看访问概览、排行榜与明细日志。
</p>
</div>
</div>
@@ -316,392 +150,51 @@ export default function AccessLogsPage() {
</div>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
{[
{
label: '访问记录',
value: formatCompactNumber(activeSummary.totalRecord),
},
{
label: '来源 IP',
value: formatCompactNumber(activeSummary.totalIp),
},
{
label: '当前视图',
value:
tab === 'list'
? '明细日志'
: tab === 'folds'
? '时间折叠'
: tab === 'ip-summary'
? 'IP 汇总'
: 'IP 趋势',
},
].map((item) => (
<div
key={item.label}
className='rounded-lg border border-dashed px-4 py-3'
>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{item.label}
</p>
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
</div>
))}
</div>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as AccessLogTab)}
>
<TabsList className='grid w-full grid-cols-2 lg:grid-cols-4'>
<TabsTrigger value='list'>明细列表</TabsTrigger>
<TabsTrigger value='folds'>时间折叠</TabsTrigger>
<TabsTrigger value='ip-summary'>IP 汇总</TabsTrigger>
<TabsTrigger value='ip-trend'>IP 趋势</TabsTrigger>
<TabsList className='grid w-full max-w-md grid-cols-2'>
<TabsTrigger value='overview'>概览</TabsTrigger>
<TabsTrigger value='list'>日志明细</TabsTrigger>
</TabsList>
<div className='mt-4 rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
tab={tab}
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
<TabsContent value='overview' className='mt-4'>
<OverviewTab
data={overviewQuery.data}
loading={overviewQuery.isLoading}
error={
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
onHoursChange={setOverviewHours}
onRetry={() => void overviewQuery.refetch()}
/>
</div>
<TabsContent value='list' className='mt-4'>
<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'>明细日志</p>
<Select value={detailSort} onValueChange={setDetailSort}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{listQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
listQuery.error instanceof Error
? listQuery.error.message
: '加载失败'
}
onRetry={() => void listQuery.refetch()}
/>
</div>
) : listQuery.isLoading ? (
<LoadingStateWithBorder />
) : (listQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={listQuery.data?.has_more ?? false}
loading={listQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value='folds' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed'>
<div className='flex items-center gap-2'>
<p className='text-sm font-medium'>时间折叠</p>
<Select
value={String(foldMinutes)}
onValueChange={(value) =>
setFoldMinutes(Number(value) as 3 | 5)
}
>
<SelectTrigger className='h-8 w-36 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='3'>3 分钟桶</SelectItem>
<SelectItem value='5'>5 分钟桶</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={foldSort} onValueChange={setFoldSort}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FOLD_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{foldsQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
foldsQuery.error instanceof Error
? foldsQuery.error.message
: '加载失败'
}
onRetry={() => void foldsQuery.refetch()}
/>
</div>
) : foldsQuery.isLoading ? (
<LoadingStateWithBorder />
) : (foldsQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无折叠数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间桶</TableHead>
<TableHead className='text-xs'>请求数</TableHead>
<TableHead className='text-xs'>独立 IP</TableHead>
<TableHead className='text-xs'>独立域名</TableHead>
<TableHead className='text-xs'>2xx</TableHead>
<TableHead className='text-xs'>4xx</TableHead>
<TableHead className='text-xs'>5xx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(foldsQuery.data?.items ?? []).map((item) => (
<TableRow
key={item.bucket_started_at}
className='border-dashed'
>
<TableCell className='text-xs'>
{formatDateTime(item.bucket_started_at)}
</TableCell>
<TableCell className='text-xs'>
{item.request_count}
</TableCell>
<TableCell className='text-xs'>
{item.unique_ip_count}
</TableCell>
<TableCell className='text-xs'>
{item.unique_host_count}
</TableCell>
<TableCell className='text-xs'>
{item.success_count}
</TableCell>
<TableCell className='text-xs'>
{item.client_error_count}
</TableCell>
<TableCell className='text-xs'>
{item.server_error_count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={foldsQuery.data?.has_more ?? false}
loading={foldsQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
<TabsContent value='list' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
/>
</div>
</TabsContent>
<TabsContent value='ip-summary' className='mt-4'>
<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'>IP 汇总</p>
<Select value={ipSort} onValueChange={setIpSort}>
<SelectTrigger className='h-8 w-52 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{ipSummaryQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
ipSummaryQuery.error instanceof Error
? ipSummaryQuery.error.message
: '加载失败'
}
onRetry={() => void ipSummaryQuery.refetch()}
/>
</div>
) : ipSummaryQuery.isLoading ? (
<LoadingStateWithBorder />
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 汇总数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>总请求数</TableHead>
<TableHead className='text-xs'>近 3 小时</TableHead>
<TableHead className='text-xs'>最后访问</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(ipSummaryQuery.data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className='border-dashed'>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>
{item.total_requests}
</TableCell>
<TableCell className='text-xs'>
{item.recent_requests}
</TableCell>
<TableCell className='text-xs'>
{formatDateTime(item.last_seen_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={ipSummaryQuery.data?.has_more ?? false}
loading={ipSummaryQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value='ip-trend' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end'>
<div className='space-y-1.5 flex-1'>
<p className='text-xs font-medium text-muted-foreground'>
趋势 IP
</p>
<input
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs'
value={trendIp}
onChange={(e) => setTrendIp(e.target.value)}
placeholder='输入要分析趋势的 IP 地址'
/>
</div>
<Button
size='sm'
onClick={() => setAppliedTrendIp(trendIp.trim())}
disabled={!trendIp.trim()}
>
查看趋势
</Button>
</div>
<div className='rounded-lg border border-dashed overflow-hidden bg-background p-4'>
{!appliedTrendIp ? (
<EmptyStateWithBorder description='请输入 IP 地址后查看 24 小时访问趋势。' />
) : ipTrendQuery.isLoading ? (
<LoadingStateWithBorder />
) : ipTrendQuery.isError ? (
<ErrorInline
message={
ipTrendQuery.error instanceof Error
? ipTrendQuery.error.message
: '加载失败'
}
onRetry={() => void ipTrendQuery.refetch()}
/>
) : trendChartData.length === 0 ? (
<EmptyStateWithBorder description='该 IP 在选定时间范围内没有访问记录。' />
) : (
<div className='space-y-3'>
<p className='text-sm font-medium'>
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
</p>
<ChartContainer
config={trendChartConfig}
className='h-64 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
</div>
)}
</div>
<DetailTab
data={listQuery.data}
loading={listQuery.isLoading}
error={listQuery.error instanceof Error ? listQuery.error : null}
page={page}
detailSort={detailSort}
onDetailSortChange={setDetailSort}
onRetry={() => void listQuery.refetch()}
onPrevPage={() => setPage((p) => Math.max(0, p - 1))}
onNextPage={() => setPage((p) => p + 1)}
isFetching={listQuery.isFetching}
/>
</TabsContent>
</Tabs>