Files
OpenFlare/frontend/app/(main)/access-logs/page.tsx
T
ryan 5a8722ff07 feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
2026-08-16 16:49:16 +08:00

362 lines
11 KiB
TypeScript

'use client';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
import { AccessLogFilters } from './components/access-log-filters';
import {
type AccessLogTab,
type OverviewRangeHours,
parseSortValue,
type SearchDraft,
} from './components/access-log-utils';
import { CleanupDialog } from './components/cleanup-dialog';
import { DetailTab } from './components/detail-tab';
import {
buildIpSummaryTimeParams,
defaultLocalRangeForHours,
IpTab,
type IpTabTimeMode,
} from './components/ip-tab';
import { OverviewTab } from './components/overview-tab';
const emptyDraft: SearchDraft = {
nodeId: '',
remoteAddr: '',
host: '',
path: '',
statusCode: '',
since: '',
until: '',
};
function resolveTab(value: string | null): AccessLogTab {
if (value === 'list') return 'list';
if (value === 'ips') return 'ips';
return 'overview';
}
function AccessLogsPageContent() {
const t = useTranslations('accessLogs');
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const tab = resolveTab(searchParams.get('tab'));
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 [ipSort, setIpSort] = useState('total_requests:desc');
const [ipPageSize, setIpPageSize] = useState(20);
const [ipPage, setIpPage] = useState(0);
const [ipHours, setIpHours] = useState<OverviewRangeHours>(168);
const [ipTimeMode, setIpTimeMode] = useState<IpTabTimeMode>('preset');
const [ipCustomSince, setIpCustomSince] = useState('');
const [ipCustomUntil, setIpCustomUntil] = useState('');
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
const ipSortState = parseSortValue(ipSort);
const ipTimeParams = buildIpSummaryTimeParams({
timeMode: ipTimeMode,
hours: ipHours,
customSince: ipCustomSince,
customUntil: ipCustomUntil,
});
const ipQueryEnabled = tab === 'ips' && ipTimeParams != null;
const handleTabChange = (value: string) => {
const next = resolveTab(value);
router.replace(
next === 'overview' ? '/access-logs' : `/access-logs?tab=${next}`,
);
};
const overviewQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'overview',
overviewHours,
overviewHosts,
],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
}),
enabled: tab === 'overview',
});
const listQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'list',
filters,
page,
pageSize,
detailSort,
],
queryFn: () =>
AccessLogService.list({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
status_code: filters.statusCode
? Number.parseInt(filters.statusCode, 10)
: undefined,
since: filters.since || undefined,
until: filters.until || undefined,
p: page,
page_size: pageSize,
sort_by: detailSortState.sortBy,
sort_order: detailSortState.sortOrder,
}),
enabled: tab === 'list',
});
const ipQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-summary',
ipTimeParams,
ipPage,
ipPageSize,
ipSort,
],
queryFn: () =>
AccessLogService.listIPSummaries({
...(ipTimeParams as NonNullable<typeof ipTimeParams>),
p: ipPage,
page_size: ipPageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
enabled: ipQueryEnabled,
});
const cleanupMutation = useMutation({
mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }),
onSuccess: async (result) => {
toast.success(t('cleaned', { count: result.deleted_count }));
setCleanupOpen(false);
await queryClient.invalidateQueries({
queryKey: ['openflare', 'access-logs'],
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('cleanupFailed'));
},
});
const handleSearch = useCallback(() => {
setFilters({
nodeId: draft.nodeId.trim(),
remoteAddr: draft.remoteAddr.trim(),
host: draft.host.trim(),
path: draft.path.trim(),
statusCode: draft.statusCode.trim(),
since: draft.since.trim(),
until: draft.until.trim(),
});
setPage(0);
}, [draft]);
const handleReset = () => {
setDraft(emptyDraft);
setFilters(emptyDraft);
setPage(0);
};
useEffect(() => {
setPage(0);
}, [tab, pageSize]);
useEffect(() => {
setIpPage(0);
}, [
tab,
ipPageSize,
ipHours,
ipTimeMode,
ipCustomSince,
ipCustomUntil,
ipSort,
]);
const refreshActive = () => {
if (tab === 'overview') void overviewQuery.refetch();
if (tab === 'list') void listQuery.refetch();
if (tab === 'ips') void ipQuery.refetch();
};
const isFetching =
overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<ScrollText className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
</div>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={refreshActive}
disabled={isFetching}
>
<RefreshCw
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
/>
{t('refresh')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setCleanupOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
{t('cleanup')}
</Button>
</div>
</div>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-lg grid-cols-3'>
<TabsTrigger value='overview'>{t('tabOverview')}</TabsTrigger>
<TabsTrigger value='ips'>{t('tabIps')}</TabsTrigger>
<TabsTrigger value='list'>{t('tabList')}</TabsTrigger>
</TabsList>
<TabsContent value='overview' className='mt-4'>
<OverviewTab
data={overviewQuery.data}
loading={overviewQuery.isLoading}
error={
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
hosts={overviewHosts}
onHoursChange={setOverviewHours}
onHostsChange={setOverviewHosts}
onRetry={() => void overviewQuery.refetch()}
/>
</TabsContent>
<TabsContent value='ips' className='mt-4'>
<IpTab
data={ipQuery.data}
loading={ipQueryEnabled && ipQuery.isLoading}
error={ipQuery.error instanceof Error ? ipQuery.error : null}
page={ipPage}
pageSize={ipPageSize}
sort={ipSort}
hours={ipHours}
timeMode={ipTimeMode}
customSince={ipCustomSince}
customUntil={ipCustomUntil}
onHoursChange={(next) => {
setIpTimeMode('preset');
setIpHours(next);
}}
onTimeModeChange={(mode) => {
setIpTimeMode(mode);
if (
mode === 'custom' &&
(!ipCustomSince.trim() || !ipCustomUntil.trim())
) {
const range = defaultLocalRangeForHours(ipHours);
setIpCustomSince(range.since);
setIpCustomUntil(range.until);
}
}}
onApplyCustomRange={(since, until) => {
setIpCustomSince(since);
setIpCustomUntil(until);
setIpTimeMode('custom');
setIpPage(0);
}}
onPageSizeChange={setIpPageSize}
onSortChange={setIpSort}
onRetry={() => void ipQuery.refetch()}
onPrevPage={() => setIpPage((p) => Math.max(0, p - 1))}
onNextPage={() => setIpPage((p) => p + 1)}
isFetching={ipQuery.isFetching}
/>
</TabsContent>
<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>
<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>
<CleanupDialog
open={cleanupOpen}
onOpenChange={setCleanupOpen}
onConfirm={(days) => cleanupMutation.mutate(days)}
loading={cleanupMutation.isPending}
/>
</div>
);
}
export default function AccessLogsPage() {
const t = useTranslations('accessLogs');
return (
<Suspense
fallback={
<LoadingStateWithBorder
title={t('loadingTitle')}
description={t('loadingDesc')}
/>
}
>
<AccessLogsPageContent />
</Suspense>
);
}