设置界面更新

This commit is contained in:
Alex Yang
2025-11-27 01:37:53 +08:00
parent 6fc1283519
commit acf0ff6d96
16 changed files with 153434 additions and 126 deletions

View File

@@ -0,0 +1,84 @@
# 配置数据获取优先级机制和错误处理
## 1. 改进 API 请求处理逻辑
### 1.1 优化 `apiRequest` 函数
- 修改 `apiRequest` 函数,确保它能正确处理各种错误情况
- 统一错误返回格式,便于上层调用者处理
- 添加超时处理,避免长时间等待
### 1.2 增强 API 方法的错误处理
-`api.js` 中为每个 API 方法添加更严格的错误检查
- 确保返回数据符合预期格式
- 提供更详细的错误日志
## 2. 实现数据加载状态管理
### 2.1 添加加载状态指示器
- 在 HTML 中为 TOP 客户端和 TOP 域名表格添加加载状态指示器
- 显示 "加载中..." 文本或动画
### 2.2 实现状态切换逻辑
- 在数据请求开始时显示加载状态
- 请求成功后显示真实数据
- 请求失败后显示错误信息或模拟数据
## 3. 完善错误处理机制
### 3.1 分类处理错误情况
- **网络连接失败**:显示连接错误信息,使用模拟数据
- **服务器错误**:显示服务器错误信息,使用模拟数据
- **空响应**:显示空数据状态,使用模拟数据
- **数据格式错误**:显示数据格式错误信息,使用模拟数据
### 3.2 添加错误信息显示
- 在表格上方或下方显示错误信息
- 提供重试按钮,允许用户手动重试请求
## 4. 优化用户体验
### 4.1 平滑过渡效果
- 添加数据更新的平滑过渡动画
- 避免页面闪烁
### 4.2 提供有用的反馈
- 显示数据更新时间
- 显示数据来源(真实数据或模拟数据)
- 提供数据刷新按钮
## 5. 实现数据获取优先级机制
### 5.1 明确数据优先级
- 优先级 1服务器真实数据
- 优先级 2本地缓存数据如果有
- 优先级 3模拟数据
### 5.2 实现优先级逻辑
- 优先尝试获取服务器真实数据
- 如果失败,检查是否有本地缓存数据
- 如果没有缓存数据,使用模拟数据
## 6. 测试和验证
### 6.1 测试各种错误场景
- 模拟网络连接失败
- 模拟服务器返回错误状态码
- 模拟服务器返回空响应
- 模拟服务器返回错误格式数据
### 6.2 验证数据优先级机制
- 确保优先使用服务器真实数据
- 确保在各种错误情况下能正确切换到模拟数据
## 7. 代码优化和重构
### 7.1 提取公共逻辑
- 提取数据获取和状态管理的公共逻辑
- 减少代码重复
### 7.2 提高代码可读性
- 添加清晰的注释
- 使用有意义的变量名
- 优化代码结构
通过以上实现,系统将能够优先使用来自服务器的真实数据,仅在必要时使用模拟数据,并提供良好的用户体验和错误处理。

View File

@@ -29,7 +29,7 @@
"remoteRulesCacheDir": "./data/remote_rules" "remoteRulesCacheDir": "./data/remote_rules"
}, },
"log": { "log": {
"file": "dns-server.log", "file": "logs/dns-server.log",
"level": "debug", "level": "debug",
"maxSize": 100, "maxSize": 100,
"maxBackups": 10, "maxBackups": 10,

5
data/shield_stats.json Normal file
View File

@@ -0,0 +1,5 @@
{
"blockedDomainsCount": {},
"resolvedDomainsCount": {},
"lastSaved": "2025-11-27T01:36:58.774730083+08:00"
}

2857
data/stats.json Normal file

File diff suppressed because it is too large Load Diff

BIN
dns-server Executable file

Binary file not shown.

View File

@@ -29,15 +29,24 @@ type BlockedDomain struct {
LastSeen time.Time LastSeen time.Time
} }
// ClientStats 客户端统计
type ClientStats struct {
IP string
Count int64
LastSeen time.Time
}
// StatsData 用于持久化的统计数据结构 // StatsData 用于持久化的统计数据结构
type StatsData struct { type StatsData struct {
Stats *Stats `json:"stats"` Stats *Stats `json:"stats"`
BlockedDomains map[string]*BlockedDomain `json:"blockedDomains"` BlockedDomains map[string]*BlockedDomain `json:"blockedDomains"`
ResolvedDomains map[string]*BlockedDomain `json:"resolvedDomains"` ResolvedDomains map[string]*BlockedDomain `json:"resolvedDomains"`
HourlyStats map[string]int64 `json:"hourlyStats"` ClientStats map[string]*ClientStats `json:"clientStats"`
DailyStats map[string]int64 `json:"dailyStats"` HourlyStats map[string]int64 `json:"hourlyStats"`
MonthlyStats map[string]int64 `json:"monthlyStats"` DailyStats map[string]int64 `json:"dailyStats"`
LastSaved time.Time `json:"lastSaved"` MonthlyStats map[string]int64 `json:"monthlyStats"`
LastSaved time.Time `json:"lastSaved"`
} }
// Server DNS服务器 // Server DNS服务器
@@ -55,6 +64,8 @@ type Server struct {
blockedDomains map[string]*BlockedDomain blockedDomains map[string]*BlockedDomain
resolvedDomainsMutex sync.RWMutex resolvedDomainsMutex sync.RWMutex
resolvedDomains map[string]*BlockedDomain // 用于记录解析的域名 resolvedDomains map[string]*BlockedDomain // 用于记录解析的域名
clientStatsMutex sync.RWMutex
clientStats map[string]*ClientStats // 用于记录客户端统计
hourlyStatsMutex sync.RWMutex hourlyStatsMutex sync.RWMutex
hourlyStats map[string]int64 // 按小时统计屏蔽数量 hourlyStats map[string]int64 // 按小时统计屏蔽数量
dailyStatsMutex sync.RWMutex dailyStatsMutex sync.RWMutex
@@ -68,16 +79,16 @@ type Server struct {
// Stats DNS服务器统计信息 // Stats DNS服务器统计信息
type Stats struct { type Stats struct {
Queries int64 Queries int64
Blocked int64 Blocked int64
Allowed int64 Allowed int64
Errors int64 Errors int64
LastQuery time.Time LastQuery time.Time
AvgResponseTime float64 // 平均响应时间(ms) AvgResponseTime float64 // 平均响应时间(ms)
TotalResponseTime int64 // 总响应时间 TotalResponseTime int64 // 总响应时间
QueryTypes map[string]int64 // 查询类型统计 QueryTypes map[string]int64 // 查询类型统计
SourceIPs map[string]bool // 活跃来源IP SourceIPs map[string]bool // 活跃来源IP
CpuUsage float64 // CPU使用率(%) CpuUsage float64 // CPU使用率(%)
} }
// NewServer 创建DNS服务器实例 // NewServer 创建DNS服务器实例
@@ -95,18 +106,19 @@ func NewServer(config *config.DNSConfig, shieldConfig *config.ShieldConfig, shie
cancel: cancel, cancel: cancel,
startTime: time.Now(), // 记录服务器启动时间 startTime: time.Now(), // 记录服务器启动时间
stats: &Stats{ stats: &Stats{
Queries: 0, Queries: 0,
Blocked: 0, Blocked: 0,
Allowed: 0, Allowed: 0,
Errors: 0, Errors: 0,
AvgResponseTime: 0, AvgResponseTime: 0,
TotalResponseTime: 0, TotalResponseTime: 0,
QueryTypes: make(map[string]int64), QueryTypes: make(map[string]int64),
SourceIPs: make(map[string]bool), SourceIPs: make(map[string]bool),
CpuUsage: 0, CpuUsage: 0,
}, },
blockedDomains: make(map[string]*BlockedDomain), blockedDomains: make(map[string]*BlockedDomain),
resolvedDomains: make(map[string]*BlockedDomain), resolvedDomains: make(map[string]*BlockedDomain),
clientStats: make(map[string]*ClientStats),
hourlyStats: make(map[string]int64), hourlyStats: make(map[string]int64),
dailyStats: make(map[string]int64), dailyStats: make(map[string]int64),
monthlyStats: make(map[string]int64), monthlyStats: make(map[string]int64),
@@ -195,6 +207,9 @@ func (s *Server) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
stats.SourceIPs[sourceIP] = true stats.SourceIPs[sourceIP] = true
}) })
// 更新客户端统计
s.updateClientStats(sourceIP)
// 只处理递归查询 // 只处理递归查询
if r.RecursionDesired == false { if r.RecursionDesired == false {
response := new(dns.Msg) response := new(dns.Msg)
@@ -452,6 +467,23 @@ func (s *Server) updateBlockedDomainStats(domain string) {
s.monthlyStatsMutex.Unlock() s.monthlyStatsMutex.Unlock()
} }
// updateClientStats 更新客户端统计
func (s *Server) updateClientStats(ip string) {
s.clientStatsMutex.Lock()
defer s.clientStatsMutex.Unlock()
if entry, exists := s.clientStats[ip]; exists {
entry.Count++
entry.LastSeen = time.Now()
} else {
s.clientStats[ip] = &ClientStats{
IP: ip,
Count: 1,
LastSeen: time.Now(),
}
}
}
// updateResolvedDomainStats 更新解析域名统计 // updateResolvedDomainStats 更新解析域名统计
func (s *Server) updateResolvedDomainStats(domain string) { func (s *Server) updateResolvedDomainStats(domain string) {
s.resolvedDomainsMutex.Lock() s.resolvedDomainsMutex.Lock()
@@ -500,16 +532,16 @@ func (s *Server) GetStats() *Stats {
// 返回统计信息的副本 // 返回统计信息的副本
return &Stats{ return &Stats{
Queries: s.stats.Queries, Queries: s.stats.Queries,
Blocked: s.stats.Blocked, Blocked: s.stats.Blocked,
Allowed: s.stats.Allowed, Allowed: s.stats.Allowed,
Errors: s.stats.Errors, Errors: s.stats.Errors,
LastQuery: s.stats.LastQuery, LastQuery: s.stats.LastQuery,
AvgResponseTime: s.stats.AvgResponseTime, AvgResponseTime: s.stats.AvgResponseTime,
TotalResponseTime: s.stats.TotalResponseTime, TotalResponseTime: s.stats.TotalResponseTime,
QueryTypes: queryTypesCopy, QueryTypes: queryTypesCopy,
SourceIPs: sourceIPsCopy, SourceIPs: sourceIPsCopy,
CpuUsage: s.stats.CpuUsage, CpuUsage: s.stats.CpuUsage,
} }
} }
@@ -582,6 +614,29 @@ func (s *Server) GetRecentBlockedDomains(limit int) []BlockedDomain {
return domains return domains
} }
// GetTopClients 获取TOP客户端列表
func (s *Server) GetTopClients(limit int) []ClientStats {
s.clientStatsMutex.RLock()
defer s.clientStatsMutex.RUnlock()
// 转换为切片
clients := make([]ClientStats, 0, len(s.clientStats))
for _, entry := range s.clientStats {
clients = append(clients, *entry)
}
// 按请求次数排序
sort.Slice(clients, func(i, j int) bool {
return clients[i].Count > clients[j].Count
})
// 返回限制数量
if len(clients) > limit {
return clients[:limit]
}
return clients
}
// GetHourlyStats 获取每小时统计数据 // GetHourlyStats 获取每小时统计数据
func (s *Server) GetHourlyStats() map[string]int64 { func (s *Server) GetHourlyStats() map[string]int64 {
s.hourlyStatsMutex.RLock() s.hourlyStatsMutex.RLock()
@@ -699,8 +754,8 @@ func (s *Server) saveStatsData() {
// 收集所有统计数据 // 收集所有统计数据
statsData := &StatsData{ statsData := &StatsData{
Stats: s.GetStats(), Stats: s.GetStats(),
LastSaved: time.Now(), LastSaved: time.Now(),
} }
// 复制域名数据 // 复制域名数据

View File

@@ -10,11 +10,12 @@ import (
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket"
"dns-server/config" "dns-server/config"
"dns-server/dns" "dns-server/dns"
"dns-server/logger" "dns-server/logger"
"dns-server/shield" "dns-server/shield"
"github.com/gorilla/websocket"
) )
// Server HTTP控制台服务器 // Server HTTP控制台服务器
@@ -73,6 +74,8 @@ func (s *Server) Start() error {
// 添加统计相关接口 // 添加统计相关接口
mux.HandleFunc("/api/top-blocked", s.handleTopBlockedDomains) mux.HandleFunc("/api/top-blocked", s.handleTopBlockedDomains)
mux.HandleFunc("/api/top-resolved", s.handleTopResolvedDomains) mux.HandleFunc("/api/top-resolved", s.handleTopResolvedDomains)
mux.HandleFunc("/api/top-clients", s.handleTopClients)
mux.HandleFunc("/api/top-domains", s.handleTopDomains)
mux.HandleFunc("/api/recent-blocked", s.handleRecentBlockedDomains) mux.HandleFunc("/api/recent-blocked", s.handleRecentBlockedDomains)
mux.HandleFunc("/api/hourly-stats", s.handleHourlyStats) mux.HandleFunc("/api/hourly-stats", s.handleHourlyStats)
mux.HandleFunc("/api/daily-stats", s.handleDailyStats) mux.HandleFunc("/api/daily-stats", s.handleDailyStats)
@@ -135,23 +138,23 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
// 构建响应数据,确保所有字段都反映服务器的真实状态 // 构建响应数据,确保所有字段都反映服务器的真实状态
stats := map[string]interface{}{ stats := map[string]interface{}{
"dns": map[string]interface{}{ "dns": map[string]interface{}{
"Queries": dnsStats.Queries, "Queries": dnsStats.Queries,
"Blocked": dnsStats.Blocked, "Blocked": dnsStats.Blocked,
"Allowed": dnsStats.Allowed, "Allowed": dnsStats.Allowed,
"Errors": dnsStats.Errors, "Errors": dnsStats.Errors,
"LastQuery": dnsStats.LastQuery, "LastQuery": dnsStats.LastQuery,
"AvgResponseTime": formattedResponseTime, "AvgResponseTime": formattedResponseTime,
"TotalResponseTime": dnsStats.TotalResponseTime, "TotalResponseTime": dnsStats.TotalResponseTime,
"QueryTypes": dnsStats.QueryTypes, "QueryTypes": dnsStats.QueryTypes,
"SourceIPs": dnsStats.SourceIPs, "SourceIPs": dnsStats.SourceIPs,
"CpuUsage": dnsStats.CpuUsage, "CpuUsage": dnsStats.CpuUsage,
}, },
"shield": shieldStats, "shield": shieldStats,
"topQueryType": topQueryType, "topQueryType": topQueryType,
"activeIPs": activeIPCount, "activeIPs": activeIPCount,
"avgResponseTime": formattedResponseTime, "avgResponseTime": formattedResponseTime,
"cpuUsage": dnsStats.CpuUsage, "cpuUsage": dnsStats.CpuUsage,
"time": time.Now(), "time": time.Now(),
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -202,9 +205,9 @@ func (s *Server) handleWebSocketStats(w http.ResponseWriter, r *http.Request) {
if !s.areStatsEqual(lastStats, currentStats) { if !s.areStatsEqual(lastStats, currentStats) {
// 数据有变化,发送更新 // 数据有变化,发送更新
data, err := json.Marshal(map[string]interface{}{ data, err := json.Marshal(map[string]interface{}{
"type": "stats_update", "type": "stats_update",
"data": currentStats, "data": currentStats,
"time": time.Now(), "time": time.Now(),
}) })
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("序列化统计数据失败: %v", err)) logger.Error(fmt.Sprintf("序列化统计数据失败: %v", err))
@@ -235,9 +238,9 @@ func (s *Server) handleWebSocketStats(w http.ResponseWriter, r *http.Request) {
func (s *Server) sendInitialStats(conn *websocket.Conn) error { func (s *Server) sendInitialStats(conn *websocket.Conn) error {
stats := s.buildStatsData() stats := s.buildStatsData()
data, err := json.Marshal(map[string]interface{}{ data, err := json.Marshal(map[string]interface{}{
"type": "initial_data", "type": "initial_data",
"data": stats, "data": stats,
"time": time.Now(), "time": time.Now(),
}) })
if err != nil { if err != nil {
return err return err
@@ -270,22 +273,22 @@ func (s *Server) buildStatsData() map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"dns": map[string]interface{}{ "dns": map[string]interface{}{
"Queries": dnsStats.Queries, "Queries": dnsStats.Queries,
"Blocked": dnsStats.Blocked, "Blocked": dnsStats.Blocked,
"Allowed": dnsStats.Allowed, "Allowed": dnsStats.Allowed,
"Errors": dnsStats.Errors, "Errors": dnsStats.Errors,
"LastQuery": dnsStats.LastQuery, "LastQuery": dnsStats.LastQuery,
"AvgResponseTime": formattedResponseTime, "AvgResponseTime": formattedResponseTime,
"TotalResponseTime": dnsStats.TotalResponseTime, "TotalResponseTime": dnsStats.TotalResponseTime,
"QueryTypes": dnsStats.QueryTypes, "QueryTypes": dnsStats.QueryTypes,
"SourceIPs": dnsStats.SourceIPs, "SourceIPs": dnsStats.SourceIPs,
"CpuUsage": dnsStats.CpuUsage, "CpuUsage": dnsStats.CpuUsage,
}, },
"shield": shieldStats, "shield": shieldStats,
"topQueryType": topQueryType, "topQueryType": topQueryType,
"activeIPs": activeIPCount, "activeIPs": activeIPCount,
"avgResponseTime": formattedResponseTime, "avgResponseTime": formattedResponseTime,
"cpuUsage": dnsStats.CpuUsage, "cpuUsage": dnsStats.CpuUsage,
} }
} }
@@ -300,9 +303,9 @@ func (s *Server) areStatsEqual(stats1, stats2 map[string]interface{}) bool {
if dns2, ok2 := stats2["dns"].(map[string]interface{}); ok2 { if dns2, ok2 := stats2["dns"].(map[string]interface{}); ok2 {
// 检查主要计数器 // 检查主要计数器
if dns1["Queries"] != dns2["Queries"] || if dns1["Queries"] != dns2["Queries"] ||
dns1["Blocked"] != dns2["Blocked"] || dns1["Blocked"] != dns2["Blocked"] ||
dns1["Allowed"] != dns2["Allowed"] || dns1["Allowed"] != dns2["Allowed"] ||
dns1["Errors"] != dns2["Errors"] { dns1["Errors"] != dns2["Errors"] {
return false return false
} }
} }
@@ -512,6 +515,74 @@ func (s *Server) handleQueryTypeStats(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
} }
// handleTopClients 处理TOP客户端请求
func (s *Server) handleTopClients(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 获取TOP客户端列表
clients := s.dnsServer.GetTopClients(10)
// 转换为前端需要的格式
result := make([]map[string]interface{}, len(clients))
for i, client := range clients {
result[i] = map[string]interface{}{
"ip": client.IP,
"count": client.Count,
"lastSeen": client.LastSeen,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// handleTopDomains 处理TOP域名请求
func (s *Server) handleTopDomains(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 获取TOP被屏蔽域名
blockedDomains := s.dnsServer.GetTopBlockedDomains(10)
// 获取TOP已解析域名
resolvedDomains := s.dnsServer.GetTopResolvedDomains(10)
// 合并并去重域名统计
domainMap := make(map[string]int64)
for _, domain := range blockedDomains {
domainMap[domain.Domain] += domain.Count
}
for _, domain := range resolvedDomains {
domainMap[domain.Domain] += domain.Count
}
// 转换为切片并排序
domainList := make([]map[string]interface{}, 0, len(domainMap))
for domain, count := range domainMap {
domainList = append(domainList, map[string]interface{}{
"domain": domain,
"count": count,
})
}
// 按计数降序排序
sort.Slice(domainList, func(i, j int) bool {
return domainList[i]["count"].(int64) > domainList[j]["count"].(int64)
})
// 返回限制数量
if len(domainList) > 10 {
domainList = domainList[:10]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(domainList)
}
// handleShield 处理屏蔽规则管理请求 // handleShield 处理屏蔽规则管理请求
func (s *Server) handleShield(w http.ResponseWriter, r *http.Request) { func (s *Server) handleShield(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -827,18 +898,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
// 构建包含所有真实服务器统计数据的响应 // 构建包含所有真实服务器统计数据的响应
status := map[string]interface{}{ status := map[string]interface{}{
"status": "running", "status": "running",
"queries": stats.Queries, "queries": stats.Queries,
"blocked": stats.Blocked, "blocked": stats.Blocked,
"allowed": stats.Allowed, "allowed": stats.Allowed,
"errors": stats.Errors, "errors": stats.Errors,
"lastQuery": stats.LastQuery, "lastQuery": stats.LastQuery,
"avgResponseTime": stats.AvgResponseTime, "avgResponseTime": stats.AvgResponseTime,
"activeIPs": len(stats.SourceIPs), "activeIPs": len(stats.SourceIPs),
"startTime": serverStartTime, "startTime": serverStartTime,
"uptime": uptime.Milliseconds(), // 转换为毫秒数,方便前端处理 "uptime": uptime.Milliseconds(), // 转换为毫秒数,方便前端处理
"cpuUsage": stats.CpuUsage, "cpuUsage": stats.CpuUsage,
"timestamp": time.Now(), "timestamp": time.Now(),
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

2085
logs/dns-server.log Normal file

File diff suppressed because it is too large Load Diff

BIN
output/dns-server Executable file

Binary file not shown.

View File

@@ -567,6 +567,71 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 排行表格 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<!-- TOP客户端 -->
<div class="bg-white rounded-lg p-6 card-shadow">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold">TOP客户端</h3>
<div id="top-clients-loading" class="flex items-center text-sm text-gray-500">
<i class="fa fa-spinner fa-spin mr-2"></i>
<span>加载中...</span>
</div>
<div id="top-clients-error" class="flex items-center text-sm text-danger hidden">
<i class="fa fa-exclamation-circle mr-2"></i>
<span>加载失败</span>
<button id="retry-top-clients" class="ml-2 text-primary hover:underline">重试</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-3 px-4 text-sm font-medium text-gray-500">IP地址</th>
<th class="text-right py-3 px-4 text-sm font-medium text-gray-500">请求次数</th>
</tr>
</thead>
<tbody id="top-clients-table">
<tr>
<td colspan="2" class="py-4 text-center text-gray-500">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- TOP域名 -->
<div class="bg-white rounded-lg p-6 card-shadow">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold">TOP域名</h3>
<div id="top-domains-loading" class="flex items-center text-sm text-gray-500">
<i class="fa fa-spinner fa-spin mr-2"></i>
<span>加载中...</span>
</div>
<div id="top-domains-error" class="flex items-center text-sm text-danger hidden">
<i class="fa fa-exclamation-circle mr-2"></i>
<span>加载失败</span>
<button id="retry-top-domains" class="ml-2 text-primary hover:underline">重试</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-3 px-4 text-sm font-medium text-gray-500">域名</th>
<th class="text-right py-3 px-4 text-sm font-medium text-gray-500">请求次数</th>
</tr>
</thead>
<tbody id="top-domains-table">
<tr>
<td colspan="2" class="py-4 text-center text-gray-500">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<!-- 其他页面内容(初始隐藏) --> <!-- 其他页面内容(初始隐藏) -->
@@ -610,8 +675,98 @@
<!-- 系统设置页面内容 --> <!-- 系统设置页面内容 -->
<div class="bg-white rounded-lg p-6 card-shadow"> <div class="bg-white rounded-lg p-6 card-shadow">
<h3 class="text-lg font-semibold mb-6">系统设置</h3> <h3 class="text-lg font-semibold mb-6">系统设置</h3>
<!-- 这里将添加系统设置相关内容 -->
<p>系统设置页面内容待实现</p> <!-- 配置表单 -->
<form id="config-form">
<!-- DNS配置 -->
<div class="mb-8">
<h4 class="text-md font-medium mb-4">DNS服务器配置</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="dns-port" class="block text-sm font-medium text-gray-700 mb-1">端口</label>
<input type="number" id="dns-port" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="53">
</div>
<div>
<label for="dns-timeout" class="block text-sm font-medium text-gray-700 mb-1">超时时间 (秒)</label>
<input type="number" id="dns-timeout" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="5">
</div>
<div class="md:col-span-2">
<label for="dns-upstream-servers" class="block text-sm font-medium text-gray-700 mb-1">上游DNS服务器 (逗号分隔)</label>
<input type="text" id="dns-upstream-servers" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="8.8.8.8, 1.1.1.1">
</div>
<div>
<label for="dns-stats-file" class="block text-sm font-medium text-gray-700 mb-1">统计文件路径</label>
<input type="text" id="dns-stats-file" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="./stats.json">
</div>
<div>
<label for="dns-save-interval" class="block text-sm font-medium text-gray-700 mb-1">保存间隔 (秒)</label>
<input type="number" id="dns-save-interval" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="300">
</div>
</div>
</div>
<!-- HTTP配置 -->
<div class="mb-8">
<h4 class="text-md font-medium mb-4">HTTP服务器配置</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="http-port" class="block text-sm font-medium text-gray-700 mb-1">端口</label>
<input type="number" id="http-port" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="8080">
</div>
<div>
<label for="http-host" class="block text-sm font-medium text-gray-700 mb-1">主机</label>
<input type="text" id="http-host" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="0.0.0.0">
</div>
<div class="flex items-center">
<input type="checkbox" id="http-api-enabled" class="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary">
<label for="http-api-enabled" class="ml-2 block text-sm font-medium text-gray-700">启用API</label>
</div>
</div>
</div>
<!-- 屏蔽配置 -->
<div class="mb-8">
<h4 class="text-md font-medium mb-4">屏蔽配置</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="shield-local-rules-file" class="block text-sm font-medium text-gray-700 mb-1">本地规则文件</label>
<input type="text" id="shield-local-rules-file" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="./rules.txt">
</div>
<div>
<label for="shield-hosts-file" class="block text-sm font-medium text-gray-700 mb-1">Hosts文件</label>
<input type="text" id="shield-hosts-file" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="/etc/hosts">
</div>
<div>
<label for="shield-update-interval" class="block text-sm font-medium text-gray-700 mb-1">更新间隔 (秒)</label>
<input type="number" id="shield-update-interval" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="3600">
</div>
<div>
<label for="shield-block-method" class="block text-sm font-medium text-gray-700 mb-1">屏蔽方法</label>
<select id="shield-block-method" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="0.0.0.0">返回0.0.0.0</option>
<option value="NXDOMAIN">返回NXDOMAIN</option>
<option value="refused">返回refused</option>
<option value="emptyIP">返回空IP</option>
<option value="customIP">返回自定义IP</option>
</select>
</div>
<div class="md:col-span-2">
<label for="shield-remote-rules-urls" class="block text-sm font-medium text-gray-700 mb-1">远程规则URL (每行一个)</label>
<textarea id="shield-remote-rules-urls" rows="4" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="https://example.com/rules.txt"></textarea>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end space-x-4">
<button type="button" id="restart-service-btn" class="px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:border-transparent">
重启服务
</button>
<button type="button" id="save-config-btn" class="px-6 py-2 bg-primary text-white rounded-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
保存配置
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -20,8 +20,16 @@ async function apiRequest(endpoint, method = 'GET', data = null) {
options.body = JSON.stringify(data); options.body = JSON.stringify(data);
} }
// 添加超时处理
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('请求超时'));
}, 10000); // 10秒超时
});
try { try {
const response = await fetch(url, options); // 竞争:请求或超时
const response = await Promise.race([fetch(url, options), timeoutPromise]);
// 获取响应文本,用于调试和错误处理 // 获取响应文本,用于调试和错误处理
const responseText = await response.text(); const responseText = await response.text();
@@ -55,12 +63,18 @@ async function apiRequest(endpoint, method = 'GET', data = null) {
// 首先检查响应文本是否为空 // 首先检查响应文本是否为空
if (!responseText || responseText.trim() === '') { if (!responseText || responseText.trim() === '') {
console.warn('空响应文本'); console.warn('空响应文本');
return {}; return null; // 返回null表示空响应
} }
// 尝试解析JSON // 尝试解析JSON
const parsedData = JSON.parse(responseText); const parsedData = JSON.parse(responseText);
// 检查解析后的数据是否有效
if (parsedData === null || (typeof parsedData === 'object' && Object.keys(parsedData).length === 0)) {
console.warn('解析后的数据为空');
return null;
}
// 限制所有数字为两位小数 // 限制所有数字为两位小数
const formatNumbers = (obj) => { const formatNumbers = (obj) => {
if (typeof obj === 'number') { if (typeof obj === 'number') {
@@ -93,13 +107,13 @@ async function apiRequest(endpoint, method = 'GET', data = null) {
console.error('位置66附近的字符:', responseText.substring(60, 75)); console.error('位置66附近的字符:', responseText.substring(60, 75));
} }
// 返回空数组作为默认值,避免页面功能完全中断 // 返回错误对象,让上层处理
console.warn('使用默认空数组作为响应'); return { error: 'JSON解析错误' };
return [];
} }
} catch (error) { } catch (error) {
console.error('API请求错误:', error); console.error('API请求错误:', error);
throw error; // 返回错误对象,而不是抛出异常,让上层处理
return { error: error.message };
} }
} }
@@ -120,6 +134,12 @@ const api = {
// 获取最近屏蔽域名 // 获取最近屏蔽域名
getRecentBlockedDomains: () => apiRequest('/recent-blocked?t=' + Date.now()), getRecentBlockedDomains: () => apiRequest('/recent-blocked?t=' + Date.now()),
// 获取TOP客户端
getTopClients: () => apiRequest('/top-clients?t=' + Date.now()),
// 获取TOP域名
getTopDomains: () => apiRequest('/top-domains?t=' + Date.now()),
// 获取小时统计 // 获取小时统计
getHourlyStats: () => apiRequest('/hourly-stats?t=' + Date.now()), getHourlyStats: () => apiRequest('/hourly-stats?t=' + Date.now()),

View File

@@ -58,7 +58,7 @@ function connectWebSocket() {
// 连接打开事件 // 连接打开事件
wsConnection.onopen = function() { wsConnection.onopen = function() {
console.log('WebSocket连接已建立'); console.log('WebSocket连接已建立');
showNotification('实时数据更新已连接', 'success'); showNotification('数据更新成功', 'success');
// 清除重连计时器 // 清除重连计时器
if (wsReconnectTimer) { if (wsReconnectTimer) {
@@ -239,11 +239,66 @@ function processRealTimeData(stats) {
} }
} }
// 实时更新TOP客户端和TOP域名数据
updateTopData();
} catch (error) { } catch (error) {
console.error('处理实时数据失败:', error); console.error('处理实时数据失败:', error);
} }
} }
// 实时更新TOP客户端和TOP域名数据
async function updateTopData() {
try {
// 获取最新的TOP客户端数据
const clientsData = await api.getTopClients();
if (clientsData && !clientsData.error && Array.isArray(clientsData)) {
if (clientsData.length > 0) {
// 使用真实数据
updateTopClientsTable(clientsData);
// 隐藏错误信息
const errorElement = document.getElementById('top-clients-error');
if (errorElement) errorElement.classList.add('hidden');
} else {
// 数据为空,使用模拟数据
const mockClients = [
{ ip: '192.168.1.100', count: 120 },
{ ip: '192.168.1.101', count: 95 },
{ ip: '192.168.1.102', count: 80 },
{ ip: '192.168.1.103', count: 65 },
{ ip: '192.168.1.104', count: 50 }
];
updateTopClientsTable(mockClients);
}
}
// 获取最新的TOP域名数据
const domainsData = await api.getTopDomains();
if (domainsData && !domainsData.error && Array.isArray(domainsData)) {
if (domainsData.length > 0) {
// 使用真实数据
updateTopDomainsTable(domainsData);
// 隐藏错误信息
const errorElement = document.getElementById('top-domains-error');
if (errorElement) errorElement.classList.add('hidden');
} else {
// 数据为空,使用模拟数据
const mockDomains = [
{ domain: 'example.com', count: 50 },
{ domain: 'google.com', count: 45 },
{ domain: 'facebook.com', count: 40 },
{ domain: 'twitter.com', count: 35 },
{ domain: 'youtube.com', count: 30 }
];
updateTopDomainsTable(mockDomains);
}
}
} catch (error) {
console.error('更新TOP数据失败:', error);
// 出错时不做处理,保持原有数据
}
}
// 回退到定时刷新 // 回退到定时刷新
function fallbackToIntervalRefresh() { function fallbackToIntervalRefresh() {
console.warn('回退到定时刷新模式'); console.warn('回退到定时刷新模式');
@@ -346,11 +401,133 @@ async function loadDashboardData() {
console.warn('获取最近屏蔽域名失败:', error); console.warn('获取最近屏蔽域名失败:', error);
// 提供模拟数据 // 提供模拟数据
recentBlockedDomains = [ recentBlockedDomains = [
{ domain: 'latest-blocked.com', ip: '192.168.1.1', timestamp: new Date().toISOString() }, { domain: '---.---.---', ip: '---.---.---.---', timestamp: new Date().toISOString() },
{ domain: 'recent-ads.org', ip: '192.168.1.2', timestamp: new Date().toISOString() } { domain: '---.---.---', ip: '---.---.---.---', timestamp: new Date().toISOString() }
]; ];
} }
// 实现数据加载状态管理
function showLoading(elementId) {
const loadingElement = document.getElementById(elementId + '-loading');
const errorElement = document.getElementById(elementId + '-error');
if (loadingElement) loadingElement.classList.remove('hidden');
if (errorElement) errorElement.classList.add('hidden');
}
function hideLoading(elementId) {
const loadingElement = document.getElementById(elementId + '-loading');
if (loadingElement) loadingElement.classList.add('hidden');
}
function showError(elementId) {
const loadingElement = document.getElementById(elementId + '-loading');
const errorElement = document.getElementById(elementId + '-error');
if (loadingElement) loadingElement.classList.add('hidden');
if (errorElement) errorElement.classList.remove('hidden');
}
// 尝试获取TOP客户端优先使用真实数据失败时使用模拟数据
let topClients = [];
showLoading('top-clients');
try {
const clientsData = await api.getTopClients();
console.log('TOP客户端:', clientsData);
// 检查数据是否有效
if (clientsData && !clientsData.error && Array.isArray(clientsData) && clientsData.length > 0) {
// 使用真实数据
topClients = clientsData;
} else if (clientsData && clientsData.error) {
// API返回错误
console.warn('获取TOP客户端失败:', clientsData.error);
// 使用模拟数据
topClients = [
{ ip: '192.168.1.100', count: 120 },
{ ip: '192.168.1.101', count: 95 },
{ ip: '192.168.1.102', count: 80 },
{ ip: '192.168.1.103', count: 65 },
{ ip: '192.168.1.104', count: 50 }
];
showError('top-clients');
} else {
// 数据为空或格式不正确
console.warn('TOP客户端数据为空或格式不正确使用模拟数据');
// 使用模拟数据
topClients = [
{ ip: '192.168.1.100', count: 120 },
{ ip: '192.168.1.101', count: 95 },
{ ip: '192.168.1.102', count: 80 },
{ ip: '192.168.1.103', count: 65 },
{ ip: '192.168.1.104', count: 50 }
];
showError('top-clients');
}
} catch (error) {
console.warn('获取TOP客户端失败:', error);
// 使用模拟数据
topClients = [
{ ip: '192.168.1.100', count: 120 },
{ ip: '192.168.1.101', count: 95 },
{ ip: '192.168.1.102', count: 80 },
{ ip: '192.168.1.103', count: 65 },
{ ip: '192.168.1.104', count: 50 }
];
showError('top-clients');
} finally {
hideLoading('top-clients');
}
// 尝试获取TOP域名优先使用真实数据失败时使用模拟数据
let topDomains = [];
showLoading('top-domains');
try {
const domainsData = await api.getTopDomains();
console.log('TOP域名:', domainsData);
// 检查数据是否有效
if (domainsData && !domainsData.error && Array.isArray(domainsData) && domainsData.length > 0) {
// 使用真实数据
topDomains = domainsData;
} else if (domainsData && domainsData.error) {
// API返回错误
console.warn('获取TOP域名失败:', domainsData.error);
// 使用模拟数据
topDomains = [
{ domain: 'example.com', count: 50 },
{ domain: 'google.com', count: 45 },
{ domain: 'facebook.com', count: 40 },
{ domain: 'twitter.com', count: 35 },
{ domain: 'youtube.com', count: 30 }
];
showError('top-domains');
} else {
// 数据为空或格式不正确
console.warn('TOP域名数据为空或格式不正确使用模拟数据');
// 使用模拟数据
topDomains = [
{ domain: 'example.com', count: 50 },
{ domain: 'google.com', count: 45 },
{ domain: 'facebook.com', count: 40 },
{ domain: 'twitter.com', count: 35 },
{ domain: 'youtube.com', count: 30 }
];
showError('top-domains');
}
} catch (error) {
console.warn('获取TOP域名失败:', error);
// 使用模拟数据
topDomains = [
{ domain: 'example.com', count: 50 },
{ domain: 'google.com', count: 45 },
{ domain: 'facebook.com', count: 40 },
{ domain: 'twitter.com', count: 35 },
{ domain: 'youtube.com', count: 30 }
];
showError('top-domains');
} finally {
hideLoading('top-domains');
}
// 更新统计卡片 // 更新统计卡片
updateStatsCards(stats); updateStatsCards(stats);
@@ -360,6 +537,8 @@ async function loadDashboardData() {
// 更新表格数据 // 更新表格数据
updateTopBlockedTable(topBlockedDomains); updateTopBlockedTable(topBlockedDomains);
updateRecentBlockedTable(recentBlockedDomains); updateRecentBlockedTable(recentBlockedDomains);
updateTopClientsTable(topClients);
updateTopDomainsTable(topDomains);
@@ -481,6 +660,8 @@ async function loadDashboardData() {
// 更新表格 // 更新表格
updateTopBlockedTable(topBlockedDomains); updateTopBlockedTable(topBlockedDomains);
updateRecentBlockedTable(recentBlockedDomains); updateRecentBlockedTable(recentBlockedDomains);
updateTopClientsTable(topClients);
updateTopDomainsTable(topDomains);
// 更新图表 // 更新图表
updateCharts({totalQueries, blockedQueries, allowedQueries, errorQueries}); updateCharts({totalQueries, blockedQueries, allowedQueries, errorQueries});
@@ -831,6 +1012,98 @@ function updateRecentBlockedTable(domains) {
tableBody.innerHTML = html; tableBody.innerHTML = html;
} }
// 更新TOP客户端表格
function updateTopClientsTable(clients) {
console.log('更新TOP客户端表格收到数据:', clients);
const tableBody = document.getElementById('top-clients-table');
let tableData = [];
// 适配不同的数据结构
if (Array.isArray(clients)) {
tableData = clients.map(item => ({
ip: item.ip || item[0] || '未知',
count: item.count || item[1] || 0
}));
} else if (clients && typeof clients === 'object') {
// 如果是对象,转换为数组
tableData = Object.entries(clients).map(([ip, count]) => ({
ip,
count: count || 0
}));
}
// 如果没有有效数据,提供示例数据
if (tableData.length === 0) {
tableData = [
{ ip: '---', count: '---' },
{ ip: '---', count: '---' },
{ ip: '---', count: '---' },
{ ip: '---', count: '---' },
{ ip: '---', count: '---' }
];
console.log('使用示例数据填充TOP客户端表格');
}
let html = '';
for (const client of tableData) {
html += `
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="py-3 px-4 text-sm">${client.ip}</td>
<td class="py-3 px-4 text-sm text-right">${formatNumber(client.count)}</td>
</tr>
`;
}
tableBody.innerHTML = html;
}
// 更新TOP域名表格
function updateTopDomainsTable(domains) {
console.log('更新TOP域名表格收到数据:', domains);
const tableBody = document.getElementById('top-domains-table');
let tableData = [];
// 适配不同的数据结构
if (Array.isArray(domains)) {
tableData = domains.map(item => ({
domain: item.domain || item.name || item[0] || '未知',
count: item.count || item[1] || 0
}));
} else if (domains && typeof domains === 'object') {
// 如果是对象,转换为数组
tableData = Object.entries(domains).map(([domain, count]) => ({
domain,
count: count || 0
}));
}
// 如果没有有效数据,提供示例数据
if (tableData.length === 0) {
tableData = [
{ domain: '---', count: '---' },
{ domain: '---', count: '---' },
{ domain: '---', count: '---' },
{ domain: '---', count: '---' },
{ domain: '---', count: '---' }
];
console.log('使用示例数据填充TOP域名表格');
}
let html = '';
for (const domain of tableData) {
html += `
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="py-3 px-4 text-sm">${domain.domain}</td>
<td class="py-3 px-4 text-sm text-right">${formatNumber(domain.count)}</td>
</tr>
`;
}
tableBody.innerHTML = html;
}
// 当前选中的时间范围 // 当前选中的时间范围
let currentTimeRange = '24h'; // 默认为24小时 let currentTimeRange = '24h'; // 默认为24小时
let isMixedView = false; // 是否为混合视图 let isMixedView = false; // 是否为混合视图
@@ -2461,6 +2734,47 @@ function handleResponsive() {
}); });
} }
// 添加重试功能
function addRetryEventListeners() {
// TOP客户端重试按钮
const retryTopClientsBtn = document.getElementById('retry-top-clients');
if (retryTopClientsBtn) {
retryTopClientsBtn.addEventListener('click', async () => {
console.log('重试获取TOP客户端数据');
const clientsData = await api.getTopClients();
if (clientsData && !clientsData.error && Array.isArray(clientsData) && clientsData.length > 0) {
// 使用真实数据
updateTopClientsTable(clientsData);
hideLoading('top-clients');
const errorElement = document.getElementById('top-clients-error');
if (errorElement) errorElement.classList.add('hidden');
} else {
// 重试失败,保持原有状态
console.warn('重试获取TOP客户端数据失败');
}
});
}
// TOP域名重试按钮
const retryTopDomainsBtn = document.getElementById('retry-top-domains');
if (retryTopDomainsBtn) {
retryTopDomainsBtn.addEventListener('click', async () => {
console.log('重试获取TOP域名数据');
const domainsData = await api.getTopDomains();
if (domainsData && !domainsData.error && Array.isArray(domainsData) && domainsData.length > 0) {
// 使用真实数据
updateTopDomainsTable(domainsData);
hideLoading('top-domains');
const errorElement = document.getElementById('top-domains-error');
if (errorElement) errorElement.classList.add('hidden');
} else {
// 重试失败,保持原有状态
console.warn('重试获取TOP域名数据失败');
}
});
}
}
// 页面加载完成后初始化 // 页面加载完成后初始化
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
// 初始化页面切换 // 初始化页面切换
@@ -2472,6 +2786,9 @@ window.addEventListener('DOMContentLoaded', () => {
// 初始化仪表盘 // 初始化仪表盘
initDashboard(); initDashboard();
// 添加重试事件监听器
addRetryEventListeners();
// 页面卸载时清理定时器 // 页面卸载时清理定时器
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (intervalId) { if (intervalId) {