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:
ryan
2026-03-14 18:21:48 +08:00
parent e25b41fd75
commit b7d38590ba
25 changed files with 669 additions and 21 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ events {
http {
include mime.types;
default_type application/octet-stream;
log_format atsflare_json escape=json '{"ts":"$time_iso8601","host":"$host","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
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;
sendfile on;
tcp_nopush on;
+22
View File
@@ -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)
}
+4
View File
@@ -107,6 +107,10 @@ func InitDB() (err error) {
if err != nil {
return err
}
err = db.AutoMigrate(&NodeAccessLog{})
if err != nil {
return err
}
err = db.AutoMigrate(&NodeHealthEvent{})
if err != nil {
return err
+30
View File
@@ -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
}
+5
View File
@@ -133,6 +133,11 @@ func SetApiRouter(router *gin.Engine) {
{
applyLogRoute.GET("/", controller.GetApplyLogs)
}
accessLogRoute := apiRouter.Group("/access-logs")
accessLogRoute.Use(middleware.AdminAuth())
{
accessLogRoute.GET("/", controller.GetAccessLogs)
}
agentRoute := apiRouter.Group("/agent")
{
discoveryRoute := agentRoute.Group("/")
+55
View File
@@ -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
}
+1
View File
@@ -36,6 +36,7 @@ type AgentNodePayload struct {
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"`
BufferedObservability []AgentBufferedObservabilityRecord `json:"buffered_observability,omitempty"`
HealthEvents []AgentNodeHealthEvent `json:"health_events"`
}
+41
View File
@@ -620,6 +620,22 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
TopDomains: map[string]int64{"example.com": 900},
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{
{
EventType: "openresty_unhealthy",
@@ -657,6 +673,14 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
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)
if err != nil {
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},
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)
}
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{
NodeID: node.NodeID,
Name: node.Name,
+46 -1
View File
@@ -17,6 +17,7 @@ const (
NodeHealthSeverityInfo = "info"
NodeHealthSeverityWarning = "warning"
NodeHealthSeverityCritical = "critical"
nodeAccessLogRetentionWindow = 24 * time.Hour
)
type AgentNodeSystemProfile struct {
@@ -60,10 +61,19 @@ type AgentNodeTrafficReport struct {
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 {
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"`
}
type AgentNodeHealthEvent struct {
@@ -78,7 +88,7 @@ func persistHeartbeatObservability(nodeID string, payload AgentNodePayload, repo
if strings.TrimSpace(nodeID) == "" {
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
}
@@ -95,6 +105,9 @@ func persistHeartbeatObservability(nodeID string, payload AgentNodePayload, repo
if err := persistNodeTrafficReport(tx, nodeID, payload.TrafficReport, reportedAt); err != nil {
return err
}
if err := persistNodeAccessLogs(tx, nodeID, payload.AccessLogs, reportedAt); err != nil {
return err
}
if payload.HealthEvents != nil {
if err := reconcileNodeHealthEvents(tx, nodeID, payload.HealthEvents, reportedAt); err != nil {
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 {
return err
}
if err := persistNodeAccessLogs(tx, nodeID, record.AccessLogs, reportedAt); err != nil {
return err
}
}
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
}
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 {
activeTypes := make(map[string]AgentNodeHealthEvent, len(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" />
</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':
return (
<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: '发布',
icon: 'release',
},
{
href: '/access-log',
label: '日志',
icon: 'log',
},
{
href: '/performance',
+1
View File
@@ -6,6 +6,7 @@ export type NavigationIconKey =
| 'certificate'
| 'proxy'
| 'release'
| 'log'
| 'performance'
| 'user'
| 'setting';