mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 02:26:36 +08:00
prettier
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {Search} from "lucide-react"
|
||||
import {Search} from 'lucide-react';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import type {AccessLogTab, SearchDraft} from "./access-log-utils"
|
||||
import {PAGE_SIZE_OPTIONS} from "./access-log-utils"
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import type {AccessLogTab, 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
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
onSearch: () => void
|
||||
onReset: () => void
|
||||
tab: AccessLogTab;
|
||||
draft: SearchDraft;
|
||||
pageSize: number;
|
||||
onDraftChange: (draft: SearchDraft) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
onSearch: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function AccessLogFilters({
|
||||
@@ -27,78 +27,84 @@ export function AccessLogFilters({
|
||||
onSearch,
|
||||
onReset,
|
||||
}: AccessLogFiltersProps) {
|
||||
const showPath = tab === "list" || tab === "folds"
|
||||
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">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">节点 ID</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>节点 ID</p>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
value={draft.nodeId}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, nodeId: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按 node_id 搜索"
|
||||
className="pl-8 h-9 text-xs"
|
||||
placeholder='按 node_id 搜索'
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">来源 IP</p>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>来源 IP</p>
|
||||
<Input
|
||||
value={draft.remoteAddr}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, remoteAddr: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按 IP 搜索"
|
||||
className="h-9 text-xs"
|
||||
placeholder='按 IP 搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">访问域名</p>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>访问域名</p>
|
||||
<Input
|
||||
value={draft.host}
|
||||
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按域名搜索"
|
||||
className="h-9 text-xs"
|
||||
placeholder='按域名搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
{showPath ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">请求路径</p>
|
||||
<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 })}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, path: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder="按路径搜索"
|
||||
className="h-9 text-xs"
|
||||
placeholder='按路径搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1.5 w-full sm:max-w-[180px]">
|
||||
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number.parseInt(value, 10))}
|
||||
onValueChange={(value) =>
|
||||
onPageSizeChange(Number.parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -110,15 +116,15 @@ export function AccessLogFilters({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={onSearch}>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={onSearch}>
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onReset}>
|
||||
<Button variant='outline' size='sm' onClick={onReset}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
export type AccessLogTab = "list" | "folds" | "ip-summary" | "ip-trend"
|
||||
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
|
||||
|
||||
export type SearchDraft = {
|
||||
nodeId: string
|
||||
remoteAddr: string
|
||||
host: string
|
||||
path: string
|
||||
}
|
||||
nodeId: string;
|
||||
remoteAddr: string;
|
||||
host: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||
|
||||
export const DETAIL_SORT_OPTIONS = [
|
||||
{ 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: '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 倒序' },
|
||||
];
|
||||
|
||||
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: "访问次数从低到高" },
|
||||
]
|
||||
{ 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: "最后访问时间从新到旧" },
|
||||
]
|
||||
{ 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(":")
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
return {
|
||||
sortBy,
|
||||
sortOrder: sortOrder === "asc" ? ("asc" as const) : ("desc" as const),
|
||||
}
|
||||
sortOrder: sortOrder === 'asc' ? ('asc' as const) : ('desc' as const),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
notation: value >= 10000 ? "compact" : "standard",
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
notation: value >= 10000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value)
|
||||
}
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useState} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {useState} from 'react';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -11,19 +11,19 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
|
||||
const CLEANUP_PRESETS = [3, 7, 30]
|
||||
const CLEANUP_PRESETS = [3, 7, 30];
|
||||
|
||||
interface CleanupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (retentionDays: number) => void
|
||||
loading: boolean
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (retentionDays: number) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function CleanupDialog({
|
||||
@@ -32,24 +32,24 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const [mode, setMode] = useState<string>("7")
|
||||
const [customDays, setCustomDays] = useState("14")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<string>('7');
|
||||
const [customDays, setCustomDays] = useState('14');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const retentionDays =
|
||||
mode === "custom"
|
||||
mode === 'custom'
|
||||
? Number.parseInt(customDays, 10)
|
||||
: Number.parseInt(mode, 10)
|
||||
: Number.parseInt(mode, 10);
|
||||
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
setError("保留天数必须大于 0")
|
||||
return
|
||||
setError('保留天数必须大于 0');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null)
|
||||
onConfirm(retentionDays)
|
||||
}
|
||||
setError(null);
|
||||
onConfirm(retentionDays);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -61,8 +61,8 @@ export function CleanupDialog({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>保留策略</Label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger>
|
||||
@@ -74,17 +74,17 @@ export function CleanupDialog({
|
||||
保留最近 {days} 天
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">自定义天数</SelectItem>
|
||||
<SelectItem value='custom'>自定义天数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="customDays">自定义保留天数</Label>
|
||||
{mode === 'custom' ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='customDays'>自定义保留天数</Label>
|
||||
<Input
|
||||
id="customDays"
|
||||
type="number"
|
||||
id='customDays'
|
||||
type='number'
|
||||
min={1}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(e.target.value)}
|
||||
@@ -93,23 +93,27 @@ export function CleanupDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
{error ? <p className='text-xs text-destructive'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
清理中...
|
||||
</>
|
||||
) : (
|
||||
"确认清理"
|
||||
'确认清理'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useMemo, 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 { useCallback, useEffect, useMemo, 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 { 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 { AccessLogFilters } from './components/access-log-filters';
|
||||
import {
|
||||
type AccessLogTab,
|
||||
DETAIL_SORT_OPTIONS,
|
||||
@@ -27,19 +45,19 @@ import {
|
||||
IP_SORT_OPTIONS,
|
||||
parseSortValue,
|
||||
type SearchDraft,
|
||||
} from "./components/access-log-utils"
|
||||
import {CleanupDialog} from "./components/cleanup-dialog"
|
||||
} from './components/access-log-utils';
|
||||
import { CleanupDialog } from './components/cleanup-dialog';
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: "请求数", color: "hsl(var(--primary))" },
|
||||
} satisfies ChartConfig
|
||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const emptyDraft: SearchDraft = {
|
||||
nodeId: "",
|
||||
remoteAddr: "",
|
||||
host: "",
|
||||
path: "",
|
||||
}
|
||||
nodeId: '',
|
||||
remoteAddr: '',
|
||||
host: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
function PaginationBar({
|
||||
page,
|
||||
@@ -48,48 +66,66 @@ function PaginationBar({
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number
|
||||
hasMore: boolean
|
||||
loading: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
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}>
|
||||
<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
|
||||
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 [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 [cleanupOpen, setCleanupOpen] = useState(false)
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<AccessLogTab>('list');
|
||||
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 [cleanupOpen, setCleanupOpen] = useState(false);
|
||||
|
||||
const detailSortState = parseSortValue(detailSort)
|
||||
const foldSortState = parseSortValue(foldSort)
|
||||
const ipSortState = parseSortValue(ipSort)
|
||||
const detailSortState = parseSortValue(detailSort);
|
||||
const foldSortState = parseSortValue(foldSort);
|
||||
const ipSortState = parseSortValue(ipSort);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "list", filters, page, pageSize, detailSort],
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'list',
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
detailSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.list({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -101,11 +137,20 @@ export default function AccessLogsPage() {
|
||||
sort_by: detailSortState.sortBy,
|
||||
sort_order: detailSortState.sortOrder,
|
||||
}),
|
||||
enabled: tab === "list",
|
||||
})
|
||||
enabled: tab === 'list',
|
||||
});
|
||||
|
||||
const foldsQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "folds", filters, page, pageSize, foldSort, foldMinutes],
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'folds',
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
foldSort,
|
||||
foldMinutes,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.listFolds({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -118,11 +163,19 @@ export default function AccessLogsPage() {
|
||||
sort_order: foldSortState.sortOrder,
|
||||
fold_minutes: foldMinutes,
|
||||
}),
|
||||
enabled: tab === "folds",
|
||||
})
|
||||
enabled: tab === 'folds',
|
||||
});
|
||||
|
||||
const ipSummaryQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "ip-summary", filters, page, pageSize, ipSort],
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'ip-summary',
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
ipSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.listIPSummaries({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -133,11 +186,11 @@ export default function AccessLogsPage() {
|
||||
sort_by: ipSortState.sortBy,
|
||||
sort_order: ipSortState.sortOrder,
|
||||
}),
|
||||
enabled: tab === "ip-summary",
|
||||
})
|
||||
enabled: tab === 'ip-summary',
|
||||
});
|
||||
|
||||
const ipTrendQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "ip-trend", filters, appliedTrendIp],
|
||||
queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp],
|
||||
queryFn: () =>
|
||||
AccessLogService.getIPTrend({
|
||||
node_id: filters.nodeId || undefined,
|
||||
@@ -146,50 +199,52 @@ export default function AccessLogsPage() {
|
||||
hours: 24,
|
||||
bucket_minutes: 30,
|
||||
}),
|
||||
enabled: tab === "ip-trend" && appliedTrendIp !== "",
|
||||
})
|
||||
enabled: tab === 'ip-trend' && appliedTrendIp !== '',
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`)
|
||||
setCleanupOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ["openflare", "access-logs"] })
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`);
|
||||
setCleanupOpen(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'access-logs'],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "清理失败")
|
||||
toast.error(error instanceof Error ? error.message : '清理失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const activeSummary = useMemo(() => {
|
||||
if (tab === "list" && listQuery.data) {
|
||||
if (tab === 'list' && listQuery.data) {
|
||||
return {
|
||||
totalRecord: listQuery.data.total_record,
|
||||
totalIp: listQuery.data.total_ip,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (tab === "folds" && foldsQuery.data) {
|
||||
if (tab === 'folds' && foldsQuery.data) {
|
||||
return {
|
||||
totalRecord: foldsQuery.data.total_record,
|
||||
totalIp: foldsQuery.data.total_ip,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (tab === "ip-summary" && ipSummaryQuery.data) {
|
||||
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])
|
||||
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])
|
||||
}));
|
||||
}, [ipTrendQuery.data?.points]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setFilters({
|
||||
@@ -197,93 +252,116 @@ export default function AccessLogsPage() {
|
||||
remoteAddr: draft.remoteAddr.trim(),
|
||||
host: draft.host.trim(),
|
||||
path: draft.path.trim(),
|
||||
})
|
||||
setPage(0)
|
||||
}, [draft])
|
||||
});
|
||||
setPage(0);
|
||||
}, [draft]);
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft(emptyDraft)
|
||||
setFilters(emptyDraft)
|
||||
setPage(0)
|
||||
setTrendIp("")
|
||||
setAppliedTrendIp("")
|
||||
}
|
||||
setDraft(emptyDraft);
|
||||
setFilters(emptyDraft);
|
||||
setPage(0);
|
||||
setTrendIp('');
|
||||
setAppliedTrendIp('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [tab, pageSize])
|
||||
setPage(0);
|
||||
}, [tab, pageSize]);
|
||||
|
||||
const refreshActive = () => {
|
||||
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()
|
||||
}
|
||||
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
|
||||
ipTrendQuery.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 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">访问日志</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
|
||||
</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" : ""}`} />
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={refreshActive}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setCleanupOpen(true)}>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
{[
|
||||
{ label: "访问记录", value: formatCompactNumber(activeSummary.totalRecord) },
|
||||
{ label: "来源 IP", value: formatCompactNumber(activeSummary.totalIp) },
|
||||
{
|
||||
label: "当前视图",
|
||||
label: '访问记录',
|
||||
value: formatCompactNumber(activeSummary.totalRecord),
|
||||
},
|
||||
{
|
||||
label: '来源 IP',
|
||||
value: formatCompactNumber(activeSummary.totalIp),
|
||||
},
|
||||
{
|
||||
label: '当前视图',
|
||||
value:
|
||||
tab === "list"
|
||||
? "明细日志"
|
||||
: tab === "folds"
|
||||
? "时间折叠"
|
||||
: tab === "ip-summary"
|
||||
? "IP 汇总"
|
||||
: "IP 趋势",
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-dashed bg-background p-4">
|
||||
<div className='mt-4 rounded-lg border border-dashed bg-background p-4'>
|
||||
<AccessLogFilters
|
||||
tab={tab}
|
||||
draft={draft}
|
||||
@@ -295,12 +373,12 @@ export default function AccessLogsPage() {
|
||||
/>
|
||||
</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>
|
||||
<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">
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -313,38 +391,50 @@ export default function AccessLogsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
{listQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={listQuery.error instanceof Error ? listQuery.error.message : "加载失败"}
|
||||
message={
|
||||
listQuery.error instanceof Error
|
||||
? listQuery.error.message
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : listQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (listQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无访问日志" />
|
||||
<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>
|
||||
<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>
|
||||
<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]">
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{item.status_code}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -363,26 +453,28 @@ export default function AccessLogsPage() {
|
||||
</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>
|
||||
<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)}
|
||||
onValueChange={(value) =>
|
||||
setFoldMinutes(Number(value) as 3 | 5)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectTrigger className='h-8 w-36 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3 分钟桶</SelectItem>
|
||||
<SelectItem value="5">5 分钟桶</SelectItem>
|
||||
<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">
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -395,12 +487,12 @@ export default function AccessLogsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
{foldsQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
foldsQuery.error instanceof Error
|
||||
? foldsQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void foldsQuery.refetch()}
|
||||
/>
|
||||
@@ -408,32 +500,47 @@ export default function AccessLogsPage() {
|
||||
) : foldsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无折叠数据" />
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -449,12 +556,12 @@ export default function AccessLogsPage() {
|
||||
</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>
|
||||
<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">
|
||||
<SelectTrigger className='h-8 w-52 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -467,12 +574,12 @@ export default function AccessLogsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
{ipSummaryQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
ipSummaryQuery.error instanceof Error
|
||||
? ipSummaryQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void ipSummaryQuery.refetch()}
|
||||
/>
|
||||
@@ -480,24 +587,30 @@ export default function AccessLogsPage() {
|
||||
) : ipSummaryQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -515,19 +628,21 @@ export default function AccessLogsPage() {
|
||||
</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>
|
||||
<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"
|
||||
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 地址"
|
||||
placeholder='输入要分析趋势的 IP 地址'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
size='sm'
|
||||
onClick={() => setAppliedTrendIp(trendIp.trim())}
|
||||
disabled={!trendIp.trim()}
|
||||
>
|
||||
@@ -535,9 +650,9 @@ export default function AccessLogsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background p-4">
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background p-4'>
|
||||
{!appliedTrendIp ? (
|
||||
<EmptyStateWithBorder description="请输入 IP 地址后查看 24 小时访问趋势。" />
|
||||
<EmptyStateWithBorder description='请输入 IP 地址后查看 24 小时访问趋势。' />
|
||||
) : ipTrendQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : ipTrendQuery.isError ? (
|
||||
@@ -545,28 +660,41 @@ export default function AccessLogsPage() {
|
||||
message={
|
||||
ipTrendQuery.error instanceof Error
|
||||
? ipTrendQuery.error.message
|
||||
: "加载失败"
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void ipTrendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.length === 0 ? (
|
||||
<EmptyStateWithBorder description="该 IP 在选定时间范围内没有访问记录。" />
|
||||
<EmptyStateWithBorder description='该 IP 在选定时间范围内没有访问记录。' />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
<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">
|
||||
<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} />
|
||||
<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)"
|
||||
type='monotone'
|
||||
dataKey='requests'
|
||||
stroke='var(--color-requests)'
|
||||
fill='var(--color-requests)'
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -584,5 +712,5 @@ export default function AccessLogsPage() {
|
||||
loading={cleanupMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user