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