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:
ryan
2026-07-12 16:38:35 +08:00
parent efcf61e32d
commit 31b4886a14
20 changed files with 1026 additions and 32 deletions
+1
View File
@@ -23,6 +23,7 @@ sidebar: false
### 新增
- Zone 概览页新增 Cloudflare 风格流量图:支持 24 小时 / 7 天 / 30 天,展示唯一访问者、请求总数与已提供数据趋势。
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
- 管理端网站入口改为 Zone 列表与 `/websites/:zoneId` 详情(概览 / 域名 / 路由 / 证书 / 设置),反代路由通过 Zone 域名选择器绑定。
+114
View File
@@ -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": {
+114
View File
@@ -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": {
+78
View File
@@ -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
+3
View File
@@ -108,6 +108,9 @@ export type {
ZoneItem,
ZoneMutationPayload,
ZoneOverview,
ZoneStats,
ZoneStatsPoint,
ZoneStatsRange,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
+23
View File
@@ -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`) }
+26
View File
@@ -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();
+1
View File
@@ -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)
}
+24
View File
@@ -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
+199
View File
@@ -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")
}
+2
View File
@@ -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) {
+11 -7
View File
@@ -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)