Files
OpenFlare/openflare_server/web/features/access-logs/components/access-logs-page.tsx
T

1277 lines
43 KiB
TypeScript

'use client';
import { Fragment, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
cleanupAccessLogs,
getAccessLogIPSummaries,
getAccessLogIPTrend,
getAccessLogs,
getFoldedAccessLogIPs,
getFoldedAccessLogs,
} from '@/features/access-logs/api/access-logs';
import type {
AccessLogCleanupPayload,
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
AccessLogList,
FoldedAccessLogIPList,
FoldedAccessLogList,
} from '@/features/access-logs/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import { formatCompactNumber } from '@/lib/utils/metrics';
type ActiveTab = 'detail' | 'ip';
type SearchDraft = {
nodeId: string;
remoteAddr: string;
host: string;
path: string;
};
type AppliedSearch = SearchDraft;
const pageSizeOptions = [20, 50, 100, 200];
const detailSortOptions = [
{ value: 'logged_at:desc', label: '时间从新到旧' },
{ value: 'logged_at:asc', label: '时间从旧到新' },
{ value: 'status_code:desc', label: '状态码从高到低' },
{ value: 'status_code:asc', label: '状态码从低到高' },
{ value: 'remote_addr:asc', label: 'IP 正序' },
{ value: 'remote_addr:desc', label: 'IP 倒序' },
{ value: 'host:asc', label: '域名正序' },
{ value: 'host:desc', label: '域名倒序' },
];
const foldedSortOptions = [
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
{ value: 'request_count:desc', label: '访问次数从高到低' },
{ value: 'request_count:asc', label: '访问次数从低到高' },
];
const ipSortOptions = [
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
{ value: 'recent_requests:asc', label: '3 小时访问次数从低到高' },
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
{ value: 'last_seen_at:asc', label: '最后访问时间从旧到新' },
];
const foldOptions = [
{ value: '0', label: '不折叠' },
{ value: '3', label: '按 3 分钟折叠' },
{ value: '5', label: '按 5 分钟折叠' },
];
const cleanupPresetOptions = [3, 7, 30];
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function parseSortValue(value: string) {
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
return {
sortBy,
sortOrder: sortOrder === 'asc' ? 'asc' : 'desc',
} as const;
}
function buildSummary(totalRecord = 0, totalIP = 0, activeTab: ActiveTab) {
return [
{ label: '访问记录', value: formatCompactNumber(totalRecord) },
{ label: '来源 IP', value: formatCompactNumber(totalIP) },
{
label: '当前视图',
value: activeTab === 'detail' ? '明细日志' : 'IP 维度',
},
];
}
function buildTrendLabels(points: Array<{ bucket_started_at: string }>) {
return points.map((point) => {
const date = new Date(point.bucket_started_at);
if (Number.isNaN(date.getTime())) {
return '--';
}
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate(),
).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(
date.getMinutes(),
).padStart(2, '0')}`;
});
}
function isFoldedAccessLogList(
value: AccessLogList | FoldedAccessLogList | undefined,
): value is FoldedAccessLogList {
if (!value || !Array.isArray(value.items)) {
return false;
}
return value.items.every((item) => {
const candidate = item as Partial<FoldedAccessLogList['items'][number]>;
return (
typeof candidate.bucket_started_at === 'string' &&
typeof candidate.request_count === 'number'
);
});
}
function isAccessLogList(
value: AccessLogList | FoldedAccessLogList | undefined,
): value is AccessLogList {
if (!value || !Array.isArray(value.items)) {
return false;
}
return value.items.every((item) => {
const candidate = item as Partial<AccessLogList['items'][number]>;
return (
typeof candidate.logged_at === 'string' &&
typeof candidate.status_code === 'number'
);
});
}
export function AccessLogsPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<ActiveTab>('detail');
const [draft, setDraft] = useState<SearchDraft>({
nodeId: '',
remoteAddr: '',
host: '',
path: '',
});
const [filters, setFilters] = useState<AppliedSearch>({
nodeId: '',
remoteAddr: '',
host: '',
path: '',
});
const [detailPage, setDetailPage] = useState(0);
const [ipPage, setIPPage] = useState(0);
const [pageSize, setPageSize] = useState(20);
const [foldMinutes, setFoldMinutes] = useState<0 | 3 | 5>(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc');
const [ipSort, setIPSort] = useState('total_requests:desc');
const [expandedBucket, setExpandedBucket] = useState<string | null>(null);
const [bucketIPPages, setBucketIPPages] = useState<Record<string, number>>(
{},
);
const [selectedIP, setSelectedIP] = useState<AccessLogIPSummaryItem | null>(
null,
);
const [cleanupDays, setCleanupDays] = useState<string>('7');
const [customCleanupDays, setCustomCleanupDays] = useState('14');
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
const foldedSortState = parseSortValue(foldedSort);
const ipSortState = parseSortValue(ipSort);
const detailQuery = useQuery<AccessLogList | FoldedAccessLogList>({
queryKey: [
'access-logs',
'detail',
filters,
detailPage,
pageSize,
detailSort,
foldMinutes,
foldedSort,
],
queryFn: () => {
if (foldMinutes > 0) {
return getFoldedAccessLogs({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: detailPage,
page_size: pageSize,
sort_by: foldedSortState.sortBy,
sort_order: foldedSortState.sortOrder,
fold_minutes: foldMinutes as 3 | 5,
});
}
return getAccessLogs({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: detailPage,
page_size: pageSize,
sort_by: detailSortState.sortBy,
sort_order: detailSortState.sortOrder,
});
},
placeholderData: (
previousData: AccessLogList | FoldedAccessLogList | undefined,
) => {
if (foldMinutes > 0) {
return isFoldedAccessLogList(previousData) ? previousData : undefined;
}
return isAccessLogList(previousData) ? previousData : undefined;
},
});
const ipSummaryQuery = useQuery<AccessLogIPSummaryList>({
queryKey: ['access-logs', 'ip-summary', filters, ipPage, pageSize, ipSort],
queryFn: () =>
getAccessLogIPSummaries({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
p: ipPage,
page_size: pageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
placeholderData: (previousData: AccessLogIPSummaryList | undefined) =>
previousData,
});
const ipTrendQuery = useQuery({
queryKey: [
'access-logs',
'ip-trend',
selectedIP?.remote_addr,
filters.nodeId,
filters.host,
],
queryFn: () =>
getAccessLogIPTrend({
node_id: filters.nodeId || undefined,
remote_addr: selectedIP?.remote_addr ?? '',
host: filters.host || undefined,
hours: 24,
bucket_minutes: 30,
}),
enabled: Boolean(selectedIP?.remote_addr),
});
const cleanupMutation = useMutation({
mutationFn: (payload: AccessLogCleanupPayload) =>
cleanupAccessLogs(payload),
onSuccess: async () => {
setCleanupModalOpen(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }),
queryClient.invalidateQueries({
queryKey: ['access-logs', 'ip-summary'],
}),
queryClient.invalidateQueries({
queryKey: ['access-logs', 'ip-trend'],
}),
]);
},
});
const detailSummaryData = detailQuery.data as
| AccessLogList
| FoldedAccessLogList
| undefined;
const summary = useMemo(
() =>
buildSummary(
detailSummaryData?.total_record ?? 0,
detailSummaryData?.total_ip ?? 0,
activeTab,
),
[activeTab, detailSummaryData?.total_ip, detailSummaryData?.total_record],
);
const trendLabels = useMemo(
() => buildTrendLabels(ipTrendQuery.data?.points ?? []),
[ipTrendQuery.data?.points],
);
const trendValues = useMemo(
() => (ipTrendQuery.data?.points ?? []).map((point) => point.request_count),
[ipTrendQuery.data?.points],
);
const handleSearch = () => {
setFilters({
nodeId: draft.nodeId.trim(),
remoteAddr: draft.remoteAddr.trim(),
host: draft.host.trim(),
path: draft.path.trim(),
});
setDetailPage(0);
setIPPage(0);
setExpandedBucket(null);
setBucketIPPages({});
};
const handleReset = () => {
const empty = { nodeId: '', remoteAddr: '', host: '', path: '' };
setDraft(empty);
setFilters(empty);
setDetailPage(0);
setIPPage(0);
setSelectedIP(null);
setExpandedBucket(null);
setBucketIPPages({});
};
const handleCleanupConfirm = () => {
const retentionDays =
cleanupDays === 'custom'
? Number.parseInt(customCleanupDays, 10)
: Number.parseInt(cleanupDays, 10);
cleanupMutation.mutate({ retention_days: retentionDays });
};
return (
<div className="space-y-6">
<PageHeader
title="日志"
description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。"
action={
<PrimaryButton
type="button"
onClick={() => setCleanupModalOpen(true)}
>
清理日志
</PrimaryButton>
}
/>
<AppCard
title="日志摘要"
description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。"
>
<div className="grid gap-4 md:grid-cols-3">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<AppCard
title="筛选与视图"
description="支持 IP、访问域名、路径、分页大小、排序与时间折叠。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: ['access-logs'],
})
}
>
刷新
</SecondaryButton>
}
>
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
{[
{
key: 'detail',
label: '明细日志',
description: '按请求明细查看与折叠聚合',
},
{
key: 'ip',
label: 'IP 维度',
description: '查看来源 IP 汇总与趋势',
},
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as ActiveTab)}
className={`min-w-[180px] rounded-2xl border px-4 py-3 text-left transition ${
activeTab === tab.key
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--border-strong)]'
}`}
>
<p className="text-sm font-semibold">{tab.label}</p>
<p className="mt-1 text-xs leading-5">{tab.description}</p>
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ResourceField label="节点 ID">
<ResourceInput
value={draft.nodeId}
onChange={(event) =>
setDraft((current) => ({
...current,
nodeId: event.target.value,
}))
}
placeholder="按 node_id 搜索"
/>
</ResourceField>
<ResourceField label="来源 IP">
<ResourceInput
value={draft.remoteAddr}
onChange={(event) =>
setDraft((current) => ({
...current,
remoteAddr: event.target.value,
}))
}
placeholder="按 IP 搜索"
/>
</ResourceField>
<ResourceField label="访问域名">
<ResourceInput
value={draft.host}
onChange={(event) =>
setDraft((current) => ({
...current,
host: event.target.value,
}))
}
placeholder="按域名搜索"
/>
</ResourceField>
<ResourceField
label="请求路径"
hint={
activeTab === 'ip'
? 'IP 维度页暂不使用路径过滤。'
: '支持按路径模糊过滤。'
}
>
<ResourceInput
value={draft.path}
disabled={activeTab === 'ip'}
onChange={(event) =>
setDraft((current) => ({
...current,
path: event.target.value,
}))
}
placeholder="按路径搜索"
/>
</ResourceField>
</div>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ResourceField label="每页条数">
<ResourceSelect
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number(event.target.value) || 20);
setDetailPage(0);
setIPPage(0);
}}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
每页 {option} 条
</option>
))}
</ResourceSelect>
</ResourceField>
<ResourceField
label={
activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'
}
>
<ResourceSelect
value={
activeTab === 'detail' && foldMinutes > 0
? foldedSort
: activeTab === 'detail'
? detailSort
: ipSort
}
onChange={(event) => {
if (activeTab === 'detail' && foldMinutes > 0) {
setFoldedSort(event.target.value);
setDetailPage(0);
return;
}
if (activeTab === 'detail') {
setDetailSort(event.target.value);
setDetailPage(0);
return;
}
setIPSort(event.target.value);
setIPPage(0);
}}
>
{(activeTab === 'detail' && foldMinutes > 0
? foldedSortOptions
: activeTab === 'detail'
? detailSortOptions
: ipSortOptions
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
{activeTab === 'detail' ? (
<ResourceField label="时间折叠">
<ResourceSelect
value={String(foldMinutes)}
onChange={(event) => {
setFoldMinutes(Number(event.target.value) as 0 | 3 | 5);
setDetailPage(0);
setExpandedBucket(null);
setBucketIPPages({});
}}
>
{foldOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
) : (
<div />
)}
<div className="flex items-end gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
应用筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
重置
</SecondaryButton>
</div>
</div>
</div>
</AppCard>
{activeTab === 'detail' ? (
<DetailTab
detailPage={detailPage}
pageSize={pageSize}
foldMinutes={foldMinutes}
filters={filters}
query={detailQuery}
expandedBucket={expandedBucket}
bucketIPPages={bucketIPPages}
onToggleBucket={(bucket) =>
setExpandedBucket((current) => (current === bucket ? null : bucket))
}
onBucketIPPageChange={(bucket, nextPage) =>
setBucketIPPages((current) => ({
...current,
[bucket]: Math.max(nextPage, 0),
}))
}
onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))}
onNextPage={() => setDetailPage((value) => value + 1)}
/>
) : (
<IPTab
pageSize={pageSize}
ipPage={ipPage}
query={ipSummaryQuery}
onPrevPage={() => setIPPage((value) => Math.max(value - 1, 0))}
onNextPage={() => setIPPage((value) => value + 1)}
onSelectIP={setSelectedIP}
/>
)}
<AppModal
isOpen={Boolean(selectedIP)}
title={selectedIP ? `IP 趋势 · ${selectedIP.remote_addr}` : 'IP 趋势'}
description="展示该来源 IP 最近 24 小时的访问次数曲线,帮助判断是否存在突增、持续轰击或间歇异常。"
size="xl"
onClose={() => setSelectedIP(null)}
>
{ipTrendQuery.isLoading ? (
<LoadingState />
) : ipTrendQuery.isError ? (
<ErrorState
title="IP 趋势加载失败"
description={getErrorMessage(ipTrendQuery.error)}
/>
) : ipTrendQuery.data ? (
<TrendChart
labels={trendLabels}
series={[
{
label: '访问次数',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.16)',
values: trendValues,
variant: 'area',
},
]}
yAxisValueFormatter={(value) => formatCompactNumber(value)}
/>
) : (
<EmptyState
title="暂无趋势数据"
description="当前 IP 在最近 24 小时内没有可展示的访问曲线。"
/>
)}
</AppModal>
<AppModal
isOpen={isCleanupModalOpen}
title="清理访问日志"
description="选择日志保留范围后,将删除更早的访问日志。该操作会影响当前日志检索与 IP 维度统计,请谨慎执行。"
onClose={() => setCleanupModalOpen(false)}
footer={
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton
type="button"
onClick={() => setCleanupModalOpen(false)}
>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={cleanupMutation.isPending}
onClick={handleCleanupConfirm}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
</PrimaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
{cleanupPresetOptions.map((days) => (
<button
key={days}
type="button"
onClick={() => setCleanupDays(String(days))}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupDays === String(days)
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
保留最近 {days} 天
</button>
))}
<button
type="button"
onClick={() => setCleanupDays('custom')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupDays === 'custom'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
自定义天数
</button>
</div>
{cleanupDays === 'custom' ? (
<ResourceField label="自定义保留天数" hint="当前支持 1 到 90 天。">
<ResourceInput
value={customCleanupDays}
onChange={(event) => setCustomCleanupDays(event.target.value)}
placeholder="输入保留天数"
type="number"
min={1}
max={90}
/>
</ResourceField>
) : null}
{cleanupMutation.isError ? (
<ErrorState
title="日志清理失败"
description={getErrorMessage(cleanupMutation.error)}
/>
) : null}
</div>
</AppModal>
</div>
);
}
function getStatusMeta(statusCode: number) {
if (statusCode >= 500) {
return { label: String(statusCode), variant: 'danger' as const };
}
if (statusCode >= 400) {
return { label: String(statusCode), variant: 'warning' as const };
}
return { label: String(statusCode), variant: 'success' as const };
}
function DetailTab({
detailPage,
pageSize,
foldMinutes,
filters,
query,
expandedBucket,
bucketIPPages,
onToggleBucket,
onBucketIPPageChange,
onPrevPage,
onNextPage,
}: {
detailPage: number;
pageSize: number;
foldMinutes: 0 | 3 | 5;
filters: AppliedSearch;
query: {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
error: unknown;
data?: AccessLogList | FoldedAccessLogList;
};
expandedBucket: string | null;
bucketIPPages: Record<string, number>;
onToggleBucket: (bucket: string) => void;
onBucketIPPageChange: (bucket: string, page: number) => void;
onPrevPage: () => void;
onNextPage: () => void;
}) {
if (query.isLoading) {
return (
<AppCard title="访问日志" description="加载中...">
<LoadingState />
</AppCard>
);
}
if (query.isError) {
return (
<AppCard title="访问日志" description="日志查询失败。">
<ErrorState
title="访问日志加载失败"
description={getErrorMessage(query.error)}
/>
</AppCard>
);
}
if (foldMinutes > 0) {
if (!isFoldedAccessLogList(query.data)) {
return (
<AppCard
title="折叠日志"
description={`当前按 ${foldMinutes} 分钟时间桶折叠,适合在高频刷新时快速定位异常波段。`}
>
<LoadingState />
</AppCard>
);
}
const data = query.data;
return (
<AppCard
title="折叠日志"
description={`当前按 ${foldMinutes} 分钟时间桶折叠,适合在高频刷新时快速定位异常波段。`}
>
<div className="space-y-4">
{data.items.length === 0 ? (
<EmptyState
title="暂无折叠日志"
description="当前筛选条件下没有可展示的时间桶。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">时间桶</th>
<th className="px-3 py-3 font-medium">总访问</th>
<th className="px-3 py-3 font-medium">来源 IP</th>
<th className="px-3 py-3 font-medium">域名数</th>
<th className="px-3 py-3 font-medium">2xx</th>
<th className="px-3 py-3 font-medium">4xx</th>
<th className="px-3 py-3 font-medium">5xx</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{data.items.map((item) => {
const isExpanded =
expandedBucket === item.bucket_started_at;
const bucketPage =
bucketIPPages[item.bucket_started_at] ?? 0;
return (
<Fragment key={item.bucket_started_at}>
<tr
key={item.bucket_started_at}
className="cursor-pointer align-top transition hover:bg-[var(--surface-elevated)]"
onClick={() => onToggleBucket(item.bucket_started_at)}
>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onToggleBucket(item.bucket_started_at);
}}
className="text-left font-medium text-[var(--foreground-primary)] underline-offset-4 hover:underline"
>
{formatDateTime(item.bucket_started_at)}
</button>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(item.bucket_started_at)}
</div>
</td>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{formatCompactNumber(item.request_count)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.unique_ip_count)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.unique_host_count)}
</td>
<td className="px-3 py-4 text-emerald-600">
{formatCompactNumber(item.success_count)}
</td>
<td className="px-3 py-4 text-amber-600">
{formatCompactNumber(item.client_error_count)}
</td>
<td className="px-3 py-4 text-rose-600">
{formatCompactNumber(item.server_error_count)}
</td>
</tr>
{isExpanded ? (
<tr key={`${item.bucket_started_at}-detail`}>
<td
colSpan={7}
className="bg-[var(--surface-elevated)] px-3 py-4"
>
<FoldedBucketIPDetails
bucketStartedAt={item.bucket_started_at}
foldMinutes={foldMinutes as 3 | 5}
filters={filters}
page={bucketPage}
pageSize={pageSize}
onPrevPage={() =>
onBucketIPPageChange(
item.bucket_started_at,
bucketPage - 1,
)
}
onNextPage={() =>
onBucketIPPageChange(
item.bucket_started_at,
bucketPage + 1,
)
}
/>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
<Pager
page={detailPage}
pageSize={pageSize}
hasMore={data.has_more}
itemCount={data.items.length}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
</AppCard>
);
}
if (!isAccessLogList(query.data)) {
return (
<AppCard
title="访问明细"
description="展示原始访问明细,支持按时间、状态码、IP 与域名排序。"
>
<LoadingState />
</AppCard>
);
}
const data = query.data;
return (
<AppCard
title="访问明细"
description="展示原始访问明细,支持按时间、状态码、IP 与域名排序。"
>
<div className="space-y-4">
{data.items.length === 0 ? (
<EmptyState
title="暂无访问日志"
description="当前筛选条件下没有可展示的访问明细。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">来源 IP</th>
<th className="px-3 py-3 font-medium">访问域名</th>
<th className="px-3 py-3 font-medium">路径</th>
<th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态码</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{data.items.map((item) => {
const statusMeta = getStatusMeta(item.status_code);
return (
<tr key={item.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{formatDateTime(item.logged_at)}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(item.logged_at)}
</div>
</td>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
<div>{item.remote_addr || '—'}</div>
{item.region ? (
<div className="mt-2">
<span className="inline-flex rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-2.5 py-1 text-[11px] font-medium text-[var(--foreground-secondary)]">
{item.region}
</span>
</div>
) : null}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{item.host || '—'}
</td>
<td
className="max-w-[360px] px-3 py-4 text-[var(--foreground-secondary)]"
title={item.path}
>
<span className="break-all">{item.path || '—'}</span>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{item.node_name || item.node_id}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{item.node_id}
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pager
page={detailPage}
pageSize={pageSize}
hasMore={data.has_more}
itemCount={data.items.length}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
</AppCard>
);
}
function IPTab({
pageSize,
ipPage,
query,
onPrevPage,
onNextPage,
onSelectIP,
}: {
pageSize: number;
ipPage: number;
query: {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
error: unknown;
data?: {
items: AccessLogIPSummaryItem[];
has_more: boolean;
};
};
onPrevPage: () => void;
onNextPage: () => void;
onSelectIP: (item: AccessLogIPSummaryItem) => void;
}) {
const items = query.data?.items ?? [];
const hasMore = query.data?.has_more ?? false;
return (
<AppCard
title="IP 维度日志"
description="聚合展示访问过系统的来源 IP,可按访问次数或最后访问时间排序,点击行查看 24 小时趋势曲线。"
>
{query.isLoading ? (
<LoadingState />
) : query.isError ? (
<ErrorState
title="IP 汇总加载失败"
description={getErrorMessage(query.error)}
/>
) : items.length === 0 ? (
<EmptyState
title="暂无 IP 访问记录"
description="当前筛选条件下没有可展示的来源 IP。"
/>
) : (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">IP</th>
<th className="px-3 py-3 font-medium">总访问次数</th>
<th className="px-3 py-3 font-medium">3 小时内访问次数</th>
<th className="px-3 py-3 font-medium">最后访问时间</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr
key={item.remote_addr}
className="cursor-pointer align-top transition hover:bg-[var(--surface-elevated)]"
onClick={() => onSelectIP(item)}
>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{item.remote_addr}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.total_requests)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.recent_requests)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{formatDateTime(item.last_seen_at)}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(item.last_seen_at)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager
page={ipPage}
pageSize={pageSize}
hasMore={hasMore}
itemCount={items.length}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
)}
</AppCard>
);
}
function FoldedBucketIPDetails({
bucketStartedAt,
foldMinutes,
filters,
page,
pageSize,
onPrevPage,
onNextPage,
}: {
bucketStartedAt: string;
foldMinutes: 3 | 5;
filters: AppliedSearch;
page: number;
pageSize: number;
onPrevPage: () => void;
onNextPage: () => void;
}) {
const query = useQuery<FoldedAccessLogIPList>({
queryKey: [
'access-logs',
'fold-bucket-ip',
filters,
bucketStartedAt,
foldMinutes,
page,
pageSize,
],
queryFn: () =>
getFoldedAccessLogIPs({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
bucket_started_at: bucketStartedAt,
fold_minutes: foldMinutes,
p: page,
page_size: pageSize,
sort_by: 'request_count',
sort_order: 'desc',
}),
placeholderData: (previousData: FoldedAccessLogIPList | undefined) =>
previousData,
});
if (query.isLoading) {
return <LoadingState />;
}
if (query.isError) {
return (
<ErrorState
title="时间桶 IP 明细加载失败"
description={getErrorMessage(query.error)}
/>
);
}
const items = query.data?.items ?? [];
if (items.length === 0) {
return (
<EmptyState
title="暂无 IP 明细"
description="该时间段内没有可展示的来源 IP。"
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
时间段内 IP 访问情况
</p>
<p className="text-xs text-[var(--foreground-muted)]">
共 {formatCompactNumber(query.data?.total_ip ?? 0)} 个
IP,按访问次数从高到低排序
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">来源 IP</th>
<th className="px-3 py-3 font-medium">访问次数</th>
<th className="px-3 py-3 font-medium">2xx</th>
<th className="px-3 py-3 font-medium">4xx</th>
<th className="px-3 py-3 font-medium">5xx</th>
<th className="px-3 py-3 font-medium">最后访问</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr key={item.remote_addr}>
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
{item.remote_addr}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.request_count)}
</td>
<td className="px-3 py-3 text-emerald-600">
{formatCompactNumber(item.success_count)}
</td>
<td className="px-3 py-3 text-amber-600">
{formatCompactNumber(item.client_error_count)}
</td>
<td className="px-3 py-3 text-rose-600">
{formatCompactNumber(item.server_error_count)}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
{formatDateTime(item.last_seen_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager
page={page}
pageSize={pageSize}
hasMore={query.data?.has_more ?? false}
itemCount={items.length}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
);
}
function Pager({
page,
pageSize,
hasMore,
itemCount,
isFetching,
onPrev,
onNext,
}: {
page: number;
pageSize: number;
hasMore: boolean;
itemCount: number;
isFetching: boolean;
onPrev: () => void;
onNext: () => void;
}) {
const canTryNext = hasMore || itemCount >= pageSize;
return (
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]">
第 {page + 1} 页,每页 {pageSize} 条。
</p>
<div className="flex gap-2">
<SecondaryButton
type="button"
disabled={page === 0 || isFetching}
onClick={onPrev}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
disabled={!canTryNext || isFetching}
onClick={onNext}
>
下一页
</SecondaryButton>
</div>
</div>
);
}