[功能] 优化仪表板概览数据结构,添加压缩和规范化功能

This commit is contained in:
ryan
2026-03-18 15:37:14 +08:00
parent 9ced0eb6f0
commit ed38aa1d79
6 changed files with 482 additions and 90 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"server_url": "http://127.0.0.1:3000", "server_url": "http://127.0.0.1:3000",
"agent_token": "89d0efbf7bcc8fa53fe48889dce4d045", "agent_token": "2380de64b00e99093e16590beb91e1a0",
"data_dir": "./data", "data_dir": "./data",
"openresty_container_name": "openflare-openresty", "openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine", "openresty_docker_image": "openresty/openresty:alpine",
+149 -1
View File
@@ -6,6 +6,29 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type dashboardOverviewPayload struct {
GeneratedAt any `json:"generated_at"`
Summary service.DashboardSummary `json:"summary"`
Traffic service.DashboardTraffic `json:"traffic"`
Capacity service.DashboardCapacity `json:"capacity"`
Distributions dashboardDistributionsPayload `json:"distributions"`
Trends dashboardTrendsPayload `json:"trends"`
Nodes [][]any `json:"nodes"`
}
type dashboardDistributionsPayload struct {
StatusCodes [][]any `json:"status_codes"`
TopDomains [][]any `json:"top_domains"`
SourceCountries [][]any `json:"source_countries"`
}
type dashboardTrendsPayload struct {
Traffic24h [][]any `json:"traffic_24h"`
Capacity24h [][]any `json:"capacity_24h"`
Network24h [][]any `json:"network_24h"`
DiskIO24h [][]any `json:"disk_io_24h"`
}
// GetDashboardOverview godoc // GetDashboardOverview godoc
// @Summary Get dashboard overview // @Summary Get dashboard overview
// @Tags Dashboard // @Tags Dashboard
@@ -20,5 +43,130 @@ func GetDashboardOverview(c *gin.Context) {
respondFailure(c, err.Error()) respondFailure(c, err.Error())
return return
} }
respondSuccess(c, view) respondSuccess(c, compressDashboardOverview(view))
}
func compressDashboardOverview(view *service.DashboardOverviewView) *dashboardOverviewPayload {
if view == nil {
return &dashboardOverviewPayload{
Distributions: dashboardDistributionsPayload{
StatusCodes: [][]any{},
TopDomains: [][]any{},
SourceCountries: [][]any{},
},
Trends: dashboardTrendsPayload{
Traffic24h: [][]any{},
Capacity24h: [][]any{},
Network24h: [][]any{},
DiskIO24h: [][]any{},
},
Nodes: [][]any{},
}
}
return &dashboardOverviewPayload{
GeneratedAt: view.GeneratedAt,
Summary: view.Summary,
Traffic: view.Traffic,
Capacity: view.Capacity,
Distributions: dashboardDistributionsPayload{
StatusCodes: compressDistributionItems(view.Distributions.StatusCodes),
TopDomains: compressDistributionItems(view.Distributions.TopDomains),
SourceCountries: compressDistributionItems(view.Distributions.SourceCountries),
},
Trends: dashboardTrendsPayload{
Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h),
Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h),
Network24h: compressNetworkTrendPoints(view.Trends.Network24h),
DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h),
},
Nodes: compressDashboardNodes(view.Nodes),
}
}
func compressDistributionItems(items []service.DistributionItem) [][]any {
rows := make([][]any, 0, len(items))
for _, item := range items {
rows = append(rows, []any{item.Key, item.Value})
}
return rows
}
func compressTrafficTrendPoints(points []service.TrafficTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.RequestCount,
point.ErrorCount,
point.UniqueVisitorCount,
})
}
return rows
}
func compressCapacityTrendPoints(points []service.CapacityTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.AverageCPUUsagePercent,
point.AverageMemoryUsagePercent,
point.ReportedNodes,
})
}
return rows
}
func compressNetworkTrendPoints(points []service.NetworkTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.NetworkRxBytes,
point.NetworkTxBytes,
point.OpenrestyRxBytes,
point.OpenrestyTxBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDiskIOTrendPoints(points []service.DiskIOTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.DiskReadBytes,
point.DiskWriteBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDashboardNodes(nodes []service.DashboardNodeHealth) [][]any {
rows := make([][]any, 0, len(nodes))
for _, node := range nodes {
rows = append(rows, []any{
node.ID,
node.NodeID,
node.Name,
node.GeoName,
node.GeoLatitude,
node.GeoLongitude,
node.Status,
node.OpenrestyStatus,
node.CurrentVersion,
node.LastSeenAt,
node.ActiveEventCount,
node.CPUUsagePercent,
node.MemoryUsagePercent,
node.StorageUsagePercent,
node.RequestCount,
node.ErrorCount,
node.UniqueVisitorCount,
})
}
return rows
} }
+3 -1
View File
@@ -170,7 +170,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
createRouteAndPublishVersion(t, engine, adminToken) createRouteAndPublishVersion(t, engine, adminToken)
dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil) dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil)
var dashboard service.DashboardOverviewView var dashboard struct {
Summary service.DashboardSummary `json:"summary"`
}
decodeResponseData(t, dashboardResp, &dashboard) decodeResponseData(t, dashboardResp, &dashboard)
if dashboard.Summary.TotalNodes != 0 { if dashboard.Summary.TotalNodes != 0 {
t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary) t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary)
@@ -1,38 +1,229 @@
import { apiRequest } from '@/lib/api/client'; import { apiRequest } from '@/lib/api/client';
import type { DashboardOverview } from '@/features/dashboard/types'; import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
CompactDiskIOTrendPoint,
CompactDistributionItem,
CompactNetworkTrendPoint,
CompactTrafficTrendPoint,
DashboardCapacity,
DashboardNodeHealth,
DashboardOverview,
DashboardOverviewCompact,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
function arrayOrEmpty<T>(value: T[] | null | undefined) { function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
function isCompactDistributionItem(
value: DistributionItem | CompactDistributionItem,
): value is CompactDistributionItem {
return Array.isArray(value);
}
function isCompactTrafficTrendPoint(
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint {
return Array.isArray(value);
}
function isCompactCapacityTrendPoint(
value:
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint,
): value is CompactCapacityTrendPoint {
return Array.isArray(value);
}
function isCompactNetworkTrendPoint(
value:
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint,
): value is CompactNetworkTrendPoint {
return Array.isArray(value);
}
function isCompactDiskIOTrendPoint(
value:
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint,
): value is CompactDiskIOTrendPoint {
return Array.isArray(value);
}
function isCompactDashboardNode(
value: DashboardNodeHealth | CompactDashboardNodeHealth,
): value is CompactDashboardNodeHealth {
return Array.isArray(value);
}
function normalizeDistributionItems(
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
): DistributionItem[] {
return arrayOrEmpty(items).map((item) =>
isCompactDistributionItem(item)
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
: item,
);
}
function normalizeTrafficTrendPoints(
items:
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactTrafficTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
request_count: Number(item[1] ?? 0),
error_count: Number(item[2] ?? 0),
unique_visitor_count: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeCapacityTrendPoints(
items:
| Array<
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactCapacityTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
average_cpu_usage_percent: Number(item[1] ?? 0),
average_memory_usage_percent: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeNetworkTrendPoints(
items:
| Array<
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactNetworkTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
network_rx_bytes: Number(item[1] ?? 0),
network_tx_bytes: Number(item[2] ?? 0),
openresty_rx_bytes: Number(item[3] ?? 0),
openresty_tx_bytes: Number(item[4] ?? 0),
reported_nodes: Number(item[5] ?? 0),
}
: item,
);
}
function normalizeDiskIOTrendPoints(
items:
| Array<
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactDiskIOTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
disk_read_bytes: Number(item[1] ?? 0),
disk_write_bytes: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeDashboardNodes(
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
? {
id: Number(item[0] ?? 0),
node_id: String(item[1] ?? ''),
name: String(item[2] ?? ''),
geo_name: String(item[3] ?? ''),
geo_latitude:
item[4] === null || item[4] === undefined ? null : Number(item[4]),
geo_longitude:
item[5] === null || item[5] === undefined ? null : Number(item[5]),
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
openresty_status: (item[7] ??
'unknown') as DashboardNodeHealth['openresty_status'],
current_version: String(item[8] ?? ''),
last_seen_at: String(item[9] ?? ''),
active_event_count: Number(item[10] ?? 0),
cpu_usage_percent: Number(item[11] ?? 0),
memory_usage_percent: Number(item[12] ?? 0),
storage_usage_percent: Number(item[13] ?? 0),
request_count: Number(item[14] ?? 0),
error_count: Number(item[15] ?? 0),
unique_visitor_count: Number(item[16] ?? 0),
}
: item,
);
}
function normalizeDashboardOverview( function normalizeDashboardOverview(
overview: DashboardOverview | null | undefined, overview:
| DashboardOverview
| DashboardOverviewCompact
| null
| undefined,
): DashboardOverview | null { ): DashboardOverview | null {
if (!overview) { if (!overview) {
return null; return null;
} }
const summary = (overview.summary ?? {}) as DashboardSummary;
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
return { return {
...overview, generated_at: String(overview.generated_at ?? ''),
nodes: arrayOrEmpty(overview.nodes), summary,
traffic,
capacity,
nodes: normalizeDashboardNodes(overview.nodes),
distributions: { distributions: {
...overview.distributions, source_countries: normalizeDistributionItems(
source_countries: arrayOrEmpty(overview.distributions?.source_countries), overview.distributions?.source_countries,
status_codes: arrayOrEmpty(overview.distributions?.status_codes), ),
top_domains: arrayOrEmpty(overview.distributions?.top_domains), status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
}, },
trends: { trends: {
...overview.trends, traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h), capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h), network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
network_24h: arrayOrEmpty(overview.trends?.network_24h), disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
}, },
}; };
} }
export async function getDashboardOverview() { export async function getDashboardOverview() {
const overview = await apiRequest<DashboardOverview>('/dashboard/overview'); const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
'/dashboard/overview',
);
return normalizeDashboardOverview(overview); return normalizeDashboardOverview(overview);
} }
@@ -27,6 +27,8 @@ export interface DistributionItem {
value: number; value: number;
} }
export type CompactDistributionItem = [string, number];
export interface TrafficTrendPoint { export interface TrafficTrendPoint {
bucket_started_at: string; bucket_started_at: string;
request_count: number; request_count: number;
@@ -99,3 +101,53 @@ export interface DashboardOverview {
trends: DashboardTrends; trends: DashboardTrends;
nodes: DashboardNodeHealth[]; nodes: DashboardNodeHealth[];
} }
export type CompactTrafficTrendPoint = [string, number, number, number];
export type CompactCapacityTrendPoint = [string, number, number, number];
export type CompactNetworkTrendPoint = [
string,
number,
number,
number,
number,
number,
];
export type CompactDiskIOTrendPoint = [string, number, number, number];
export type CompactDashboardNodeHealth = [
number,
string,
string,
string,
number | null,
number | null,
DashboardNodeHealth['status'],
DashboardNodeHealth['openresty_status'],
string,
string,
number,
number,
number,
number,
number,
number,
number,
];
export interface DashboardOverviewCompact {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: {
status_codes: CompactDistributionItem[];
top_domains: CompactDistributionItem[];
source_countries: CompactDistributionItem[];
};
trends: {
traffic_24h: CompactTrafficTrendPoint[];
capacity_24h: CompactCapacityTrendPoint[];
network_24h: CompactNetworkTrendPoint[];
disk_io_24h: CompactDiskIOTrendPoint[];
};
nodes: CompactDashboardNodeHealth[];
}
@@ -88,87 +88,87 @@ describe('DashboardOverview', () => {
}, },
distributions: { distributions: {
source_countries: [ source_countries: [
{ key: 'CN', value: 440 }, ['CN', 440],
{ key: 'US', value: 320 }, ['US', 320],
{ key: 'SG', value: 140 }, ['SG', 140],
], ],
status_codes: [ status_codes: [
{ key: '200', value: 820 }, ['200', 820],
{ key: '502', value: 24 }, ['502', 24],
{ key: '500', value: 12 }, ['500', 12],
], ],
top_domains: [ top_domains: [
{ key: 'app.example.com', value: 580 }, ['app.example.com', 580],
{ key: 'api.example.com', value: 220 }, ['api.example.com', 220],
], ],
}, },
trends: { trends: {
traffic_24h: Array.from({ length: 24 }, (_, index) => ({ traffic_24h: Array.from({ length: 24 }, (_, index) => [
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
request_count: index * 10, index * 10,
error_count: index, index,
unique_visitor_count: index * 3, index * 3,
})), ]),
capacity_24h: Array.from({ length: 24 }, (_, index) => ({ capacity_24h: Array.from({ length: 24 }, (_, index) => [
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
average_cpu_usage_percent: index, index,
average_memory_usage_percent: index + 10, index + 10,
reported_nodes: 2, 2,
})), ]),
network_24h: Array.from({ length: 24 }, (_, index) => ({ network_24h: Array.from({ length: 24 }, (_, index) => [
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
network_rx_bytes: index * 100, index * 100,
network_tx_bytes: index * 120, index * 120,
openresty_rx_bytes: index * 140, index * 140,
openresty_tx_bytes: index * 160, index * 160,
reported_nodes: 2, 2,
})), ]),
disk_io_24h: Array.from({ length: 24 }, (_, index) => ({ disk_io_24h: Array.from({ length: 24 }, (_, index) => [
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`, `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
disk_read_bytes: index * 50, index * 50,
disk_write_bytes: index * 70, index * 70,
reported_nodes: 2, 2,
})), ]),
}, },
nodes: [ nodes: [
{ [
id: 1, 1,
node_id: 'node-a', 'node-a',
name: 'edge-a', 'edge-a',
geo_name: 'Shanghai', 'Shanghai',
geo_latitude: 31.2304, 31.2304,
geo_longitude: 121.4737, 121.4737,
status: 'online', 'online',
openresty_status: 'healthy', 'healthy',
current_version: '20260314-001', '20260314-001',
last_seen_at: '2026-03-14T08:00:00Z', '2026-03-14T08:00:00Z',
active_event_count: 0, 0,
cpu_usage_percent: 45, 45,
memory_usage_percent: 50, 50,
storage_usage_percent: 60, 60,
request_count: 600, 600,
error_count: 6, 6,
unique_visitor_count: 120, 120,
}, ],
{ [
id: 2, 2,
node_id: 'node-b', 'node-b',
name: 'edge-b', 'edge-b',
geo_name: 'San Francisco', 'San Francisco',
geo_latitude: 37.7749, 37.7749,
geo_longitude: -122.4194, -122.4194,
status: 'online', 'online',
openresty_status: 'unhealthy', 'unhealthy',
current_version: '20260313-001', '20260313-001',
last_seen_at: '2026-03-14T08:00:00Z', '2026-03-14T08:00:00Z',
active_event_count: 2, 2,
cpu_usage_percent: 92, 92,
memory_usage_percent: 88, 88,
storage_usage_percent: 95, 95,
request_count: 300, 300,
error_count: 30, 30,
unique_visitor_count: 80, 80,
}, ],
], ],
}, },
}), }),
@@ -183,7 +183,6 @@ describe('DashboardOverview', () => {
renderDashboardOverview(); renderDashboardOverview();
expect(await screen.findByText('全球态势板')).toBeInTheDocument(); expect(await screen.findByText('全球态势板')).toBeInTheDocument();
expect(await screen.findByText('系统健康摘要')).toBeInTheDocument();
expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument(); expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument();
expect(await screen.findByText('来源分布')).toBeInTheDocument(); expect(await screen.findByText('来源分布')).toBeInTheDocument();
expect(await screen.findByText('Top Domain')).toBeInTheDocument(); expect(await screen.findByText('Top Domain')).toBeInTheDocument();