mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
feat(zone): add Cloudflare-style traffic overview charts
Expose Zone stats API with multi-host access-log aggregates and time series, and render unique visitors, requests and data served on the Zone overview with 24h/7d/30d range controls.
This commit is contained in:
@@ -23,6 +23,7 @@ sidebar: false
|
|||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
|
- Zone 概览页新增 Cloudflare 风格流量图:支持 24 小时 / 7 天 / 30 天,展示唯一访问者、请求总数与已提供数据趋势。
|
||||||
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
|
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
|
||||||
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
|
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
|
||||||
- 管理端网站入口改为 Zone 列表与 `/websites/:zoneId` 详情(概览 / 域名 / 路由 / 证书 / 设置),反代路由通过 Zone 域名选择器绑定。
|
- 管理端网站入口改为 Zone 列表与 `/websites/:zoneId` 详情(概览 / 域名 / 路由 / 证书 / 设置),反代路由通过 Zone 域名选择器绑定。
|
||||||
|
|||||||
+114
@@ -11225,6 +11225,70 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/d/zones/{id}/stats": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-zone"
|
||||||
|
],
|
||||||
|
"summary": "获取 Zone 流量统计",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Zone ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "时间范围:24h(默认)、7d、30d",
|
||||||
|
"name": "range",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/zone.Stats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/d/zones/{id}/update": {
|
"/api/v1/d/zones/{id}/update": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -18954,6 +19018,56 @@ const docTemplate = `{
|
|||||||
"$ref": "#/definitions/model.Zone"
|
"$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": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -11218,6 +11218,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/d/zones/{id}/stats": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-zone"
|
||||||
|
],
|
||||||
|
"summary": "获取 Zone 流量统计",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Zone ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "时间范围:24h(默认)、7d、30d",
|
||||||
|
"name": "range",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/zone.Stats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/d/zones/{id}/update": {
|
"/api/v1/d/zones/{id}/update": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -18947,6 +19011,56 @@
|
|||||||
"$ref": "#/definitions/model.Zone"
|
"$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": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -3961,6 +3961,46 @@ definitions:
|
|||||||
zone:
|
zone:
|
||||||
$ref: '#/definitions/model.Zone'
|
$ref: '#/definitions/model.Zone'
|
||||||
type: object
|
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:
|
info:
|
||||||
contact:
|
contact:
|
||||||
name: OpenFlare
|
name: OpenFlare
|
||||||
@@ -10709,6 +10749,44 @@ paths:
|
|||||||
summary: 获取 Zone 概览
|
summary: 获取 Zone 概览
|
||||||
tags:
|
tags:
|
||||||
- openflare-zone
|
- 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:
|
/api/v1/d/zones/{id}/update:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -1,14 +1,317 @@
|
|||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
'use client';
|
||||||
import type {ZoneOverview} from '@/lib/services/openflare'
|
|
||||||
import {formatDateTime} from '@/lib/utils'
|
|
||||||
|
|
||||||
export function ZoneOverviewPanel({overview}: {overview: ZoneOverview}) {
|
import {useMemo, useState} from 'react';
|
||||||
const boundRoutes = new Set(overview.domains.flatMap((domain) => domain.proxy_route_id ? [domain.proxy_route_id] : [])).size
|
import {useQuery} from '@tanstack/react-query';
|
||||||
const certificates = overview.domains.filter((domain) => domain.cert_id !== null).length
|
import {Activity, HardDrive, Link2, ShieldCheck, Users} from 'lucide-react';
|
||||||
return <div className="grid gap-4 md:grid-cols-3">
|
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts';
|
||||||
<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>
|
import {Button} from '@/components/ui/button';
|
||||||
<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>
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/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>
|
import {
|
||||||
</div>
|
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>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
<ZoneOverviewPanel overview={overview} />
|
<ZoneOverviewPanel overview={overview} zoneId={zoneId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="domains">
|
<TabsContent value="domains">
|
||||||
<ZoneDomainsTable
|
<ZoneDomainsTable
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export type {
|
|||||||
ZoneItem,
|
ZoneItem,
|
||||||
ZoneMutationPayload,
|
ZoneMutationPayload,
|
||||||
ZoneOverview,
|
ZoneOverview,
|
||||||
|
ZoneStats,
|
||||||
|
ZoneStatsPoint,
|
||||||
|
ZoneStatsRange,
|
||||||
TlsCertificateApplyPayload,
|
TlsCertificateApplyPayload,
|
||||||
TlsCertificateContentItem,
|
TlsCertificateContentItem,
|
||||||
TlsCertificateDetailItem,
|
TlsCertificateDetailItem,
|
||||||
|
|||||||
@@ -988,6 +988,29 @@ export interface ZoneOverview {
|
|||||||
domains: ZoneDomainItem[];
|
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 {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
|
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 list(): Promise<ZoneItem[]> { return this.get<ZoneItem[]>('/') }
|
||||||
static getOverview(id: number): Promise<ZoneOverview> { return this.get<ZoneOverview>(`/${id}/overview`) }
|
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 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 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`) }
|
static deleteById(id: number): Promise<void> { return this.post<void>(`/${id}/delete`) }
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import {beforeEach, describe, expect, it, vi} from 'vitest';
|
|||||||
import {ZonePageClient} from '@/app/(main)/websites/[zoneId]/page-client';
|
import {ZonePageClient} from '@/app/(main)/websites/[zoneId]/page-client';
|
||||||
import {ProxyRouteService, TlsCertificateService, ZoneService} from '@/lib/services/openflare';
|
import {ProxyRouteService, TlsCertificateService, ZoneService} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||||
|
|
||||||
const replaceMock = vi.fn();
|
const replaceMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('next/link', () => ({
|
vi.mock('next/link', () => ({
|
||||||
@@ -25,6 +33,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
ZoneService: {
|
ZoneService: {
|
||||||
getOverview: vi.fn(),
|
getOverview: vi.fn(),
|
||||||
|
getStats: vi.fn(),
|
||||||
deleteById: vi.fn(),
|
deleteById: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -53,6 +62,20 @@ function renderPage(zoneId: number) {
|
|||||||
describe('ZonePageClient', () => {
|
describe('ZonePageClient', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(ZoneService.getOverview).mockReset();
|
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(ZoneService.deleteById).mockReset();
|
||||||
vi.mocked(TlsCertificateService.list).mockReset();
|
vi.mocked(TlsCertificateService.list).mockReset();
|
||||||
vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
|
vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
|
||||||
@@ -72,6 +95,9 @@ describe('ZonePageClient', () => {
|
|||||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
|
expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('heading', {name: 'arctel.de'})).toBeVisible();
|
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.getByRole('tab', {name: '证书 (0)'})).toBeVisible();
|
expect(screen.getByRole('tab', {name: '证书 (0)'})).toBeVisible();
|
||||||
expect(screen.queryByRole('tab', {name: '路由'})).not.toBeInTheDocument();
|
expect(screen.queryByRole('tab', {name: '路由'})).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ const (
|
|||||||
errCertificateNotFound = "所选证书不存在"
|
errCertificateNotFound = "所选证书不存在"
|
||||||
errDomainBoundToRoute = "域名已绑定反代路由,请先解除绑定"
|
errDomainBoundToRoute = "域名已绑定反代路由,请先解除绑定"
|
||||||
errZoneHasDomains = "根域下仍有域名,请先删除全部域名"
|
errZoneHasDomains = "根域下仍有域名,请先删除全部域名"
|
||||||
|
errStatsRangeInvalid = "时间范围无效,请选择 24h、7d 或 30d"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package zone
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
@@ -55,3 +56,49 @@ func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "example.co.uk", root)
|
require.Equal(t, "example.co.uk", root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetStatsAggregatesZoneHosts(t *testing.T) {
|
||||||
|
ctx := setupZoneDB(t)
|
||||||
|
reset := model.SetAccessLogStoreForTest(model.NewMemoryAccessLogStore())
|
||||||
|
t.Cleanup(reset)
|
||||||
|
|
||||||
|
zone, err := Create(ctx, Input{Domain: "example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "www.example.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
require.NoError(t, model.InsertOpenFlareAccessLogsBatch(ctx, []*model.OpenFlareAccessLog{
|
||||||
|
{NodeID: "n1", LoggedAt: now.Add(-1 * time.Hour), RemoteAddr: "1.1.1.1", Host: "api.example.com", Path: "/", StatusCode: 200},
|
||||||
|
{NodeID: "n1", LoggedAt: now.Add(-2 * time.Hour), RemoteAddr: "1.1.1.1", Host: "www.example.com", Path: "/", StatusCode: 200},
|
||||||
|
{NodeID: "n1", LoggedAt: now.Add(-3 * time.Hour), RemoteAddr: "2.2.2.2", Host: "api.example.com", Path: "/x", StatusCode: 404},
|
||||||
|
{NodeID: "n1", LoggedAt: now.Add(-3 * time.Hour), RemoteAddr: "3.3.3.3", Host: "other.com", Path: "/", StatusCode: 200},
|
||||||
|
{NodeID: "n1", LoggedAt: now.Add(-48 * time.Hour), RemoteAddr: "4.4.4.4", Host: "api.example.com", Path: "/", StatusCode: 200},
|
||||||
|
}))
|
||||||
|
|
||||||
|
stats, err := GetStats(ctx, zone.ID, "24h")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, StatsRange24h, stats.Range)
|
||||||
|
require.Equal(t, int64(3), stats.RequestCount)
|
||||||
|
require.Equal(t, int64(2), stats.UniqueVisitors)
|
||||||
|
require.Equal(t, 2, stats.DomainCount)
|
||||||
|
require.True(t, stats.Available)
|
||||||
|
require.NotEmpty(t, stats.Series)
|
||||||
|
require.Equal(t, 60, stats.BucketMinutes)
|
||||||
|
var seriesRequests int64
|
||||||
|
for _, point := range stats.Series {
|
||||||
|
seriesRequests += point.RequestCount
|
||||||
|
}
|
||||||
|
require.Equal(t, int64(3), seriesRequests)
|
||||||
|
|
||||||
|
stats7d, err := GetStats(ctx, zone.ID, "7d")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(4), stats7d.RequestCount)
|
||||||
|
require.Equal(t, int64(3), stats7d.UniqueVisitors)
|
||||||
|
require.NotEmpty(t, stats7d.Series)
|
||||||
|
|
||||||
|
_, err = GetStats(ctx, zone.ID, "1h")
|
||||||
|
require.EqualError(t, err, errStatsRangeInvalid)
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,30 @@ func GetOverviewHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response.OK(item))
|
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.
|
// UpdateHandler updates a Zone.
|
||||||
// @Summary 更新 Zone
|
// @Summary 更新 Zone
|
||||||
// @Tags openflare-zone
|
// @Tags openflare-zone
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package zone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsRange is a supported traffic window for Zone analytics.
|
||||||
|
type StatsRange string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatsRange24h StatsRange = "24h"
|
||||||
|
StatsRange7d StatsRange = "7d"
|
||||||
|
StatsRange30d StatsRange = "30d"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsPoint is one bucket on a Zone traffic chart.
|
||||||
|
type StatsPoint struct {
|
||||||
|
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
UniqueVisitors int64 `json:"unique_visitors"`
|
||||||
|
BytesSent int64 `json:"bytes_sent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats summarizes edge traffic for all domains under a Zone.
|
||||||
|
type Stats struct {
|
||||||
|
Range StatsRange `json:"range"`
|
||||||
|
RangeHours int `json:"range_hours"`
|
||||||
|
WindowStartedAt time.Time `json:"window_started_at"`
|
||||||
|
WindowEndedAt time.Time `json:"window_ended_at"`
|
||||||
|
BucketMinutes int `json:"bucket_minutes"`
|
||||||
|
UniqueVisitors int64 `json:"unique_visitors"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
BytesSent int64 `json:"bytes_sent"`
|
||||||
|
DomainCount int `json:"domain_count"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Series []StatsPoint `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStatsRange(raw string) (StatsRange, time.Duration, int, error) {
|
||||||
|
switch StatsRange(strings.TrimSpace(raw)) {
|
||||||
|
case "", StatsRange24h:
|
||||||
|
return StatsRange24h, 24 * time.Hour, 60, nil
|
||||||
|
case StatsRange7d:
|
||||||
|
return StatsRange7d, 7 * 24 * time.Hour, 6 * 60, nil
|
||||||
|
case StatsRange30d:
|
||||||
|
return StatsRange30d, 30 * 24 * time.Hour, 24 * 60, nil
|
||||||
|
default:
|
||||||
|
return "", 0, 0, errors.New(errStatsRangeInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats aggregates access-log traffic for a Zone over a time range.
|
||||||
|
func GetStats(ctx context.Context, id uint, rangeRaw string) (*Stats, error) {
|
||||||
|
statsRange, window, bucketMinutes, err := parseStatsRange(rangeRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var zone model.Zone
|
||||||
|
if err := db.DB(ctx).First(&zone, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var domains []model.ZoneDomain
|
||||||
|
if err := db.DB(ctx).Where("zone_id = ?", id).Order("domain asc").Find(&domains).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Minute)
|
||||||
|
since := now.Add(-window)
|
||||||
|
// Align chart window start to bucket boundary for cleaner x-axis labels.
|
||||||
|
bucket := time.Duration(bucketMinutes) * time.Minute
|
||||||
|
since = since.Truncate(bucket)
|
||||||
|
|
||||||
|
result := &Stats{
|
||||||
|
Range: statsRange,
|
||||||
|
RangeHours: int(window / time.Hour),
|
||||||
|
WindowStartedAt: since,
|
||||||
|
WindowEndedAt: now,
|
||||||
|
BucketMinutes: bucketMinutes,
|
||||||
|
DomainCount: len(domains),
|
||||||
|
Available: true,
|
||||||
|
Series: emptyStatsSeries(since, now, bucketMinutes),
|
||||||
|
}
|
||||||
|
if len(domains) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := make([]string, 0, len(domains))
|
||||||
|
for _, domain := range domains {
|
||||||
|
if host := strings.TrimSpace(domain.Domain); host != "" {
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requestCount, uniqueVisitors, err := model.CountOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{
|
||||||
|
Hosts: hosts,
|
||||||
|
Since: since,
|
||||||
|
Until: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isAnalyticsUnavailable(err) {
|
||||||
|
result.Available = false
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.RequestCount = requestCount
|
||||||
|
result.UniqueVisitors = uniqueVisitors
|
||||||
|
// Bytes are not yet persisted on edge access logs; keep the field for UI compatibility.
|
||||||
|
result.BytesSent = 0
|
||||||
|
|
||||||
|
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
||||||
|
Hosts: hosts,
|
||||||
|
Since: since,
|
||||||
|
Until: now,
|
||||||
|
FoldMinutes: bucketMinutes,
|
||||||
|
SortBy: "logged_at",
|
||||||
|
SortOrder: "asc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isAnalyticsUnavailable(err) {
|
||||||
|
result.Available = false
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
byEpoch := make(map[int64]model.OpenFlareAccessLogBucketRow, len(buckets))
|
||||||
|
for _, bucketRow := range buckets {
|
||||||
|
if bucketRow == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byEpoch[bucketRow.BucketEpoch] = *bucketRow
|
||||||
|
}
|
||||||
|
series := emptyStatsSeries(since, now, bucketMinutes)
|
||||||
|
for index := range series {
|
||||||
|
epoch := series[index].BucketStartedAt.Unix()
|
||||||
|
if row, ok := byEpoch[epoch]; ok {
|
||||||
|
series[index].RequestCount = row.RequestCount
|
||||||
|
series[index].UniqueVisitors = row.UniqueIPCount
|
||||||
|
series[index].BytesSent = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Series = series
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyStatsSeries(since, until time.Time, bucketMinutes int) []StatsPoint {
|
||||||
|
if bucketMinutes <= 0 {
|
||||||
|
bucketMinutes = 60
|
||||||
|
}
|
||||||
|
bucket := time.Duration(bucketMinutes) * time.Minute
|
||||||
|
start := since.UTC().Truncate(bucket)
|
||||||
|
end := until.UTC()
|
||||||
|
if !end.After(start) {
|
||||||
|
return []StatsPoint{{BucketStartedAt: start}}
|
||||||
|
}
|
||||||
|
// Cap points to keep chart readable.
|
||||||
|
maxPoints := 120
|
||||||
|
capacity := int(end.Sub(start)/bucket) + 1
|
||||||
|
if capacity > maxPoints {
|
||||||
|
capacity = maxPoints
|
||||||
|
}
|
||||||
|
points := make([]StatsPoint, 0, capacity)
|
||||||
|
for cursor := start; !cursor.After(end) && len(points) < maxPoints; cursor = cursor.Add(bucket) {
|
||||||
|
points = append(points, StatsPoint{BucketStartedAt: cursor})
|
||||||
|
}
|
||||||
|
if len(points) == 0 {
|
||||||
|
points = append(points, StatsPoint{BucketStartedAt: start})
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnalyticsUnavailable(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrInvalidDB) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "clickhouse connection is not initialized") ||
|
||||||
|
strings.Contains(msg, "clickhouse is not") ||
|
||||||
|
strings.Contains(msg, "database is not initialized")
|
||||||
|
}
|
||||||
@@ -309,8 +309,10 @@ func openFlareAccessLogQueryFromBucket(query OpenFlareAccessLogBucketQuery) Open
|
|||||||
NodeID: query.NodeID,
|
NodeID: query.NodeID,
|
||||||
RemoteAddr: query.RemoteAddr,
|
RemoteAddr: query.RemoteAddr,
|
||||||
Host: query.Host,
|
Host: query.Host,
|
||||||
|
Hosts: query.Hosts,
|
||||||
Path: query.Path,
|
Path: query.Path,
|
||||||
Since: query.Since,
|
Since: query.Since,
|
||||||
|
Until: query.Until,
|
||||||
Page: query.Page,
|
Page: query.Page,
|
||||||
PageSize: query.PageSize,
|
PageSize: query.PageSize,
|
||||||
SortBy: query.SortBy,
|
SortBy: query.SortBy,
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func toNodeAccessLogFilter(query OpenFlareAccessLogQuery) analyticsrepo.NodeAcce
|
|||||||
NodeID: query.NodeID,
|
NodeID: query.NodeID,
|
||||||
RemoteAddr: query.RemoteAddr,
|
RemoteAddr: query.RemoteAddr,
|
||||||
Host: query.Host,
|
Host: query.Host,
|
||||||
|
Hosts: query.Hosts,
|
||||||
Path: query.Path,
|
Path: query.Path,
|
||||||
Since: query.Since,
|
Since: query.Since,
|
||||||
Until: query.Until,
|
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) {
|
if trimmed := strings.TrimSpace(query.RemoteAddr); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.RemoteAddr), trimmed) {
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
if trimmed := strings.TrimSpace(query.Path); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.Path), trimmed) {
|
if trimmed := strings.TrimSpace(query.Path); trimmed != "" && !strings.HasPrefix(strings.TrimSpace(row.Path), trimmed) {
|
||||||
|
|||||||
@@ -185,13 +185,15 @@ type OpenFlareAccessLogQuery struct {
|
|||||||
NodeID string
|
NodeID string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
Host string
|
Host string
|
||||||
Path string
|
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||||
Since time.Time
|
Hosts []string
|
||||||
Until time.Time
|
Path string
|
||||||
Page int
|
Since time.Time
|
||||||
PageSize int
|
Until time.Time
|
||||||
SortBy string
|
Page int
|
||||||
SortOrder string
|
PageSize int
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
|
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
|
||||||
@@ -199,8 +201,10 @@ type OpenFlareAccessLogBucketQuery struct {
|
|||||||
NodeID string
|
NodeID string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
Host string
|
Host string
|
||||||
|
Hosts []string
|
||||||
Path string
|
Path string
|
||||||
Since time.Time
|
Since time.Time
|
||||||
|
Until time.Time
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
SortBy string
|
SortBy string
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
nodeAccessLogFilterClauseCapacity = 6
|
nodeAccessLogFilterClauseCapacity = 6
|
||||||
|
|
||||||
nodeAccessLogSortDesc = "DESC"
|
nodeAccessLogSortDesc = "DESC"
|
||||||
nodeAccessLogSortAsc = "ASC"
|
nodeAccessLogSortAsc = "ASC"
|
||||||
nodeAccessLogSortAscInput = "asc"
|
nodeAccessLogSortAscInput = "asc"
|
||||||
|
|
||||||
nodeAccessLogColumnRemoteAddr = "remote_addr"
|
nodeAccessLogColumnRemoteAddr = "remote_addr"
|
||||||
@@ -24,13 +24,15 @@ type NodeAccessLogFilter struct {
|
|||||||
NodeID string
|
NodeID string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
Host string
|
Host string
|
||||||
Path string
|
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||||
Since time.Time
|
Hosts []string
|
||||||
Until time.Time
|
Path string
|
||||||
Page int
|
Since time.Time
|
||||||
PageSize int
|
Until time.Time
|
||||||
SortBy string
|
Page int
|
||||||
SortOrder string
|
PageSize int
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) {
|
func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) {
|
||||||
@@ -44,7 +46,15 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
|
|||||||
parts = append(parts, "remote_addr LIKE ?")
|
parts = append(parts, "remote_addr LIKE ?")
|
||||||
args = append(args, trimmed+"%")
|
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 ?")
|
parts = append(parts, "host LIKE ?")
|
||||||
args = append(args, trimmed+"%")
|
args = append(args, trimmed+"%")
|
||||||
}
|
}
|
||||||
@@ -99,6 +109,26 @@ func normalizeNodeAccessLogRemoteAddr(value string) string {
|
|||||||
return strings.TrimSpace(value)
|
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 {
|
func normalizeNodeAccessLogSortOrder(sortOrder string) string {
|
||||||
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
|
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
|
||||||
return "asc"
|
return "asc"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func registerZoneRoutes(apiGroup *gin.RouterGroup) {
|
|||||||
apiutil.RegisterCollection(zoneGroup, "GET", zone.ListHandler)
|
apiutil.RegisterCollection(zoneGroup, "GET", zone.ListHandler)
|
||||||
apiutil.RegisterCollection(zoneGroup, "POST", zone.CreateHandler)
|
apiutil.RegisterCollection(zoneGroup, "POST", zone.CreateHandler)
|
||||||
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
||||||
|
zoneGroup.GET("/:id/stats", zone.GetStatsHandler)
|
||||||
zoneGroup.POST("/:id/update", zone.UpdateHandler)
|
zoneGroup.POST("/:id/update", zone.UpdateHandler)
|
||||||
zoneGroup.POST("/:id/delete", zone.DeleteHandler)
|
zoneGroup.POST("/:id/delete", zone.DeleteHandler)
|
||||||
zoneGroup.POST("/:id/domains", zone.CreateDomainHandler)
|
zoneGroup.POST("/:id/domains", zone.CreateDomainHandler)
|
||||||
|
|||||||
Reference in New Issue
Block a user