From 6e86901a580438722e743d28116ee9713f39d35a Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 21 Jun 2026 10:52:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8A=82=E7=82=B9=E8=AF=A6?= =?UTF-8?q?=E6=83=85=20OpenResty=20=E8=BF=9E=E6=8E=A5=E6=95=B0=E4=B8=8E?= =?UTF-8?q?=E5=90=9E=E5=90=90=E6=98=BE=E7=A4=BA=E4=B8=BA=E3=80=8C=E2=80=94?= =?UTF-8?q?=E3=80=8D=EF=BC=9A=E8=8A=82=E7=82=B9=E5=8F=AF=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=20API=20=E5=B0=86=20OpenResty=20=E8=A7=82=E6=B5=8B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=90=88=E5=B9=B6=E8=BF=9B=20`metric=5Fsnapshots`?= =?UTF-8?q?=EF=BC=9B=E6=8C=87=E6=A0=87=E6=96=87=E6=A1=88=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E3=80=8C=E8=AF=B7=E6=B1=82/=E5=88=86=E9=92=9F=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E8=BF=91=2060=20=E7=A7=92=E7=AA=97=E5=8F=A3=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E8=BF=9E=E6=8E=A5=E6=95=B0=E4=B8=BA=200=20=E6=97=B6?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E6=98=BE=E7=A4=BA=200=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/index.md | 2 + docs/docs.go | 102 ++++++++-------- docs/swagger.json | 102 ++++++++-------- docs/swagger.yaml | 69 ++++++----- .../nodes/components/node-observability.tsx | 31 ++--- .../app/(main)/nodes/components/node-utils.ts | 15 +-- .../apps/openflare/observability/analytics.go | 109 ++++++++++++++++-- .../openflare/observability/analytics_test.go | 65 ++++++++++- .../openflare/observability/node_logics.go | 14 ++- 9 files changed, 344 insertions(+), 165 deletions(-) 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({ /> @@ -521,11 +518,11 @@ export function NodeObservability({ } /> @@ -594,11 +591,7 @@ export function NodeObservability({ /> @@ -661,25 +654,23 @@ 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{