mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
feat: add 24-hour traffic and capacity trend charts to dashboard and node detail pages
- Implemented TrendChart component for visualizing traffic and capacity trends. - Enhanced DashboardOverview and NodeDetailPage components to display 24-hour request and capacity trends. - Updated types to include traffic and capacity trend data structures. - Created observability trends service to aggregate traffic and capacity data. - Added tests for traffic report generation and trend data handling. - Introduced new dependencies for charting (echarts and echarts-for-react).
This commit is contained in:
@@ -313,6 +313,7 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
|
||||
}
|
||||
profile := observability.BuildProfile(r.Config, r.StateStore)
|
||||
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore)
|
||||
trafficReport := observability.BuildTrafficReport(r.Config, r.StateStore)
|
||||
healthEvents := observability.BuildHealthEvents(snapshot)
|
||||
return protocol.NodePayload{
|
||||
NodeID: nodeID,
|
||||
@@ -326,6 +327,7 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
|
||||
OpenrestyMessage: snapshot.OpenrestyMessage,
|
||||
Profile: profile,
|
||||
Snapshot: metricSnapshot,
|
||||
TrafficReport: trafficReport,
|
||||
HealthEvents: healthEvents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,8 @@ func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
tempDir := t.TempDir()
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
if err := stateStore.Save(&state.Snapshot{
|
||||
NodeID: "node-observe",
|
||||
CurrentVersion: "20260314-001",
|
||||
@@ -299,11 +300,22 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
||||
NodeIP: "10.0.0.51",
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
DataDir: t.TempDir(),
|
||||
DataDir: tempDir,
|
||||
RouteConfigPath: filepath.Join(tempDir, "conf.d", "atsflare_routes.conf"),
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(runner.Config.RouteConfigPath), 0o755); err != nil {
|
||||
t.Fatalf("failed to prepare route config dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "atsflare_access.log"),
|
||||
[]byte("{\"ts\":\"2026-03-14T10:00:00Z\",\"host\":\"edge.example.com\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to prepare access log: %v", err)
|
||||
}
|
||||
|
||||
firstPayload := runner.nodePayload("node-observe")
|
||||
if firstPayload.Profile == nil {
|
||||
@@ -312,6 +324,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
||||
if firstPayload.Snapshot == nil {
|
||||
t.Fatal("expected first heartbeat payload to include metric snapshot")
|
||||
}
|
||||
if firstPayload.TrafficReport == nil || firstPayload.TrafficReport.RequestCount != 1 {
|
||||
t.Fatalf("expected first heartbeat payload to include traffic report, got %+v", firstPayload.TrafficReport)
|
||||
}
|
||||
if len(firstPayload.HealthEvents) != 2 {
|
||||
t.Fatalf("expected health events for openresty and sync error, got %+v", firstPayload.HealthEvents)
|
||||
}
|
||||
@@ -323,6 +338,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
||||
if secondPayload.Snapshot == nil {
|
||||
t.Fatal("expected metric snapshot to continue reporting on subsequent heartbeat")
|
||||
}
|
||||
if secondPayload.TrafficReport != nil {
|
||||
t.Fatalf("expected unchanged traffic window to be omitted on subsequent heartbeat, got %+v", secondPayload.TrafficReport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||
|
||||
@@ -19,8 +19,10 @@ import (
|
||||
|
||||
const CertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||
const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
|
||||
const AccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
|
||||
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
|
||||
const DockerRouteConfigPath = "/etc/nginx/conf.d/atsflare_routes.conf"
|
||||
const DockerAccessLogPath = "/etc/nginx/conf.d/atsflare_access.log"
|
||||
|
||||
const dockerRuntimeCommand = "openresty"
|
||||
|
||||
@@ -285,6 +287,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
|
||||
}
|
||||
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
|
||||
}
|
||||
normalizedRoute := string(data)
|
||||
if m.NginxCertDir != "" {
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
|
||||
@@ -609,11 +614,14 @@ func (m *Manager) renderRouteConfig(content string) string {
|
||||
}
|
||||
|
||||
func (m *Manager) renderMainConfig(content string) string {
|
||||
includePath := m.routeConfigIncludePath()
|
||||
if includePath == "" {
|
||||
return content
|
||||
rendered := content
|
||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||
rendered = strings.ReplaceAll(rendered, RouteConfigPlaceholder, includePath)
|
||||
}
|
||||
return strings.ReplaceAll(content, RouteConfigPlaceholder, includePath)
|
||||
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||
rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func (m *Manager) routeConfigIncludePath() string {
|
||||
@@ -623,6 +631,14 @@ func (m *Manager) routeConfigIncludePath() string {
|
||||
return strings.TrimSpace(m.RouteConfigPath)
|
||||
}
|
||||
|
||||
func (m *Manager) accessLogRuntimePath() string {
|
||||
includePath := m.routeConfigIncludePath()
|
||||
if strings.TrimSpace(includePath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(filepath.Dir(includePath), "atsflare_access.log"))
|
||||
}
|
||||
|
||||
func checksum(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -337,7 +337,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
|
||||
err := manager.Apply(
|
||||
context.Background(),
|
||||
"include __ATSF_ROUTE_CONFIG__;\n",
|
||||
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
|
||||
"ssl_certificate __ATSF_CERT_DIR__/1.crt;\n",
|
||||
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
)
|
||||
@@ -349,7 +349,8 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
if string(mainData) != "include "+routePath+";\n" {
|
||||
expectedMain := "include " + routePath + ";\naccess_log " + filepath.Join(filepath.Dir(routePath), "atsflare_access.log") + " atsflare_json;\n"
|
||||
if string(mainData) != expectedMain {
|
||||
t.Fatalf("unexpected main config: %s", string(mainData))
|
||||
}
|
||||
|
||||
@@ -366,7 +367,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||
}
|
||||
expected := bundleChecksum(
|
||||
"include __ATSF_ROUTE_CONFIG__;\n",
|
||||
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
|
||||
"ssl_certificate __ATSF_CERT_DIR__/1.crt;\n",
|
||||
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
)
|
||||
@@ -388,7 +389,7 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
if err := manager.Apply(context.Background(), "include __ATSF_ROUTE_CONFIG__;\n", "server { listen 80; }\n", nil); err != nil {
|
||||
if err := manager.Apply(context.Background(), "include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n", "server { listen 80; }\n", nil); err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -396,7 +397,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
if string(mainData) != "include "+DockerRouteConfigPath+";\n" {
|
||||
expectedMain := "include " + DockerRouteConfigPath + ";\naccess_log " + DockerAccessLogPath + " atsflare_json;\n"
|
||||
if string(mainData) != expectedMain {
|
||||
t.Fatalf("unexpected main config include path: %s", string(mainData))
|
||||
}
|
||||
|
||||
@@ -405,7 +407,7 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||
}
|
||||
expected := bundleChecksum(
|
||||
"include __ATSF_ROUTE_CONFIG__;\n",
|
||||
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
|
||||
"server { listen 80; }\n",
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"atsflare-agent/internal/config"
|
||||
"atsflare-agent/internal/protocol"
|
||||
"atsflare-agent/internal/state"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type accessLogRecord struct {
|
||||
Timestamp string `json:"ts"`
|
||||
Host string `json:"host"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type trafficAggregate struct {
|
||||
windowStartedAt time.Time
|
||||
windowEndedAt time.Time
|
||||
requestCount int64
|
||||
errorCount int64
|
||||
statusCodes map[string]int64
|
||||
topDomains map[string]int64
|
||||
visitors map[string]struct{}
|
||||
}
|
||||
|
||||
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store) *protocol.NodeTrafficReport {
|
||||
if cfg == nil || stateStore == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logPath := managedAccessLogPath(cfg)
|
||||
file, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if snapshot.AccessLogOffset != 0 {
|
||||
snapshot.AccessLogOffset = 0
|
||||
_ = stateStore.Save(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
offset := snapshot.AccessLogOffset
|
||||
if offset < 0 || offset > info.Size() {
|
||||
offset = 0
|
||||
}
|
||||
if _, err = file.Seek(offset, io.SeekStart); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
currentOffset := offset
|
||||
aggregate := newTrafficAggregate()
|
||||
|
||||
for {
|
||||
line, readErr := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
currentOffset += int64(len(line))
|
||||
aggregate.consume(line)
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.AccessLogOffset = currentOffset
|
||||
_ = stateStore.Save(snapshot)
|
||||
|
||||
return aggregate.report()
|
||||
}
|
||||
|
||||
func managedAccessLogPath(cfg *config.Config) string {
|
||||
if cfg == nil || strings.TrimSpace(cfg.RouteConfigPath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(cfg.RouteConfigPath), "atsflare_access.log")
|
||||
}
|
||||
|
||||
func newTrafficAggregate() *trafficAggregate {
|
||||
return &trafficAggregate{
|
||||
statusCodes: make(map[string]int64),
|
||||
topDomains: make(map[string]int64),
|
||||
visitors: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (aggregate *trafficAggregate) consume(line []byte) {
|
||||
trimmed := strings.TrimSpace(string(line))
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var record accessLogRecord
|
||||
if err := json.Unmarshal([]byte(trimmed), &record); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp, err := parseAccessLogTime(record.Timestamp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if aggregate.windowStartedAt.IsZero() || timestamp.Before(aggregate.windowStartedAt) {
|
||||
aggregate.windowStartedAt = timestamp
|
||||
}
|
||||
if aggregate.windowEndedAt.IsZero() || timestamp.After(aggregate.windowEndedAt) {
|
||||
aggregate.windowEndedAt = timestamp
|
||||
}
|
||||
|
||||
aggregate.requestCount++
|
||||
if record.Status >= 500 {
|
||||
aggregate.errorCount++
|
||||
}
|
||||
if record.Status > 0 {
|
||||
aggregate.statusCodes[strconv.Itoa(record.Status)]++
|
||||
}
|
||||
if host := strings.TrimSpace(record.Host); host != "" {
|
||||
aggregate.topDomains[host]++
|
||||
}
|
||||
if remoteAddr := strings.TrimSpace(record.RemoteAddr); remoteAddr != "" {
|
||||
aggregate.visitors[remoteAddr] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (aggregate *trafficAggregate) report() *protocol.NodeTrafficReport {
|
||||
if aggregate.requestCount == 0 || aggregate.windowStartedAt.IsZero() || aggregate.windowEndedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.NodeTrafficReport{
|
||||
WindowStartedAtUnix: aggregate.windowStartedAt.Unix(),
|
||||
WindowEndedAtUnix: aggregate.windowEndedAt.Unix(),
|
||||
RequestCount: aggregate.requestCount,
|
||||
ErrorCount: aggregate.errorCount,
|
||||
UniqueVisitorCount: int64(len(aggregate.visitors)),
|
||||
StatusCodes: cloneTrafficCounts(aggregate.statusCodes, 0),
|
||||
TopDomains: topCounts(aggregate.topDomains, 8),
|
||||
SourceCountries: map[string]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccessLogTime(value string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func cloneTrafficCounts(values map[string]int64, limit int) map[string]int64 {
|
||||
if len(values) == 0 {
|
||||
return map[string]int64{}
|
||||
}
|
||||
items := make([]trafficCountItem, 0, len(values))
|
||||
for key, value := range values {
|
||||
items = append(items, trafficCountItem{key: key, value: value})
|
||||
}
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
if items[i].value == items[j].value {
|
||||
return items[i].key < items[j].key
|
||||
}
|
||||
return items[i].value > items[j].value
|
||||
})
|
||||
if limit > 0 && len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
result := make(map[string]int64, len(items))
|
||||
for _, item := range items {
|
||||
result[item.key] = item.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type trafficCountItem struct {
|
||||
key string
|
||||
value int64
|
||||
}
|
||||
|
||||
func topCounts(values map[string]int64, limit int) map[string]int64 {
|
||||
return cloneTrafficCounts(values, limit)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"atsflare-agent/internal/config"
|
||||
"atsflare-agent/internal/state"
|
||||
)
|
||||
|
||||
func TestBuildTrafficReportAggregatesManagedAccessLog(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
|
||||
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
|
||||
content := []byte(
|
||||
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n" +
|
||||
"{\"ts\":\"2026-03-14T08:00:05Z\",\"host\":\"app.example.com\",\"remote_addr\":\"10.0.0.2\",\"status\":503}\n" +
|
||||
"{\"ts\":\"2026-03-14T08:00:08Z\",\"host\":\"api.example.com\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
|
||||
)
|
||||
if err := os.WriteFile(logPath, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore)
|
||||
if report == nil {
|
||||
t.Fatal("expected traffic report")
|
||||
}
|
||||
if report.RequestCount != 3 || report.ErrorCount != 1 || report.UniqueVisitorCount != 2 {
|
||||
t.Fatalf("unexpected traffic report counters: %+v", report)
|
||||
}
|
||||
if report.StatusCodes["200"] != 2 || report.StatusCodes["503"] != 1 {
|
||||
t.Fatalf("unexpected status codes: %+v", report.StatusCodes)
|
||||
}
|
||||
if report.TopDomains["app.example.com"] != 2 || report.TopDomains["api.example.com"] != 1 {
|
||||
t.Fatalf("unexpected top domains: %+v", report.TopDomains)
|
||||
}
|
||||
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if snapshot.AccessLogOffset != int64(len(content)) {
|
||||
t.Fatalf("unexpected access log offset: %d", snapshot.AccessLogOffset)
|
||||
}
|
||||
|
||||
secondReport := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore)
|
||||
if secondReport != nil {
|
||||
t.Fatalf("expected no report without appended lines, got %+v", secondReport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrafficReportResetsOffsetAfterTruncate(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
|
||||
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
|
||||
if err := os.WriteFile(logPath, []byte("{\"ts\":\"2026-03-14T09:00:00Z\",\"host\":\"app.example.com\",\"remote_addr\":\"10.0.0.3\",\"status\":200}\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
if err := stateStore.Save(&state.Snapshot{AccessLogOffset: 4096}); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
report := BuildTrafficReport(&config.Config{RouteConfigPath: routeConfigPath}, stateStore)
|
||||
if report == nil || report.RequestCount != 1 {
|
||||
t.Fatalf("expected one request after truncate reset, got %+v", report)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Snapshot struct {
|
||||
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
|
||||
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
|
||||
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
|
||||
AccessLogOffset int64 `json:"access_log_offset"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
|
||||
@@ -99,6 +99,8 @@ events {
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
log_format atsflare_json escape=json '{"ts":"$time_iso8601","host":"$host","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
|
||||
access_log {{OpenRestyAccessLogPath}} atsflare_json;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
|
||||
@@ -35,3 +35,8 @@ func ListNodeHealthEvents(nodeID string, activeOnly bool, limit int) (events []*
|
||||
err = query.Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func ListActiveNodeHealthEvents() (events []*NodeHealthEvent, err error) {
|
||||
err = DB.Where("status = ?", "active").Order("last_triggered_at desc").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
@@ -37,3 +37,12 @@ func ListNodeMetricSnapshots(nodeID string, since time.Time, limit int) (snapsho
|
||||
err = query.Find(&snapshots).Error
|
||||
return snapshots, err
|
||||
}
|
||||
|
||||
func ListMetricSnapshotsSince(since time.Time) (snapshots []*NodeMetricSnapshot, err error) {
|
||||
query := DB.Order("captured_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
}
|
||||
err = query.Find(&snapshots).Error
|
||||
return snapshots, err
|
||||
}
|
||||
|
||||
@@ -32,3 +32,12 @@ func ListNodeRequestReports(nodeID string, since time.Time, limit int) (reports
|
||||
err = query.Find(&reports).Error
|
||||
return reports, err
|
||||
}
|
||||
|
||||
func ListRequestReportsSince(since time.Time) (reports []*NodeRequestReport, err error) {
|
||||
query := DB.Order("window_ended_at desc")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("window_ended_at >= ?", since)
|
||||
}
|
||||
err = query.Find(&reports).Error
|
||||
return reports, err
|
||||
}
|
||||
|
||||
@@ -117,12 +117,14 @@ type configBundle struct {
|
||||
const (
|
||||
nginxCertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||
nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
|
||||
nginxAccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
|
||||
)
|
||||
|
||||
var requiredMainConfigTemplatePlaceholders = []string{
|
||||
"{{OpenRestyWorkerProcesses}}",
|
||||
"{{OpenRestyWorkerConnections}}",
|
||||
"{{OpenRestyWorkerRlimitNofile}}",
|
||||
"{{OpenRestyAccessLogPath}}",
|
||||
"{{OpenRestyEventsUseDirective}}",
|
||||
"{{OpenRestyEventsMultiAcceptDirective}}",
|
||||
"{{OpenRestyKeepaliveTimeout}}",
|
||||
@@ -638,6 +640,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
|
||||
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
|
||||
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
|
||||
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
|
||||
"{{OpenRestyAccessLogPath}}", nginxAccessLogPlaceholder,
|
||||
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
|
||||
"{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
|
||||
"{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout),
|
||||
|
||||
@@ -15,6 +15,7 @@ type DashboardOverviewView struct {
|
||||
Traffic DashboardTraffic `json:"traffic"`
|
||||
Capacity DashboardCapacity `json:"capacity"`
|
||||
Config DashboardConfig `json:"config"`
|
||||
Trends DashboardTrends `json:"trends"`
|
||||
Nodes []DashboardNodeHealth `json:"nodes"`
|
||||
ActiveAlerts []DashboardAlert `json:"active_alerts"`
|
||||
}
|
||||
@@ -51,6 +52,11 @@ type DashboardConfig struct {
|
||||
PendingNodes int `json:"pending_nodes"`
|
||||
}
|
||||
|
||||
type DashboardTrends struct {
|
||||
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
|
||||
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
|
||||
}
|
||||
|
||||
type DashboardNodeHealth struct {
|
||||
ID uint `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
@@ -94,13 +100,33 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
return nil, versionErr
|
||||
}
|
||||
|
||||
snapshots, err := model.ListMetricSnapshotsSince(since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reports, err := model.ListRequestReportsSince(since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activeEvents, err := model.ListActiveNodeHealthEvents()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := &DashboardOverviewView{
|
||||
GeneratedAt: now,
|
||||
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
|
||||
Trends: DashboardTrends{
|
||||
Traffic24h: buildTrafficTrendPoints(now, reports),
|
||||
Capacity24h: buildCapacityTrendPoints(now, snapshots),
|
||||
},
|
||||
}
|
||||
|
||||
var cpuNodeCount int
|
||||
var memoryNodeCount int
|
||||
latestSnapshots := latestMetricSnapshotsByNode(snapshots)
|
||||
latestTrafficReports := latestTrafficReportsByNode(reports)
|
||||
activeEventsByNode := activeHealthEventsByNode(activeEvents)
|
||||
|
||||
for _, node := range nodes {
|
||||
computedStatus := computeNodeStatus(node)
|
||||
@@ -122,12 +148,9 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
view.Summary.LaggingNodes++
|
||||
}
|
||||
|
||||
latestSnapshot := latestMetricSnapshotForNode(node.NodeID, since)
|
||||
latestTraffic := latestTrafficReportForNode(node.NodeID, since)
|
||||
activeEvents, eventErr := model.ListNodeHealthEvents(node.NodeID, true, 20)
|
||||
if eventErr != nil {
|
||||
return nil, eventErr
|
||||
}
|
||||
latestSnapshot := latestSnapshots[node.NodeID]
|
||||
latestTraffic := latestTrafficReports[node.NodeID]
|
||||
nodeActiveEvents := activeEventsByNode[node.NodeID]
|
||||
|
||||
nodeHealth := DashboardNodeHealth{
|
||||
ID: node.ID,
|
||||
@@ -137,10 +160,10 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
OpenrestyStatus: node.OpenrestyStatus,
|
||||
CurrentVersion: node.CurrentVersion,
|
||||
LastSeenAt: node.LastSeenAt,
|
||||
ActiveEventCount: len(activeEvents),
|
||||
ActiveEventCount: len(nodeActiveEvents),
|
||||
}
|
||||
|
||||
for _, event := range activeEvents {
|
||||
for _, event := range nodeActiveEvents {
|
||||
view.ActiveAlerts = append(view.ActiveAlerts, DashboardAlert{
|
||||
NodeID: node.NodeID,
|
||||
NodeName: node.Name,
|
||||
@@ -188,7 +211,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
view.Traffic.ReportedNodes++
|
||||
}
|
||||
|
||||
view.Summary.ActiveAlerts += len(activeEvents)
|
||||
view.Summary.ActiveAlerts += len(nodeActiveEvents)
|
||||
view.Nodes = append(view.Nodes, nodeHealth)
|
||||
}
|
||||
|
||||
@@ -225,22 +248,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func latestMetricSnapshotForNode(nodeID string, since time.Time) *model.NodeMetricSnapshot {
|
||||
snapshots, err := model.ListNodeMetricSnapshots(nodeID, since, 1)
|
||||
if err != nil || len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
return snapshots[0]
|
||||
}
|
||||
|
||||
func latestTrafficReportForNode(nodeID string, since time.Time) *model.NodeRequestReport {
|
||||
reports, err := model.ListNodeRequestReports(nodeID, since, 1)
|
||||
if err != nil || len(reports) == 0 {
|
||||
return nil
|
||||
}
|
||||
return reports[0]
|
||||
}
|
||||
|
||||
func percentage(used int64, total int64) float64 {
|
||||
if used <= 0 || total <= 0 {
|
||||
return 0
|
||||
@@ -248,6 +255,45 @@ func percentage(used int64, total int64) float64 {
|
||||
return (float64(used) / float64(total)) * 100
|
||||
}
|
||||
|
||||
func latestMetricSnapshotsByNode(snapshots []*model.NodeMetricSnapshot) map[string]*model.NodeMetricSnapshot {
|
||||
result := make(map[string]*model.NodeMetricSnapshot, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
if snapshot == nil || snapshot.NodeID == "" {
|
||||
continue
|
||||
}
|
||||
if existing, ok := result[snapshot.NodeID]; ok && !snapshot.CapturedAt.After(existing.CapturedAt) {
|
||||
continue
|
||||
}
|
||||
result[snapshot.NodeID] = snapshot
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func latestTrafficReportsByNode(reports []*model.NodeRequestReport) map[string]*model.NodeRequestReport {
|
||||
result := make(map[string]*model.NodeRequestReport, len(reports))
|
||||
for _, report := range reports {
|
||||
if report == nil || report.NodeID == "" {
|
||||
continue
|
||||
}
|
||||
if existing, ok := result[report.NodeID]; ok && !report.WindowEndedAt.After(existing.WindowEndedAt) {
|
||||
continue
|
||||
}
|
||||
result[report.NodeID] = report
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func activeHealthEventsByNode(events []*model.NodeHealthEvent) map[string][]*model.NodeHealthEvent {
|
||||
result := make(map[string][]*model.NodeHealthEvent)
|
||||
for _, event := range events {
|
||||
if event == nil || event.NodeID == "" {
|
||||
continue
|
||||
}
|
||||
result[event.NodeID] = append(result[event.NodeID], event)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func severityWeight(severity string) int {
|
||||
switch severity {
|
||||
case NodeHealthSeverityCritical:
|
||||
|
||||
@@ -54,6 +54,9 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
|
||||
if !strings.Contains(result.Version.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
|
||||
t.Fatal("expected main config to include managed route config placeholder")
|
||||
}
|
||||
if !strings.Contains(result.Version.MainConfig, "access_log __ATSF_ACCESS_LOG__ atsflare_json;") {
|
||||
t.Fatal("expected main config to include managed access log placeholder")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
|
||||
t.Fatal("expected rendered config to include https server block")
|
||||
}
|
||||
@@ -279,6 +282,9 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
|
||||
if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
|
||||
t.Fatal("expected preview main config to preserve managed route include")
|
||||
}
|
||||
if !strings.Contains(preview.MainConfig, "access_log __ATSF_ACCESS_LOG__ atsflare_json;") {
|
||||
t.Fatal("expected preview main config to preserve managed access log placeholder")
|
||||
}
|
||||
|
||||
invalidTemplate := strings.ReplaceAll(
|
||||
common.OpenRestyMainConfigTemplate,
|
||||
@@ -288,6 +294,15 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
|
||||
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
|
||||
t.Fatal("expected template without managed route placeholder to fail validation")
|
||||
}
|
||||
|
||||
invalidTemplate = strings.ReplaceAll(
|
||||
common.OpenRestyMainConfigTemplate,
|
||||
"{{OpenRestyAccessLogPath}}",
|
||||
"",
|
||||
)
|
||||
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
|
||||
t.Fatal("expected template without managed access log placeholder to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRestyCommonRequestOptionsRender(t *testing.T) {
|
||||
|
||||
@@ -25,16 +25,23 @@ type NodeObservabilityView struct {
|
||||
MetricSnapshots []*model.NodeMetricSnapshot `json:"metric_snapshots"`
|
||||
TrafficReports []*model.NodeRequestReport `json:"traffic_reports"`
|
||||
HealthEvents []*model.NodeHealthEvent `json:"health_events"`
|
||||
Trends NodeObservabilityTrends `json:"trends"`
|
||||
}
|
||||
|
||||
type NodeObservabilityTrends struct {
|
||||
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
|
||||
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
|
||||
}
|
||||
|
||||
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
|
||||
now := time.Now()
|
||||
node, err := model.GetNodeByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := normalizeObservabilityLimit(query.Limit)
|
||||
since := time.Now().Add(-normalizeObservabilityWindow(query.Hours))
|
||||
since := now.Add(-normalizeObservabilityWindow(query.Hours))
|
||||
|
||||
profile, err := model.GetNodeSystemProfile(node.NodeID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -52,6 +59,14 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trendSnapshots, err := model.ListNodeMetricSnapshots(node.NodeID, now.Add(-24*time.Hour), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trendReports, err := model.ListNodeRequestReports(node.NodeID, now.Add(-24*time.Hour), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := model.ListNodeHealthEvents(node.NodeID, false, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -63,6 +78,10 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
||||
MetricSnapshots: snapshots,
|
||||
TrafficReports: reports,
|
||||
HealthEvents: events,
|
||||
Trends: NodeObservabilityTrends{
|
||||
Traffic24h: buildTrafficTrendPoints(now, trendReports),
|
||||
Capacity24h: buildCapacityTrendPoints(now, trendSnapshots),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,12 @@ func TestGetNodeObservability(t *testing.T) {
|
||||
if len(view.HealthEvents) != 1 || view.HealthEvents[0].EventType != "sync_error" {
|
||||
t.Fatalf("unexpected health events: %+v", view.HealthEvents)
|
||||
}
|
||||
if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 {
|
||||
t.Fatalf("expected 24-point trends, got %+v", view.Trends)
|
||||
}
|
||||
if view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1].RequestCount != 123 {
|
||||
t.Fatalf("unexpected traffic trend tail: %+v", view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
|
||||
@@ -589,6 +595,9 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
|
||||
if view.Profile != nil {
|
||||
t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile)
|
||||
}
|
||||
if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 {
|
||||
t.Fatalf("expected empty 24-point trends, got %+v", view.Trends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardOverview(t *testing.T) {
|
||||
@@ -715,4 +724,10 @@ func TestGetDashboardOverview(t *testing.T) {
|
||||
if len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 {
|
||||
t.Fatalf("unexpected dashboard nodes/alerts: %+v %+v", view.Nodes, view.ActiveAlerts)
|
||||
}
|
||||
if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 {
|
||||
t.Fatalf("expected 24-point dashboard trends, got %+v", view.Trends)
|
||||
}
|
||||
if view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1].RequestCount != 900 {
|
||||
t.Fatalf("unexpected dashboard traffic trend tail: %+v", view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"atsflare/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
const observabilityTrendBuckets = 24
|
||||
|
||||
type TrafficTrendPoint struct {
|
||||
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
UniqueVisitorCount int64 `json:"unique_visitor_count"`
|
||||
}
|
||||
|
||||
type CapacityTrendPoint struct {
|
||||
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||
AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"`
|
||||
AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"`
|
||||
ReportedNodes int `json:"reported_nodes"`
|
||||
}
|
||||
|
||||
type capacityTrendAccumulator struct {
|
||||
cpuSum float64
|
||||
cpuCount int
|
||||
memSum float64
|
||||
memCount int
|
||||
nodes map[string]struct{}
|
||||
}
|
||||
|
||||
func buildTrafficTrendPoints(now time.Time, reports []*model.NodeRequestReport) []TrafficTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
points := make([]TrafficTrendPoint, observabilityTrendBuckets)
|
||||
for index := range points {
|
||||
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
|
||||
}
|
||||
|
||||
for _, report := range reports {
|
||||
index, ok := trendBucketIndex(report.WindowEndedAt, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].RequestCount += report.RequestCount
|
||||
points[index].ErrorCount += report.ErrorCount
|
||||
points[index].UniqueVisitorCount += report.UniqueVisitorCount
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
func buildCapacityTrendPoints(now time.Time, snapshots []*model.NodeMetricSnapshot) []CapacityTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
points := make([]CapacityTrendPoint, observabilityTrendBuckets)
|
||||
accumulators := make([]capacityTrendAccumulator, observabilityTrendBuckets)
|
||||
for index := range points {
|
||||
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
|
||||
accumulators[index].nodes = make(map[string]struct{})
|
||||
}
|
||||
|
||||
for _, snapshot := range snapshots {
|
||||
index, ok := trendBucketIndex(snapshot.CapturedAt, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if snapshot.CPUUsagePercent > 0 {
|
||||
accumulators[index].cpuSum += snapshot.CPUUsagePercent
|
||||
accumulators[index].cpuCount++
|
||||
}
|
||||
if memoryUsage := percentage(snapshot.MemoryUsedBytes, snapshot.MemoryTotalBytes); memoryUsage > 0 {
|
||||
accumulators[index].memSum += memoryUsage
|
||||
accumulators[index].memCount++
|
||||
}
|
||||
if snapshot.NodeID != "" {
|
||||
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for index := range points {
|
||||
if accumulators[index].cpuCount > 0 {
|
||||
points[index].AverageCPUUsagePercent = accumulators[index].cpuSum / float64(accumulators[index].cpuCount)
|
||||
}
|
||||
if accumulators[index].memCount > 0 {
|
||||
points[index].AverageMemoryUsagePercent = accumulators[index].memSum / float64(accumulators[index].memCount)
|
||||
}
|
||||
points[index].ReportedNodes = len(accumulators[index].nodes)
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
func trendWindowStart(now time.Time) time.Time {
|
||||
return now.Truncate(time.Hour).Add(-(observabilityTrendBuckets - 1) * time.Hour)
|
||||
}
|
||||
|
||||
func trendBucketIndex(timestamp time.Time, start time.Time) (int, bool) {
|
||||
if timestamp.Before(start) {
|
||||
return 0, false
|
||||
}
|
||||
delta := timestamp.Sub(start)
|
||||
index := int(delta / time.Hour)
|
||||
if index < 0 || index >= observabilityTrendBuckets {
|
||||
return 0, false
|
||||
}
|
||||
return index, true
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
type TrendChartSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
fillColor?: string;
|
||||
values: number[];
|
||||
variant?: 'line' | 'area';
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TrendChartProps = {
|
||||
labels: string[];
|
||||
series: TrendChartSeries[];
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
||||
|
||||
export function TrendChart({ labels, series, height = 220 }: TrendChartProps) {
|
||||
if (labels.length === 0 || series.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
||||
暂无趋势数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const maxValue =
|
||||
Math.max(
|
||||
1,
|
||||
...series.flatMap((item) => item.values.map((value) => value || 0)),
|
||||
) * 1.1;
|
||||
|
||||
return {
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: labels,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.24)',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: series.map((item) => ({
|
||||
name: item.label,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: item.color,
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
},
|
||||
areaStyle:
|
||||
item.variant === 'area'
|
||||
? {
|
||||
color: item.fillColor ?? `${item.color}33`,
|
||||
}
|
||||
: undefined,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
scale: true,
|
||||
},
|
||||
data: item.values,
|
||||
})),
|
||||
};
|
||||
}, [labels, series]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{series.map((item) => {
|
||||
const latestValue = item.values[item.values.length - 1] ?? 0;
|
||||
const formatter = item.valueFormatter ?? defaultFormatter;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{formatter(latestValue)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
@@ -32,6 +33,14 @@ function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function getAlertVariant(
|
||||
alert: DashboardAlert,
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
@@ -215,6 +224,68 @@ export function DashboardOverview() {
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="观察整体请求量和错误量是否出现异常抬升。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.traffic_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.request_count,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '错误量',
|
||||
color: '#ef4444',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.error_count,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="24 小时容量趋势"
|
||||
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.capacity_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_cpu_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
|
||||
@@ -30,6 +30,25 @@ export interface DashboardConfig {
|
||||
pending_nodes: number;
|
||||
}
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface CapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardTrends {
|
||||
traffic_24h: TrafficTrendPoint[];
|
||||
capacity_24h: CapacityTrendPoint[];
|
||||
}
|
||||
|
||||
export interface DashboardNodeHealth {
|
||||
id: number;
|
||||
node_id: string;
|
||||
@@ -63,6 +82,7 @@ export interface DashboardOverview {
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
config: DashboardConfig;
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
active_alerts: DashboardAlert[];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
@@ -143,6 +144,14 @@ function formatUptime(seconds?: number | null) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function getHealthEventVariant(
|
||||
event: NodeObservability['health_events'][number],
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
@@ -829,6 +838,76 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="按小时聚合该节点的请求量和错误量,判断是否存在突发流量或持续异常。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={
|
||||
observability?.trends.traffic_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
) ?? []
|
||||
}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values:
|
||||
observability?.trends.traffic_24h.map(
|
||||
(point) => point.request_count,
|
||||
) ?? [],
|
||||
},
|
||||
{
|
||||
label: '错误量',
|
||||
color: '#ef4444',
|
||||
values:
|
||||
observability?.trends.traffic_24h.map(
|
||||
(point) => point.error_count,
|
||||
) ?? [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="24 小时容量趋势"
|
||||
description="观察该节点 CPU 与内存使用率在 24 小时内的变化,辅助扩容和排障。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={
|
||||
observability?.trends.capacity_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
) ?? []
|
||||
}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values:
|
||||
observability?.trends.capacity_24h.map(
|
||||
(point) => point.average_cpu_usage_percent,
|
||||
) ?? [],
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values:
|
||||
observability?.trends.capacity_24h.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
) ?? [],
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<AppCard title="更新模式">
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -99,6 +99,25 @@ export interface NodeTrafficReport {
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
}
|
||||
|
||||
export interface NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
@@ -116,4 +135,5 @@ export interface NodeObservability {
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
trends: NodeObservabilityTrends;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"marked": "^17.0.4",
|
||||
"next": "15.2.2",
|
||||
"react": "19.0.0",
|
||||
|
||||
Generated
+44
@@ -17,6 +17,12 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
echarts-for-react:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6(echarts@6.0.0)(react@19.0.0)
|
||||
marked:
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4
|
||||
@@ -1421,6 +1427,15 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts-for-react@3.0.6:
|
||||
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
|
||||
peerDependencies:
|
||||
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
|
||||
react: ^15.0.0 || >=16.0.0
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
electron-to-chromium@1.5.307:
|
||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||
|
||||
@@ -2528,6 +2543,9 @@ packages:
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
size-sensor@1.0.3:
|
||||
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2701,6 +2719,9 @@ packages:
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -2922,6 +2943,9 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
zustand@5.0.11:
|
||||
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -4131,6 +4155,18 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.0.0):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 19.0.0
|
||||
size-sensor: 1.0.3
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
@@ -5421,6 +5457,8 @@ snapshots:
|
||||
is-arrayish: 0.3.4
|
||||
optional: true
|
||||
|
||||
size-sensor@1.0.3: {}
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -5590,6 +5628,8 @@ snapshots:
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsutils@3.21.0(typescript@5.9.3):
|
||||
@@ -5853,6 +5893,10 @@ snapshots:
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
zustand@5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.0.0):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
@@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: () => <div data-testid="echarts-mock" />,
|
||||
}));
|
||||
|
||||
describe('DashboardOverview', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -15,80 +19,86 @@ describe('DashboardOverview', () => {
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/nodes/')) {
|
||||
if (url.includes('/dashboard/overview')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, status: 'online', auto_update_enabled: true }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/config-versions/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, version: '20260311-001', is_active: true, created_at: '2026-03-11T10:00:00Z' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/managed-domains/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, domain: '*.example.com', enabled: true, cert_id: 1 }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/tls-certificates/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'example',
|
||||
not_after: '2026-04-01T00:00:00Z',
|
||||
updated_at: '2026-03-10T10:00:00Z',
|
||||
data: {
|
||||
generated_at: '2026-03-14T08:00:00Z',
|
||||
summary: {
|
||||
total_nodes: 2,
|
||||
online_nodes: 2,
|
||||
offline_nodes: 0,
|
||||
pending_nodes: 0,
|
||||
unhealthy_nodes: 1,
|
||||
active_alerts: 1,
|
||||
lagging_nodes: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/proxy-routes/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, enabled: true, enable_https: true, custom_headers: '[{"key":"X-Test","value":"1"}]' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/user/search')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, role: 100, status: 1, username: 'root' }],
|
||||
traffic: {
|
||||
request_count: 900,
|
||||
unique_visitors: 200,
|
||||
error_count: 36,
|
||||
estimated_qps: 15,
|
||||
reported_nodes: 2,
|
||||
},
|
||||
capacity: {
|
||||
average_cpu_usage_percent: 68.5,
|
||||
average_memory_usage_percent: 71.8,
|
||||
high_cpu_nodes: 1,
|
||||
high_memory_nodes: 1,
|
||||
high_storage_nodes: 1,
|
||||
},
|
||||
config: {
|
||||
active_version: '20260314-001',
|
||||
lagging_nodes: 1,
|
||||
pending_nodes: 0,
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
request_count: index * 10,
|
||||
error_count: index,
|
||||
unique_visitor_count: index * 3,
|
||||
})),
|
||||
capacity_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
average_cpu_usage_percent: index,
|
||||
average_memory_usage_percent: index + 10,
|
||||
reported_nodes: 2,
|
||||
})),
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
node_id: 'node-a',
|
||||
name: 'edge-a',
|
||||
status: 'online',
|
||||
openresty_status: 'healthy',
|
||||
current_version: '20260314-001',
|
||||
last_seen_at: '2026-03-14T08:00:00Z',
|
||||
active_event_count: 0,
|
||||
cpu_usage_percent: 45,
|
||||
memory_usage_percent: 50,
|
||||
storage_usage_percent: 60,
|
||||
request_count: 600,
|
||||
error_count: 6,
|
||||
unique_visitor_count: 120,
|
||||
},
|
||||
],
|
||||
active_alerts: [
|
||||
{
|
||||
node_id: 'node-b',
|
||||
node_name: 'edge-b',
|
||||
event_type: 'openresty_unhealthy',
|
||||
severity: 'critical',
|
||||
message: 'reload failed',
|
||||
last_triggered_at: '2026-03-14T07:59:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -112,8 +122,8 @@ describe('DashboardOverview', () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('控制台仪表盘')).toBeInTheDocument();
|
||||
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('节点健康列表')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user