diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 4e371283..d9cca519 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -23,6 +23,7 @@ sidebar: false
### 新增
+- Zone 概览页新增 Cloudflare 风格流量图:支持 24 小时 / 7 天 / 30 天,展示唯一访问者、请求总数与已提供数据趋势。
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
- 管理端网站入口改为 Zone 列表与 `/websites/:zoneId` 详情(概览 / 域名 / 路由 / 证书 / 设置),反代路由通过 Zone 域名选择器绑定。
diff --git a/docs/docs.go b/docs/docs.go
index e5ab06c5..02aa09f3 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -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": {
diff --git a/docs/swagger.json b/docs/swagger.json
index 0a2183f9..3a7389e2 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -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": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 48a4f105..8139dbc8 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -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:
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx
index a744642b..84a83e98 100644
--- a/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx
@@ -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
-
域名显式 FQDN{overview.domains.length}
-
关联路由已绑定的不同规则{boundRoutes}
-
已配证书域名级 TLS 绑定{certificates}
-
Zone 信息创建时间
{formatDateTime(overview.zone.created_at)}
-
+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('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 (
+
+
+
+
+ {rangeOptions.map((option) => (
+
+ ))}
+
+
+ {formatWindowLabel(stats?.window_started_at, stats?.window_ended_at)}
+
+
+
+ {statsQuery.isLoading ? (
+
+ {Array.from({length: 3}).map((_, index) => (
+
+ ))}
+
+ ) : statsQuery.isError ? (
+
+ 加载流量统计失败
+
+
+
+
+ ) : (
+
+ {!stats?.available ? (
+
+ 分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。
+
+ ) : null}
+
+
+
+
formatBytes(value, {zeroText: '0 B'})}
+ />
+
+ )}
+
+
+
+
+
+ Zone 信息
+
+
+
+
根域
+
{overview.zone.domain}
+
+
+
创建时间
+
{formatDateTime(overview.zone.created_at)}
+
+
+
+
+
+ );
+}
+
+function MetricTrendCard({
+ icon: Icon,
+ label,
+ value,
+ data,
+ dataKey,
+ config,
+ gradientId,
+ valueFormatter,
+}: {
+ icon: typeof Users;
+ label: string;
+ value: string;
+ data: Array>;
+ dataKey: string;
+ config: ChartConfig;
+ gradientId: string;
+ valueFormatter?: (value: number) => string;
+}) {
+ return (
+
+
+
+
+
+
+ {label}
+
+
{value}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ valueFormatter
+ ? valueFormatter(Number(tick))
+ : formatCompactNumber(Number(tick))
+ }
+ />
+ {
+ const numeric = Number(raw);
+ return valueFormatter
+ ? valueFormatter(numeric)
+ : formatCompactNumber(numeric);
+ }}
+ />
+ }
+ />
+
+
+
+
+
+
+
+ );
}
diff --git a/frontend/app/(main)/websites/[zoneId]/page-client.tsx b/frontend/app/(main)/websites/[zoneId]/page-client.tsx
index 9dcbbc66..0f6f6bd1 100644
--- a/frontend/app/(main)/websites/[zoneId]/page-client.tsx
+++ b/frontend/app/(main)/websites/[zoneId]/page-client.tsx
@@ -180,7 +180,7 @@ export function ZonePageClient({zoneId}: {zoneId: number}) {
-
+
{ return this.get('/') }
static getOverview(id: number): Promise { return this.get(`/${id}/overview`) }
+ static getStats(id: number, range: ZoneStatsRange = '24h'): Promise {
+ return this.get(`/${id}/stats`, {range})
+ }
static create(payload: ZoneMutationPayload): Promise { return this.post('/', payload) }
static update(id: number, payload: ZoneMutationPayload): Promise { return this.post(`/${id}/update`, payload) }
static deleteById(id: number): Promise { return this.post(`/${id}/delete`) }
diff --git a/frontend/tests/zone/zone-page.test.tsx b/frontend/tests/zone/zone-page.test.tsx
index f86a90a1..37fee031 100644
--- a/frontend/tests/zone/zone-page.test.tsx
+++ b/frontend/tests/zone/zone-page.test.tsx
@@ -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();
diff --git a/internal/apps/openflare/zone/errs.go b/internal/apps/openflare/zone/errs.go
index a026b179..880c7591 100644
--- a/internal/apps/openflare/zone/errs.go
+++ b/internal/apps/openflare/zone/errs.go
@@ -15,4 +15,5 @@ const (
errCertificateNotFound = "所选证书不存在"
errDomainBoundToRoute = "域名已绑定反代路由,请先解除绑定"
errZoneHasDomains = "根域下仍有域名,请先删除全部域名"
+ errStatsRangeInvalid = "时间范围无效,请选择 24h、7d 或 30d"
)
diff --git a/internal/apps/openflare/zone/logics_test.go b/internal/apps/openflare/zone/logics_test.go
index 4e44cf50..d0ed5a84 100644
--- a/internal/apps/openflare/zone/logics_test.go
+++ b/internal/apps/openflare/zone/logics_test.go
@@ -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)
+}
diff --git a/internal/apps/openflare/zone/routers.go b/internal/apps/openflare/zone/routers.go
index 7c3bb2db..6005bec6 100644
--- a/internal/apps/openflare/zone/routers.go
+++ b/internal/apps/openflare/zone/routers.go
@@ -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
diff --git a/internal/apps/openflare/zone/stats.go b/internal/apps/openflare/zone/stats.go
new file mode 100644
index 00000000..9b42db4e
--- /dev/null
+++ b/internal/apps/openflare/zone/stats.go
@@ -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")
+}
diff --git a/internal/model/openflare_access_log.go b/internal/model/openflare_access_log.go
index 54220fd5..f5c08ff6 100644
--- a/internal/model/openflare_access_log.go
+++ b/internal/model/openflare_access_log.go
@@ -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,
diff --git a/internal/model/openflare_access_log_store.go b/internal/model/openflare_access_log_store.go
index b68b6725..9cd1eca6 100644
--- a/internal/model/openflare_access_log_store.go
+++ b/internal/model/openflare_access_log_store.go
@@ -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,
diff --git a/internal/model/openflare_access_log_store_memory.go b/internal/model/openflare_access_log_store_memory.go
index 709e5dcc..cb282f86 100644
--- a/internal/model/openflare_access_log_store_memory.go
+++ b/internal/model/openflare_access_log_store_memory.go
@@ -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) {
diff --git a/internal/model/openflare_observability.go b/internal/model/openflare_observability.go
index db31e6ef..b3680659 100644
--- a/internal/model/openflare_observability.go
+++ b/internal/model/openflare_observability.go
@@ -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
diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go
index d5309f33..00a2a59e 100644
--- a/internal/repository/analytics/node_access_log_filter.go
+++ b/internal/repository/analytics/node_access_log_filter.go
@@ -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"
diff --git a/internal/router/v1/openflare/register_zone.go b/internal/router/v1/openflare/register_zone.go
index 875df018..afc992ef 100644
--- a/internal/router/v1/openflare/register_zone.go
+++ b/internal/router/v1/openflare/register_zone.go
@@ -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)