mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +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)
|
profile := observability.BuildProfile(r.Config, r.StateStore)
|
||||||
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore)
|
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore)
|
||||||
|
trafficReport := observability.BuildTrafficReport(r.Config, r.StateStore)
|
||||||
healthEvents := observability.BuildHealthEvents(snapshot)
|
healthEvents := observability.BuildHealthEvents(snapshot)
|
||||||
return protocol.NodePayload{
|
return protocol.NodePayload{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
@@ -326,6 +327,7 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
|
|||||||
OpenrestyMessage: snapshot.OpenrestyMessage,
|
OpenrestyMessage: snapshot.OpenrestyMessage,
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
Snapshot: metricSnapshot,
|
Snapshot: metricSnapshot,
|
||||||
|
TrafficReport: trafficReport,
|
||||||
HealthEvents: healthEvents,
|
HealthEvents: healthEvents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,8 @@ func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(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{
|
if err := stateStore.Save(&state.Snapshot{
|
||||||
NodeID: "node-observe",
|
NodeID: "node-observe",
|
||||||
CurrentVersion: "20260314-001",
|
CurrentVersion: "20260314-001",
|
||||||
@@ -299,11 +300,22 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
|||||||
NodeIP: "10.0.0.51",
|
NodeIP: "10.0.0.51",
|
||||||
AgentVersion: config.AgentVersion,
|
AgentVersion: config.AgentVersion,
|
||||||
NginxVersion: "1.27.1.2",
|
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),
|
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||||
},
|
},
|
||||||
StateStore: stateStore,
|
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")
|
firstPayload := runner.nodePayload("node-observe")
|
||||||
if firstPayload.Profile == nil {
|
if firstPayload.Profile == nil {
|
||||||
@@ -312,6 +324,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
|||||||
if firstPayload.Snapshot == nil {
|
if firstPayload.Snapshot == nil {
|
||||||
t.Fatal("expected first heartbeat payload to include metric snapshot")
|
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 {
|
if len(firstPayload.HealthEvents) != 2 {
|
||||||
t.Fatalf("expected health events for openresty and sync error, got %+v", firstPayload.HealthEvents)
|
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 {
|
if secondPayload.Snapshot == nil {
|
||||||
t.Fatal("expected metric snapshot to continue reporting on subsequent heartbeat")
|
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) {
|
func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import (
|
|||||||
|
|
||||||
const CertDirPlaceholder = "__ATSF_CERT_DIR__"
|
const CertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||||
const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
|
const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
|
||||||
|
const AccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
|
||||||
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
|
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
|
||||||
const DockerRouteConfigPath = "/etc/nginx/conf.d/atsflare_routes.conf"
|
const DockerRouteConfigPath = "/etc/nginx/conf.d/atsflare_routes.conf"
|
||||||
|
const DockerAccessLogPath = "/etc/nginx/conf.d/atsflare_access.log"
|
||||||
|
|
||||||
const dockerRuntimeCommand = "openresty"
|
const dockerRuntimeCommand = "openresty"
|
||||||
|
|
||||||
@@ -285,6 +287,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
|||||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||||
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
|
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
|
||||||
}
|
}
|
||||||
|
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||||
|
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
|
||||||
|
}
|
||||||
normalizedRoute := string(data)
|
normalizedRoute := string(data)
|
||||||
if m.NginxCertDir != "" {
|
if m.NginxCertDir != "" {
|
||||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
|
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 {
|
func (m *Manager) renderMainConfig(content string) string {
|
||||||
includePath := m.routeConfigIncludePath()
|
rendered := content
|
||||||
if includePath == "" {
|
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||||
return content
|
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 {
|
func (m *Manager) routeConfigIncludePath() string {
|
||||||
@@ -623,6 +631,14 @@ func (m *Manager) routeConfigIncludePath() string {
|
|||||||
return strings.TrimSpace(m.RouteConfigPath)
|
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 {
|
func checksum(content string) string {
|
||||||
sum := sha256.Sum256([]byte(content))
|
sum := sha256.Sum256([]byte(content))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
|||||||
|
|
||||||
err := manager.Apply(
|
err := manager.Apply(
|
||||||
context.Background(),
|
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",
|
"ssl_certificate __ATSF_CERT_DIR__/1.crt;\n",
|
||||||
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||||
)
|
)
|
||||||
@@ -349,7 +349,8 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read main config: %v", err)
|
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))
|
t.Fatalf("unexpected main config: %s", string(mainData))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +367,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
|||||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||||
}
|
}
|
||||||
expected := bundleChecksum(
|
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",
|
"ssl_certificate __ATSF_CERT_DIR__/1.crt;\n",
|
||||||
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
[]protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||||
)
|
)
|
||||||
@@ -388,7 +389,7 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
|||||||
Executor: &fakeExecutor{},
|
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)
|
t.Fatalf("Apply failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +397,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read main config: %v", err)
|
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))
|
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)
|
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||||
}
|
}
|
||||||
expected := bundleChecksum(
|
expected := bundleChecksum(
|
||||||
"include __ATSF_ROUTE_CONFIG__;\n",
|
"include __ATSF_ROUTE_CONFIG__;\naccess_log __ATSF_ACCESS_LOG__ atsflare_json;\n",
|
||||||
"server { listen 80; }\n",
|
"server { listen 80; }\n",
|
||||||
nil,
|
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"`
|
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
|
||||||
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
|
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
|
||||||
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
|
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
|
||||||
|
AccessLogOffset int64 `json:"access_log_offset"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ events {
|
|||||||
http {
|
http {
|
||||||
include mime.types;
|
include mime.types;
|
||||||
default_type application/octet-stream;
|
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;
|
sendfile on;
|
||||||
tcp_nopush on;
|
tcp_nopush on;
|
||||||
tcp_nodelay on;
|
tcp_nodelay on;
|
||||||
|
|||||||
@@ -35,3 +35,8 @@ func ListNodeHealthEvents(nodeID string, activeOnly bool, limit int) (events []*
|
|||||||
err = query.Find(&events).Error
|
err = query.Find(&events).Error
|
||||||
return events, err
|
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
|
err = query.Find(&snapshots).Error
|
||||||
return snapshots, err
|
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
|
err = query.Find(&reports).Error
|
||||||
return reports, err
|
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 (
|
const (
|
||||||
nginxCertDirPlaceholder = "__ATSF_CERT_DIR__"
|
nginxCertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||||
nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
|
nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
|
||||||
|
nginxAccessLogPlaceholder = "__ATSF_ACCESS_LOG__"
|
||||||
)
|
)
|
||||||
|
|
||||||
var requiredMainConfigTemplatePlaceholders = []string{
|
var requiredMainConfigTemplatePlaceholders = []string{
|
||||||
"{{OpenRestyWorkerProcesses}}",
|
"{{OpenRestyWorkerProcesses}}",
|
||||||
"{{OpenRestyWorkerConnections}}",
|
"{{OpenRestyWorkerConnections}}",
|
||||||
"{{OpenRestyWorkerRlimitNofile}}",
|
"{{OpenRestyWorkerRlimitNofile}}",
|
||||||
|
"{{OpenRestyAccessLogPath}}",
|
||||||
"{{OpenRestyEventsUseDirective}}",
|
"{{OpenRestyEventsUseDirective}}",
|
||||||
"{{OpenRestyEventsMultiAcceptDirective}}",
|
"{{OpenRestyEventsMultiAcceptDirective}}",
|
||||||
"{{OpenRestyKeepaliveTimeout}}",
|
"{{OpenRestyKeepaliveTimeout}}",
|
||||||
@@ -638,6 +640,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
|
|||||||
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
|
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
|
||||||
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
|
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
|
||||||
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
|
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
|
||||||
|
"{{OpenRestyAccessLogPath}}", nginxAccessLogPlaceholder,
|
||||||
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
|
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
|
||||||
"{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
|
"{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
|
||||||
"{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout),
|
"{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type DashboardOverviewView struct {
|
|||||||
Traffic DashboardTraffic `json:"traffic"`
|
Traffic DashboardTraffic `json:"traffic"`
|
||||||
Capacity DashboardCapacity `json:"capacity"`
|
Capacity DashboardCapacity `json:"capacity"`
|
||||||
Config DashboardConfig `json:"config"`
|
Config DashboardConfig `json:"config"`
|
||||||
|
Trends DashboardTrends `json:"trends"`
|
||||||
Nodes []DashboardNodeHealth `json:"nodes"`
|
Nodes []DashboardNodeHealth `json:"nodes"`
|
||||||
ActiveAlerts []DashboardAlert `json:"active_alerts"`
|
ActiveAlerts []DashboardAlert `json:"active_alerts"`
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,11 @@ type DashboardConfig struct {
|
|||||||
PendingNodes int `json:"pending_nodes"`
|
PendingNodes int `json:"pending_nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DashboardTrends struct {
|
||||||
|
Traffic24h []TrafficTrendPoint `json:"traffic_24h"`
|
||||||
|
Capacity24h []CapacityTrendPoint `json:"capacity_24h"`
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardNodeHealth struct {
|
type DashboardNodeHealth struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
@@ -94,13 +100,33 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
|||||||
return nil, versionErr
|
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{
|
view := &DashboardOverviewView{
|
||||||
GeneratedAt: now,
|
GeneratedAt: now,
|
||||||
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
|
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
|
||||||
|
Trends: DashboardTrends{
|
||||||
|
Traffic24h: buildTrafficTrendPoints(now, reports),
|
||||||
|
Capacity24h: buildCapacityTrendPoints(now, snapshots),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var cpuNodeCount int
|
var cpuNodeCount int
|
||||||
var memoryNodeCount int
|
var memoryNodeCount int
|
||||||
|
latestSnapshots := latestMetricSnapshotsByNode(snapshots)
|
||||||
|
latestTrafficReports := latestTrafficReportsByNode(reports)
|
||||||
|
activeEventsByNode := activeHealthEventsByNode(activeEvents)
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
computedStatus := computeNodeStatus(node)
|
computedStatus := computeNodeStatus(node)
|
||||||
@@ -122,12 +148,9 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
|||||||
view.Summary.LaggingNodes++
|
view.Summary.LaggingNodes++
|
||||||
}
|
}
|
||||||
|
|
||||||
latestSnapshot := latestMetricSnapshotForNode(node.NodeID, since)
|
latestSnapshot := latestSnapshots[node.NodeID]
|
||||||
latestTraffic := latestTrafficReportForNode(node.NodeID, since)
|
latestTraffic := latestTrafficReports[node.NodeID]
|
||||||
activeEvents, eventErr := model.ListNodeHealthEvents(node.NodeID, true, 20)
|
nodeActiveEvents := activeEventsByNode[node.NodeID]
|
||||||
if eventErr != nil {
|
|
||||||
return nil, eventErr
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeHealth := DashboardNodeHealth{
|
nodeHealth := DashboardNodeHealth{
|
||||||
ID: node.ID,
|
ID: node.ID,
|
||||||
@@ -137,10 +160,10 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
|||||||
OpenrestyStatus: node.OpenrestyStatus,
|
OpenrestyStatus: node.OpenrestyStatus,
|
||||||
CurrentVersion: node.CurrentVersion,
|
CurrentVersion: node.CurrentVersion,
|
||||||
LastSeenAt: node.LastSeenAt,
|
LastSeenAt: node.LastSeenAt,
|
||||||
ActiveEventCount: len(activeEvents),
|
ActiveEventCount: len(nodeActiveEvents),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, event := range activeEvents {
|
for _, event := range nodeActiveEvents {
|
||||||
view.ActiveAlerts = append(view.ActiveAlerts, DashboardAlert{
|
view.ActiveAlerts = append(view.ActiveAlerts, DashboardAlert{
|
||||||
NodeID: node.NodeID,
|
NodeID: node.NodeID,
|
||||||
NodeName: node.Name,
|
NodeName: node.Name,
|
||||||
@@ -188,7 +211,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
|||||||
view.Traffic.ReportedNodes++
|
view.Traffic.ReportedNodes++
|
||||||
}
|
}
|
||||||
|
|
||||||
view.Summary.ActiveAlerts += len(activeEvents)
|
view.Summary.ActiveAlerts += len(nodeActiveEvents)
|
||||||
view.Nodes = append(view.Nodes, nodeHealth)
|
view.Nodes = append(view.Nodes, nodeHealth)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,22 +248,6 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
|||||||
return view, nil
|
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 {
|
func percentage(used int64, total int64) float64 {
|
||||||
if used <= 0 || total <= 0 {
|
if used <= 0 || total <= 0 {
|
||||||
return 0
|
return 0
|
||||||
@@ -248,6 +255,45 @@ func percentage(used int64, total int64) float64 {
|
|||||||
return (float64(used) / float64(total)) * 100
|
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 {
|
func severityWeight(severity string) int {
|
||||||
switch severity {
|
switch severity {
|
||||||
case NodeHealthSeverityCritical:
|
case NodeHealthSeverityCritical:
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
|
|||||||
if !strings.Contains(result.Version.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
|
if !strings.Contains(result.Version.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
|
||||||
t.Fatal("expected main config to include managed route config placeholder")
|
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;") {
|
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
|
||||||
t.Fatal("expected rendered config to include https server block")
|
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__;") {
|
if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
|
||||||
t.Fatal("expected preview main config to preserve managed route include")
|
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(
|
invalidTemplate := strings.ReplaceAll(
|
||||||
common.OpenRestyMainConfigTemplate,
|
common.OpenRestyMainConfigTemplate,
|
||||||
@@ -288,6 +294,15 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
|
|||||||
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
|
if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
|
||||||
t.Fatal("expected template without managed route placeholder to fail validation")
|
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) {
|
func TestOpenRestyCommonRequestOptionsRender(t *testing.T) {
|
||||||
|
|||||||
@@ -25,16 +25,23 @@ type NodeObservabilityView struct {
|
|||||||
MetricSnapshots []*model.NodeMetricSnapshot `json:"metric_snapshots"`
|
MetricSnapshots []*model.NodeMetricSnapshot `json:"metric_snapshots"`
|
||||||
TrafficReports []*model.NodeRequestReport `json:"traffic_reports"`
|
TrafficReports []*model.NodeRequestReport `json:"traffic_reports"`
|
||||||
HealthEvents []*model.NodeHealthEvent `json:"health_events"`
|
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) {
|
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
|
||||||
|
now := time.Now()
|
||||||
node, err := model.GetNodeByID(id)
|
node, err := model.GetNodeByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := normalizeObservabilityLimit(query.Limit)
|
limit := normalizeObservabilityLimit(query.Limit)
|
||||||
since := time.Now().Add(-normalizeObservabilityWindow(query.Hours))
|
since := now.Add(-normalizeObservabilityWindow(query.Hours))
|
||||||
|
|
||||||
profile, err := model.GetNodeSystemProfile(node.NodeID)
|
profile, err := model.GetNodeSystemProfile(node.NodeID)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -52,6 +59,14 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
events, err := model.ListNodeHealthEvents(node.NodeID, false, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -63,6 +78,10 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
|||||||
MetricSnapshots: snapshots,
|
MetricSnapshots: snapshots,
|
||||||
TrafficReports: reports,
|
TrafficReports: reports,
|
||||||
HealthEvents: events,
|
HealthEvents: events,
|
||||||
|
Trends: NodeObservabilityTrends{
|
||||||
|
Traffic24h: buildTrafficTrendPoints(now, trendReports),
|
||||||
|
Capacity24h: buildCapacityTrendPoints(now, trendSnapshots),
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -564,6 +564,12 @@ func TestGetNodeObservability(t *testing.T) {
|
|||||||
if len(view.HealthEvents) != 1 || view.HealthEvents[0].EventType != "sync_error" {
|
if len(view.HealthEvents) != 1 || view.HealthEvents[0].EventType != "sync_error" {
|
||||||
t.Fatalf("unexpected health events: %+v", view.HealthEvents)
|
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) {
|
func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
|
||||||
@@ -589,6 +595,9 @@ func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) {
|
|||||||
if view.Profile != nil {
|
if view.Profile != nil {
|
||||||
t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile)
|
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) {
|
func TestGetDashboardOverview(t *testing.T) {
|
||||||
@@ -715,4 +724,10 @@ func TestGetDashboardOverview(t *testing.T) {
|
|||||||
if len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 {
|
if len(view.Nodes) != 2 || len(view.ActiveAlerts) != 1 {
|
||||||
t.Fatalf("unexpected dashboard nodes/alerts: %+v %+v", view.Nodes, view.ActiveAlerts)
|
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 Link from 'next/link';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { TrendChart } from '@/components/data/trend-chart';
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
@@ -32,6 +33,14 @@ function getErrorMessage(error: unknown) {
|
|||||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
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(
|
function getAlertVariant(
|
||||||
alert: DashboardAlert,
|
alert: DashboardAlert,
|
||||||
): 'success' | 'warning' | 'danger' | 'info' {
|
): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
@@ -215,6 +224,68 @@ export function DashboardOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</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]">
|
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
<AppCard
|
<AppCard
|
||||||
title="节点健康列表"
|
title="节点健康列表"
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ export interface DashboardConfig {
|
|||||||
pending_nodes: number;
|
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 {
|
export interface DashboardNodeHealth {
|
||||||
id: number;
|
id: number;
|
||||||
node_id: string;
|
node_id: string;
|
||||||
@@ -63,6 +82,7 @@ export interface DashboardOverview {
|
|||||||
traffic: DashboardTraffic;
|
traffic: DashboardTraffic;
|
||||||
capacity: DashboardCapacity;
|
capacity: DashboardCapacity;
|
||||||
config: DashboardConfig;
|
config: DashboardConfig;
|
||||||
|
trends: DashboardTrends;
|
||||||
nodes: DashboardNodeHealth[];
|
nodes: DashboardNodeHealth[];
|
||||||
active_alerts: DashboardAlert[];
|
active_alerts: DashboardAlert[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TrendChart } from '@/components/data/trend-chart';
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
@@ -143,6 +144,14 @@ function formatUptime(seconds?: number | null) {
|
|||||||
return `${minutes} 分钟`;
|
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(
|
function getHealthEventVariant(
|
||||||
event: NodeObservability['health_events'][number],
|
event: NodeObservability['health_events'][number],
|
||||||
): 'success' | 'warning' | 'danger' | 'info' {
|
): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
@@ -829,6 +838,76 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</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">
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
<AppCard title="更新模式">
|
<AppCard title="更新模式">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -99,6 +99,25 @@ export interface NodeTrafficReport {
|
|||||||
source_countries_json: string;
|
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 {
|
export interface NodeHealthEvent {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
@@ -116,4 +135,5 @@ export interface NodeObservability {
|
|||||||
metric_snapshots: NodeMetricSnapshot[];
|
metric_snapshots: NodeMetricSnapshot[];
|
||||||
traffic_reports: NodeTrafficReport[];
|
traffic_reports: NodeTrafficReport[];
|
||||||
health_events: NodeHealthEvent[];
|
health_events: NodeHealthEvent[];
|
||||||
|
trends: NodeObservabilityTrends;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"next": "15.2.2",
|
"next": "15.2.2",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
|
|||||||
Generated
+44
@@ -17,6 +17,12 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
marked:
|
||||||
specifier: ^17.0.4
|
specifier: ^17.0.4
|
||||||
version: 17.0.4
|
version: 17.0.4
|
||||||
@@ -1421,6 +1427,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
electron-to-chromium@1.5.307:
|
||||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||||
|
|
||||||
@@ -2528,6 +2543,9 @@ packages:
|
|||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
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:
|
slash@3.0.0:
|
||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2701,6 +2719,9 @@ packages:
|
|||||||
tslib@1.14.1:
|
tslib@1.14.1:
|
||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2922,6 +2943,9 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
zustand@5.0.11:
|
zustand@5.0.11:
|
||||||
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
|
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -4131,6 +4155,18 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.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: {}
|
electron-to-chromium@1.5.307: {}
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
@@ -5421,6 +5457,8 @@ snapshots:
|
|||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
size-sensor@1.0.3: {}
|
||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
@@ -5590,6 +5628,8 @@ snapshots:
|
|||||||
|
|
||||||
tslib@1.14.1: {}
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tsutils@3.21.0(typescript@5.9.3):
|
tsutils@3.21.0(typescript@5.9.3):
|
||||||
@@ -5853,6 +5893,10 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.25.76: {}
|
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):
|
zustand@5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.0.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@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';
|
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
|
||||||
|
|
||||||
|
vi.mock('echarts-for-react', () => ({
|
||||||
|
default: () => <div data-testid="echarts-mock" />,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('DashboardOverview', () => {
|
describe('DashboardOverview', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -15,80 +19,86 @@ describe('DashboardOverview', () => {
|
|||||||
vi.fn((input: RequestInfo | URL) => {
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
|
||||||
if (url.includes('/nodes/')) {
|
if (url.includes('/dashboard/overview')) {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: '',
|
message: '',
|
||||||
data: [{ id: 1, status: 'online', auto_update_enabled: true }],
|
data: {
|
||||||
}),
|
generated_at: '2026-03-14T08:00:00Z',
|
||||||
),
|
summary: {
|
||||||
);
|
total_nodes: 2,
|
||||||
}
|
online_nodes: 2,
|
||||||
|
offline_nodes: 0,
|
||||||
if (url.includes('/config-versions/')) {
|
pending_nodes: 0,
|
||||||
return Promise.resolve(
|
unhealthy_nodes: 1,
|
||||||
new Response(
|
active_alerts: 1,
|
||||||
JSON.stringify({
|
lagging_nodes: 1,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
],
|
traffic: {
|
||||||
}),
|
request_count: 900,
|
||||||
),
|
unique_visitors: 200,
|
||||||
);
|
error_count: 36,
|
||||||
}
|
estimated_qps: 15,
|
||||||
|
reported_nodes: 2,
|
||||||
if (url.includes('/proxy-routes/')) {
|
},
|
||||||
return Promise.resolve(
|
capacity: {
|
||||||
new Response(
|
average_cpu_usage_percent: 68.5,
|
||||||
JSON.stringify({
|
average_memory_usage_percent: 71.8,
|
||||||
success: true,
|
high_cpu_nodes: 1,
|
||||||
message: '',
|
high_memory_nodes: 1,
|
||||||
data: [{ id: 1, enabled: true, enable_https: true, custom_headers: '[{"key":"X-Test","value":"1"}]' }],
|
high_storage_nodes: 1,
|
||||||
}),
|
},
|
||||||
),
|
config: {
|
||||||
);
|
active_version: '20260314-001',
|
||||||
}
|
lagging_nodes: 1,
|
||||||
|
pending_nodes: 0,
|
||||||
if (url.includes('/user/search')) {
|
},
|
||||||
return Promise.resolve(
|
trends: {
|
||||||
new Response(
|
traffic_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||||
JSON.stringify({
|
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||||
success: true,
|
request_count: index * 10,
|
||||||
message: '',
|
error_count: index,
|
||||||
data: [{ id: 1, role: 100, status: 1, username: 'root' }],
|
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>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('控制台仪表盘')).toBeInTheDocument();
|
expect(await screen.findByText('系统运行总览')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('节点摘要')).toBeInTheDocument();
|
expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('用户概览')).toBeInTheDocument();
|
expect(await screen.findByText('节点健康列表')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user