From b56290d79d310db2cf5e536761e37fbbcb0043d2 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 19:18:15 +0800 Subject: [PATCH] feat(rate-limits): use 5m trend buckets and 24h/7d ranges Overview API accepts bucket_minutes (5|60); rate-limit analysis uses 5-minute buckets and drops 15d/30d presets. Widen rank value column. --- docs/changelog/index.md | 2 +- .../components/access-log-utils.ts | 11 ++++ .../components/overview-toolbar.tsx | 10 ++- .../rate-limits/components/analysis-tab.tsx | 10 +-- .../components/rate-pressure-chart.tsx | 11 +++- frontend/app/(main)/rate-limits/page.tsx | 7 +- frontend/components/data/rank-chart.tsx | 2 +- frontend/lib/services/openflare/types.ts | 2 + .../observability/access_log_logics.go | 64 +++++++++++++------ .../apps/openflare/observability/routers.go | 10 +-- 10 files changed, 90 insertions(+), 39 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 2c93fb17..7a94fc3c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -30,7 +30,7 @@ sidebar: false - 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。 - Agent Docker 部署命令默认挂载命名卷 `openflare-agent-pages` 持久化 Pages 目录,重建容器时无需重新拉取静态站点包。 -- 限流页新增「分析」视图:默认展示近 24 小时请求压力(RPS)与独立访客双轴趋势,支持域名过滤与时间预设,并按窗口平均 RPS 排行域名与 IP;原全局默认配置迁入「配置」页签。 +- 限流页新增「分析」视图:默认展示近 24 小时请求压力(RPS)与独立访客双轴趋势(5 分钟桶),支持域名过滤与 24 小时/7 天预设,并按窗口平均 RPS 排行域名与 IP;原全局默认配置迁入「配置」页签。 ## [v3.4.1] - 2026-07-19 diff --git a/frontend/app/(main)/access-logs/components/access-log-utils.ts b/frontend/app/(main)/access-logs/components/access-log-utils.ts index 829bf527..52fd4ba4 100644 --- a/frontend/app/(main)/access-logs/components/access-log-utils.ts +++ b/frontend/app/(main)/access-logs/components/access-log-utils.ts @@ -9,6 +9,9 @@ export type SearchDraft = { export type OverviewRangeHours = 24 | 168 | 360 | 720; +/** 限流分析等短窗口场景:仅 24 小时 / 7 天 */ +export type RateLimitRangeHours = 24 | 168; + export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]; export const OVERVIEW_RANGE_OPTIONS: { @@ -21,6 +24,14 @@ export const OVERVIEW_RANGE_OPTIONS: { { value: 720, label: '30 天' }, ]; +export const RATE_LIMIT_RANGE_OPTIONS: { + value: RateLimitRangeHours; + label: string; +}[] = [ + { value: 24, label: '24 小时' }, + { value: 168, label: '7 天' }, +]; + export const DETAIL_SORT_OPTIONS = [ { value: 'logged_at:desc', label: '时间从新到旧' }, { value: 'logged_at:asc', label: '时间从旧到新' }, diff --git a/frontend/app/(main)/access-logs/components/overview-toolbar.tsx b/frontend/app/(main)/access-logs/components/overview-toolbar.tsx index 7722ac36..c00d9d9e 100644 --- a/frontend/app/(main)/access-logs/components/overview-toolbar.tsx +++ b/frontend/app/(main)/access-logs/components/overview-toolbar.tsx @@ -24,7 +24,9 @@ import { cn } from '@/lib/utils'; import { OVERVIEW_RANGE_OPTIONS, + RATE_LIMIT_RANGE_OPTIONS, type OverviewRangeHours, + type RateLimitRangeHours, } from './access-log-utils'; type ManagedZoneDomains = { @@ -309,11 +311,13 @@ export function OverviewToolbar({ hosts, onHoursChange, onHostsChange, + rangeOptions = OVERVIEW_RANGE_OPTIONS, }: { - hours: OverviewRangeHours; + hours: OverviewRangeHours | RateLimitRangeHours; hosts: string[]; onHoursChange: (hours: OverviewRangeHours) => void; onHostsChange: (hosts: string[]) => void; + rangeOptions?: ReadonlyArray<{ value: number; label: string }>; }) { return (
@@ -334,7 +338,7 @@ export function OverviewToolbar({ size='sm' className='justify-end' > - {OVERVIEW_RANGE_OPTIONS.map((option) => ( + {rangeOptions.map((option) => ( ); } + +export { RATE_LIMIT_RANGE_OPTIONS }; diff --git a/frontend/app/(main)/rate-limits/components/analysis-tab.tsx b/frontend/app/(main)/rate-limits/components/analysis-tab.tsx index 8106e1ed..7a54099f 100644 --- a/frontend/app/(main)/rate-limits/components/analysis-tab.tsx +++ b/frontend/app/(main)/rate-limits/components/analysis-tab.tsx @@ -13,7 +13,8 @@ import type { import { formatOverviewRangeHint, - type OverviewRangeHours, + RATE_LIMIT_RANGE_OPTIONS, + type RateLimitRangeHours, } from '../../access-logs/components/access-log-utils'; import { OverviewToolbar } from '../../access-logs/components/overview-toolbar'; import { RatePressureChart } from './rate-pressure-chart'; @@ -50,9 +51,9 @@ export function AnalysisTab({ data?: AccessLogOverview; loading: boolean; error: Error | null; - hours: OverviewRangeHours; + hours: RateLimitRangeHours; hosts: string[]; - onHoursChange: (hours: OverviewRangeHours) => void; + onHoursChange: (hours: RateLimitRangeHours) => void; onHostsChange: (hosts: string[]) => void; onRetry: () => void; }) { @@ -65,8 +66,9 @@ export function AnalysisTab({ onHoursChange(next as RateLimitRangeHours)} onHostsChange={onHostsChange} + rangeOptions={RATE_LIMIT_RANGE_OPTIONS} /> {loading ? ( diff --git a/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx b/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx index 13e45b5a..b44bcf2c 100644 --- a/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx +++ b/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx @@ -14,7 +14,7 @@ import { type OverviewRangeHours, } from '../../access-logs/components/access-log-utils'; -const BUCKET_SECONDS = 3600; +const DEFAULT_BUCKET_SECONDS = 300; const RPS_COLOR = '#38bdf8'; const VISITS_COLOR = '#a78bfa'; @@ -58,6 +58,11 @@ type RatePressureChartProps = { }; export function RatePressureChart({ data, hours }: RatePressureChartProps) { + const bucketSeconds = + (data?.bucket_minutes && data.bucket_minutes > 0 + ? data.bucket_minutes + : 5) * 60 || DEFAULT_BUCKET_SECONDS; + const rangeLabel = useMemo(() => { const end = data?.generated_at ? new Date(data.generated_at) : new Date(); const start = new Date(end.getTime() - hours * 3600 * 1000); @@ -82,12 +87,12 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) { const time = req?.bucket_started_at ?? visit?.bucket_started_at ?? ''; rawTimes.push(time); labels.push(formatAxisTime(time, hours)); - rpsValues.push((req?.value ?? 0) / BUCKET_SECONDS); + rpsValues.push((req?.value ?? 0) / bucketSeconds); visitValues.push(visit?.value ?? 0); } return { labels, rpsValues, visitValues, rawTimes }; - }, [data?.trends.requests, data?.trends.visits, hours]); + }, [bucketSeconds, data?.trends.requests, data?.trends.visits, hours]); const option = useMemo(() => { const rpsMax = calculateNiceAxisMax(chartModel.rpsValues); diff --git a/frontend/app/(main)/rate-limits/page.tsx b/frontend/app/(main)/rate-limits/page.tsx index cee42455..d821d93e 100644 --- a/frontend/app/(main)/rate-limits/page.tsx +++ b/frontend/app/(main)/rate-limits/page.tsx @@ -11,7 +11,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AccessLogService } from '@/lib/services/openflare'; -import type { OverviewRangeHours } from '../access-logs/components/access-log-utils'; +import type { RateLimitRangeHours } from '../access-logs/components/access-log-utils'; import { AnalysisTab } from './components/analysis-tab'; import { ConfigTab } from './components/config-tab'; @@ -27,15 +27,16 @@ function RateLimitsPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const tab = resolveTab(searchParams.get('tab')); - const [hours, setHours] = useState(24); + const [hours, setHours] = useState(24); const [hosts, setHosts] = useState([]); const overviewQuery = useQuery({ - queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts], + queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts, 5], queryFn: () => AccessLogService.getOverview({ hours, hosts: hosts.length > 0 ? hosts : undefined, + bucket_minutes: 5, }), enabled: !!user?.is_admin && tab === 'analysis', }); diff --git a/frontend/components/data/rank-chart.tsx b/frontend/components/data/rank-chart.tsx index d0162b35..3a4a3bde 100644 --- a/frontend/components/data/rank-chart.tsx +++ b/frontend/components/data/rank-chart.tsx @@ -82,7 +82,7 @@ export function RankChart({ }} />
- + {valueFormatter(item.value)} diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 3273196d..88fdd897 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -505,6 +505,7 @@ export interface AccessLogOverviewFilters { host?: string; hosts?: string[]; hours?: number; + bucket_minutes?: number; } export interface AccessLogOverviewMetricPoint { @@ -515,6 +516,7 @@ export interface AccessLogOverviewMetricPoint { export interface AccessLogOverview { generated_at: string; hours: number; + bucket_minutes?: number; summary: { total_requests: number; total_visits: number; diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go index 29bb5d0c..35eb195e 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/internal/apps/openflare/observability/access_log_logics.go @@ -232,10 +232,11 @@ type AccessLogIPAnalysisView struct { // AccessLogOverviewQuery filters access log overview queries. type AccessLogOverviewQuery struct { - NodeID string `json:"node_id"` - Host string `json:"host"` - Hosts []string `json:"hosts"` - Hours int `json:"hours"` + NodeID string `json:"node_id"` + Host string `json:"host"` + Hosts []string `json:"hosts"` + Hours int `json:"hours"` + BucketMinutes int `json:"bucket_minutes"` } // AccessLogOverviewMetricPoint is a single overview trend bucket. @@ -262,6 +263,7 @@ type AccessLogOverviewTrends struct { type AccessLogOverview struct { GeneratedAt time.Time `json:"generated_at"` Hours int `json:"hours"` + BucketMinutes int `json:"bucket_minutes"` Summary AccessLogOverviewSummary `json:"summary"` Trends AccessLogOverviewTrends `json:"trends"` TopPaths []DistributionItem `json:"top_paths"` @@ -287,10 +289,11 @@ type AccessLogCleanupResult struct { } const ( - defaultAccessLogOverviewHours = 24 - maxAccessLogOverviewHours = 24 * 30 - accessLogOverviewTopLimit = 10 - accessLogOverviewUASampleLimit = 200 + defaultAccessLogOverviewHours = 24 + maxAccessLogOverviewHours = 24 * 30 + defaultAccessLogOverviewBucketMinutes = 60 + accessLogOverviewTopLimit = 10 + accessLogOverviewUASampleLimit = 200 ) // GetAccessLogOverview returns summary metrics, trends, and top rankings. @@ -312,13 +315,14 @@ func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*A } requestPoints, visitPoints, bandwidthPoints := buildAccessLogOverviewTrends( - ctx, now, normalized.Hours, query, + ctx, now, normalized.Hours, normalized.BucketMinutes, query, ) deviceTypes, topBrowsers, topOSes, topUserAgents := buildAccessLogUADistributions(ctx, query) return &AccessLogOverview{ - GeneratedAt: now, - Hours: normalized.Hours, + GeneratedAt: now, + Hours: normalized.Hours, + BucketMinutes: normalized.BucketMinutes, Summary: AccessLogOverviewSummary{ TotalRequests: summaryRow.RequestCount, TotalVisits: summaryRow.UniqueIPCount, @@ -348,6 +352,7 @@ func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOver if hours > maxAccessLogOverviewHours { hours = maxAccessLogOverviewHours } + bucketMinutes := normalizeAccessLogOverviewBucketMinutes(input.BucketMinutes) hosts := normalizeAccessLogOverviewHosts(input.Hosts) host := strings.TrimSpace(input.Host) if len(hosts) == 0 && host != "" { @@ -359,10 +364,20 @@ func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOver host = "" } return AccessLogOverviewQuery{ - NodeID: strings.TrimSpace(input.NodeID), - Host: host, - Hosts: hosts, - Hours: hours, + NodeID: strings.TrimSpace(input.NodeID), + Host: host, + Hosts: hosts, + Hours: hours, + BucketMinutes: bucketMinutes, + } +} + +func normalizeAccessLogOverviewBucketMinutes(value int) int { + switch value { + case 5, 60: + return value + default: + return defaultAccessLogOverviewBucketMinutes } } @@ -454,6 +469,7 @@ func buildAccessLogOverviewTrends( ctx context.Context, now time.Time, hours int, + bucketMinutes int, query model.OpenFlareAccessLogQuery, ) ( requests []AccessLogOverviewMetricPoint, @@ -463,12 +479,18 @@ func buildAccessLogOverviewTrends( if hours <= 0 { hours = defaultAccessLogOverviewHours } - start := now.Truncate(time.Hour).Add(-time.Duration(hours-1) * time.Hour) - requests = make([]AccessLogOverviewMetricPoint, hours) - visits = make([]AccessLogOverviewMetricPoint, hours) - bandwidth = make([]AccessLogOverviewMetricPoint, hours) + bucketMinutes = normalizeAccessLogOverviewBucketMinutes(bucketMinutes) + bucketDuration := time.Duration(bucketMinutes) * time.Minute + bucketCount := hours * 60 / bucketMinutes + if bucketCount <= 0 { + bucketCount = 1 + } + start := now.Truncate(bucketDuration).Add(-time.Duration(bucketCount-1) * bucketDuration) + requests = make([]AccessLogOverviewMetricPoint, bucketCount) + visits = make([]AccessLogOverviewMetricPoint, bucketCount) + bandwidth = make([]AccessLogOverviewMetricPoint, bucketCount) for index := range requests { - bucketAt := start.Add(time.Duration(index) * time.Hour) + bucketAt := start.Add(time.Duration(index) * bucketDuration) requests[index].BucketStartedAt = bucketAt visits[index].BucketStartedAt = bucketAt bandwidth[index].BucketStartedAt = bucketAt @@ -480,7 +502,7 @@ func buildAccessLogOverviewTrends( Hosts: query.Hosts, Since: query.Since, Until: query.Until, - FoldMinutes: 60, + FoldMinutes: bucketMinutes, SortBy: defaultAccessLogSortBy, SortOrder: sortOrderAsc, }) diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index 78bbceed..5f6116a8 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -22,6 +22,7 @@ import ( // @Param host query string false "请求 Host(单域名)" // @Param hosts query []string false "请求 Host 列表(多域名精确匹配)" // @Param hours query int false "统计时间范围(小时)" +// @Param bucket_minutes query int false "趋势桶分钟数(5 或 60,默认 60)" // @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览" // @Failure 400 {object} response.Any "参数错误" // @Failure 401 {object} response.Any "未登录" @@ -30,10 +31,11 @@ import ( // @Router /api/v1/d/access-logs/overview [get] func GetAccessLogOverviewHandler(c *gin.Context) { result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{ - NodeID: c.Query("node_id"), - Host: c.Query("host"), - Hosts: readQueryStringArray(c, "hosts"), - Hours: readQueryInt(c, "hours"), + NodeID: c.Query("node_id"), + Host: c.Query("host"), + Hosts: readQueryStringArray(c, "hosts"), + Hours: readQueryInt(c, "hours"), + BucketMinutes: readQueryInt(c, "bucket_minutes"), }) if apiutil.AbortBadRequestOnError(c, err) { return