mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&NodeAccessLog{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&NodeHealthEvent{})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
accessLogRoute := apiRouter.Group("/access-logs")
|
||||
accessLogRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
accessLogRoute.GET("/", controller.GetAccessLogs)
|
||||
}
|
||||
agentRoute := apiRouter.Group("/agent")
|
||||
{
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,6 +6,7 @@ export type NavigationIconKey =
|
||||
| 'certificate'
|
||||
| 'proxy'
|
||||
| 'release'
|
||||
| 'log'
|
||||
| 'performance'
|
||||
| 'user'
|
||||
| 'setting';
|
||||
|
||||
Reference in New Issue
Block a user