update once

This commit is contained in:
XOF
2026-01-06 02:25:24 +08:00
commit 7bf4f27be3
25 changed files with 4587 additions and 0 deletions

205
internal/web/handler.go Normal file
View 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
View 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();
});

View 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>

View 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;
}
}