update once
This commit is contained in:
205
internal/web/handler.go
Normal file
205
internal/web/handler.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"godns/internal/stats"
|
||||
"godns/pkg/logger"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
// Handler Web服务处理器
|
||||
type Handler struct {
|
||||
stats stats.StatsRecorder
|
||||
version string
|
||||
checkUpdateCh chan<- struct{}
|
||||
logger logger.Logger
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewHandler 创建Web处理器
|
||||
func NewHandler(s stats.StatsRecorder, ver string, checkCh chan<- struct{}, log logger.Logger, username, password string) *Handler {
|
||||
return &Handler{
|
||||
stats: s,
|
||||
version: ver,
|
||||
checkUpdateCh: checkCh,
|
||||
logger: log,
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuth 中间件
|
||||
func (h *Handler) basicAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// 如果未配置鉴权,直接放行
|
||||
if h.username == "" || h.password == "" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(h.username)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(pass), []byte(h.password)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="NBDNS Monitor"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// API路由
|
||||
mux.HandleFunc("/api/stats", h.basicAuth(h.handleStats))
|
||||
mux.HandleFunc("/api/version", h.basicAuth(h.handleVersion))
|
||||
mux.HandleFunc("/api/check-update", h.basicAuth(h.handleCheckUpdate))
|
||||
mux.HandleFunc("/api/stats/reset", h.basicAuth(h.handleStatsReset))
|
||||
|
||||
// 静态文件服务
|
||||
staticFS, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
h.logger.Printf("Failed to load static files: %v", err)
|
||||
return
|
||||
}
|
||||
mux.Handle("/", h.basicAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.FileServer(http.FS(staticFS)).ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// handleStats 处理统计信息请求
|
||||
func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
// 只允许GET请求
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取统计快照
|
||||
snapshot := h.stats.GetSnapshot()
|
||||
|
||||
// 设置响应头
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
// 编码JSON并返回
|
||||
if err := json.NewEncoder(w).Encode(snapshot); err != nil {
|
||||
h.logger.Printf("Error encoding stats JSON: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ResetResponse 重置响应
|
||||
type ResetResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// handleStatsReset 处理统计数据重置请求
|
||||
func (h *Handler) handleStatsReset(w http.ResponseWriter, r *http.Request) {
|
||||
// 只允许POST请求
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 重置统计数据
|
||||
h.stats.Reset()
|
||||
h.logger.Printf("Statistics reset by user request")
|
||||
|
||||
// 设置响应头
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// 返回成功响应
|
||||
if err := json.NewEncoder(w).Encode(ResetResponse{
|
||||
Success: true,
|
||||
Message: "统计数据已重置",
|
||||
}); err != nil {
|
||||
h.logger.Printf("Error encoding reset response JSON: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// VersionResponse 版本信息响应
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// handleVersion 处理版本查询请求
|
||||
func (h *Handler) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ver := h.version
|
||||
if ver == "" {
|
||||
ver = "0.0.0"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(VersionResponse{Version: ver}); err != nil {
|
||||
h.logger.Printf("Error encoding version JSON: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCheckResponse 更新检查响应
|
||||
type UpdateCheckResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// handleCheckUpdate 处理检查更新请求(生产者2:用户手动触发)
|
||||
func (h *Handler) handleCheckUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ver := h.version
|
||||
if ver == "" {
|
||||
ver = "0.0.0"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
// 触发后台检查更新(非阻塞)
|
||||
select {
|
||||
case h.checkUpdateCh <- struct{}{}:
|
||||
h.logger.Printf("Update check triggered by user")
|
||||
json.NewEncoder(w).Encode(UpdateCheckResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: ver,
|
||||
LatestVersion: ver,
|
||||
Message: "已触发更新检查,请查看服务器日志",
|
||||
})
|
||||
default:
|
||||
// 如果通道已满,说明已经在检查中
|
||||
json.NewEncoder(w).Encode(UpdateCheckResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: ver,
|
||||
LatestVersion: ver,
|
||||
Message: "更新检查正在进行中",
|
||||
})
|
||||
}
|
||||
}
|
||||
307
internal/web/static/app.js
Normal file
307
internal/web/static/app.js
Normal file
@@ -0,0 +1,307 @@
|
||||
// 自动刷新间隔(毫秒)
|
||||
const REFRESH_INTERVAL = 3000;
|
||||
let refreshTimer = null;
|
||||
let countdownTimer = null;
|
||||
let countdown = 0;
|
||||
let isCheckingUpdate = false;
|
||||
let isResettingStats = false;
|
||||
|
||||
// 格式化数字,添加千位分隔符
|
||||
function formatNumber(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
// 格式化百分比
|
||||
function formatPercent(num) {
|
||||
return num.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
// 更新运行时信息
|
||||
function updateRuntimeStats(runtime) {
|
||||
document.getElementById('uptime').textContent = runtime.uptime_str || '-';
|
||||
document.getElementById('goroutines').textContent = formatNumber(runtime.goroutines || 0);
|
||||
document.getElementById('mem-alloc').textContent = formatNumber(runtime.mem_alloc_mb || 0) + ' MB';
|
||||
document.getElementById('mem-sys').textContent = formatNumber(runtime.mem_sys_mb || 0) + ' MB';
|
||||
document.getElementById('mem-total').textContent = formatNumber(runtime.mem_total_mb || 0) + ' MB';
|
||||
document.getElementById('num-gc').textContent = formatNumber(runtime.num_gc || 0);
|
||||
|
||||
// 更新统计时长
|
||||
const statsDuration = runtime.stats_duration_str || '-';
|
||||
document.getElementById('stats-duration').textContent = '统计时长: ' + statsDuration;
|
||||
}
|
||||
|
||||
// 更新查询统计
|
||||
function updateQueryStats(queries) {
|
||||
document.getElementById('total-queries').textContent = formatNumber(queries.total || 0);
|
||||
document.getElementById('doh-queries').textContent = formatNumber(queries.doh || 0);
|
||||
document.getElementById('cache-hits').textContent = formatNumber(queries.cache_hits || 0);
|
||||
document.getElementById('cache-misses').textContent = formatNumber(queries.cache_misses || 0);
|
||||
document.getElementById('failed-queries').textContent = formatNumber(queries.failed || 0);
|
||||
document.getElementById('hit-rate').textContent = formatPercent(queries.hit_rate || 0);
|
||||
}
|
||||
|
||||
// 更新上游服务器表格
|
||||
function updateUpstreamTable(upstreams) {
|
||||
const tbody = document.getElementById('upstream-tbody');
|
||||
|
||||
if (!upstreams || upstreams.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="no-data">暂无数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
upstreams.forEach(upstream => {
|
||||
const errorClass = upstream.error_rate > 10 ? 'error-high' : '';
|
||||
html += `
|
||||
<tr>
|
||||
<td>${upstream.address || '-'}</td>
|
||||
<td>${formatNumber(upstream.total_queries || 0)}</td>
|
||||
<td class="${errorClass}">${formatNumber(upstream.errors || 0)}</td>
|
||||
<td class="${errorClass}">${formatPercent(upstream.error_rate || 0)}</td>
|
||||
<td>${upstream.last_used || 'Never'}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新 Top 客户端 IP 表格
|
||||
function updateTopClientsTable(topClients) {
|
||||
const tbody = document.getElementById('top-clients-tbody');
|
||||
|
||||
if (!topClients || topClients.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="no-data">暂无数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
topClients.forEach((client, index) => {
|
||||
const rankClass = index < 3 ? `rank-${index + 1}` : '';
|
||||
html += `
|
||||
<tr class="${rankClass}">
|
||||
<td class="rank-cell">${index + 1}</td>
|
||||
<td>${client.key || '-'}</td>
|
||||
<td>${formatNumber(client.count || 0)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新 Top 查询域名表格
|
||||
function updateTopDomainsTable(topDomains) {
|
||||
const tbody = document.getElementById('top-domains-tbody');
|
||||
|
||||
if (!topDomains || topDomains.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="no-data">暂无数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
topDomains.forEach((domain, index) => {
|
||||
const rankClass = index < 3 ? `rank-${index + 1}` : '';
|
||||
const topClient = domain.top_client || '-';
|
||||
html += `
|
||||
<tr class="${rankClass}">
|
||||
<td class="rank-cell">${index + 1}</td>
|
||||
<td class="domain-cell" title="${domain.key}">${domain.key || '-'}</td>
|
||||
<td>${formatNumber(domain.count || 0)}</td>
|
||||
<td>${topClient}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新倒计时显示
|
||||
function updateCountdown() {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
countdown = 0;
|
||||
}
|
||||
document.getElementById('last-update').textContent = `下次刷新: ${countdown}秒`;
|
||||
}
|
||||
|
||||
// 重置倒计时
|
||||
function resetCountdown() {
|
||||
countdown = REFRESH_INTERVAL / 1000;
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
}
|
||||
countdownTimer = setInterval(updateCountdown, 1000);
|
||||
updateCountdown();
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取统计数据失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 更新各部分数据
|
||||
updateRuntimeStats(data.runtime);
|
||||
updateQueryStats(data.queries);
|
||||
updateUpstreamTable(data.upstreams);
|
||||
updateTopClientsTable(data.top_clients);
|
||||
updateTopDomainsTable(data.top_domains);
|
||||
|
||||
// 重置倒计时
|
||||
resetCountdown();
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载统计数据出错:', error);
|
||||
document.getElementById('last-update').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
refreshTimer = setInterval(loadStats, REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载版本号
|
||||
async function loadVersion() {
|
||||
try {
|
||||
const response = await fetch('/api/version');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取版本号失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
document.getElementById('version-display').textContent = 'v' + data.version;
|
||||
} catch (error) {
|
||||
console.error('加载版本号出错:', error);
|
||||
document.getElementById('version-display').textContent = 'v0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
async function checkUpdate() {
|
||||
if (isCheckingUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('check-update-btn');
|
||||
const originalText = btn.textContent;
|
||||
|
||||
try {
|
||||
isCheckingUpdate = true;
|
||||
btn.textContent = '⏳';
|
||||
btn.disabled = true;
|
||||
|
||||
const response = await fetch('/api/check-update');
|
||||
if (!response.ok) {
|
||||
throw new Error('检查更新失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.has_update) {
|
||||
alert(`${data.message}\n当前版本: v${data.current_version}\n最新版本: v${data.latest_version}\n\n请访问 GitHub 下载最新版本`);
|
||||
} else {
|
||||
alert(`${data.message}\n当前版本: v${data.current_version}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新出错:', error);
|
||||
alert('检查更新失败,请稍后再试');
|
||||
} finally {
|
||||
isCheckingUpdate = false;
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置统计数据
|
||||
async function resetStats() {
|
||||
if (isResettingStats) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确认对话框
|
||||
if (!confirm('确定要重置所有统计数据吗?此操作无法撤销。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('reset-stats-btn');
|
||||
const originalText = btn.textContent;
|
||||
|
||||
try {
|
||||
isResettingStats = true;
|
||||
btn.textContent = '⏳ 重置中...';
|
||||
btn.disabled = true;
|
||||
|
||||
const response = await fetch('/api/stats/reset', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('重置统计数据失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message || '统计数据已重置');
|
||||
// 立即刷新数据
|
||||
await loadStats();
|
||||
} else {
|
||||
alert('重置失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置统计数据出错:', error);
|
||||
alert('重置统计数据失败,请稍后再试');
|
||||
} finally {
|
||||
isResettingStats = false;
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 立即加载一次数据
|
||||
loadStats();
|
||||
loadVersion();
|
||||
|
||||
// 启动自动刷新
|
||||
startAutoRefresh();
|
||||
|
||||
// 绑定检查更新按钮
|
||||
document.getElementById('check-update-btn').addEventListener('click', checkUpdate);
|
||||
|
||||
// 绑定重置统计按钮
|
||||
document.getElementById('reset-stats-btn').addEventListener('click', resetStats);
|
||||
|
||||
// 页面可见性变化时控制刷新
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
stopAutoRefresh();
|
||||
} else {
|
||||
loadStats();
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 页面卸载时停止刷新
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopAutoRefresh();
|
||||
});
|
||||
172
internal/web/static/index.html
Normal file
172
internal/web/static/index.html
Normal file
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoDNS 监控面板</title>
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌐</text></svg>">
|
||||
<link rel="stylesheet" href="style.css?v=1.2.4">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>GoDNS 监控面板</h1>
|
||||
<div class="update-info">
|
||||
<span id="last-update">正在加载...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard">
|
||||
<!-- 运行时信息 -->
|
||||
<section class="card">
|
||||
<h2>运行时信息</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">运行时长</span>
|
||||
<span class="stat-value" id="uptime">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Goroutines</span>
|
||||
<span class="stat-value" id="goroutines">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">已分配内存</span>
|
||||
<span class="stat-value" id="mem-alloc">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">系统内存</span>
|
||||
<span class="stat-value" id="mem-sys">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总分配内存</span>
|
||||
<span class="stat-value" id="mem-total">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">GC 次数</span>
|
||||
<span class="stat-value" id="num-gc">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DNS 查询统计 -->
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>DNS 查询统计</h2>
|
||||
<div class="stats-controls">
|
||||
<span class="stats-duration" id="stats-duration">统计时长: -</span>
|
||||
<button id="reset-stats-btn" class="reset-btn" title="重置统计数据">🔄 重置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item highlight">
|
||||
<span class="stat-label">总查询数</span>
|
||||
<span class="stat-value" id="total-queries">-</span>
|
||||
</div>
|
||||
<div class="stat-item info">
|
||||
<span class="stat-label">DoH 请求</span>
|
||||
<span class="stat-value" id="doh-queries">-</span>
|
||||
</div>
|
||||
<div class="stat-item success">
|
||||
<span class="stat-label">缓存命中</span>
|
||||
<span class="stat-value" id="cache-hits">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">缓存未命中</span>
|
||||
<span class="stat-value" id="cache-misses">-</span>
|
||||
</div>
|
||||
<div class="stat-item warning">
|
||||
<span class="stat-label">失败查询</span>
|
||||
<span class="stat-value" id="failed-queries">-</span>
|
||||
</div>
|
||||
<div class="stat-item highlight">
|
||||
<span class="stat-label">缓存命中率</span>
|
||||
<span class="stat-value" id="hit-rate">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 上游服务器统计 -->
|
||||
<section class="card full-width">
|
||||
<h2>上游服务器统计</h2>
|
||||
<div class="table-container">
|
||||
<table id="upstream-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务器地址</th>
|
||||
<th>总查询数</th>
|
||||
<th>错误数</th>
|
||||
<th>错误率</th>
|
||||
<th>最后使用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="upstream-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="no-data">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top 客户端 IP -->
|
||||
<section class="card">
|
||||
<h2>Top 客户端 IP</h2>
|
||||
<div class="table-container">
|
||||
<table id="top-clients-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>IP 地址</th>
|
||||
<th>查询次数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-clients-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="no-data">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top 查询域名 -->
|
||||
<section class="card">
|
||||
<h2>Top 查询域名</h2>
|
||||
<div class="table-container">
|
||||
<table id="top-domains-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>域名</th>
|
||||
<th>查询次数</th>
|
||||
<th>Top 客户端</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-domains-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="no-data">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="footer-content">
|
||||
<span>GoDNS - 智能 DNS 服务器</span>
|
||||
<div class="version-info">
|
||||
<span id="version-display">v0.0.0</span>
|
||||
<button id="check-update-btn" class="update-btn" title="检查更新">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=1.2.0"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
358
internal/web/static/style.css
Normal file
358
internal/web/static/style.css
Normal file
@@ -0,0 +1,358 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #1e293b;
|
||||
padding: 24px 32px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.update-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#last-update {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 600px), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e293b;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.card.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.stats-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-duration {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.reset-btn:hover:not(:disabled) {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.reset-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #0f172a;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.stat-item.highlight {
|
||||
background: #1e293b;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
background: #1e293b;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.stat-item.info {
|
||||
background: #1e293b;
|
||||
border-color: #06b6d4;
|
||||
}
|
||||
|
||||
.stat-item.warning {
|
||||
background: #1e293b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid #334155;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error-high {
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rank-cell {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
#top-clients-table th:nth-child(1),
|
||||
#top-clients-table td:nth-child(1),
|
||||
#top-domains-table th:nth-child(1),
|
||||
#top-domains-table td:nth-child(1) {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
.rank-1 .rank-cell {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.rank-2 {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.rank-2 .rank-cell {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.rank-3 {
|
||||
background: rgba(251, 146, 60, 0.1);
|
||||
}
|
||||
|
||||
.rank-3 .rank-cell {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.domain-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #1e293b;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
#version-display {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.update-btn:hover:not(:disabled) {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.update-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.8rem;
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#upstream-table th:nth-child(5),
|
||||
#upstream-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user