mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
feat: add access log functionality and related components
- Introduced NodeAccessLog model and corresponding database migrations. - Implemented access log retrieval in the service layer. - Created API endpoint for accessing logs with appropriate security measures. - Developed frontend components for displaying access logs, including filtering and summary statistics. - Updated observability buffer to include access logs and ensure proper merging and retention. - Enhanced traffic report and observability tests to validate new access log features. - Updated documentation to reflect changes in access log handling and data retention policies.
This commit is contained in:
@@ -321,7 +321,7 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
|
|||||||
profile := observability.BuildProfile(r.Config, r.StateStore)
|
profile := observability.BuildProfile(r.Config, r.StateStore)
|
||||||
managedOpenRestyMetrics := observability.CollectManagedOpenRestyMetrics(r.Config)
|
managedOpenRestyMetrics := observability.CollectManagedOpenRestyMetrics(r.Config)
|
||||||
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore, managedOpenRestyMetrics)
|
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore, managedOpenRestyMetrics)
|
||||||
trafficReport := observability.BuildTrafficReport(r.Config, r.StateStore, managedOpenRestyMetrics)
|
trafficReport, accessLogs := observability.BuildTrafficObservability(r.Config, r.StateStore, managedOpenRestyMetrics)
|
||||||
healthEvents := observability.BuildHealthEvents(snapshot)
|
healthEvents := observability.BuildHealthEvents(snapshot)
|
||||||
return protocol.NodePayload{
|
return protocol.NodePayload{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
@@ -336,13 +336,14 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
|
|||||||
Profile: profile,
|
Profile: profile,
|
||||||
Snapshot: metricSnapshot,
|
Snapshot: metricSnapshot,
|
||||||
TrafficReport: trafficReport,
|
TrafficReport: trafficReport,
|
||||||
|
AccessLogs: accessLogs,
|
||||||
HealthEvents: healthEvents,
|
HealthEvents: healthEvents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, []int64) {
|
func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, []int64) {
|
||||||
payload := r.nodePayload(nodeID)
|
payload := r.nodePayload(nodeID)
|
||||||
if r.ObservabilityBuffer == nil || payload.Snapshot == nil {
|
if r.ObservabilityBuffer == nil || (payload.Snapshot == nil && payload.TrafficReport == nil && len(payload.AccessLogs) == 0) {
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -356,6 +357,7 @@ func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, [
|
|||||||
WindowStartedAtUnix: windowStartedAtUnix,
|
WindowStartedAtUnix: windowStartedAtUnix,
|
||||||
Snapshot: payload.Snapshot,
|
Snapshot: payload.Snapshot,
|
||||||
TrafficReport: payload.TrafficReport,
|
TrafficReport: payload.TrafficReport,
|
||||||
|
AccessLogs: payload.AccessLogs,
|
||||||
QueuedAtUnix: now.Unix(),
|
QueuedAtUnix: now.Unix(),
|
||||||
}
|
}
|
||||||
if err := r.ObservabilityBuffer.Upsert(record, retainAfterUnix); err != nil {
|
if err := r.ObservabilityBuffer.Upsert(record, retainAfterUnix); err != nil {
|
||||||
@@ -379,6 +381,7 @@ func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, [
|
|||||||
WindowStartedAtUnix: item.WindowStartedAtUnix,
|
WindowStartedAtUnix: item.WindowStartedAtUnix,
|
||||||
Snapshot: item.Snapshot,
|
Snapshot: item.Snapshot,
|
||||||
TrafficReport: item.TrafficReport,
|
TrafficReport: item.TrafficReport,
|
||||||
|
AccessLogs: item.AccessLogs,
|
||||||
})
|
})
|
||||||
ackWindows = append(ackWindows, item.WindowStartedAtUnix)
|
ackWindows = append(ackWindows, item.WindowStartedAtUnix)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "atsflare_access.log"),
|
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "atsflare_access.log"),
|
||||||
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
||||||
0o644,
|
0o644,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("failed to prepare access log: %v", err)
|
t.Fatalf("failed to prepare access log: %v", err)
|
||||||
@@ -327,6 +327,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
|||||||
if firstPayload.TrafficReport == nil || firstPayload.TrafficReport.RequestCount != 1 {
|
if firstPayload.TrafficReport == nil || firstPayload.TrafficReport.RequestCount != 1 {
|
||||||
t.Fatalf("expected first heartbeat payload to include traffic report, got %+v", firstPayload.TrafficReport)
|
t.Fatalf("expected first heartbeat payload to include traffic report, got %+v", firstPayload.TrafficReport)
|
||||||
}
|
}
|
||||||
|
if len(firstPayload.AccessLogs) != 1 || firstPayload.AccessLogs[0].Path != "/" {
|
||||||
|
t.Fatalf("expected first heartbeat payload to include access logs, got %+v", firstPayload.AccessLogs)
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -341,6 +344,9 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
|||||||
if secondPayload.TrafficReport != nil {
|
if secondPayload.TrafficReport != nil {
|
||||||
t.Fatalf("expected unchanged traffic window to be omitted on subsequent heartbeat, got %+v", secondPayload.TrafficReport)
|
t.Fatalf("expected unchanged traffic window to be omitted on subsequent heartbeat, got %+v", secondPayload.TrafficReport)
|
||||||
}
|
}
|
||||||
|
if len(secondPayload.AccessLogs) != 0 {
|
||||||
|
t.Fatalf("expected unchanged access log delta to be omitted on subsequent heartbeat, got %+v", secondPayload.AccessLogs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T) {
|
func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T) {
|
||||||
@@ -391,7 +397,7 @@ func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T)
|
|||||||
}
|
}
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "atsflare_access.log"),
|
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "atsflare_access.log"),
|
||||||
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
||||||
0o644,
|
0o644,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("failed to prepare access log: %v", err)
|
t.Fatalf("failed to prepare access log: %v", err)
|
||||||
@@ -408,6 +414,9 @@ func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T)
|
|||||||
if len(secondPayload.BufferedObservability) != 1 {
|
if len(secondPayload.BufferedObservability) != 1 {
|
||||||
t.Fatalf("expected second heartbeat to replay one buffered observation, got %+v", secondPayload.BufferedObservability)
|
t.Fatalf("expected second heartbeat to replay one buffered observation, got %+v", secondPayload.BufferedObservability)
|
||||||
}
|
}
|
||||||
|
if len(secondPayload.BufferedObservability[0].AccessLogs) != 0 {
|
||||||
|
t.Fatalf("expected seeded buffered observation to keep empty access logs, got %+v", secondPayload.BufferedObservability[0].AccessLogs)
|
||||||
|
}
|
||||||
|
|
||||||
replayable, err := bufferStore.Replayable(0, 0)
|
replayable, err := bufferStore.Replayable(0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ type accessLogRecord struct {
|
|||||||
Timestamp string `json:"ts"`
|
Timestamp string `json:"ts"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
RemoteAddr string `json:"remote_addr"`
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
Path string `json:"path"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"[^"]*"\s+(\d{3})\s+\S+`)
|
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"(?:\S+)\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
|
||||||
|
|
||||||
type trafficAggregate struct {
|
type trafficAggregate struct {
|
||||||
windowStartedAt time.Time
|
windowStartedAt time.Time
|
||||||
@@ -34,16 +35,37 @@ type trafficAggregate struct {
|
|||||||
statusCodes map[string]int64
|
statusCodes map[string]int64
|
||||||
topDomains map[string]int64
|
topDomains map[string]int64
|
||||||
visitors map[string]struct{}
|
visitors map[string]struct{}
|
||||||
|
logs []protocol.NodeAccessLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeTrafficReport {
|
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeTrafficReport {
|
||||||
if managed != nil && managed.TrafficReport != nil {
|
report, _ := BuildTrafficObservability(cfg, stateStore, managed)
|
||||||
return managed.TrafficReport
|
return report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog) {
|
||||||
if cfg == nil || stateStore == nil {
|
if cfg == nil || stateStore == nil {
|
||||||
return nil
|
if managed != nil && managed.TrafficReport != nil {
|
||||||
|
return managed.TrafficReport, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aggregate := readAccessLogDelta(cfg, stateStore)
|
||||||
|
accessLogs := []protocol.NodeAccessLog{}
|
||||||
|
if aggregate != nil {
|
||||||
|
accessLogs = aggregate.accessLogs()
|
||||||
|
}
|
||||||
|
if managed != nil && managed.TrafficReport != nil {
|
||||||
|
return managed.TrafficReport, accessLogs
|
||||||
|
}
|
||||||
|
if aggregate == nil {
|
||||||
|
return nil, accessLogs
|
||||||
|
}
|
||||||
|
return aggregate.report(), accessLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAccessLogDelta(cfg *config.Config, stateStore *state.Store) *trafficAggregate {
|
||||||
snapshot, err := stateStore.Load()
|
snapshot, err := stateStore.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -97,7 +119,7 @@ func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *ma
|
|||||||
snapshot.AccessLogOffset = currentOffset
|
snapshot.AccessLogOffset = currentOffset
|
||||||
_ = stateStore.Save(snapshot)
|
_ = stateStore.Save(snapshot)
|
||||||
|
|
||||||
return aggregate.report()
|
return aggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
func managedAccessLogPath(cfg *config.Config) string {
|
func managedAccessLogPath(cfg *config.Config) string {
|
||||||
@@ -146,12 +168,20 @@ func (aggregate *trafficAggregate) consume(line []byte) {
|
|||||||
if remoteAddr := strings.TrimSpace(record.RemoteAddr); remoteAddr != "" {
|
if remoteAddr := strings.TrimSpace(record.RemoteAddr); remoteAddr != "" {
|
||||||
aggregate.visitors[remoteAddr] = struct{}{}
|
aggregate.visitors[remoteAddr] = struct{}{}
|
||||||
}
|
}
|
||||||
|
aggregate.logs = append(aggregate.logs, protocol.NodeAccessLog{
|
||||||
|
LoggedAtUnix: record.Timestamp.Unix(),
|
||||||
|
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
|
||||||
|
Host: strings.TrimSpace(record.Host),
|
||||||
|
Path: normalizeAccessLogPath(record.Path),
|
||||||
|
StatusCode: record.Status,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type parsedAccessLogRecord struct {
|
type parsedAccessLogRecord struct {
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
Host string
|
Host string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
|
Path string
|
||||||
Status int
|
Status int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,26 +206,28 @@ func parseJSONAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
|
|||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
Host: strings.TrimSpace(record.Host),
|
Host: strings.TrimSpace(record.Host),
|
||||||
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
|
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
|
||||||
|
Path: normalizeAccessLogPath(record.Path),
|
||||||
Status: record.Status,
|
Status: record.Status,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCombinedAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
|
func parseCombinedAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
|
||||||
matches := combinedAccessLogPattern.FindStringSubmatch(raw)
|
matches := combinedAccessLogPattern.FindStringSubmatch(raw)
|
||||||
if len(matches) != 4 {
|
if len(matches) != 5 {
|
||||||
return parsedAccessLogRecord{}, false
|
return parsedAccessLogRecord{}, false
|
||||||
}
|
}
|
||||||
timestamp, err := parseAccessLogTime(matches[2])
|
timestamp, err := parseAccessLogTime(matches[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return parsedAccessLogRecord{}, false
|
return parsedAccessLogRecord{}, false
|
||||||
}
|
}
|
||||||
status, err := strconv.Atoi(matches[3])
|
status, err := strconv.Atoi(matches[4])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return parsedAccessLogRecord{}, false
|
return parsedAccessLogRecord{}, false
|
||||||
}
|
}
|
||||||
return parsedAccessLogRecord{
|
return parsedAccessLogRecord{
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
RemoteAddr: strings.TrimSpace(matches[1]),
|
RemoteAddr: strings.TrimSpace(matches[1]),
|
||||||
|
Path: normalizeAccessLogPath(matches[3]),
|
||||||
Status: status,
|
Status: status,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
@@ -217,6 +249,13 @@ func (aggregate *trafficAggregate) report() *protocol.NodeTrafficReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (aggregate *trafficAggregate) accessLogs() []protocol.NodeAccessLog {
|
||||||
|
if aggregate == nil || len(aggregate.logs) == 0 {
|
||||||
|
return []protocol.NodeAccessLog{}
|
||||||
|
}
|
||||||
|
return append([]protocol.NodeAccessLog(nil), aggregate.logs...)
|
||||||
|
}
|
||||||
|
|
||||||
func parseAccessLogTime(value string) (time.Time, error) {
|
func parseAccessLogTime(value string) (time.Time, error) {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -258,6 +297,20 @@ type trafficCountItem struct {
|
|||||||
value int64
|
value int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAccessLogPath(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "/") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return "/" + trimmed
|
||||||
|
}
|
||||||
|
|
||||||
func topCounts(values map[string]int64, limit int) map[string]int64 {
|
func topCounts(values map[string]int64, limit int) map[string]int64 {
|
||||||
return cloneTrafficCounts(values, limit)
|
return cloneTrafficCounts(values, limit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ func TestBuildTrafficReportAggregatesManagedAccessLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
|
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
|
||||||
content := []byte(
|
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:00Z\",\"host\":\"app.example.com\",\"path\":\"/\",\"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:05Z\",\"host\":\"app.example.com\",\"path\":\"/healthz\",\"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",
|
"{\"ts\":\"2026-03-14T08:00:08Z\",\"host\":\"api.example.com\",\"path\":\"/api\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
|
||||||
)
|
)
|
||||||
if err := os.WriteFile(logPath, content, 0o644); err != nil {
|
if err := os.WriteFile(logPath, content, 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
@@ -62,7 +62,7 @@ func TestBuildTrafficReportResetsOffsetAfterTruncate(t *testing.T) {
|
|||||||
t.Fatalf("MkdirAll failed: %v", err)
|
t.Fatalf("MkdirAll failed: %v", err)
|
||||||
}
|
}
|
||||||
logPath := filepath.Join(filepath.Dir(routeConfigPath), "atsflare_access.log")
|
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 {
|
if err := os.WriteFile(logPath, []byte("{\"ts\":\"2026-03-14T09:00:00Z\",\"host\":\"app.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.3\",\"status\":200}\n"), 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +77,34 @@ func TestBuildTrafficReportResetsOffsetAfterTruncate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildTrafficObservabilityReturnsAccessLogs(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\",\"path\":\"/login\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n" +
|
||||||
|
"{\"ts\":\"2026-03-14T08:00:05Z\",\"host\":\"api.example.com\",\"path\":\"/v1/ping\",\"remote_addr\":\"10.0.0.2\",\"status\":502}\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, accessLogs := BuildTrafficObservability(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
|
||||||
|
if report == nil || report.RequestCount != 2 {
|
||||||
|
t.Fatalf("expected traffic report, got %+v", report)
|
||||||
|
}
|
||||||
|
if len(accessLogs) != 2 {
|
||||||
|
t.Fatalf("expected access logs, got %+v", accessLogs)
|
||||||
|
}
|
||||||
|
if accessLogs[0].Path != "/login" || accessLogs[1].Path != "/v1/ping" {
|
||||||
|
t.Fatalf("unexpected access log paths: %+v", accessLogs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
|
func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
|
routeConfigPath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf")
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type NodePayload struct {
|
|||||||
Profile *NodeSystemProfile `json:"profile,omitempty"`
|
Profile *NodeSystemProfile `json:"profile,omitempty"`
|
||||||
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||||
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||||
|
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
|
||||||
BufferedObservability []BufferedObservabilityRecord `json:"buffered_observability,omitempty"`
|
BufferedObservability []BufferedObservabilityRecord `json:"buffered_observability,omitempty"`
|
||||||
HealthEvents []NodeHealthEvent `json:"health_events"`
|
HealthEvents []NodeHealthEvent `json:"health_events"`
|
||||||
}
|
}
|
||||||
@@ -93,10 +94,19 @@ type NodeTrafficReport struct {
|
|||||||
SourceCountries map[string]int64 `json:"source_countries"`
|
SourceCountries map[string]int64 `json:"source_countries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeAccessLog struct {
|
||||||
|
LoggedAtUnix int64 `json:"logged_at_unix"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
}
|
||||||
|
|
||||||
type BufferedObservabilityRecord struct {
|
type BufferedObservabilityRecord struct {
|
||||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||||
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||||
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||||
|
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeHealthEvent struct {
|
type NodeHealthEvent struct {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"atsflare-agent/internal/protocol"
|
"atsflare-agent/internal/protocol"
|
||||||
@@ -16,6 +17,7 @@ type ObservabilityBufferRecord struct {
|
|||||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||||
Snapshot *protocol.NodeMetricSnapshot `json:"snapshot,omitempty"`
|
Snapshot *protocol.NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||||
TrafficReport *protocol.NodeTrafficReport `json:"traffic_report,omitempty"`
|
TrafficReport *protocol.NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||||
|
AccessLogs []protocol.NodeAccessLog `json:"access_logs,omitempty"`
|
||||||
QueuedAtUnix int64 `json:"queued_at_unix"`
|
QueuedAtUnix int64 `json:"queued_at_unix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ func NewObservabilityBufferStore(path string) *ObservabilityBufferStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, retainAfterUnix int64) error {
|
func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, retainAfterUnix int64) error {
|
||||||
if s == nil || record.WindowStartedAtUnix <= 0 || (record.Snapshot == nil && record.TrafficReport == nil) {
|
if s == nil || record.WindowStartedAtUnix <= 0 || (record.Snapshot == nil && record.TrafficReport == nil && len(record.AccessLogs) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -45,7 +47,7 @@ func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, reta
|
|||||||
if records[index].WindowStartedAtUnix != record.WindowStartedAtUnix {
|
if records[index].WindowStartedAtUnix != record.WindowStartedAtUnix {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
records[index] = record
|
records[index] = mergeObservabilityBufferRecord(records[index], record)
|
||||||
replaced = true
|
replaced = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,55 @@ func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, reta
|
|||||||
return s.saveUnlocked(records)
|
return s.saveUnlocked(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeObservabilityBufferRecord(existing ObservabilityBufferRecord, incoming ObservabilityBufferRecord) ObservabilityBufferRecord {
|
||||||
|
merged := existing
|
||||||
|
if incoming.Snapshot != nil {
|
||||||
|
merged.Snapshot = incoming.Snapshot
|
||||||
|
}
|
||||||
|
if incoming.TrafficReport != nil {
|
||||||
|
merged.TrafficReport = incoming.TrafficReport
|
||||||
|
}
|
||||||
|
merged.AccessLogs = mergeAccessLogs(existing.AccessLogs, incoming.AccessLogs)
|
||||||
|
if incoming.QueuedAtUnix > 0 {
|
||||||
|
merged.QueuedAtUnix = incoming.QueuedAtUnix
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAccessLogs(existing []protocol.NodeAccessLog, incoming []protocol.NodeAccessLog) []protocol.NodeAccessLog {
|
||||||
|
if len(existing) == 0 && len(incoming) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
merged := make([]protocol.NodeAccessLog, 0, len(existing)+len(incoming))
|
||||||
|
seen := make(map[string]struct{}, len(existing)+len(incoming))
|
||||||
|
appendIfNeeded := func(items []protocol.NodeAccessLog) {
|
||||||
|
for _, item := range items {
|
||||||
|
key := accessLogKey(item)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
merged = append(merged, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendIfNeeded(existing)
|
||||||
|
appendIfNeeded(incoming)
|
||||||
|
sort.Slice(merged, func(i int, j int) bool {
|
||||||
|
if merged[i].LoggedAtUnix == merged[j].LoggedAtUnix {
|
||||||
|
return accessLogKey(merged[i]) < accessLogKey(merged[j])
|
||||||
|
}
|
||||||
|
return merged[i].LoggedAtUnix < merged[j].LoggedAtUnix
|
||||||
|
})
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func accessLogKey(item protocol.NodeAccessLog) string {
|
||||||
|
return strconv.FormatInt(item.LoggedAtUnix, 10) + "|" + item.RemoteAddr + "|" + item.Host + "|" + item.Path + "|" + strconv.Itoa(item.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ObservabilityBufferStore) Replayable(currentWindowStartedAtUnix int64, retainAfterUnix int64) ([]ObservabilityBufferRecord, error) {
|
func (s *ObservabilityBufferStore) Replayable(currentWindowStartedAtUnix int64, retainAfterUnix int64) ([]ObservabilityBufferRecord, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -58,6 +58,36 @@ func TestObservabilityBufferStoreUpsertReplayAndAck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
|
||||||
|
store := NewObservabilityBufferStore(filepath.Join(t.TempDir(), "observability-buffer.json"))
|
||||||
|
|
||||||
|
if err := store.Upsert(ObservabilityBufferRecord{
|
||||||
|
WindowStartedAtUnix: 1710403200,
|
||||||
|
AccessLogs: []protocol.NodeAccessLog{
|
||||||
|
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200},
|
||||||
|
},
|
||||||
|
}, 1710403000); err != nil {
|
||||||
|
t.Fatalf("first upsert failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Upsert(ObservabilityBufferRecord{
|
||||||
|
WindowStartedAtUnix: 1710403200,
|
||||||
|
AccessLogs: []protocol.NodeAccessLog{
|
||||||
|
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200},
|
||||||
|
{LoggedAtUnix: 1710403205, RemoteAddr: "10.0.0.2", Host: "app.example.com", Path: "/b", StatusCode: 502},
|
||||||
|
},
|
||||||
|
}, 1710403000); err != nil {
|
||||||
|
t.Fatalf("second upsert failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := store.Replayable(0, 1710403000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Replayable failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 || len(records[0].AccessLogs) != 2 {
|
||||||
|
t.Fatalf("expected merged access logs, got %+v", records)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestObservabilityWindowStartedAt(t *testing.T) {
|
func TestObservabilityWindowStartedAt(t *testing.T) {
|
||||||
if value := ObservabilityWindowStartedAt(nil, &protocol.NodeTrafficReport{WindowStartedAtUnix: 1710403200}); value != 1710403200 {
|
if value := ObservabilityWindowStartedAt(nil, &protocol.NodeTrafficReport{WindowStartedAtUnix: 1710403200}); value != 1710403200 {
|
||||||
t.Fatalf("unexpected traffic window start: %d", value)
|
t.Fatalf("unexpected traffic window start: %d", value)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ 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}';
|
log_format atsflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
|
||||||
access_log {{OpenRestyAccessLogPath}} atsflare_json;
|
access_log {{OpenRestyAccessLogPath}} atsflare_json;
|
||||||
sendfile on;
|
sendfile on;
|
||||||
tcp_nopush on;
|
tcp_nopush on;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import "atsflare/service"
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// GetAccessLogs godoc
|
||||||
|
// @Summary List access logs
|
||||||
|
// @Tags AccessLogs
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param node_id query string false "Node ID"
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Router /api/access-logs/ [get]
|
||||||
|
func GetAccessLogs(c *gin.Context) {
|
||||||
|
logs, err := service.ListAccessLogs(c.Query("node_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, logs)
|
||||||
|
}
|
||||||
@@ -107,6 +107,10 @@ func InitDB() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = db.AutoMigrate(&NodeAccessLog{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = db.AutoMigrate(&NodeHealthEvent{})
|
err = db.AutoMigrate(&NodeHealthEvent{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type NodeAccessLog struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||||
|
LoggedAt time.Time `json:"logged_at" gorm:"index"`
|
||||||
|
RemoteAddr string `json:"remote_addr" gorm:"size:128"`
|
||||||
|
Host string `json:"host" gorm:"size:255"`
|
||||||
|
Path string `json:"path" gorm:"size:2048"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
RawJSON string `json:"raw_json" gorm:"type:text"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListNodeAccessLogs(nodeID string, since time.Time, limit int) (logs []*NodeAccessLog, err error) {
|
||||||
|
query := DB.Order("logged_at desc, id desc")
|
||||||
|
if nodeID != "" {
|
||||||
|
query = query.Where("node_id = ?", nodeID)
|
||||||
|
}
|
||||||
|
if !since.IsZero() {
|
||||||
|
query = query.Where("logged_at >= ?", since)
|
||||||
|
}
|
||||||
|
if limit > 0 {
|
||||||
|
query = query.Limit(limit)
|
||||||
|
}
|
||||||
|
err = query.Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
@@ -133,6 +133,11 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
{
|
{
|
||||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||||
}
|
}
|
||||||
|
accessLogRoute := apiRouter.Group("/access-logs")
|
||||||
|
accessLogRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
accessLogRoute.GET("/", controller.GetAccessLogs)
|
||||||
|
}
|
||||||
agentRoute := apiRouter.Group("/agent")
|
agentRoute := apiRouter.Group("/agent")
|
||||||
{
|
{
|
||||||
discoveryRoute := agentRoute.Group("/")
|
discoveryRoute := agentRoute.Group("/")
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"atsflare/model"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const accessLogListLimit = 500
|
||||||
|
|
||||||
|
type AccessLogView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
NodeName string `json:"node_name"`
|
||||||
|
LoggedAt time.Time `json:"logged_at"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAccessLogs(nodeID string) ([]AccessLogView, error) {
|
||||||
|
logs, err := model.ListNodeAccessLogs(strings.TrimSpace(nodeID), time.Now().Add(-nodeAccessLogRetentionWindow), accessLogListLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodes, err := model.ListNodes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodeNames := make(map[string]string, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nodeNames[node.NodeID] = node.Name
|
||||||
|
}
|
||||||
|
views := make([]AccessLogView, 0, len(logs))
|
||||||
|
for _, item := range logs {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
views = append(views, AccessLogView{
|
||||||
|
ID: item.ID,
|
||||||
|
NodeID: item.NodeID,
|
||||||
|
NodeName: nodeNames[item.NodeID],
|
||||||
|
LoggedAt: item.LoggedAt,
|
||||||
|
RemoteAddr: item.RemoteAddr,
|
||||||
|
Host: item.Host,
|
||||||
|
Path: item.Path,
|
||||||
|
StatusCode: item.StatusCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ type AgentNodePayload struct {
|
|||||||
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
|
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
|
||||||
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||||
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
|
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
|
||||||
|
AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"`
|
||||||
BufferedObservability []AgentBufferedObservabilityRecord `json:"buffered_observability,omitempty"`
|
BufferedObservability []AgentBufferedObservabilityRecord `json:"buffered_observability,omitempty"`
|
||||||
HealthEvents []AgentNodeHealthEvent `json:"health_events"`
|
HealthEvents []AgentNodeHealthEvent `json:"health_events"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -620,6 +620,22 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
|
|||||||
TopDomains: map[string]int64{"example.com": 900},
|
TopDomains: map[string]int64{"example.com": 900},
|
||||||
SourceCountries: map[string]int64{"CN": 700, "US": 200},
|
SourceCountries: map[string]int64{"CN": 700, "US": 200},
|
||||||
},
|
},
|
||||||
|
AccessLogs: []AgentNodeAccessLog{
|
||||||
|
{
|
||||||
|
LoggedAtUnix: time.Now().Add(-45 * time.Second).Unix(),
|
||||||
|
RemoteAddr: "203.0.113.10",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/login",
|
||||||
|
StatusCode: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LoggedAtUnix: time.Now().Add(-40 * time.Second).Unix(),
|
||||||
|
RemoteAddr: "198.51.100.20",
|
||||||
|
Host: "api.example.com",
|
||||||
|
Path: "/v1/ping",
|
||||||
|
StatusCode: 502,
|
||||||
|
},
|
||||||
|
},
|
||||||
HealthEvents: []AgentNodeHealthEvent{
|
HealthEvents: []AgentNodeHealthEvent{
|
||||||
{
|
{
|
||||||
EventType: "openresty_unhealthy",
|
EventType: "openresty_unhealthy",
|
||||||
@@ -657,6 +673,14 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
|
|||||||
t.Fatalf("unexpected request reports: %+v", reports)
|
t.Fatalf("unexpected request reports: %+v", reports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected node access logs query to succeed: %v", err)
|
||||||
|
}
|
||||||
|
if len(accessLogs) != 2 || accessLogs[0].Path == "" {
|
||||||
|
t.Fatalf("unexpected access logs: %+v", accessLogs)
|
||||||
|
}
|
||||||
|
|
||||||
events, err := model.ListNodeHealthEvents(node.NodeID, true, 10)
|
events, err := model.ListNodeHealthEvents(node.NodeID, true, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected node health events query to succeed: %v", err)
|
t.Fatalf("expected node health events query to succeed: %v", err)
|
||||||
@@ -724,6 +748,15 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
|
|||||||
TopDomains: map[string]int64{"edge.example.com": 40},
|
TopDomains: map[string]int64{"edge.example.com": 40},
|
||||||
SourceCountries: map[string]int64{"CN": 20},
|
SourceCountries: map[string]int64{"CN": 20},
|
||||||
},
|
},
|
||||||
|
AccessLogs: []AgentNodeAccessLog{
|
||||||
|
{
|
||||||
|
LoggedAtUnix: now.Add(-110 * time.Second).Unix(),
|
||||||
|
RemoteAddr: "203.0.113.21",
|
||||||
|
Host: "edge.example.com",
|
||||||
|
Path: "/buffered",
|
||||||
|
StatusCode: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -747,6 +780,14 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
|
|||||||
t.Fatalf("expected current and buffered reports, got %+v", reports)
|
t.Fatalf("expected current and buffered reports, got %+v", reports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected node access logs query to succeed: %v", err)
|
||||||
|
}
|
||||||
|
if len(accessLogs) != 1 || accessLogs[0].Path != "/buffered" {
|
||||||
|
t.Fatalf("expected buffered access logs to persist, got %+v", accessLogs)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = HeartbeatNode(node, AgentNodePayload{
|
_, err = HeartbeatNode(node, AgentNodePayload{
|
||||||
NodeID: node.NodeID,
|
NodeID: node.NodeID,
|
||||||
Name: node.Name,
|
Name: node.Name,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
NodeHealthSeverityInfo = "info"
|
NodeHealthSeverityInfo = "info"
|
||||||
NodeHealthSeverityWarning = "warning"
|
NodeHealthSeverityWarning = "warning"
|
||||||
NodeHealthSeverityCritical = "critical"
|
NodeHealthSeverityCritical = "critical"
|
||||||
|
nodeAccessLogRetentionWindow = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentNodeSystemProfile struct {
|
type AgentNodeSystemProfile struct {
|
||||||
@@ -60,10 +61,19 @@ type AgentNodeTrafficReport struct {
|
|||||||
SourceCountries map[string]int64 `json:"source_countries"`
|
SourceCountries map[string]int64 `json:"source_countries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentNodeAccessLog struct {
|
||||||
|
LoggedAtUnix int64 `json:"logged_at_unix"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
}
|
||||||
|
|
||||||
type AgentBufferedObservabilityRecord struct {
|
type AgentBufferedObservabilityRecord struct {
|
||||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||||
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||||
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
|
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
|
||||||
|
AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentNodeHealthEvent struct {
|
type AgentNodeHealthEvent struct {
|
||||||
@@ -78,7 +88,7 @@ func persistHeartbeatObservability(nodeID string, payload AgentNodePayload, repo
|
|||||||
if strings.TrimSpace(nodeID) == "" {
|
if strings.TrimSpace(nodeID) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if payload.Profile == nil && payload.Snapshot == nil && payload.TrafficReport == nil && len(payload.BufferedObservability) == 0 && payload.HealthEvents == nil {
|
if payload.Profile == nil && payload.Snapshot == nil && payload.TrafficReport == nil && len(payload.AccessLogs) == 0 && len(payload.BufferedObservability) == 0 && payload.HealthEvents == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +105,9 @@ func persistHeartbeatObservability(nodeID string, payload AgentNodePayload, repo
|
|||||||
if err := persistNodeTrafficReport(tx, nodeID, payload.TrafficReport, reportedAt); err != nil {
|
if err := persistNodeTrafficReport(tx, nodeID, payload.TrafficReport, reportedAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := persistNodeAccessLogs(tx, nodeID, payload.AccessLogs, reportedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if payload.HealthEvents != nil {
|
if payload.HealthEvents != nil {
|
||||||
if err := reconcileNodeHealthEvents(tx, nodeID, payload.HealthEvents, reportedAt); err != nil {
|
if err := reconcileNodeHealthEvents(tx, nodeID, payload.HealthEvents, reportedAt); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -114,6 +127,9 @@ func persistBufferedObservability(tx *gorm.DB, nodeID string, records []AgentBuf
|
|||||||
if err := persistNodeTrafficReport(tx, nodeID, record.TrafficReport, reportedAt); err != nil {
|
if err := persistNodeTrafficReport(tx, nodeID, record.TrafficReport, reportedAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := persistNodeAccessLogs(tx, nodeID, record.AccessLogs, reportedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -186,6 +202,35 @@ func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTraff
|
|||||||
return tx.Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, record.WindowStartedAt, record.WindowEndedAt).Assign(record).FirstOrCreate(record).Error
|
return tx.Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, record.WindowStartedAt, record.WindowEndedAt).Assign(record).FirstOrCreate(record).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog, reportedAt time.Time) error {
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, item := range logs {
|
||||||
|
record := &model.NodeAccessLog{
|
||||||
|
NodeID: nodeID,
|
||||||
|
LoggedAt: timeFromUnix(item.LoggedAtUnix, reportedAt),
|
||||||
|
RemoteAddr: strings.TrimSpace(item.RemoteAddr),
|
||||||
|
Host: strings.TrimSpace(item.Host),
|
||||||
|
Path: strings.TrimSpace(item.Path),
|
||||||
|
StatusCode: item.StatusCode,
|
||||||
|
RawJSON: marshalJSON(item),
|
||||||
|
}
|
||||||
|
if err := tx.Where(
|
||||||
|
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
|
||||||
|
nodeID,
|
||||||
|
record.LoggedAt,
|
||||||
|
record.RemoteAddr,
|
||||||
|
record.Host,
|
||||||
|
record.Path,
|
||||||
|
record.StatusCode,
|
||||||
|
).Assign(record).FirstOrCreate(record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Where("node_id = ? AND logged_at < ?", nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow)).Delete(&model.NodeAccessLog{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time) error {
|
func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time) error {
|
||||||
activeTypes := make(map[string]AgentNodeHealthEvent, len(events))
|
activeTypes := make(map[string]AgentNodeHealthEvent, len(events))
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AccessLogsPage } from '@/features/access-logs/components/access-logs-page';
|
||||||
|
|
||||||
|
export default function AccessLogsRoute() {
|
||||||
|
return <AccessLogsPage />;
|
||||||
|
}
|
||||||
@@ -80,6 +80,15 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
|||||||
<path d="M5 19.5h14" />
|
<path d="M5 19.5h14" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'log':
|
||||||
|
return (
|
||||||
|
<svg {...commonProps}>
|
||||||
|
<rect x="5" y="4" width="14" height="16" rx="2.5" />
|
||||||
|
<path d="M8 8h8" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
<path d="M8 16h5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'performance':
|
case 'performance':
|
||||||
return (
|
return (
|
||||||
<svg {...commonProps}>
|
<svg {...commonProps}>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type { AccessLogItem } from '@/features/access-logs/types';
|
||||||
|
|
||||||
|
export function getAccessLogs(nodeId?: string) {
|
||||||
|
const normalizedNodeId = nodeId?.trim();
|
||||||
|
const query = normalizedNodeId
|
||||||
|
? `?node_id=${encodeURIComponent(normalizedNodeId)}`
|
||||||
|
: '';
|
||||||
|
return apiRequest<AccessLogItem[]>(`/access-logs/${query}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
import { getAccessLogs } from '@/features/access-logs/api/access-logs';
|
||||||
|
import type { AccessLogItem } from '@/features/access-logs/types';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceInput,
|
||||||
|
SecondaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
|
const accessLogsQueryKey = (nodeId: string) => ['access-logs', nodeId] as const;
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(logs: AccessLogItem[]) {
|
||||||
|
const uniqueIPs = new Set(logs.map((item) => item.remote_addr).filter(Boolean));
|
||||||
|
const uniqueNodes = new Set(logs.map((item) => item.node_id).filter(Boolean));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '访问记录', value: logs.length },
|
||||||
|
{ label: '来源 IP', value: uniqueIPs.size },
|
||||||
|
{ label: '命中节点', value: uniqueNodes.size },
|
||||||
|
{
|
||||||
|
label: '5xx 响应',
|
||||||
|
value: logs.filter((item) => item.status_code >= 500).length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusMeta(statusCode: number) {
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
return { label: String(statusCode), variant: 'danger' as const };
|
||||||
|
}
|
||||||
|
if (statusCode >= 400) {
|
||||||
|
return { label: String(statusCode), variant: 'warning' as const };
|
||||||
|
}
|
||||||
|
return { label: String(statusCode), variant: 'success' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccessLogsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [nodeFilterInput, setNodeFilterInput] = useState('');
|
||||||
|
const [nodeFilter, setNodeFilter] = useState('');
|
||||||
|
|
||||||
|
const logsQuery = useQuery({
|
||||||
|
queryKey: accessLogsQueryKey(nodeFilter),
|
||||||
|
queryFn: () => getAccessLogs(nodeFilter),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]);
|
||||||
|
const summary = useMemo(() => buildSummary(logs), [logs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="日志"
|
||||||
|
description="查看最近时间窗口内的访问记录,包含来源 IP、访问域名、路径、命中节点与响应状态码。"
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href="/node"
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
|
>
|
||||||
|
返回节点页
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title="日志摘要"
|
||||||
|
description="帮助快速了解最近访问量、来源范围和异常响应规模。"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{summary.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||||
|
>
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title="访问记录"
|
||||||
|
description="默认展示全部节点的最近访问日志,可按 node_id 快速过滤。"
|
||||||
|
action={
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: accessLogsQueryKey(nodeFilter),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</SecondaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
|
<ResourceInput
|
||||||
|
value={nodeFilterInput}
|
||||||
|
onChange={(event) => setNodeFilterInput(event.target.value)}
|
||||||
|
placeholder="输入 node_id 过滤访问日志"
|
||||||
|
className="lg:max-w-md"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNodeFilter(nodeFilterInput.trim())}
|
||||||
|
>
|
||||||
|
筛选
|
||||||
|
</PrimaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNodeFilter('');
|
||||||
|
setNodeFilterInput('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logsQuery.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : logsQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="访问日志加载失败"
|
||||||
|
description={getErrorMessage(logsQuery.error)}
|
||||||
|
/>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无访问日志"
|
||||||
|
description="当前时间窗口内还没有可展示的访问记录。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[var(--foreground-secondary)]">
|
||||||
|
<th className="px-3 py-3 font-medium">时间</th>
|
||||||
|
<th className="px-3 py-3 font-medium">原 IP</th>
|
||||||
|
<th className="px-3 py-3 font-medium">访问域名</th>
|
||||||
|
<th className="px-3 py-3 font-medium">路径</th>
|
||||||
|
<th className="px-3 py-3 font-medium">节点</th>
|
||||||
|
<th className="px-3 py-3 font-medium">状态码</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--border-default)]">
|
||||||
|
{logs.map((item) => {
|
||||||
|
const statusMeta = getStatusMeta(item.status_code);
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="align-top">
|
||||||
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
|
<div>{formatDateTime(item.logged_at)}</div>
|
||||||
|
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||||
|
{formatRelativeTime(item.logged_at)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||||
|
{item.remote_addr || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
|
{item.host || '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="max-w-[360px] px-3 py-4 text-[var(--foreground-secondary)]"
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
<span className="break-all">{item.path || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
|
<div>{item.node_name || item.node_id}</div>
|
||||||
|
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||||
|
{item.node_id}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<StatusBadge
|
||||||
|
label={statusMeta.label}
|
||||||
|
variant={statusMeta.variant}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface AccessLogItem {
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
node_name: string;
|
||||||
|
logged_at: string;
|
||||||
|
remote_addr: string;
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
status_code: number;
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ export const dashboardNavigation: NavigationItem[] = [
|
|||||||
label: '发布',
|
label: '发布',
|
||||||
icon: 'release',
|
icon: 'release',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/access-log',
|
||||||
|
label: '日志',
|
||||||
|
icon: 'log',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/performance',
|
href: '/performance',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type NavigationIconKey =
|
|||||||
| 'certificate'
|
| 'certificate'
|
||||||
| 'proxy'
|
| 'proxy'
|
||||||
| 'release'
|
| 'release'
|
||||||
|
| 'log'
|
||||||
| 'performance'
|
| 'performance'
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'setting';
|
| 'setting';
|
||||||
|
|||||||
+3
-1
@@ -82,7 +82,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
|
|||||||
* 为了支持世界地图看板,允许节点维护低频地图元数据,如位置名、纬度和经度;这类字段仍属于控制面摘要信息,可保留在 `nodes`
|
* 为了支持世界地图看板,允许节点维护低频地图元数据,如位置名、纬度和经度;这类字段仍属于控制面摘要信息,可保留在 `nodes`
|
||||||
* 节点详情页聚焦系统信息、实时资源、网络流量、24 小时趋势和目标版本,不继续堆积低价值静态字段
|
* 节点详情页聚焦系统信息、实时资源、网络流量、24 小时趋势和目标版本,不继续堆积低价值静态字段
|
||||||
* 在不引入 Prometheus、ClickHouse、Kafka 等新基础设施前提下完成第六版;数据采集、聚合与查询继续落在现有 Server/SQLite 基线内
|
* 在不引入 Prometheus、ClickHouse、Kafka 等新基础设施前提下完成第六版;数据采集、聚合与查询继续落在现有 Server/SQLite 基线内
|
||||||
* 原始请求数据不作为长期日志平台对外开放;第六版允许保留受控时间窗口内的明细,用于聚合分析、趋势计算与节点详情辅助排查
|
* 原始请求数据不作为长期日志平台对外开放;第六版允许保留受控时间窗口内的明细,用于聚合分析、趋势计算、节点详情辅助排查,以及管理端“日志”页面查看最近时间窗口内的访问记录
|
||||||
* 第六版不做完整 APM、调用链追踪、日志检索平台、任意 SQL 分析接口或自定义报表系统
|
* 第六版不做完整 APM、调用链追踪、日志检索平台、任意 SQL 分析接口或自定义报表系统
|
||||||
|
|
||||||
新增能力超出上述边界时,必须先更新本文档,再进入实现。
|
新增能力超出上述边界时,必须先更新本文档,再进入实现。
|
||||||
@@ -181,6 +181,7 @@ ATSFlare Agent (register / heartbeat / sync / apply / update)
|
|||||||
* `tls_certificates`:托管证书与私钥
|
* `tls_certificates`:托管证书与私钥
|
||||||
* `managed_domains`:域名资产及默认证书关系
|
* `managed_domains`:域名资产及默认证书关系
|
||||||
* `node_request_reports`:节点通过 heartbeat 批量上报的请求明细或请求批次
|
* `node_request_reports`:节点通过 heartbeat 批量上报的请求明细或请求批次
|
||||||
|
* `node_access_logs`:节点最近时间窗口内的受控访问明细,仅保留用于管理端排查的必要字段
|
||||||
* `node_metric_snapshots`:节点实时资源、磁盘 IO 与网络流量快照
|
* `node_metric_snapshots`:节点实时资源、磁盘 IO 与网络流量快照
|
||||||
* `traffic_analytics_rollups`:按时间窗口聚合后的访问指标,用于总览与节点详情看板
|
* `traffic_analytics_rollups`:按时间窗口聚合后的访问指标,用于总览与节点详情看板
|
||||||
* `node_health_events`:节点运行状态变化、阈值异常与配置偏差事件
|
* `node_health_events`:节点运行状态变化、阈值异常与配置偏差事件
|
||||||
@@ -203,6 +204,7 @@ ATSFlare Agent (register / heartbeat / sync / apply / update)
|
|||||||
* 节点请求数据必须随 heartbeat 按批次上报,Server 不主动反向拉取节点日志文件
|
* 节点请求数据必须随 heartbeat 按批次上报,Server 不主动反向拉取节点日志文件
|
||||||
* 指标看板使用服务端聚合结果,不在前端重复做大规模统计计算
|
* 指标看板使用服务端聚合结果,不在前端重复做大规模统计计算
|
||||||
* 节点资源快照与请求明细必须能按时间排序并绑定节点,保证 24 小时趋势与节点详情可回放
|
* 节点资源快照与请求明细必须能按时间排序并绑定节点,保证 24 小时趋势与节点详情可回放
|
||||||
|
* 管理端日志页只展示受控保留窗口内的必要访问字段,如原始来源 IP、访问域名、路径、命中的节点与响应状态码,不演变为通用日志检索平台
|
||||||
* 原始请求明细、聚合统计与节点基础状态必须在时间窗口上可对齐
|
* 原始请求明细、聚合统计与节点基础状态必须在时间窗口上可对齐
|
||||||
* 首页总览的系统状态必须基于统一服务端口径生成,不能由多个历史列表接口在前端临时拼装推导
|
* 首页总览的系统状态必须基于统一服务端口径生成,不能由多个历史列表接口在前端临时拼装推导
|
||||||
* 健康事件必须支持“触发中/已恢复”状态,避免首页异常永远累积
|
* 健康事件必须支持“触发中/已恢复”状态,避免首页异常永远累积
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
* `tls_certificates`
|
* `tls_certificates`
|
||||||
* `managed_domains`
|
* `managed_domains`
|
||||||
* `node_request_reports`
|
* `node_request_reports`
|
||||||
|
* `node_access_logs`
|
||||||
* `node_metric_snapshots`
|
* `node_metric_snapshots`
|
||||||
* `traffic_analytics_rollups`
|
* `traffic_analytics_rollups`
|
||||||
* `node_health_events`
|
* `node_health_events`
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
* 第六版新增的请求明细、资源快照和聚合统计必须按节点与时间窗口关联
|
* 第六版新增的请求明细、资源快照和聚合统计必须按节点与时间窗口关联
|
||||||
* `node_metric_snapshots` 必须是追加式时间序列快照,不通过覆盖 `nodes` 当前值替代历史
|
* `node_metric_snapshots` 必须是追加式时间序列快照,不通过覆盖 `nodes` 当前值替代历史
|
||||||
* `traffic_analytics_rollups` 必须区分时间粒度与统计范围,优先存储窗口聚合而不是无限制保留原始逐请求明细
|
* `traffic_analytics_rollups` 必须区分时间粒度与统计范围,优先存储窗口聚合而不是无限制保留原始逐请求明细
|
||||||
|
* `node_access_logs` 仅保留管理端排查所需的受控访问字段与短期保留窗口,不承担全文检索或长期归档职责
|
||||||
* `node_health_events` 必须具备事件类型、严重级别、首次触发时间、最近触发时间和恢复时间,便于首页总览做异常归并
|
* `node_health_events` 必须具备事件类型、严重级别、首次触发时间、最近触发时间和恢复时间,便于首页总览做异常归并
|
||||||
* 访问分析优先复用现有 Server/SQLite 基线,不为第六版引入新的时序数据库或消息队列
|
* 访问分析优先复用现有 Server/SQLite 基线,不为第六版引入新的时序数据库或消息队列
|
||||||
* 聚合统计与原始明细的保留策略必须明确,避免无限制累积
|
* 聚合统计与原始明细的保留策略必须明确,避免无限制累积
|
||||||
|
|||||||
Reference in New Issue
Block a user