From 31b4886a1416fafa5f394df635d51dc3b7caabcf Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 12 Jul 2026 16:38:35 +0800 Subject: [PATCH] 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. --- docs/changelog/index.md | 1 + docs/docs.go | 114 ++++++ docs/swagger.json | 114 ++++++ docs/swagger.yaml | 78 +++++ .../[zoneId]/components/zone-overview.tsx | 327 +++++++++++++++++- .../(main)/websites/[zoneId]/page-client.tsx | 2 +- frontend/lib/services/openflare/index.ts | 3 + frontend/lib/services/openflare/types.ts | 23 ++ .../lib/services/openflare/zone.service.ts | 13 +- frontend/tests/zone/zone-page.test.tsx | 26 ++ internal/apps/openflare/zone/errs.go | 1 + internal/apps/openflare/zone/logics_test.go | 47 +++ internal/apps/openflare/zone/routers.go | 24 ++ internal/apps/openflare/zone/stats.go | 199 +++++++++++ internal/model/openflare_access_log.go | 2 + internal/model/openflare_access_log_store.go | 1 + .../openflare_access_log_store_memory.go | 14 +- internal/model/openflare_observability.go | 18 +- .../analytics/node_access_log_filter.go | 50 ++- internal/router/v1/openflare/register_zone.go | 1 + 20 files changed, 1026 insertions(+), 32 deletions(-) create mode 100644 internal/apps/openflare/zone/stats.go 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 信息

根域

{overview.zone.domain}

创建时间

{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)