diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 50ec157c..a8f206d5 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -22,6 +22,8 @@ sidebar: false
### 修复
+- 修复节点详情 OpenResty 连接数与吞吐显示为「—」:节点可观测 API 将 OpenResty 观测数据合并进 `metric_snapshots`;指标文案改为「请求/分钟」(近 60 秒窗口),连接数为 0 时正常显示 0。
+
- 修复仪表盘「24 小时请求趋势」摘要误显示当前小时请求量/错误量:改为汇总近 24 小时总量。
- 修复 Pages 部署包上传报「请求超时,请稍后重试」:上传请求使用独立 10 分钟超时(覆盖默认 15 秒),并在大文件上传完成后提示服务端处理中。
diff --git a/docs/docs.go b/docs/docs.go
index 98341409..a4ee2620 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -5350,7 +5350,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
- "description": "返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限",
+ "description": "返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限",
"produces": [
"application/json"
],
@@ -14433,50 +14433,6 @@ const docTemplate = `{
}
}
},
- "model.OpenFlareMetricSnapshot": {
- "type": "object",
- "properties": {
- "captured_at": {
- "type": "string"
- },
- "cpu_usage_percent": {
- "type": "number"
- },
- "created_at": {
- "type": "string"
- },
- "disk_read_bytes": {
- "type": "integer"
- },
- "disk_write_bytes": {
- "type": "integer"
- },
- "id": {
- "type": "integer"
- },
- "memory_total_bytes": {
- "type": "integer"
- },
- "memory_used_bytes": {
- "type": "integer"
- },
- "network_rx_bytes": {
- "type": "integer"
- },
- "network_tx_bytes": {
- "type": "integer"
- },
- "node_id": {
- "type": "string"
- },
- "storage_total_bytes": {
- "type": "integer"
- },
- "storage_used_bytes": {
- "type": "integer"
- }
- }
- },
"model.OpenFlareNode": {
"type": "object",
"properties": {
@@ -15297,7 +15253,7 @@ const docTemplate = `{
"metric_snapshots": {
"type": "array",
"items": {
- "$ref": "#/definitions/model.OpenFlareMetricSnapshot"
+ "$ref": "#/definitions/observability.NodeMetricSnapshotView"
}
},
"node_id": {
@@ -15928,6 +15884,56 @@ const docTemplate = `{
}
}
},
+ "observability.NodeMetricSnapshotView": {
+ "type": "object",
+ "properties": {
+ "captured_at": {
+ "type": "string"
+ },
+ "cpu_usage_percent": {
+ "type": "number"
+ },
+ "disk_read_bytes": {
+ "type": "integer"
+ },
+ "disk_write_bytes": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "memory_total_bytes": {
+ "type": "integer"
+ },
+ "memory_used_bytes": {
+ "type": "integer"
+ },
+ "network_rx_bytes": {
+ "type": "integer"
+ },
+ "network_tx_bytes": {
+ "type": "integer"
+ },
+ "node_id": {
+ "type": "string"
+ },
+ "openresty_connections": {
+ "type": "integer"
+ },
+ "openresty_rx_bytes": {
+ "type": "integer"
+ },
+ "openresty_tx_bytes": {
+ "type": "integer"
+ },
+ "storage_total_bytes": {
+ "type": "integer"
+ },
+ "storage_used_bytes": {
+ "type": "integer"
+ }
+ }
+ },
"observability.NodeTrends": {
"type": "object",
"properties": {
@@ -16371,6 +16377,10 @@ const docTemplate = `{
},
"total_size": {
"type": "integer"
+ },
+ "upload_id": {
+ "type": "string",
+ "example": "0"
}
}
},
diff --git a/docs/swagger.json b/docs/swagger.json
index f0aa7147..01d47992 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -5343,7 +5343,7 @@
"SessionCookie": []
}
],
- "description": "返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限",
+ "description": "返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限",
"produces": [
"application/json"
],
@@ -14426,50 +14426,6 @@
}
}
},
- "model.OpenFlareMetricSnapshot": {
- "type": "object",
- "properties": {
- "captured_at": {
- "type": "string"
- },
- "cpu_usage_percent": {
- "type": "number"
- },
- "created_at": {
- "type": "string"
- },
- "disk_read_bytes": {
- "type": "integer"
- },
- "disk_write_bytes": {
- "type": "integer"
- },
- "id": {
- "type": "integer"
- },
- "memory_total_bytes": {
- "type": "integer"
- },
- "memory_used_bytes": {
- "type": "integer"
- },
- "network_rx_bytes": {
- "type": "integer"
- },
- "network_tx_bytes": {
- "type": "integer"
- },
- "node_id": {
- "type": "string"
- },
- "storage_total_bytes": {
- "type": "integer"
- },
- "storage_used_bytes": {
- "type": "integer"
- }
- }
- },
"model.OpenFlareNode": {
"type": "object",
"properties": {
@@ -15290,7 +15246,7 @@
"metric_snapshots": {
"type": "array",
"items": {
- "$ref": "#/definitions/model.OpenFlareMetricSnapshot"
+ "$ref": "#/definitions/observability.NodeMetricSnapshotView"
}
},
"node_id": {
@@ -15921,6 +15877,56 @@
}
}
},
+ "observability.NodeMetricSnapshotView": {
+ "type": "object",
+ "properties": {
+ "captured_at": {
+ "type": "string"
+ },
+ "cpu_usage_percent": {
+ "type": "number"
+ },
+ "disk_read_bytes": {
+ "type": "integer"
+ },
+ "disk_write_bytes": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "memory_total_bytes": {
+ "type": "integer"
+ },
+ "memory_used_bytes": {
+ "type": "integer"
+ },
+ "network_rx_bytes": {
+ "type": "integer"
+ },
+ "network_tx_bytes": {
+ "type": "integer"
+ },
+ "node_id": {
+ "type": "string"
+ },
+ "openresty_connections": {
+ "type": "integer"
+ },
+ "openresty_rx_bytes": {
+ "type": "integer"
+ },
+ "openresty_tx_bytes": {
+ "type": "integer"
+ },
+ "storage_total_bytes": {
+ "type": "integer"
+ },
+ "storage_used_bytes": {
+ "type": "integer"
+ }
+ }
+ },
"observability.NodeTrends": {
"type": "object",
"properties": {
@@ -16364,6 +16370,10 @@
},
"total_size": {
"type": "integer"
+ },
+ "upload_id": {
+ "type": "string",
+ "example": "0"
}
}
},
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index d7448a37..4f0a37fe 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -1130,35 +1130,6 @@ definitions:
updated_at:
type: string
type: object
- model.OpenFlareMetricSnapshot:
- properties:
- captured_at:
- type: string
- cpu_usage_percent:
- type: number
- created_at:
- type: string
- disk_read_bytes:
- type: integer
- disk_write_bytes:
- type: integer
- id:
- type: integer
- memory_total_bytes:
- type: integer
- memory_used_bytes:
- type: integer
- network_rx_bytes:
- type: integer
- network_tx_bytes:
- type: integer
- node_id:
- type: string
- storage_total_bytes:
- type: integer
- storage_used_bytes:
- type: integer
- type: object
model.OpenFlareNode:
properties:
auto_update_enabled:
@@ -1713,7 +1684,7 @@ definitions:
type: array
metric_snapshots:
items:
- $ref: '#/definitions/model.OpenFlareMetricSnapshot'
+ $ref: '#/definitions/observability.NodeMetricSnapshotView'
type: array
node_id:
type: string
@@ -2126,6 +2097,39 @@ definitions:
traffic:
$ref: '#/definitions/observability.TrafficWindowSummary'
type: object
+ observability.NodeMetricSnapshotView:
+ properties:
+ captured_at:
+ type: string
+ cpu_usage_percent:
+ type: number
+ disk_read_bytes:
+ type: integer
+ disk_write_bytes:
+ type: integer
+ id:
+ type: integer
+ memory_total_bytes:
+ type: integer
+ memory_used_bytes:
+ type: integer
+ network_rx_bytes:
+ type: integer
+ network_tx_bytes:
+ type: integer
+ node_id:
+ type: string
+ openresty_connections:
+ type: integer
+ openresty_rx_bytes:
+ type: integer
+ openresty_tx_bytes:
+ type: integer
+ storage_total_bytes:
+ type: integer
+ storage_used_bytes:
+ type: integer
+ type: object
observability.NodeTrends:
properties:
capacity_24h:
@@ -2416,6 +2420,9 @@ definitions:
type: string
total_size:
type: integer
+ upload_id:
+ example: "0"
+ type: string
type: object
pages.Input:
properties:
@@ -7125,7 +7132,7 @@ paths:
- openflare-apply-log
/api/v1/d/config-versions:
get:
- description: 返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限
+ description: 返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限
produces:
- application/json
responses:
diff --git a/frontend/app/(main)/nodes/components/node-observability.tsx b/frontend/app/(main)/nodes/components/node-observability.tsx
index 6c679fd3..9a7a0800 100644
--- a/frontend/app/(main)/nodes/components/node-observability.tsx
+++ b/frontend/app/(main)/nodes/components/node-observability.tsx
@@ -31,6 +31,7 @@ import {
aggregateTrafficBreakdown,
formatBytes,
formatBytesPerSecond,
+ formatMetricCount,
formatPercent,
formatRelativeTime,
formatUptime,
@@ -455,11 +456,7 @@ export function NodeObservability({
/>
- 最近窗口请求 + 请求/分钟
- {trafficSummary - ? trafficSummary.request_count.toLocaleString('zh-CN') - : '—'} + {formatMetricCount(trafficSummary?.request_count)}
{trafficSummary - ? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · UV ${trafficSummary.unique_visitor_count}` + ? `近 60 秒 · UV ${formatMetricCount(trafficSummary.unique_visitor_count)}` : '暂无窗口流量摘要'}
- 最近窗口错误 + 近 60 秒错误
- {trafficSummary ? trafficSummary.error_count.toLocaleString('zh-CN') : '—'} + {formatMetricCount(trafficSummary?.error_count)}
{trafficSummary diff --git a/frontend/app/(main)/nodes/components/node-utils.ts b/frontend/app/(main)/nodes/components/node-utils.ts index f87d506c..ae7ac17a 100644 --- a/frontend/app/(main)/nodes/components/node-utils.ts +++ b/frontend/app/(main)/nodes/components/node-utils.ts @@ -1,10 +1,4 @@ -import type { - ApplyResult, - NodeItem, - NodeStatus, - NodeTrafficReport, - OpenrestyStatus, -} from '@/lib/services/openflare'; +import type {ApplyResult, NodeItem, NodeStatus, NodeTrafficReport, OpenrestyStatus,} from '@/lib/services/openflare'; export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__'; export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__'; @@ -176,6 +170,13 @@ export function formatPercent(value?: number | null) { return `${value.toFixed(1)}%`; } +export function formatMetricCount(value?: number | null) { + if (value === undefined || value === null || !Number.isFinite(value)) { + return '—'; + } + return value.toLocaleString('zh-CN'); +} + export function formatBytesPerSecond( value?: number | null, windowSeconds = 1, diff --git a/internal/apps/openflare/observability/analytics.go b/internal/apps/openflare/observability/analytics.go index d1778aef..228a839c 100644 --- a/internal/apps/openflare/observability/analytics.go +++ b/internal/apps/openflare/observability/analytics.go @@ -35,6 +35,27 @@ type TrafficDistributions struct { SourceCountries []DistributionItem `json:"source_countries"` } +const metricSnapshotOpenrestyMatchWindow = 2 * time.Minute + +// NodeMetricSnapshotView is a metric snapshot enriched with OpenResty observations. +type NodeMetricSnapshotView struct { + ID uint `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + CapturedAt time.Time `json:"captured_at"` + CPUUsagePercent float64 `json:"cpu_usage_percent"` + MemoryUsedBytes int64 `json:"memory_used_bytes"` + MemoryTotalBytes int64 `json:"memory_total_bytes"` + StorageUsedBytes int64 `json:"storage_used_bytes"` + StorageTotalBytes int64 `json:"storage_total_bytes"` + DiskReadBytes int64 `json:"disk_read_bytes"` + DiskWriteBytes int64 `json:"disk_write_bytes"` + NetworkRxBytes int64 `json:"network_rx_bytes"` + NetworkTxBytes int64 `json:"network_tx_bytes"` + OpenrestyRxBytes int64 `json:"openresty_rx_bytes"` + OpenrestyTxBytes int64 `json:"openresty_tx_bytes"` + OpenrestyConnections int64 `json:"openresty_connections"` +} + // TrafficWindowSummary summarizes a traffic reporting window. type TrafficWindowSummary struct { WindowStartedAt time.Time `json:"window_started_at"` @@ -112,9 +133,9 @@ type diskCounterState struct { seen bool } -func buildTrafficWindowSummary(report *model.OpenFlareRequestReport) TrafficWindowSummary { +func buildTrafficWindowSummary(report *model.OpenFlareRequestReport) *TrafficWindowSummary { if report == nil { - return TrafficWindowSummary{} + return nil } summary := TrafficWindowSummary{ WindowStartedAt: report.WindowStartedAt, @@ -129,7 +150,44 @@ func buildTrafficWindowSummary(report *model.OpenFlareRequestReport) TrafficWind if report.RequestCount > 0 { summary.ErrorRatePercent = (float64(report.ErrorCount) / float64(report.RequestCount)) * 100 } - return summary + return &summary +} + +// BuildMetricSnapshotViews merges metric snapshots with OpenResty observations for API responses. +func BuildMetricSnapshotViews( + snapshots []*model.OpenFlareMetricSnapshot, + openrestyObs []*model.OpenFlareNodeObservationOpenresty, +) []*NodeMetricSnapshotView { + if len(snapshots) == 0 { + return []*NodeMetricSnapshotView{} + } + views := make([]*NodeMetricSnapshotView, 0, len(snapshots)) + for _, snapshot := range snapshots { + if snapshot == nil { + continue + } + view := &NodeMetricSnapshotView{ + ID: snapshot.ID, + NodeID: snapshot.NodeID, + CapturedAt: snapshot.CapturedAt, + CPUUsagePercent: snapshot.CPUUsagePercent, + MemoryUsedBytes: snapshot.MemoryUsedBytes, + MemoryTotalBytes: snapshot.MemoryTotalBytes, + StorageUsedBytes: snapshot.StorageUsedBytes, + StorageTotalBytes: snapshot.StorageTotalBytes, + DiskReadBytes: snapshot.DiskReadBytes, + DiskWriteBytes: snapshot.DiskWriteBytes, + NetworkRxBytes: snapshot.NetworkRxBytes, + NetworkTxBytes: snapshot.NetworkTxBytes, + } + if matched := matchOpenrestyObservation(snapshot.CapturedAt, openrestyObs); matched != nil { + view.OpenrestyRxBytes = matched.OpenrestyRxBytes + view.OpenrestyTxBytes = matched.OpenrestyTxBytes + view.OpenrestyConnections = matched.OpenrestyConnections + } + views = append(views, view) + } + return views } // BuildTrafficDistributions aggregates traffic distribution charts. @@ -352,21 +410,54 @@ func BuildDiskIOTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSna } func latestMetricSnapshot(snapshots []*model.OpenFlareMetricSnapshot) *model.OpenFlareMetricSnapshot { + var latest *model.OpenFlareMetricSnapshot for _, snapshot := range snapshots { - if snapshot != nil { - return snapshot + if snapshot == nil { + continue + } + if latest == nil || snapshot.CapturedAt.After(latest.CapturedAt) { + latest = snapshot } } - return nil + return latest } func latestTrafficReport(reports []*model.OpenFlareRequestReport) *model.OpenFlareRequestReport { + var latest *model.OpenFlareRequestReport for _, report := range reports { - if report != nil { - return report + if report == nil { + continue + } + if latest == nil || report.WindowEndedAt.After(latest.WindowEndedAt) { + latest = report } } - return nil + return latest +} + +func matchOpenrestyObservation( + capturedAt time.Time, + observations []*model.OpenFlareNodeObservationOpenresty, +) *model.OpenFlareNodeObservationOpenresty { + var matched *model.OpenFlareNodeObservationOpenresty + var bestDelta time.Duration = metricSnapshotOpenrestyMatchWindow + time.Second + for _, observation := range observations { + if observation == nil { + continue + } + delta := capturedAt.Sub(observation.CapturedAt) + if delta < 0 { + delta = -delta + } + if delta > metricSnapshotOpenrestyMatchWindow { + continue + } + if matched == nil || delta < bestDelta { + matched = observation + bestDelta = delta + } + } + return matched } // LatestMetricSnapshotsByNode returns the latest snapshot per node. diff --git a/internal/apps/openflare/observability/analytics_test.go b/internal/apps/openflare/observability/analytics_test.go index 41d20f86..c2bf53e1 100644 --- a/internal/apps/openflare/observability/analytics_test.go +++ b/internal/apps/openflare/observability/analytics_test.go @@ -51,4 +51,67 @@ func TestBuildTrafficTrendPointsBucketsByHour(t *testing.T) { if currentHour.ErrorCount != 0 { t.Fatalf("current hour error_count = %d, want 0", currentHour.ErrorCount) } -} \ No newline at end of file +} + +func TestBuildMetricSnapshotViewsMergesOpenrestyObservation(t *testing.T) { + t.Parallel() + + capturedAt := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) + snapshots := []*model.OpenFlareMetricSnapshot{ + { + ID: 1, + NodeID: "node-a", + CapturedAt: capturedAt, + CPUUsagePercent: 12.5, + }, + } + openrestyObs := []*model.OpenFlareNodeObservationOpenresty{ + { + NodeID: "node-a", + CapturedAt: capturedAt.Add(5 * time.Second), + OpenrestyRxBytes: 4096, + OpenrestyTxBytes: 8192, + OpenrestyConnections: 7, + }, + } + + views := BuildMetricSnapshotViews(snapshots, openrestyObs) + if len(views) != 1 { + t.Fatalf("BuildMetricSnapshotViews() len = %d, want 1", len(views)) + } + if views[0].OpenrestyRxBytes != 4096 { + t.Fatalf("OpenrestyRxBytes = %d, want 4096", views[0].OpenrestyRxBytes) + } + if views[0].OpenrestyTxBytes != 8192 { + t.Fatalf("OpenrestyTxBytes = %d, want 8192", views[0].OpenrestyTxBytes) + } + if views[0].OpenrestyConnections != 7 { + t.Fatalf("OpenrestyConnections = %d, want 7", views[0].OpenrestyConnections) + } +} + +func TestLatestTrafficReportUsesLatestWindowEndedAt(t *testing.T) { + t.Parallel() + + older := &model.OpenFlareRequestReport{ + WindowEndedAt: time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC), + RequestCount: 3, + } + newer := &model.OpenFlareRequestReport{ + WindowEndedAt: time.Date(2026, 6, 19, 11, 0, 0, 0, time.UTC), + RequestCount: 9, + } + + latest := latestTrafficReport([]*model.OpenFlareRequestReport{older, newer}) + if latest == nil || latest.RequestCount != 9 { + t.Fatalf("latestTrafficReport() = %#v, want newer report with request_count 9", latest) + } +} + +func TestBuildTrafficWindowSummaryNilWithoutReport(t *testing.T) { + t.Parallel() + + if summary := buildTrafficWindowSummary(nil); summary != nil { + t.Fatalf("buildTrafficWindowSummary(nil) = %#v, want nil", summary) + } +} diff --git a/internal/apps/openflare/observability/node_logics.go b/internal/apps/openflare/observability/node_logics.go index fd06fd13..355757a7 100644 --- a/internal/apps/openflare/observability/node_logics.go +++ b/internal/apps/openflare/observability/node_logics.go @@ -28,9 +28,9 @@ type NodeQuery struct { // NodeAnalytics groups node observability analytics. type NodeAnalytics struct { - Traffic TrafficWindowSummary `json:"traffic"` - Distributions TrafficDistributions `json:"distributions"` - Health HealthSummary `json:"health"` + Traffic *TrafficWindowSummary `json:"traffic"` + Distributions TrafficDistributions `json:"distributions"` + Health HealthSummary `json:"health"` } // NodeTrends groups node observability trend series. @@ -66,7 +66,7 @@ type RelayProxyStat struct { type NodeView struct { NodeID string `json:"node_id"` Profile *model.OpenFlareNodeSystemProfile `json:"profile"` - MetricSnapshots []*model.OpenFlareMetricSnapshot `json:"metric_snapshots"` + MetricSnapshots []*NodeMetricSnapshotView `json:"metric_snapshots"` TrafficReports []*model.OpenFlareRequestReport `json:"traffic_reports"` HealthEvents []*model.OpenFlareHealthEvent `json:"health_events"` Analytics NodeAnalytics `json:"analytics"` @@ -103,6 +103,10 @@ func GetNodeObservability(ctx context.Context, id uint, query NodeQuery) (*NodeV if err != nil { return nil, err } + openrestyObs, err := model.ListOpenFlareNodeObservationOpenresty(ctx, node.NodeID, since, limit) + if err != nil { + return nil, err + } reports, err := model.ListOpenFlareRequestReportsSince(ctx, node.NodeID, since, limit) if err != nil { return nil, err @@ -131,7 +135,7 @@ func GetNodeObservability(ctx context.Context, id uint, query NodeQuery) (*NodeV view := &NodeView{ NodeID: node.NodeID, Profile: profile, - MetricSnapshots: snapshots, + MetricSnapshots: BuildMetricSnapshotViews(snapshots, openrestyObs), TrafficReports: reports, HealthEvents: events, Analytics: NodeAnalytics{