mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
feat(zone): add Cloudflare-style traffic overview charts
Expose Zone stats API with multi-host access-log aggregates and time series, and render unique visitors, requests and data served on the Zone overview with 24h/7d/30d range controls.
This commit is contained in:
@@ -23,6 +23,7 @@ sidebar: false
|
||||
|
||||
### 新增
|
||||
|
||||
- Zone 概览页新增 Cloudflare 风格流量图:支持 24 小时 / 7 天 / 30 天,展示唯一访问者、请求总数与已提供数据趋势。
|
||||
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
|
||||
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
|
||||
- 管理端网站入口改为 Zone 列表与 `/websites/:zoneId` 详情(概览 / 域名 / 路由 / 证书 / 设置),反代路由通过 Zone 域名选择器绑定。
|
||||
|
||||
+114
@@ -11225,6 +11225,70 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/zones/{id}/stats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-zone"
|
||||
],
|
||||
"summary": "获取 Zone 流量统计",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Zone ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "时间范围:24h(默认)、7d、30d",
|
||||
"name": "range",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Any"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/zone.Stats"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/zones/{id}/update": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -18954,6 +19018,56 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/model.Zone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.Stats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"available": {
|
||||
"description": "Available is false when analytics storage is unavailable (e.g. ClickHouse off).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"bytes_sent": {
|
||||
"description": "BytesSent is total response bytes. Currently always 0 until access logs store body_bytes_sent.",
|
||||
"type": "integer"
|
||||
},
|
||||
"domain_count": {
|
||||
"description": "DomainCount is the number of explicit Zone domains used to scope hosts.",
|
||||
"type": "integer"
|
||||
},
|
||||
"range": {
|
||||
"$ref": "#/definitions/zone.StatsRange"
|
||||
},
|
||||
"range_hours": {
|
||||
"type": "integer"
|
||||
},
|
||||
"request_count": {
|
||||
"description": "RequestCount is total access-log requests for Zone domains.",
|
||||
"type": "integer"
|
||||
},
|
||||
"unique_visitors": {
|
||||
"description": "UniqueVisitors is distinct client IPs (remote_addr) over the window.",
|
||||
"type": "integer"
|
||||
},
|
||||
"window_ended_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"window_started_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.StatsRange": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"24h",
|
||||
"7d",
|
||||
"30d"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"StatsRange24h",
|
||||
"StatsRange7d",
|
||||
"StatsRange30d"
|
||||
]
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
@@ -11218,6 +11218,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/zones/{id}/stats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-zone"
|
||||
],
|
||||
"summary": "获取 Zone 流量统计",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Zone ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "时间范围:24h(默认)、7d、30d",
|
||||
"name": "range",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Any"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/zone.Stats"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/zones/{id}/update": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -18947,6 +19011,56 @@
|
||||
"$ref": "#/definitions/model.Zone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.Stats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"available": {
|
||||
"description": "Available is false when analytics storage is unavailable (e.g. ClickHouse off).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"bytes_sent": {
|
||||
"description": "BytesSent is total response bytes. Currently always 0 until access logs store body_bytes_sent.",
|
||||
"type": "integer"
|
||||
},
|
||||
"domain_count": {
|
||||
"description": "DomainCount is the number of explicit Zone domains used to scope hosts.",
|
||||
"type": "integer"
|
||||
},
|
||||
"range": {
|
||||
"$ref": "#/definitions/zone.StatsRange"
|
||||
},
|
||||
"range_hours": {
|
||||
"type": "integer"
|
||||
},
|
||||
"request_count": {
|
||||
"description": "RequestCount is total access-log requests for Zone domains.",
|
||||
"type": "integer"
|
||||
},
|
||||
"unique_visitors": {
|
||||
"description": "UniqueVisitors is distinct client IPs (remote_addr) over the window.",
|
||||
"type": "integer"
|
||||
},
|
||||
"window_ended_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"window_started_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.StatsRange": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"24h",
|
||||
"7d",
|
||||
"30d"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"StatsRange24h",
|
||||
"StatsRange7d",
|
||||
"StatsRange30d"
|
||||
]
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
@@ -3961,6 +3961,46 @@ definitions:
|
||||
zone:
|
||||
$ref: '#/definitions/model.Zone'
|
||||
type: object
|
||||
zone.Stats:
|
||||
properties:
|
||||
available:
|
||||
description: Available is false when analytics storage is unavailable (e.g.
|
||||
ClickHouse off).
|
||||
type: boolean
|
||||
bytes_sent:
|
||||
description: BytesSent is total response bytes. Currently always 0 until access
|
||||
logs store body_bytes_sent.
|
||||
type: integer
|
||||
domain_count:
|
||||
description: DomainCount is the number of explicit Zone domains used to scope
|
||||
hosts.
|
||||
type: integer
|
||||
range:
|
||||
$ref: '#/definitions/zone.StatsRange'
|
||||
range_hours:
|
||||
type: integer
|
||||
request_count:
|
||||
description: RequestCount is total access-log requests for Zone domains.
|
||||
type: integer
|
||||
unique_visitors:
|
||||
description: UniqueVisitors is distinct client IPs (remote_addr) over the
|
||||
window.
|
||||
type: integer
|
||||
window_ended_at:
|
||||
type: string
|
||||
window_started_at:
|
||||
type: string
|
||||
type: object
|
||||
zone.StatsRange:
|
||||
enum:
|
||||
- 24h
|
||||
- 7d
|
||||
- 30d
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- StatsRange24h
|
||||
- StatsRange7d
|
||||
- StatsRange30d
|
||||
info:
|
||||
contact:
|
||||
name: OpenFlare
|
||||
@@ -10709,6 +10749,44 @@ paths:
|
||||
summary: 获取 Zone 概览
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/stats:
|
||||
get:
|
||||
description: 按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 时间范围:24h(默认)、7d、30d
|
||||
in: query
|
||||
name: range
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/zone.Stats'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取 Zone 流量统计
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
|
||||
@@ -1,14 +1,317 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import type {ZoneOverview} from '@/lib/services/openflare'
|
||||
import {formatDateTime} from '@/lib/utils'
|
||||
'use client';
|
||||
|
||||
export function ZoneOverviewPanel({overview}: {overview: ZoneOverview}) {
|
||||
const boundRoutes = new Set(overview.domains.flatMap((domain) => domain.proxy_route_id ? [domain.proxy_route_id] : [])).size
|
||||
const certificates = overview.domains.filter((domain) => domain.cert_id !== null).length
|
||||
return <div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">域名</CardTitle><CardDescription>显式 FQDN</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{overview.domains.length}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">关联路由</CardTitle><CardDescription>已绑定的不同规则</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{boundRoutes}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">已配证书</CardTitle><CardDescription>域名级 TLS 绑定</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{certificates}</CardContent></Card>
|
||||
<Card className="shadow-none md:col-span-3"><CardHeader className="pb-2"><CardTitle className="text-sm">Zone 信息</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm sm:grid-cols-2"><div><p className="text-xs text-muted-foreground">根域</p><p className="mt-1 font-medium">{overview.zone.domain}</p></div><div><p className="text-xs text-muted-foreground">创建时间</p><p className="mt-1">{formatDateTime(overview.zone.created_at)}</p></div></CardContent></Card>
|
||||
</div>
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Activity, HardDrive, Link2, ShieldCheck, Users} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneOverview,
|
||||
type ZoneStatsRange,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {formatBytes, formatCompactNumber} from '@/lib/utils/metrics';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const rangeOptions: Array<{value: ZoneStatsRange; label: string}> = [
|
||||
{value: '24h', label: '24 小时'},
|
||||
{value: '7d', label: '7 天'},
|
||||
{value: '30d', label: '30 天'},
|
||||
];
|
||||
|
||||
const visitorsChartConfig = {
|
||||
value: {label: '唯一访问者', color: 'hsl(217 91% 60%)'},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const requestsChartConfig = {
|
||||
value: {label: '请求总数', color: 'hsl(217 91% 60%)'},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const bytesChartConfig = {
|
||||
value: {label: '已提供数据', color: 'hsl(217 91% 60%)'},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function formatAxisLabel(iso: string, range: ZoneStatsRange) {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
if (range === '24h') {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: undefined,
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function formatWindowLabel(startedAt?: string, endedAt?: string) {
|
||||
if (!startedAt || !endedAt) {
|
||||
return '—';
|
||||
}
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
const fmt = (value: Date) =>
|
||||
value
|
||||
.toLocaleDateString('en-GB', {day: 'numeric', month: 'long'})
|
||||
.toUpperCase();
|
||||
return `${fmt(start)} — ${fmt(end)}`;
|
||||
}
|
||||
|
||||
export function ZoneOverviewPanel({
|
||||
overview,
|
||||
zoneId,
|
||||
}: {
|
||||
overview: ZoneOverview;
|
||||
zoneId: number;
|
||||
}) {
|
||||
const [range, setRange] = useState<ZoneStatsRange>('24h');
|
||||
const boundRoutes = new Set(
|
||||
overview.domains.flatMap((domain) =>
|
||||
domain.proxy_route_id ? [domain.proxy_route_id] : [],
|
||||
),
|
||||
).size;
|
||||
const certificates = overview.domains.filter(
|
||||
(domain) => domain.cert_id !== null,
|
||||
).length;
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, zoneId, 'stats', range],
|
||||
queryFn: () => ZoneService.getStats(zoneId, range),
|
||||
enabled: zoneId > 0,
|
||||
});
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(stats?.series ?? []).map((point) => ({
|
||||
label: formatAxisLabel(point.bucket_started_at, range),
|
||||
at: point.bucket_started_at,
|
||||
visitors: point.unique_visitors,
|
||||
requests: point.request_count,
|
||||
bytes: point.bytes_sent,
|
||||
})),
|
||||
[range, stats?.series],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="inline-flex rounded-lg border bg-muted/40 p-0.5">
|
||||
{rangeOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-7 rounded-md px-3 text-xs font-medium shadow-none',
|
||||
range === option.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => setRange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
{formatWindowLabel(stats?.window_started_at, stats?.window_ended_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{statsQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({length: 3}).map((_, index) => (
|
||||
<Skeleton key={index} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : statsQuery.isError ? (
|
||||
<div className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
加载流量统计失败
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void statsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{!stats?.available ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<MetricTrendCard
|
||||
icon={Users}
|
||||
label="唯一访问者"
|
||||
value={formatCompactNumber(stats?.unique_visitors ?? 0)}
|
||||
data={chartData}
|
||||
dataKey="visitors"
|
||||
config={visitorsChartConfig}
|
||||
gradientId="zone-visitors"
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={Activity}
|
||||
label="请求总数"
|
||||
value={formatCompactNumber(stats?.request_count ?? 0)}
|
||||
data={chartData}
|
||||
dataKey="requests"
|
||||
config={requestsChartConfig}
|
||||
gradientId="zone-requests"
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={HardDrive}
|
||||
label="已提供的数据总计"
|
||||
value={formatBytes(stats?.bytes_sent ?? 0, {zeroText: '0 B'})}
|
||||
data={chartData}
|
||||
dataKey="bytes"
|
||||
config={bytesChartConfig}
|
||||
gradientId="zone-bytes"
|
||||
valueFormatter={(value) => formatBytes(value, {zeroText: '0 B'})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-dashed shadow-none md:col-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Zone 信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">根域</p>
|
||||
<p className="mt-1 font-medium">{overview.zone.domain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">创建时间</p>
|
||||
<p className="mt-1">{formatDateTime(overview.zone.created_at)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTrendCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
data,
|
||||
dataKey,
|
||||
config,
|
||||
gradientId,
|
||||
valueFormatter,
|
||||
}: {
|
||||
icon: typeof Users;
|
||||
label: string;
|
||||
value: string;
|
||||
data: Array<Record<string, string | number>>;
|
||||
dataKey: string;
|
||||
config: ChartConfig;
|
||||
gradientId: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="overflow-hidden border shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<div className="grid gap-0 md:grid-cols-[minmax(140px,200px)_1fr]">
|
||||
<div className="flex flex-col justify-between gap-3 border-b p-4 md:border-b-0 md:border-r">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
<div className="h-36 min-h-[9rem] w-full px-2 py-3 md:h-40">
|
||||
<ChartContainer config={config} className="h-full w-full aspect-auto">
|
||||
<AreaChart data={data} margin={{top: 8, right: 12, left: 0, bottom: 0}}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-value)" stopOpacity={0.28} />
|
||||
<stop offset="95%" stopColor="var(--color-value)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-border/60"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={28}
|
||||
tickMargin={8}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
tickMargin={4}
|
||||
className="text-[10px]"
|
||||
allowDecimals={false}
|
||||
tickFormatter={(tick) =>
|
||||
valueFormatter
|
||||
? valueFormatter(Number(tick))
|
||||
: formatCompactNumber(Number(tick))
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(raw) => {
|
||||
const numeric = Number(raw);
|
||||
return valueFormatter
|
||||
? valueFormatter(numeric)
|
||||
: formatCompactNumber(numeric);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
name="value"
|
||||
stroke="var(--color-value)"
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ export function ZonePageClient({zoneId}: {zoneId: number}) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<ZoneOverviewPanel overview={overview} />
|
||||
<ZoneOverviewPanel overview={overview} zoneId={zoneId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains">
|
||||
<ZoneDomainsTable
|
||||
|
||||
@@ -108,6 +108,9 @@ export type {
|
||||
ZoneItem,
|
||||
ZoneMutationPayload,
|
||||
ZoneOverview,
|
||||
ZoneStats,
|
||||
ZoneStatsPoint,
|
||||
ZoneStatsRange,
|
||||
TlsCertificateApplyPayload,
|
||||
TlsCertificateContentItem,
|
||||
TlsCertificateDetailItem,
|
||||
|
||||
@@ -988,6 +988,29 @@ export interface ZoneOverview {
|
||||
domains: ZoneDomainItem[];
|
||||
}
|
||||
|
||||
export type ZoneStatsRange = '24h' | '7d' | '30d';
|
||||
|
||||
export interface ZoneStatsPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
unique_visitors: number;
|
||||
bytes_sent: number;
|
||||
}
|
||||
|
||||
export interface ZoneStats {
|
||||
range: ZoneStatsRange;
|
||||
range_hours: number;
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
bucket_minutes: number;
|
||||
unique_visitors: number;
|
||||
request_count: number;
|
||||
bytes_sent: number;
|
||||
domain_count: number;
|
||||
available: boolean;
|
||||
series: ZoneStatsPoint[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import {OpenFlareBaseService} from './base.service'
|
||||
import type {ZoneDomainItem, ZoneDomainMutationPayload, ZoneItem, ZoneMutationPayload, ZoneOverview} from './types'
|
||||
import type {
|
||||
ZoneDomainItem,
|
||||
ZoneDomainMutationPayload,
|
||||
ZoneItem,
|
||||
ZoneMutationPayload,
|
||||
ZoneOverview,
|
||||
ZoneStats,
|
||||
ZoneStatsRange,
|
||||
} from './types'
|
||||
|
||||
export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
|
||||
@@ -8,6 +16,9 @@ export class ZoneService extends OpenFlareBaseService {
|
||||
|
||||
static list(): Promise<ZoneItem[]> { return this.get<ZoneItem[]>('/') }
|
||||
static getOverview(id: number): Promise<ZoneOverview> { return this.get<ZoneOverview>(`/${id}/overview`) }
|
||||
static getStats(id: number, range: ZoneStatsRange = '24h'): Promise<ZoneStats> {
|
||||
return this.get<ZoneStats>(`/${id}/stats`, {range})
|
||||
}
|
||||
static create(payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>('/', payload) }
|
||||
static update(id: number, payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>(`/${id}/update`, payload) }
|
||||
static deleteById(id: number): Promise<void> { return this.post<void>(`/${id}/delete`) }
|
||||
|
||||
@@ -5,6 +5,14 @@ import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {ZonePageClient} from '@/app/(main)/websites/[zoneId]/page-client';
|
||||
import {ProxyRouteService, TlsCertificateService, ZoneService} from '@/lib/services/openflare';
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
|
||||
const replaceMock = vi.fn();
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
@@ -25,6 +33,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
...actual,
|
||||
ZoneService: {
|
||||
getOverview: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
@@ -53,6 +62,20 @@ function renderPage(zoneId: number) {
|
||||
describe('ZonePageClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ZoneService.getOverview).mockReset();
|
||||
vi.mocked(ZoneService.getStats).mockReset();
|
||||
vi.mocked(ZoneService.getStats).mockResolvedValue({
|
||||
range: '24h',
|
||||
range_hours: 24,
|
||||
window_started_at: new Date().toISOString(),
|
||||
window_ended_at: new Date().toISOString(),
|
||||
bucket_minutes: 60,
|
||||
unique_visitors: 0,
|
||||
request_count: 0,
|
||||
bytes_sent: 0,
|
||||
domain_count: 0,
|
||||
available: true,
|
||||
series: [],
|
||||
});
|
||||
vi.mocked(ZoneService.deleteById).mockReset();
|
||||
vi.mocked(TlsCertificateService.list).mockReset();
|
||||
vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
|
||||
@@ -72,6 +95,9 @@ describe('ZonePageClient', () => {
|
||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
|
||||
});
|
||||
expect(await screen.findByRole('heading', {name: 'arctel.de'})).toBeVisible();
|
||||
expect(await screen.findByText('唯一访问者')).toBeVisible();
|
||||
expect(screen.getByText('请求总数')).toBeVisible();
|
||||
expect(screen.getByText('已提供的数据总计')).toBeVisible();
|
||||
expect(screen.getByRole('tab', {name: '域名 (0)'})).toBeVisible();
|
||||
expect(screen.getByRole('tab', {name: '证书 (0)'})).toBeVisible();
|
||||
expect(screen.queryByRole('tab', {name: '路由'})).not.toBeInTheDocument();
|
||||
|
||||
@@ -15,4 +15,5 @@ const (
|
||||
errCertificateNotFound = "所选证书不存在"
|
||||
errDomainBoundToRoute = "域名已绑定反代路由,请先解除绑定"
|
||||
errZoneHasDomains = "根域下仍有域名,请先删除全部域名"
|
||||
errStatsRangeInvalid = "时间范围无效,请选择 24h、7d 或 30d"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ package zone
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -55,3 +56,49 @@ func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "example.co.uk", root)
|
||||
}
|
||||
|
||||
func TestGetStatsAggregatesZoneHosts(t *testing.T) {
|
||||
ctx := setupZoneDB(t)
|
||||
reset := model.SetAccessLogStoreForTest(model.NewMemoryAccessLogStore())
|
||||
t.Cleanup(reset)
|
||||
|
||||
zone, err := Create(ctx, Input{Domain: "example.com"})
|
||||
require.NoError(t, err)
|
||||
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"})
|
||||
require.NoError(t, err)
|
||||
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "www.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, model.InsertOpenFlareAccessLogsBatch(ctx, []*model.OpenFlareAccessLog{
|
||||
{NodeID: "n1", LoggedAt: now.Add(-1 * time.Hour), RemoteAddr: "1.1.1.1", Host: "api.example.com", Path: "/", StatusCode: 200},
|
||||
{NodeID: "n1", LoggedAt: now.Add(-2 * time.Hour), RemoteAddr: "1.1.1.1", Host: "www.example.com", Path: "/", StatusCode: 200},
|
||||
{NodeID: "n1", LoggedAt: now.Add(-3 * time.Hour), RemoteAddr: "2.2.2.2", Host: "api.example.com", Path: "/x", StatusCode: 404},
|
||||
{NodeID: "n1", LoggedAt: now.Add(-3 * time.Hour), RemoteAddr: "3.3.3.3", Host: "other.com", Path: "/", StatusCode: 200},
|
||||
{NodeID: "n1", LoggedAt: now.Add(-48 * time.Hour), RemoteAddr: "4.4.4.4", Host: "api.example.com", Path: "/", StatusCode: 200},
|
||||
}))
|
||||
|
||||
stats, err := GetStats(ctx, zone.ID, "24h")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, StatsRange24h, stats.Range)
|
||||
require.Equal(t, int64(3), stats.RequestCount)
|
||||
require.Equal(t, int64(2), stats.UniqueVisitors)
|
||||
require.Equal(t, 2, stats.DomainCount)
|
||||
require.True(t, stats.Available)
|
||||
require.NotEmpty(t, stats.Series)
|
||||
require.Equal(t, 60, stats.BucketMinutes)
|
||||
var seriesRequests int64
|
||||
for _, point := range stats.Series {
|
||||
seriesRequests += point.RequestCount
|
||||
}
|
||||
require.Equal(t, int64(3), seriesRequests)
|
||||
|
||||
stats7d, err := GetStats(ctx, zone.ID, "7d")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), stats7d.RequestCount)
|
||||
require.Equal(t, int64(3), stats7d.UniqueVisitors)
|
||||
require.NotEmpty(t, stats7d.Series)
|
||||
|
||||
_, err = GetStats(ctx, zone.ID, "1h")
|
||||
require.EqualError(t, err, errStatsRangeInvalid)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,30 @@ func GetOverviewHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(item))
|
||||
}
|
||||
|
||||
// GetStatsHandler returns Zone traffic metrics for a time range.
|
||||
// @Summary 获取 Zone 流量统计
|
||||
// @Description 按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。
|
||||
// @Tags openflare-zone
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "Zone ID"
|
||||
// @Param range query string false "时间范围:24h(默认)、7d、30d"
|
||||
// @Success 200 {object} response.Any{data=zone.Stats}
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Failure 404 {object} response.Any
|
||||
// @Router /api/v1/d/zones/{id}/stats [get]
|
||||
func GetStatsHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := GetStats(c.Request.Context(), id, c.Query("range"))
|
||||
if abort(c, err, errZoneNotFound) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(item))
|
||||
}
|
||||
|
||||
// UpdateHandler updates a Zone.
|
||||
// @Summary 更新 Zone
|
||||
// @Tags openflare-zone
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package zone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StatsRange is a supported traffic window for Zone analytics.
|
||||
type StatsRange string
|
||||
|
||||
const (
|
||||
StatsRange24h StatsRange = "24h"
|
||||
StatsRange7d StatsRange = "7d"
|
||||
StatsRange30d StatsRange = "30d"
|
||||
)
|
||||
|
||||
// StatsPoint is one bucket on a Zone traffic chart.
|
||||
type StatsPoint struct {
|
||||
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
UniqueVisitors int64 `json:"unique_visitors"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
}
|
||||
|
||||
// Stats summarizes edge traffic for all domains under a Zone.
|
||||
type Stats struct {
|
||||
Range StatsRange `json:"range"`
|
||||
RangeHours int `json:"range_hours"`
|
||||
WindowStartedAt time.Time `json:"window_started_at"`
|
||||
WindowEndedAt time.Time `json:"window_ended_at"`
|
||||
BucketMinutes int `json:"bucket_minutes"`
|
||||
UniqueVisitors int64 `json:"unique_visitors"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
DomainCount int `json:"domain_count"`
|
||||
Available bool `json:"available"`
|
||||
Series []StatsPoint `json:"series"`
|
||||
}
|
||||
|
||||
func parseStatsRange(raw string) (StatsRange, time.Duration, int, error) {
|
||||
switch StatsRange(strings.TrimSpace(raw)) {
|
||||
case "", StatsRange24h:
|
||||
return StatsRange24h, 24 * time.Hour, 60, nil
|
||||
case StatsRange7d:
|
||||
return StatsRange7d, 7 * 24 * time.Hour, 6 * 60, nil
|
||||
case StatsRange30d:
|
||||
return StatsRange30d, 30 * 24 * time.Hour, 24 * 60, nil
|
||||
default:
|
||||
return "", 0, 0, errors.New(errStatsRangeInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats aggregates access-log traffic for a Zone over a time range.
|
||||
func GetStats(ctx context.Context, id uint, rangeRaw string) (*Stats, error) {
|
||||
statsRange, window, bucketMinutes, err := parseStatsRange(rangeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var zone model.Zone
|
||||
if err := db.DB(ctx).First(&zone, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var domains []model.ZoneDomain
|
||||
if err := db.DB(ctx).Where("zone_id = ?", id).Order("domain asc").Find(&domains).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
since := now.Add(-window)
|
||||
// Align chart window start to bucket boundary for cleaner x-axis labels.
|
||||
bucket := time.Duration(bucketMinutes) * time.Minute
|
||||
since = since.Truncate(bucket)
|
||||
|
||||
result := &Stats{
|
||||
Range: statsRange,
|
||||
RangeHours: int(window / time.Hour),
|
||||
WindowStartedAt: since,
|
||||
WindowEndedAt: now,
|
||||
BucketMinutes: bucketMinutes,
|
||||
DomainCount: len(domains),
|
||||
Available: true,
|
||||
Series: emptyStatsSeries(since, now, bucketMinutes),
|
||||
}
|
||||
if len(domains) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(domains))
|
||||
for _, domain := range domains {
|
||||
if host := strings.TrimSpace(domain.Domain); host != "" {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
requestCount, uniqueVisitors, err := model.CountOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{
|
||||
Hosts: hosts,
|
||||
Since: since,
|
||||
Until: now,
|
||||
})
|
||||
if err != nil {
|
||||
if isAnalyticsUnavailable(err) {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result.RequestCount = requestCount
|
||||
result.UniqueVisitors = uniqueVisitors
|
||||
// Bytes are not yet persisted on edge access logs; keep the field for UI compatibility.
|
||||
result.BytesSent = 0
|
||||
|
||||
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
||||
Hosts: hosts,
|
||||
Since: since,
|
||||
Until: now,
|
||||
FoldMinutes: bucketMinutes,
|
||||
SortBy: "logged_at",
|
||||
SortOrder: "asc",
|
||||
})
|
||||
if err != nil {
|
||||
if isAnalyticsUnavailable(err) {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byEpoch := make(map[int64]model.OpenFlareAccessLogBucketRow, len(buckets))
|
||||
for _, bucketRow := range buckets {
|
||||
if bucketRow == nil {
|
||||
continue
|
||||
}
|
||||
byEpoch[bucketRow.BucketEpoch] = *bucketRow
|
||||
}
|
||||
series := emptyStatsSeries(since, now, bucketMinutes)
|
||||
for index := range series {
|
||||
epoch := series[index].BucketStartedAt.Unix()
|
||||
if row, ok := byEpoch[epoch]; ok {
|
||||
series[index].RequestCount = row.RequestCount
|
||||
series[index].UniqueVisitors = row.UniqueIPCount
|
||||
series[index].BytesSent = 0
|
||||
}
|
||||
}
|
||||
result.Series = series
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func emptyStatsSeries(since, until time.Time, bucketMinutes int) []StatsPoint {
|
||||
if bucketMinutes <= 0 {
|
||||
bucketMinutes = 60
|
||||
}
|
||||
bucket := time.Duration(bucketMinutes) * time.Minute
|
||||
start := since.UTC().Truncate(bucket)
|
||||
end := until.UTC()
|
||||
if !end.After(start) {
|
||||
return []StatsPoint{{BucketStartedAt: start}}
|
||||
}
|
||||
// Cap points to keep chart readable.
|
||||
maxPoints := 120
|
||||
capacity := int(end.Sub(start)/bucket) + 1
|
||||
if capacity > maxPoints {
|
||||
capacity = maxPoints
|
||||
}
|
||||
points := make([]StatsPoint, 0, capacity)
|
||||
for cursor := start; !cursor.After(end) && len(points) < maxPoints; cursor = cursor.Add(bucket) {
|
||||
points = append(points, StatsPoint{BucketStartedAt: cursor})
|
||||
}
|
||||
if len(points) == 0 {
|
||||
points = append(points, StatsPoint{BucketStartedAt: start})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func isAnalyticsUnavailable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrInvalidDB) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "clickhouse connection is not initialized") ||
|
||||
strings.Contains(msg, "clickhouse is not") ||
|
||||
strings.Contains(msg, "database is not initialized")
|
||||
}
|
||||
@@ -309,8 +309,10 @@ func openFlareAccessLogQueryFromBucket(query OpenFlareAccessLogBucketQuery) Open
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Hosts: query.Hosts,
|
||||
Path: query.Path,
|
||||
Since: query.Since,
|
||||
Until: query.Until,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
SortBy: query.SortBy,
|
||||
|
||||
@@ -263,6 +263,7 @@ func toNodeAccessLogFilter(query OpenFlareAccessLogQuery) analyticsrepo.NodeAcce
|
||||
NodeID: query.NodeID,
|
||||
RemoteAddr: query.RemoteAddr,
|
||||
Host: query.Host,
|
||||
Hosts: query.Hosts,
|
||||
Path: query.Path,
|
||||
Since: query.Since,
|
||||
Until: query.Until,
|
||||
|
||||
@@ -450,7 +450,19 @@ func memoryAccessLogMatches(row *OpenFlareAccessLog, query OpenFlareAccessLogQue
|
||||
if trimmed := strings.TrimSpace(query.RemoteAddr); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.RemoteAddr), trimmed) {
|
||||
return false
|
||||
}
|
||||
if trimmed := strings.TrimSpace(query.Host); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.Host), trimmed) {
|
||||
if len(query.Hosts) > 0 {
|
||||
rowHost := strings.ToLower(strings.TrimSpace(row.Host))
|
||||
matched := false
|
||||
for _, host := range query.Hosts {
|
||||
if strings.ToLower(strings.TrimSpace(host)) == rowHost {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
} else if trimmed := strings.TrimSpace(query.Host); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.Host), trimmed) {
|
||||
return false
|
||||
}
|
||||
if trimmed := strings.TrimSpace(query.Path); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.Path), trimmed) {
|
||||
|
||||
@@ -185,13 +185,15 @@ type OpenFlareAccessLogQuery struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||
Hosts []string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
|
||||
@@ -199,8 +201,10 @@ type OpenFlareAccessLogBucketQuery struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Hosts []string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
const (
|
||||
nodeAccessLogFilterClauseCapacity = 6
|
||||
|
||||
nodeAccessLogSortDesc = "DESC"
|
||||
nodeAccessLogSortAsc = "ASC"
|
||||
nodeAccessLogSortDesc = "DESC"
|
||||
nodeAccessLogSortAsc = "ASC"
|
||||
nodeAccessLogSortAscInput = "asc"
|
||||
|
||||
nodeAccessLogColumnRemoteAddr = "remote_addr"
|
||||
@@ -24,13 +24,15 @@ type NodeAccessLogFilter struct {
|
||||
NodeID string
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||
Hosts []string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) {
|
||||
@@ -44,7 +46,15 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
|
||||
parts = append(parts, "remote_addr LIKE ?")
|
||||
args = append(args, trimmed+"%")
|
||||
}
|
||||
if trimmed := strings.TrimSpace(filter.Host); trimmed != "" {
|
||||
hosts := normalizeNodeAccessLogHosts(filter.Hosts)
|
||||
if len(hosts) > 0 {
|
||||
placeholders := make([]string, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, host)
|
||||
}
|
||||
parts = append(parts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")")
|
||||
} else if trimmed := strings.TrimSpace(filter.Host); trimmed != "" {
|
||||
parts = append(parts, "host LIKE ?")
|
||||
args = append(args, trimmed+"%")
|
||||
}
|
||||
@@ -99,6 +109,26 @@ func normalizeNodeAccessLogRemoteAddr(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func normalizeNodeAccessLogHosts(hosts []string) []string {
|
||||
if len(hosts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(hosts))
|
||||
result := make([]string, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(host))
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeNodeAccessLogSortOrder(sortOrder string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
|
||||
return "asc"
|
||||
|
||||
@@ -15,6 +15,7 @@ func registerZoneRoutes(apiGroup *gin.RouterGroup) {
|
||||
apiutil.RegisterCollection(zoneGroup, "GET", zone.ListHandler)
|
||||
apiutil.RegisterCollection(zoneGroup, "POST", zone.CreateHandler)
|
||||
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
||||
zoneGroup.GET("/:id/stats", zone.GetStatsHandler)
|
||||
zoneGroup.POST("/:id/update", zone.UpdateHandler)
|
||||
zoneGroup.POST("/:id/delete", zone.DeleteHandler)
|
||||
zoneGroup.POST("/:id/domains", zone.CreateDomainHandler)
|
||||
|
||||
Reference in New Issue
Block a user