2851 lines
110 KiB
JavaScript
2851 lines
110 KiB
JavaScript
// dashboard.js - 仪表盘功能实现
|
||
|
||
// 全局变量
|
||
let ratioChart = null;
|
||
let dnsRequestsChart = null;
|
||
let detailedDnsRequestsChart = null; // 详细DNS请求趋势图表(浮窗)
|
||
let queryTypeChart = null; // 解析类型统计饼图
|
||
let intervalId = null;
|
||
let wsConnection = null;
|
||
let wsReconnectTimer = null;
|
||
// 存储统计卡片图表实例
|
||
let statCardCharts = {};
|
||
// 存储统计卡片历史数据
|
||
let statCardHistoryData = {};
|
||
|
||
// 引入颜色配置文件
|
||
const COLOR_CONFIG = window.COLOR_CONFIG || {};
|
||
|
||
// 初始化仪表盘
|
||
async function initDashboard() {
|
||
try {
|
||
console.log('页面打开时强制刷新数据...');
|
||
|
||
// 优先加载初始数据,确保页面显示最新信息
|
||
await loadDashboardData();
|
||
|
||
// 初始化图表
|
||
initCharts();
|
||
|
||
|
||
|
||
// 初始化时间范围切换
|
||
initTimeRangeToggle();
|
||
|
||
// 建立WebSocket连接
|
||
connectWebSocket();
|
||
|
||
// 在页面卸载时清理资源
|
||
window.addEventListener('beforeunload', cleanupResources);
|
||
} catch (error) {
|
||
console.error('初始化仪表盘失败:', error);
|
||
showNotification('初始化失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// 建立WebSocket连接
|
||
function connectWebSocket() {
|
||
try {
|
||
// 构建WebSocket URL
|
||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats`;
|
||
|
||
console.log('正在连接WebSocket:', wsUrl);
|
||
|
||
// 创建WebSocket连接
|
||
wsConnection = new WebSocket(wsUrl);
|
||
|
||
// 连接打开事件
|
||
wsConnection.onopen = function() {
|
||
console.log('WebSocket连接已建立');
|
||
showNotification('数据更新成功', 'success');
|
||
|
||
// 清除重连计时器
|
||
if (wsReconnectTimer) {
|
||
clearTimeout(wsReconnectTimer);
|
||
wsReconnectTimer = null;
|
||
}
|
||
};
|
||
|
||
// 接收消息事件
|
||
wsConnection.onmessage = function(event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.type === 'initial_data' || data.type === 'stats_update') {
|
||
console.log('收到实时数据更新');
|
||
processRealTimeData(data.data);
|
||
}
|
||
} catch (error) {
|
||
console.error('处理WebSocket消息失败:', error);
|
||
}
|
||
};
|
||
|
||
// 连接关闭事件
|
||
wsConnection.onclose = function(event) {
|
||
console.warn('WebSocket连接已关闭,代码:', event.code);
|
||
wsConnection = null;
|
||
|
||
// 设置重连
|
||
setupReconnect();
|
||
};
|
||
|
||
// 连接错误事件
|
||
wsConnection.onerror = function(error) {
|
||
console.error('WebSocket连接错误:', error);
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error('创建WebSocket连接失败:', error);
|
||
// 如果WebSocket连接失败,回退到定时刷新
|
||
fallbackToIntervalRefresh();
|
||
}
|
||
}
|
||
|
||
// 设置重连逻辑
|
||
function setupReconnect() {
|
||
if (wsReconnectTimer) {
|
||
return; // 已经有重连计时器在运行
|
||
}
|
||
|
||
const reconnectDelay = 5000; // 5秒后重连
|
||
console.log(`将在${reconnectDelay}ms后尝试重新连接WebSocket`);
|
||
|
||
wsReconnectTimer = setTimeout(() => {
|
||
connectWebSocket();
|
||
}, reconnectDelay);
|
||
}
|
||
|
||
// 处理实时数据更新
|
||
function processRealTimeData(stats) {
|
||
try {
|
||
// 更新统计卡片 - 这会更新所有统计卡片,包括CPU使用率卡片
|
||
updateStatsCards(stats);
|
||
|
||
// 获取查询类型统计数据
|
||
let queryTypeStats = null;
|
||
if (stats.dns && stats.dns.QueryTypes) {
|
||
queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({
|
||
type,
|
||
count
|
||
}));
|
||
}
|
||
|
||
// 更新图表数据
|
||
updateCharts(stats, queryTypeStats);
|
||
|
||
|
||
|
||
// 尝试从stats中获取总查询数等信息
|
||
if (stats.dns) {
|
||
totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0);
|
||
blockedQueries = stats.dns.Blocked;
|
||
errorQueries = stats.dns.Errors || 0;
|
||
allowedQueries = stats.dns.Allowed;
|
||
} else {
|
||
totalQueries = stats.totalQueries || 0;
|
||
blockedQueries = stats.blockedQueries || 0;
|
||
errorQueries = stats.errorQueries || 0;
|
||
allowedQueries = stats.allowedQueries || 0;
|
||
}
|
||
|
||
// 更新新卡片数据
|
||
if (document.getElementById('avg-response-time')) {
|
||
const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---';
|
||
|
||
// 计算响应时间趋势
|
||
let responsePercent = '---';
|
||
let trendClass = 'text-gray-400';
|
||
let trendIcon = '---';
|
||
|
||
if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) {
|
||
// 存储当前值用于下次计算趋势
|
||
const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime;
|
||
window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime;
|
||
|
||
// 计算变化百分比
|
||
if (prevResponseTime > 0) {
|
||
const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100;
|
||
responsePercent = Math.abs(changePercent).toFixed(1) + '%';
|
||
|
||
// 设置趋势图标和颜色
|
||
if (changePercent > 0) {
|
||
trendIcon = '↓';
|
||
trendClass = 'text-danger';
|
||
} else if (changePercent < 0) {
|
||
trendIcon = '↑';
|
||
trendClass = 'text-success';
|
||
} else {
|
||
trendIcon = '•';
|
||
trendClass = 'text-gray-500';
|
||
}
|
||
}
|
||
}
|
||
|
||
document.getElementById('avg-response-time').textContent = responseTime;
|
||
const responseTimePercentElem = document.getElementById('response-time-percent');
|
||
if (responseTimePercentElem) {
|
||
responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent;
|
||
responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`;
|
||
}
|
||
}
|
||
|
||
if (document.getElementById('top-query-type')) {
|
||
const queryType = stats.topQueryType || '---';
|
||
|
||
const queryPercentElem = document.getElementById('query-type-percentage');
|
||
if (queryPercentElem) {
|
||
queryPercentElem.textContent = '• ---';
|
||
queryPercentElem.className = 'text-sm flex items-center text-gray-500';
|
||
}
|
||
|
||
document.getElementById('top-query-type').textContent = queryType;
|
||
}
|
||
|
||
if (document.getElementById('active-ips')) {
|
||
const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---';
|
||
|
||
// 计算活跃IP趋势
|
||
let ipsPercent = '---';
|
||
let trendClass = 'text-gray-400';
|
||
let trendIcon = '---';
|
||
|
||
if (stats.activeIPs !== undefined) {
|
||
const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs;
|
||
window.dashboardHistoryData.prevActiveIPs = stats.activeIPs;
|
||
|
||
if (prevActiveIPs > 0) {
|
||
const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100;
|
||
ipsPercent = Math.abs(changePercent).toFixed(1) + '%';
|
||
|
||
if (changePercent > 0) {
|
||
trendIcon = '↑';
|
||
trendClass = 'text-primary';
|
||
} else if (changePercent < 0) {
|
||
trendIcon = '↓';
|
||
trendClass = 'text-secondary';
|
||
} else {
|
||
trendIcon = '•';
|
||
trendClass = 'text-gray-500';
|
||
}
|
||
}
|
||
}
|
||
|
||
document.getElementById('active-ips').textContent = activeIPs;
|
||
const activeIpsPercentElem = document.getElementById('active-ips-percentage');
|
||
if (activeIpsPercentElem) {
|
||
activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent;
|
||
activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`;
|
||
}
|
||
}
|
||
|
||
// 实时更新TOP客户端和TOP域名数据
|
||
updateTopData();
|
||
|
||
} catch (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() {
|
||
console.warn('回退到定时刷新模式');
|
||
showNotification('实时更新连接失败,已切换到定时刷新模式', 'warning');
|
||
|
||
// 如果已经有定时器,先清除
|
||
if (intervalId) {
|
||
clearInterval(intervalId);
|
||
}
|
||
|
||
// 设置新的定时器
|
||
intervalId = setInterval(async () => {
|
||
try {
|
||
await loadDashboardData();
|
||
} catch (error) {
|
||
console.error('定时刷新失败:', error);
|
||
}
|
||
}, 5000); // 每5秒更新一次
|
||
}
|
||
|
||
// 清理资源
|
||
function cleanupResources() {
|
||
// 清除WebSocket连接
|
||
if (wsConnection) {
|
||
wsConnection.close();
|
||
wsConnection = null;
|
||
}
|
||
|
||
// 清除重连计时器
|
||
if (wsReconnectTimer) {
|
||
clearTimeout(wsReconnectTimer);
|
||
wsReconnectTimer = null;
|
||
}
|
||
|
||
// 清除定时刷新
|
||
if (intervalId) {
|
||
clearInterval(intervalId);
|
||
intervalId = null;
|
||
}
|
||
}
|
||
|
||
// 加载仪表盘数据
|
||
async function loadDashboardData() {
|
||
console.log('开始加载仪表盘数据');
|
||
try {
|
||
// 获取基本统计数据
|
||
const stats = await api.getStats();
|
||
console.log('统计数据:', stats);
|
||
|
||
// 获取查询类型统计数据
|
||
let queryTypeStats = null;
|
||
try {
|
||
queryTypeStats = await api.getQueryTypeStats();
|
||
console.log('查询类型统计数据:', queryTypeStats);
|
||
} catch (error) {
|
||
console.warn('获取查询类型统计失败:', error);
|
||
// 如果API调用失败,尝试从stats中提取查询类型数据
|
||
if (stats && stats.dns && stats.dns.QueryTypes) {
|
||
queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({
|
||
type,
|
||
count
|
||
}));
|
||
console.log('从stats中提取的查询类型统计:', queryTypeStats);
|
||
}
|
||
}
|
||
|
||
// 尝试获取TOP被屏蔽域名,如果失败则提供模拟数据
|
||
let topBlockedDomains = [];
|
||
try {
|
||
topBlockedDomains = await api.getTopBlockedDomains();
|
||
console.log('TOP被屏蔽域名:', topBlockedDomains);
|
||
|
||
// 确保返回的数据是数组
|
||
if (!Array.isArray(topBlockedDomains)) {
|
||
console.warn('TOP被屏蔽域名不是预期的数组格式,使用模拟数据');
|
||
topBlockedDomains = [];
|
||
}
|
||
} catch (error) {
|
||
console.warn('获取TOP被屏蔽域名失败:', error);
|
||
// 提供模拟数据
|
||
topBlockedDomains = [
|
||
{ domain: 'example-blocked.com', count: 15, lastSeen: new Date().toISOString() },
|
||
{ domain: 'ads.example.org', count: 12, lastSeen: new Date().toISOString() },
|
||
{ domain: 'tracking.example.net', count: 8, lastSeen: new Date().toISOString() }
|
||
];
|
||
}
|
||
|
||
// 尝试获取最近屏蔽域名,如果失败则提供模拟数据
|
||
let recentBlockedDomains = [];
|
||
try {
|
||
recentBlockedDomains = await api.getRecentBlockedDomains();
|
||
console.log('最近屏蔽域名:', recentBlockedDomains);
|
||
|
||
// 确保返回的数据是数组
|
||
if (!Array.isArray(recentBlockedDomains)) {
|
||
console.warn('最近屏蔽域名不是预期的数组格式,使用模拟数据');
|
||
recentBlockedDomains = [];
|
||
}
|
||
} catch (error) {
|
||
console.warn('获取最近屏蔽域名失败:', error);
|
||
// 提供模拟数据
|
||
recentBlockedDomains = [
|
||
{ domain: '---.---.---', ip: '---.---.---.---', 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);
|
||
|
||
// 更新图表数据,传入查询类型统计
|
||
updateCharts(stats, queryTypeStats);
|
||
|
||
// 更新表格数据
|
||
updateTopBlockedTable(topBlockedDomains);
|
||
updateRecentBlockedTable(recentBlockedDomains);
|
||
updateTopClientsTable(topClients);
|
||
updateTopDomainsTable(topDomains);
|
||
|
||
|
||
|
||
// 尝试从stats中获取总查询数等信息
|
||
if (stats.dns) {
|
||
totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0);
|
||
blockedQueries = stats.dns.Blocked;
|
||
errorQueries = stats.dns.Errors || 0;
|
||
allowedQueries = stats.dns.Allowed;
|
||
} else {
|
||
totalQueries = stats.totalQueries || 0;
|
||
blockedQueries = stats.blockedQueries || 0;
|
||
errorQueries = stats.errorQueries || 0;
|
||
allowedQueries = stats.allowedQueries || 0;
|
||
}
|
||
|
||
// 全局历史数据对象,用于存储趋势计算所需的上一次值
|
||
window.dashboardHistoryData = window.dashboardHistoryData || {};
|
||
|
||
// 更新新卡片数据 - 使用API返回的真实数据
|
||
if (document.getElementById('avg-response-time')) {
|
||
// 保留两位小数并添加单位
|
||
const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---';
|
||
|
||
// 计算响应时间趋势
|
||
let responsePercent = '---';
|
||
let trendClass = 'text-gray-400';
|
||
let trendIcon = '---';
|
||
|
||
if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) {
|
||
// 存储当前值用于下次计算趋势
|
||
const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime;
|
||
window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime;
|
||
|
||
// 计算变化百分比
|
||
if (prevResponseTime > 0) {
|
||
const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100;
|
||
responsePercent = Math.abs(changePercent).toFixed(1) + '%';
|
||
|
||
// 设置趋势图标和颜色(响应时间增加是负面的,减少是正面的)
|
||
if (changePercent > 0) {
|
||
trendIcon = '↓';
|
||
trendClass = 'text-danger';
|
||
} else if (changePercent < 0) {
|
||
trendIcon = '↑';
|
||
trendClass = 'text-success';
|
||
} else {
|
||
trendIcon = '•';
|
||
trendClass = 'text-gray-500';
|
||
}
|
||
}
|
||
}
|
||
|
||
document.getElementById('avg-response-time').textContent = responseTime;
|
||
const responseTimePercentElem = document.getElementById('response-time-percent');
|
||
if (responseTimePercentElem) {
|
||
responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent;
|
||
responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`;
|
||
}
|
||
}
|
||
|
||
if (document.getElementById('top-query-type')) {
|
||
// 直接使用API返回的查询类型
|
||
const queryType = stats.topQueryType || '---';
|
||
|
||
// 设置默认趋势显示
|
||
const queryPercentElem = document.getElementById('query-type-percentage');
|
||
if (queryPercentElem) {
|
||
queryPercentElem.textContent = '• ---';
|
||
queryPercentElem.className = 'text-sm flex items-center text-gray-500';
|
||
}
|
||
|
||
document.getElementById('top-query-type').textContent = queryType;
|
||
}
|
||
|
||
if (document.getElementById('active-ips')) {
|
||
// 直接使用API返回的活跃IP数
|
||
const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---';
|
||
|
||
// 计算活跃IP趋势
|
||
let ipsPercent = '---';
|
||
let trendClass = 'text-gray-400';
|
||
let trendIcon = '---';
|
||
|
||
if (stats.activeIPs !== undefined && stats.activeIPs !== null) {
|
||
// 存储当前值用于下次计算趋势
|
||
const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs;
|
||
window.dashboardHistoryData.prevActiveIPs = stats.activeIPs;
|
||
|
||
// 计算变化百分比
|
||
if (prevActiveIPs > 0) {
|
||
const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100;
|
||
ipsPercent = Math.abs(changePercent).toFixed(1) + '%';
|
||
|
||
// 设置趋势图标和颜色
|
||
if (changePercent > 0) {
|
||
trendIcon = '↑';
|
||
trendClass = 'text-success';
|
||
} else if (changePercent < 0) {
|
||
trendIcon = '↓';
|
||
trendClass = 'text-danger';
|
||
} else {
|
||
trendIcon = '•';
|
||
trendClass = 'text-gray-500';
|
||
}
|
||
}
|
||
}
|
||
|
||
document.getElementById('active-ips').textContent = activeIPs;
|
||
const activeIpsPercentElem = document.getElementById('active-ips-percent');
|
||
if (activeIpsPercentElem) {
|
||
activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent;
|
||
activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// 更新表格
|
||
updateTopBlockedTable(topBlockedDomains);
|
||
updateRecentBlockedTable(recentBlockedDomains);
|
||
updateTopClientsTable(topClients);
|
||
updateTopDomainsTable(topDomains);
|
||
|
||
// 更新图表
|
||
updateCharts({totalQueries, blockedQueries, allowedQueries, errorQueries});
|
||
|
||
|
||
|
||
// 确保响应时间图表使用API实时数据
|
||
if (document.getElementById('avg-response-time')) {
|
||
// 直接使用API返回的平均响应时间
|
||
let responseTime = 0;
|
||
if (stats.dns && stats.dns.AvgResponseTime) {
|
||
responseTime = stats.dns.AvgResponseTime;
|
||
} else if (stats.avgResponseTime !== undefined) {
|
||
responseTime = stats.avgResponseTime;
|
||
} else if (stats.responseTime) {
|
||
responseTime = stats.responseTime;
|
||
}
|
||
|
||
if (responseTime > 0 && statCardCharts['response-time-chart']) {
|
||
// 限制小数位数为两位并更新图表
|
||
updateChartData('response-time-chart', parseFloat(responseTime).toFixed(2));
|
||
}
|
||
}
|
||
|
||
// 更新运行状态
|
||
updateUptime();
|
||
} catch (error) {
|
||
console.error('加载仪表盘数据失败:', error);
|
||
// 静默失败,不显示通知以免打扰用户
|
||
}
|
||
}
|
||
|
||
// 更新统计卡片
|
||
function updateStatsCards(stats) {
|
||
console.log('更新统计卡片,收到数据:', stats);
|
||
|
||
// 适配不同的数据结构
|
||
let totalQueries = 0, blockedQueries = 0, allowedQueries = 0, errorQueries = 0;
|
||
let topQueryType = 'A', queryTypePercentage = 0;
|
||
let activeIPs = 0, activeIPsPercentage = 0;
|
||
|
||
// 检查数据结构,兼容可能的不同格式
|
||
if (stats) {
|
||
// 优先使用顶层字段
|
||
totalQueries = stats.totalQueries || 0;
|
||
blockedQueries = stats.blockedQueries || 0;
|
||
allowedQueries = stats.allowedQueries || 0;
|
||
errorQueries = stats.errorQueries || 0;
|
||
topQueryType = stats.topQueryType || 'A';
|
||
queryTypePercentage = stats.queryTypePercentage || 0;
|
||
activeIPs = stats.activeIPs || 0;
|
||
activeIPsPercentage = stats.activeIPsPercentage || 0;
|
||
|
||
|
||
// 如果dns对象存在,优先使用其中的数据
|
||
if (stats.dns) {
|
||
totalQueries = stats.dns.Queries || totalQueries;
|
||
blockedQueries = stats.dns.Blocked || blockedQueries;
|
||
allowedQueries = stats.dns.Allowed || allowedQueries;
|
||
errorQueries = stats.dns.Errors || errorQueries;
|
||
|
||
// 计算最常用查询类型的百分比
|
||
if (stats.dns.QueryTypes && stats.dns.Queries > 0) {
|
||
const topTypeCount = stats.dns.QueryTypes[topQueryType] || 0;
|
||
queryTypePercentage = (topTypeCount / stats.dns.Queries) * 100;
|
||
}
|
||
|
||
// 计算活跃IP百分比(基于已有的活跃IP数)
|
||
if (activeIPs > 0 && stats.dns.SourceIPs) {
|
||
activeIPsPercentage = activeIPs / Object.keys(stats.dns.SourceIPs).length * 100;
|
||
}
|
||
}
|
||
} else if (Array.isArray(stats) && stats.length > 0) {
|
||
// 可能的数据结构3: 数组形式
|
||
totalQueries = stats[0].total || 0;
|
||
blockedQueries = stats[0].blocked || 0;
|
||
allowedQueries = stats[0].allowed || 0;
|
||
errorQueries = stats[0].error || 0;
|
||
topQueryType = stats[0].topQueryType || 'A';
|
||
queryTypePercentage = stats[0].queryTypePercentage || 0;
|
||
activeIPs = stats[0].activeIPs || 0;
|
||
activeIPsPercentage = stats[0].activeIPsPercentage || 0;
|
||
|
||
}
|
||
|
||
// 为数字元素添加翻页滚动特效
|
||
function animateValue(elementId, newValue) {
|
||
const element = document.getElementById(elementId);
|
||
if (!element) return;
|
||
|
||
const oldValue = parseInt(element.textContent.replace(/,/g, '')) || 0;
|
||
const formattedNewValue = formatNumber(newValue);
|
||
|
||
// 如果值没有变化,不执行动画
|
||
if (oldValue === newValue && element.textContent === formattedNewValue) {
|
||
return;
|
||
}
|
||
|
||
// 先移除可能存在的光晕效果类
|
||
element.classList.remove('number-glow', 'number-glow-blue', 'number-glow-red', 'number-glow-green', 'number-glow-yellow');
|
||
|
||
// 保存原始样式和内容
|
||
const originalStyle = element.getAttribute('style') || '';
|
||
const originalContent = element.innerHTML;
|
||
|
||
// 配置翻页容器样式
|
||
const containerStyle = `
|
||
position: relative;
|
||
display: inline-block;
|
||
overflow: hidden;
|
||
height: ${element.offsetHeight}px;
|
||
width: ${element.offsetWidth}px;
|
||
`;
|
||
|
||
// 创建翻页容器
|
||
const flipContainer = document.createElement('div');
|
||
flipContainer.style.cssText = containerStyle;
|
||
flipContainer.className = 'number-flip-container';
|
||
|
||
// 创建旧值元素
|
||
const oldValueElement = document.createElement('div');
|
||
oldValueElement.textContent = originalContent;
|
||
oldValueElement.style.cssText = `
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: transform 400ms ease-in-out;
|
||
transform-origin: center;
|
||
`;
|
||
|
||
// 创建新值元素
|
||
const newValueElement = document.createElement('div');
|
||
newValueElement.textContent = formattedNewValue;
|
||
newValueElement.style.cssText = `
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: transform 400ms ease-in-out;
|
||
transform-origin: center;
|
||
transform: translateY(100%);
|
||
`;
|
||
|
||
// 复制原始元素的样式到新元素
|
||
const computedStyle = getComputedStyle(element);
|
||
[oldValueElement, newValueElement].forEach(el => {
|
||
el.style.fontSize = computedStyle.fontSize;
|
||
el.style.fontWeight = computedStyle.fontWeight;
|
||
el.style.color = computedStyle.color;
|
||
el.style.fontFamily = computedStyle.fontFamily;
|
||
el.style.textAlign = computedStyle.textAlign;
|
||
el.style.lineHeight = computedStyle.lineHeight;
|
||
});
|
||
|
||
// 替换原始元素的内容
|
||
element.textContent = '';
|
||
flipContainer.appendChild(oldValueElement);
|
||
flipContainer.appendChild(newValueElement);
|
||
element.appendChild(flipContainer);
|
||
|
||
// 启动翻页动画
|
||
setTimeout(() => {
|
||
oldValueElement.style.transform = 'translateY(-100%)';
|
||
newValueElement.style.transform = 'translateY(0)';
|
||
}, 50);
|
||
|
||
// 动画结束后,恢复原始元素
|
||
setTimeout(() => {
|
||
// 清理并设置最终值
|
||
element.innerHTML = formattedNewValue;
|
||
if (originalStyle) {
|
||
element.setAttribute('style', originalStyle);
|
||
} else {
|
||
element.removeAttribute('style');
|
||
}
|
||
|
||
// 添加当前卡片颜色的深色光晕效果
|
||
// 根据父级卡片类型确定光晕颜色
|
||
const card = element.closest('.stat-card, .bg-blue-50, .bg-red-50, .bg-green-50, .bg-yellow-50');
|
||
let glowColorClass = '';
|
||
|
||
// 使用更精准的卡片颜色检测
|
||
if (card) {
|
||
// 根据卡片类名确定深色光晕颜色
|
||
if (card.classList.contains('bg-blue-50') || card.id.includes('total') || card.id.includes('response')) {
|
||
// 蓝色卡片 - 深蓝色光晕
|
||
glowColorClass = 'number-glow-dark-blue';
|
||
} else if (card.classList.contains('bg-red-50') || card.id.includes('blocked')) {
|
||
// 红色卡片 - 深红色光晕
|
||
glowColorClass = 'number-glow-dark-red';
|
||
} else if (card.classList.contains('bg-green-50') || card.id.includes('allowed') || card.id.includes('active')) {
|
||
// 绿色卡片 - 深绿色光晕
|
||
glowColorClass = 'number-glow-dark-green';
|
||
} else if (card.classList.contains('bg-yellow-50') || card.id.includes('error') || card.id.includes('cpu')) {
|
||
// 黄色卡片 - 深黄色光晕
|
||
glowColorClass = 'number-glow-dark-yellow';
|
||
}
|
||
}
|
||
|
||
// 如果确定了光晕颜色类,则添加它
|
||
if (glowColorClass) {
|
||
element.classList.add(glowColorClass);
|
||
|
||
// 2秒后移除光晕效果
|
||
setTimeout(() => {
|
||
element.classList.remove('number-glow-dark-blue', 'number-glow-dark-red', 'number-glow-dark-green', 'number-glow-dark-yellow');
|
||
}, 2000);
|
||
}
|
||
}, 450);
|
||
}
|
||
|
||
// 更新百分比元素的函数
|
||
function updatePercentage(elementId, value) {
|
||
const element = document.getElementById(elementId);
|
||
if (!element) return;
|
||
|
||
element.style.opacity = '0';
|
||
element.style.transition = 'opacity 200ms ease-out';
|
||
|
||
setTimeout(() => {
|
||
element.textContent = value;
|
||
element.style.opacity = '1';
|
||
}, 200);
|
||
}
|
||
|
||
// 平滑更新数量显示
|
||
animateValue('total-queries', totalQueries);
|
||
animateValue('blocked-queries', blockedQueries);
|
||
animateValue('allowed-queries', allowedQueries);
|
||
animateValue('error-queries', errorQueries);
|
||
animateValue('active-ips', activeIPs);
|
||
|
||
// 平滑更新文本和百分比
|
||
updatePercentage('top-query-type', topQueryType);
|
||
updatePercentage('query-type-percentage', `${Math.round(queryTypePercentage)}%`);
|
||
updatePercentage('active-ips-percent', `${Math.round(activeIPsPercentage)}%`);
|
||
|
||
// 计算并平滑更新百分比
|
||
if (totalQueries > 0) {
|
||
updatePercentage('blocked-percent', `${Math.round((blockedQueries / totalQueries) * 100)}%`);
|
||
updatePercentage('allowed-percent', `${Math.round((allowedQueries / totalQueries) * 100)}%`);
|
||
updatePercentage('error-percent', `${Math.round((errorQueries / totalQueries) * 100)}%`);
|
||
updatePercentage('queries-percent', '100%');
|
||
} else {
|
||
updatePercentage('queries-percent', '---');
|
||
updatePercentage('blocked-percent', '---');
|
||
updatePercentage('allowed-percent', '---');
|
||
updatePercentage('error-percent', '---');
|
||
}
|
||
|
||
|
||
}
|
||
|
||
// 更新Top屏蔽域名表格
|
||
function updateTopBlockedTable(domains) {
|
||
console.log('更新Top屏蔽域名表格,收到数据:', domains);
|
||
const tableBody = document.getElementById('top-blocked-table');
|
||
|
||
let tableData = [];
|
||
|
||
// 适配不同的数据结构
|
||
if (Array.isArray(domains)) {
|
||
tableData = domains.map(item => ({
|
||
name: item.name || item.domain || item[0] || '未知',
|
||
count: item.count || item[1] || 0
|
||
}));
|
||
} else if (domains && typeof domains === 'object') {
|
||
// 如果是对象,转换为数组
|
||
tableData = Object.entries(domains).map(([domain, count]) => ({
|
||
name: domain,
|
||
count: count || 0
|
||
}));
|
||
}
|
||
|
||
// 如果没有有效数据,提供示例数据
|
||
if (tableData.length === 0) {
|
||
tableData = [
|
||
{ name: '---', count: '---' },
|
||
{ name: '---', count: '---' },
|
||
{ name: '---', count: '---' },
|
||
{ name: '---', count: '---' },
|
||
{ name: '---', 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.name}</td>
|
||
<td class="py-3 px-4 text-sm text-right">${formatNumber(domain.count)}</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
tableBody.innerHTML = html;
|
||
}
|
||
|
||
// 更新最近屏蔽域名表格
|
||
function updateRecentBlockedTable(domains) {
|
||
console.log('更新最近屏蔽域名表格,收到数据:', domains);
|
||
const tableBody = document.getElementById('recent-blocked-table');
|
||
|
||
let tableData = [];
|
||
|
||
// 适配不同的数据结构
|
||
if (Array.isArray(domains)) {
|
||
tableData = domains.map(item => ({
|
||
name: item.name || item.domain || item[0] || '未知',
|
||
timestamp: item.timestamp || item.time || Date.now()
|
||
}));
|
||
}
|
||
|
||
// 如果没有有效数据,提供示例数据
|
||
if (tableData.length === 0) {
|
||
const now = Date.now();
|
||
tableData = [
|
||
{ name: '---', timestamp: now - 5 * 60 * 1000 },
|
||
{ name: '---', timestamp: now - 15 * 60 * 1000 },
|
||
{ name: '---', timestamp: now - 30 * 60 * 1000 },
|
||
{ name: '---', timestamp: now - 45 * 60 * 1000 },
|
||
{ name: '---', timestamp: now - 60 * 60 * 1000 }
|
||
];
|
||
console.log('使用示例数据填充最近屏蔽域名表格');
|
||
}
|
||
|
||
let html = '';
|
||
for (const domain of tableData) {
|
||
const time = formatTime(domain.timestamp);
|
||
html += `
|
||
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||
<td class="py-3 px-4 text-sm">${domain.name}</td>
|
||
<td class="py-3 px-4 text-sm text-right text-gray-500">${time}</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
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 isMixedView = true; // 是否为混合视图 - 默认显示混合视图
|
||
let lastSelectedIndex = 0; // 最后选中的按钮索引
|
||
|
||
// 详细图表专用变量
|
||
let detailedCurrentTimeRange = '24h'; // 详细图表当前时间范围
|
||
let detailedIsMixedView = false; // 详细图表是否为混合视图
|
||
|
||
// 初始化时间范围切换
|
||
function initTimeRangeToggle() {
|
||
console.log('初始化时间范围切换');
|
||
// 查找所有可能的时间范围按钮类名
|
||
const timeRangeButtons = document.querySelectorAll('.time-range-btn, .time-range-button, .timerange-btn, button[data-range]');
|
||
console.log('找到时间范围按钮数量:', timeRangeButtons.length);
|
||
|
||
if (timeRangeButtons.length === 0) {
|
||
console.warn('未找到时间范围按钮,请检查HTML中的类名');
|
||
return;
|
||
}
|
||
|
||
// 定义三个按钮的不同样式配置,增加activeHover属性
|
||
const buttonStyles = [
|
||
{ // 24小时按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-blue-100'],
|
||
active: ['bg-blue-500', 'text-white'],
|
||
activeHover: ['hover:bg-blue-400'] // 选中时的浅色悬停
|
||
},
|
||
{ // 7天按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-green-100'],
|
||
active: ['bg-green-500', 'text-white'],
|
||
activeHover: ['hover:bg-green-400'] // 选中时的浅色悬停
|
||
},
|
||
{ // 30天按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-purple-100'],
|
||
active: ['bg-purple-500', 'text-white'],
|
||
activeHover: ['hover:bg-purple-400'] // 选中时的浅色悬停
|
||
},
|
||
{ // 混合视图按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-gray-200'],
|
||
active: ['bg-gray-500', 'text-white'],
|
||
activeHover: ['hover:bg-gray-400'] // 选中时的浅色悬停
|
||
}
|
||
];
|
||
|
||
// 为所有按钮设置初始样式和事件
|
||
timeRangeButtons.forEach((button, index) => {
|
||
// 使用相应的样式配置
|
||
const styleConfig = buttonStyles[index % buttonStyles.length];
|
||
|
||
// 移除所有按钮的初始样式
|
||
button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700',
|
||
'bg-green-500', 'bg-purple-500', 'bg-gray-100');
|
||
|
||
// 设置非选中状态样式
|
||
button.classList.add('transition-colors', 'duration-200');
|
||
button.classList.add(...styleConfig.normal);
|
||
button.classList.add(...styleConfig.hover);
|
||
|
||
// 移除鼠标悬停提示
|
||
|
||
console.log('为按钮设置初始样式:', button.textContent.trim(), '索引:', index, '类名:', Array.from(button.classList).join(', '));
|
||
|
||
button.addEventListener('click', function(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
console.log('点击按钮:', button.textContent.trim(), '索引:', index);
|
||
|
||
// 检查是否是再次点击已选中的按钮
|
||
const isActive = button.classList.contains('active');
|
||
|
||
// 重置所有按钮为非选中状态
|
||
timeRangeButtons.forEach((btn, btnIndex) => {
|
||
const btnStyle = buttonStyles[btnIndex % buttonStyles.length];
|
||
|
||
// 移除所有可能的激活状态类
|
||
btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500');
|
||
btn.classList.remove(...btnStyle.active);
|
||
btn.classList.remove(...btnStyle.activeHover);
|
||
|
||
// 添加非选中状态类
|
||
btn.classList.add(...btnStyle.normal);
|
||
btn.classList.add(...btnStyle.hover);
|
||
});
|
||
|
||
if (isActive && index < 3) { // 再次点击已选中的时间范围按钮
|
||
// 切换到混合视图
|
||
isMixedView = true;
|
||
currentTimeRange = 'mixed';
|
||
console.log('切换到混合视图');
|
||
|
||
// 设置当前按钮为特殊混合视图状态(保持原按钮选中但添加混合视图标记)
|
||
button.classList.remove(...styleConfig.normal);
|
||
button.classList.remove(...styleConfig.hover);
|
||
button.classList.add('active', 'mixed-view-active');
|
||
button.classList.add(...styleConfig.active);
|
||
button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停
|
||
} else {
|
||
// 普通选中模式
|
||
isMixedView = false;
|
||
lastSelectedIndex = index;
|
||
|
||
// 设置当前按钮为激活状态
|
||
button.classList.remove(...styleConfig.normal);
|
||
button.classList.remove(...styleConfig.hover);
|
||
button.classList.add('active');
|
||
button.classList.add(...styleConfig.active);
|
||
button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停
|
||
|
||
// 获取并更新当前时间范围
|
||
let rangeValue;
|
||
if (button.dataset.range) {
|
||
rangeValue = button.dataset.range;
|
||
} else {
|
||
const btnText = button.textContent.trim();
|
||
if (btnText.includes('24')) {
|
||
rangeValue = '24h';
|
||
} else if (btnText.includes('7')) {
|
||
rangeValue = '7d';
|
||
} else if (btnText.includes('30')) {
|
||
rangeValue = '30d';
|
||
} else {
|
||
rangeValue = btnText.replace(/[^0-9a-zA-Z]/g, '');
|
||
}
|
||
}
|
||
currentTimeRange = rangeValue;
|
||
console.log('更新时间范围为:', currentTimeRange);
|
||
}
|
||
|
||
// 重新加载数据
|
||
loadDashboardData();
|
||
// 更新DNS请求图表
|
||
drawDNSRequestsChart();
|
||
});
|
||
|
||
// 移除自定义鼠标悬停提示效果
|
||
});
|
||
|
||
// 确保默认选中第一个按钮并显示混合内容
|
||
if (timeRangeButtons.length > 0) {
|
||
const firstButton = timeRangeButtons[0];
|
||
const firstStyle = buttonStyles[0];
|
||
|
||
// 先重置所有按钮
|
||
timeRangeButtons.forEach((btn, index) => {
|
||
const btnStyle = buttonStyles[index % buttonStyles.length];
|
||
btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500', 'mixed-view-active');
|
||
btn.classList.remove(...btnStyle.active);
|
||
btn.classList.remove(...btnStyle.activeHover);
|
||
btn.classList.add(...btnStyle.normal);
|
||
btn.classList.add(...btnStyle.hover);
|
||
});
|
||
|
||
// 然后设置第一个按钮为激活状态,并标记为混合视图
|
||
firstButton.classList.remove(...firstStyle.normal);
|
||
firstButton.classList.remove(...firstStyle.hover);
|
||
firstButton.classList.add('active', 'mixed-view-active');
|
||
firstButton.classList.add(...firstStyle.active);
|
||
firstButton.classList.add(...firstStyle.activeHover);
|
||
console.log('默认选中第一个按钮并显示混合内容:', firstButton.textContent.trim());
|
||
|
||
// 设置默认显示混合内容
|
||
isMixedView = true;
|
||
currentTimeRange = 'mixed';
|
||
}
|
||
}
|
||
|
||
// 注意:这个函数已被后面的实现覆盖,请使用后面的drawDetailedDNSRequestsChart函数
|
||
|
||
// 初始化图表
|
||
function initCharts() {
|
||
// 初始化比例图表
|
||
const ratioChartElement = document.getElementById('ratio-chart');
|
||
if (!ratioChartElement) {
|
||
console.error('未找到比例图表元素');
|
||
return;
|
||
}
|
||
const ratioCtx = ratioChartElement.getContext('2d');
|
||
ratioChart = new Chart(ratioCtx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: ['正常解析', '被屏蔽', '错误'],
|
||
datasets: [{
|
||
data: ['---', '---', '---'],
|
||
backgroundColor: ['#00B42A', '#F53F3F', '#FF7D00'],
|
||
borderWidth: 2, // 添加边框宽度,增强区块分隔
|
||
borderColor: '#fff', // 白色边框,使各个扇区更清晰
|
||
hoverOffset: 10, // 添加悬停偏移效果,增强交互体验
|
||
hoverBorderWidth: 3 // 悬停时增加边框宽度
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
// 添加全局动画配置,确保图表创建和更新时都平滑过渡
|
||
animation: {
|
||
duration: 500, // 延长动画时间,使过渡更平滑
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom',
|
||
labels: {
|
||
boxWidth: 12, // 减小图例框的宽度
|
||
font: {
|
||
size: 11 // 减小字体大小
|
||
},
|
||
padding: 10 // 减小内边距
|
||
}
|
||
},
|
||
tooltip: {
|
||
enabled: true,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
padding: 10,
|
||
titleFont: {
|
||
size: 12
|
||
},
|
||
bodyFont: {
|
||
size: 11
|
||
},
|
||
callbacks: {
|
||
label: function(context) {
|
||
const label = context.label || '';
|
||
const value = context.raw || 0;
|
||
const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0);
|
||
const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
|
||
return `${label}: ${value} (${percentage}%)`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
cutout: '65%', // 减小中心空白区域比例,增大扇形区域以更好显示线段指示
|
||
// 添加线段指示相关配置
|
||
elements: {
|
||
arc: {
|
||
// 确保圆弧绘制时有足够的精度
|
||
borderAlign: 'center',
|
||
tension: 0.1 // 添加轻微的张力,使圆弧更平滑
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 初始化解析类型统计饼图
|
||
const queryTypeChartElement = document.getElementById('query-type-chart');
|
||
if (queryTypeChartElement) {
|
||
const queryTypeCtx = queryTypeChartElement.getContext('2d');
|
||
// 预定义的颜色数组,用于解析类型
|
||
const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e'];
|
||
|
||
queryTypeChart = new Chart(queryTypeCtx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: ['暂无数据'],
|
||
datasets: [{
|
||
data: [1],
|
||
backgroundColor: [queryTypeColors[0]],
|
||
borderWidth: 2, // 添加边框宽度,增强区块分隔
|
||
borderColor: '#fff', // 白色边框,使各个扇区更清晰
|
||
hoverOffset: 10, // 添加悬停偏移效果,增强交互体验
|
||
hoverBorderWidth: 3 // 悬停时增加边框宽度
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
// 添加全局动画配置,确保图表创建和更新时都平滑过渡
|
||
animation: {
|
||
duration: 300,
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom',
|
||
labels: {
|
||
boxWidth: 12, // 减小图例框的宽度
|
||
font: {
|
||
size: 11 // 减小字体大小
|
||
},
|
||
padding: 10 // 减小内边距
|
||
}
|
||
},
|
||
tooltip: {
|
||
enabled: true,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
padding: 10,
|
||
titleFont: {
|
||
size: 12
|
||
},
|
||
bodyFont: {
|
||
size: 11
|
||
},
|
||
callbacks: {
|
||
label: function(context) {
|
||
const label = context.label || '';
|
||
const value = context.raw || 0;
|
||
const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0);
|
||
const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
|
||
return `${label}: ${value} (${percentage}%)`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
cutout: '65%', // 减小中心空白区域比例,增大扇形区域以更好显示线段指示
|
||
// 添加线段指示相关配置
|
||
elements: {
|
||
arc: {
|
||
// 确保圆弧绘制时有足够的精度
|
||
borderAlign: 'center',
|
||
tension: 0.1 // 添加轻微的张力,使圆弧更平滑
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
console.warn('未找到解析类型统计图表元素');
|
||
}
|
||
|
||
// 初始化DNS请求统计图表
|
||
drawDNSRequestsChart();
|
||
|
||
// 初始化展开按钮功能
|
||
initExpandButton();
|
||
}
|
||
|
||
// 初始化展开按钮事件
|
||
function initExpandButton() {
|
||
const expandBtn = document.getElementById('expand-chart-btn');
|
||
const chartModal = document.getElementById('chart-modal');
|
||
const closeModalBtn = document.getElementById('close-modal-btn'); // 修复ID匹配
|
||
|
||
// 添加调试日志
|
||
console.log('初始化展开按钮功能:', { expandBtn, chartModal, closeModalBtn });
|
||
|
||
if (expandBtn && chartModal && closeModalBtn) {
|
||
// 展开按钮点击事件
|
||
expandBtn.addEventListener('click', () => {
|
||
console.log('展开按钮被点击');
|
||
// 显示浮窗
|
||
chartModal.classList.remove('hidden');
|
||
|
||
// 初始化或更新详细图表
|
||
drawDetailedDNSRequestsChart();
|
||
|
||
// 初始化浮窗中的时间范围切换
|
||
initDetailedTimeRangeToggle();
|
||
|
||
// 延迟更新图表大小,确保容器大小已计算
|
||
setTimeout(() => {
|
||
if (detailedDnsRequestsChart) {
|
||
detailedDnsRequestsChart.resize();
|
||
}
|
||
}, 100);
|
||
});
|
||
|
||
// 关闭按钮点击事件
|
||
closeModalBtn.addEventListener('click', () => {
|
||
console.log('关闭按钮被点击');
|
||
chartModal.classList.add('hidden');
|
||
});
|
||
|
||
// 点击遮罩层关闭浮窗(使用chartModal作为遮罩层)
|
||
chartModal.addEventListener('click', (e) => {
|
||
// 检查点击目标是否是遮罩层本身(即最外层div)
|
||
if (e.target === chartModal) {
|
||
console.log('点击遮罩层关闭');
|
||
chartModal.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// ESC键关闭浮窗
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && !chartModal.classList.contains('hidden')) {
|
||
console.log('ESC键关闭浮窗');
|
||
chartModal.classList.add('hidden');
|
||
}
|
||
});
|
||
} else {
|
||
console.error('无法找到必要的DOM元素');
|
||
}
|
||
}
|
||
|
||
// 初始化详细图表的时间范围切换
|
||
function initDetailedTimeRangeToggle() {
|
||
// 只选择图表模态框内的时间范围按钮,避免与主视图冲突
|
||
const chartModal = document.getElementById('chart-modal');
|
||
const detailedTimeRangeButtons = chartModal ? chartModal.querySelectorAll('.time-range-btn') : [];
|
||
|
||
console.log('初始化详细图表时间范围切换,找到按钮数量:', detailedTimeRangeButtons.length);
|
||
|
||
// 初始化详细图表的默认状态,与主图表保持一致
|
||
detailedCurrentTimeRange = currentTimeRange;
|
||
detailedIsMixedView = isMixedView;
|
||
|
||
// 定义按钮样式配置,与主视图保持一致
|
||
const buttonStyles = [
|
||
{ // 24小时按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-blue-100'],
|
||
active: ['bg-blue-500', 'text-white'],
|
||
activeHover: ['hover:bg-blue-400']
|
||
},
|
||
{ // 7天按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-green-100'],
|
||
active: ['bg-green-500', 'text-white'],
|
||
activeHover: ['hover:bg-green-400']
|
||
},
|
||
{ // 30天按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-purple-100'],
|
||
active: ['bg-purple-500', 'text-white'],
|
||
activeHover: ['hover:bg-purple-400']
|
||
},
|
||
{ // 混合视图按钮
|
||
normal: ['bg-gray-100', 'text-gray-700'],
|
||
hover: ['hover:bg-gray-200'],
|
||
active: ['bg-gray-500', 'text-white'],
|
||
activeHover: ['hover:bg-gray-400']
|
||
}
|
||
];
|
||
|
||
// 设置初始按钮状态
|
||
detailedTimeRangeButtons.forEach((button, index) => {
|
||
const styleConfig = buttonStyles[index % buttonStyles.length];
|
||
|
||
// 移除所有初始样式
|
||
button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700',
|
||
'bg-green-500', 'bg-purple-500', 'bg-gray-100', 'mixed-view-active');
|
||
|
||
// 设置非选中状态样式
|
||
button.classList.add('transition-colors', 'duration-200');
|
||
button.classList.add(...styleConfig.normal);
|
||
button.classList.add(...styleConfig.hover);
|
||
|
||
// 如果是第一个按钮且当前是混合视图,设置为混合视图激活状态
|
||
if (index === 0 && detailedIsMixedView) {
|
||
button.classList.remove(...styleConfig.normal);
|
||
button.classList.remove(...styleConfig.hover);
|
||
button.classList.add('active', 'mixed-view-active');
|
||
button.classList.add(...styleConfig.active);
|
||
button.classList.add(...styleConfig.activeHover);
|
||
}
|
||
});
|
||
|
||
detailedTimeRangeButtons.forEach((button, index) => {
|
||
button.addEventListener('click', () => {
|
||
const styleConfig = buttonStyles[index % buttonStyles.length];
|
||
|
||
// 检查是否是再次点击已选中的按钮
|
||
const isActive = button.classList.contains('active');
|
||
|
||
// 重置所有按钮为非选中状态
|
||
detailedTimeRangeButtons.forEach((btn, btnIndex) => {
|
||
const btnStyle = buttonStyles[btnIndex % buttonStyles.length];
|
||
|
||
// 移除所有可能的激活状态类
|
||
btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500', 'mixed-view-active');
|
||
btn.classList.remove(...btnStyle.active);
|
||
btn.classList.remove(...btnStyle.activeHover);
|
||
|
||
// 添加非选中状态类
|
||
btn.classList.add(...btnStyle.normal);
|
||
btn.classList.add(...btnStyle.hover);
|
||
});
|
||
|
||
if (isActive && index < 3) { // 再次点击已选中的时间范围按钮
|
||
// 切换到混合视图
|
||
detailedIsMixedView = true;
|
||
detailedCurrentTimeRange = 'mixed';
|
||
console.log('详细图表切换到混合视图');
|
||
|
||
// 设置当前按钮为特殊混合视图状态
|
||
button.classList.remove(...styleConfig.normal);
|
||
button.classList.remove(...styleConfig.hover);
|
||
button.classList.add('active', 'mixed-view-active');
|
||
button.classList.add(...styleConfig.active);
|
||
button.classList.add(...styleConfig.activeHover);
|
||
} else {
|
||
// 普通选中模式
|
||
detailedIsMixedView = false;
|
||
|
||
// 设置当前按钮为激活状态
|
||
button.classList.remove(...styleConfig.normal);
|
||
button.classList.remove(...styleConfig.hover);
|
||
button.classList.add('active');
|
||
button.classList.add(...styleConfig.active);
|
||
button.classList.add(...styleConfig.activeHover);
|
||
|
||
// 获取并更新当前时间范围
|
||
let rangeValue;
|
||
if (button.dataset.range) {
|
||
rangeValue = button.dataset.range;
|
||
} else {
|
||
const btnText = button.textContent.trim();
|
||
if (btnText.includes('24')) {
|
||
rangeValue = '24h';
|
||
} else if (btnText.includes('7')) {
|
||
rangeValue = '7d';
|
||
} else if (btnText.includes('30')) {
|
||
rangeValue = '30d';
|
||
} else {
|
||
rangeValue = btnText.replace(/[^0-9a-zA-Z]/g, '');
|
||
}
|
||
}
|
||
detailedCurrentTimeRange = rangeValue;
|
||
console.log('详细图表更新时间范围为:', detailedCurrentTimeRange);
|
||
}
|
||
|
||
// 重新绘制详细图表
|
||
drawDetailedDNSRequestsChart();
|
||
});
|
||
});
|
||
}
|
||
|
||
// 绘制详细的DNS请求趋势图表
|
||
function drawDetailedDNSRequestsChart() {
|
||
console.log('绘制详细DNS请求趋势图表,时间范围:', detailedCurrentTimeRange, '混合视图:', detailedIsMixedView);
|
||
|
||
const ctx = document.getElementById('detailed-dns-requests-chart');
|
||
if (!ctx) {
|
||
console.error('未找到详细DNS请求图表元素');
|
||
return;
|
||
}
|
||
|
||
const chartContext = ctx.getContext('2d');
|
||
|
||
// 混合视图配置
|
||
const datasetsConfig = [
|
||
{ label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' },
|
||
{ label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' },
|
||
{ label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' }
|
||
];
|
||
|
||
// 检查是否为混合视图
|
||
if (detailedIsMixedView || detailedCurrentTimeRange === 'mixed') {
|
||
console.log('渲染混合视图详细图表');
|
||
|
||
// 显示图例
|
||
const showLegend = true;
|
||
|
||
// 获取所有时间范围的数据
|
||
Promise.all(datasetsConfig.map(config =>
|
||
config.api().catch(error => {
|
||
console.error(`获取${config.label}数据失败:`, error);
|
||
// 返回空数据
|
||
const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30);
|
||
return {
|
||
labels: Array(count).fill(''),
|
||
data: Array(count).fill(0)
|
||
};
|
||
})
|
||
)).then(results => {
|
||
// 创建数据集
|
||
const datasets = results.map((data, index) => ({
|
||
label: datasetsConfig[index].label,
|
||
data: data.data,
|
||
borderColor: datasetsConfig[index].color,
|
||
backgroundColor: datasetsConfig[index].fillColor,
|
||
tension: 0.4,
|
||
fill: false,
|
||
borderWidth: 2
|
||
}));
|
||
|
||
// 创建或更新图表
|
||
if (detailedDnsRequestsChart) {
|
||
detailedDnsRequestsChart.data.labels = results[0].labels;
|
||
detailedDnsRequestsChart.data.datasets = datasets;
|
||
detailedDnsRequestsChart.options.plugins.legend.display = showLegend;
|
||
// 使用平滑过渡动画更新图表
|
||
detailedDnsRequestsChart.update({
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
});
|
||
} else {
|
||
detailedDnsRequestsChart = new Chart(chartContext, {
|
||
type: 'line',
|
||
data: {
|
||
labels: results[0].labels,
|
||
datasets: datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: {
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: showLegend,
|
||
position: 'top'
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.1)'
|
||
}
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}).catch(error => {
|
||
console.error('绘制混合视图详细图表失败:', error);
|
||
});
|
||
} else {
|
||
// 普通视图
|
||
// 根据详细视图时间范围选择API函数和对应的颜色
|
||
let apiFunction;
|
||
let chartColor;
|
||
let chartFillColor;
|
||
|
||
switch (detailedCurrentTimeRange) {
|
||
case '7d':
|
||
apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] }));
|
||
chartColor = '#22c55e'; // 绿色,与混合视图中的7天数据颜色一致
|
||
chartFillColor = 'rgba(34, 197, 94, 0.1)';
|
||
break;
|
||
case '30d':
|
||
apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] }));
|
||
chartColor = '#a855f7'; // 紫色,与混合视图中的30天数据颜色一致
|
||
chartFillColor = 'rgba(168, 85, 247, 0.1)';
|
||
break;
|
||
default: // 24h
|
||
apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] }));
|
||
chartColor = '#3b82f6'; // 蓝色,与混合视图中的24小时数据颜色一致
|
||
chartFillColor = 'rgba(59, 130, 246, 0.1)';
|
||
}
|
||
|
||
// 获取统计数据
|
||
apiFunction().then(data => {
|
||
// 创建或更新图表
|
||
if (detailedDnsRequestsChart) {
|
||
detailedDnsRequestsChart.data.labels = data.labels;
|
||
detailedDnsRequestsChart.data.datasets = [{
|
||
label: 'DNS请求数量',
|
||
data: data.data,
|
||
borderColor: chartColor,
|
||
backgroundColor: chartFillColor,
|
||
tension: 0.4,
|
||
fill: true
|
||
}];
|
||
detailedDnsRequestsChart.options.plugins.legend.display = false;
|
||
// 使用平滑过渡动画更新图表
|
||
detailedDnsRequestsChart.update({
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
});
|
||
} else {
|
||
detailedDnsRequestsChart = new Chart(chartContext, {
|
||
type: 'line',
|
||
data: {
|
||
labels: data.labels,
|
||
datasets: [{
|
||
label: 'DNS请求数量',
|
||
data: data.data,
|
||
borderColor: chartColor,
|
||
backgroundColor: chartFillColor,
|
||
tension: 0.4,
|
||
fill: true
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: {
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: 'DNS请求趋势',
|
||
font: {
|
||
size: 14
|
||
}
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.1)'
|
||
}
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}).catch(error => {
|
||
console.error('绘制详细DNS请求图表失败:', error);
|
||
// 错误处理:使用空数据
|
||
const count = detailedCurrentTimeRange === '24h' ? 24 : (detailedCurrentTimeRange === '7d' ? 7 : 30);
|
||
const emptyData = {
|
||
labels: Array(count).fill(''),
|
||
data: Array(count).fill(0)
|
||
};
|
||
|
||
if (detailedDnsRequestsChart) {
|
||
detailedDnsRequestsChart.data.labels = emptyData.labels;
|
||
detailedDnsRequestsChart.data.datasets[0].data = emptyData.data;
|
||
detailedDnsRequestsChart.update();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 绘制DNS请求统计图表
|
||
function drawDNSRequestsChart() {
|
||
const ctx = document.getElementById('dns-requests-chart');
|
||
if (!ctx) {
|
||
console.error('未找到DNS请求图表元素');
|
||
return;
|
||
}
|
||
|
||
const chartContext = ctx.getContext('2d');
|
||
|
||
// 混合视图配置
|
||
const datasetsConfig = [
|
||
{ label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' },
|
||
{ label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' },
|
||
{ label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' }
|
||
];
|
||
|
||
// 检查是否为混合视图
|
||
if (isMixedView || currentTimeRange === 'mixed') {
|
||
console.log('渲染混合视图图表');
|
||
|
||
// 显示图例
|
||
const showLegend = true;
|
||
|
||
// 获取所有时间范围的数据
|
||
Promise.all(datasetsConfig.map(config =>
|
||
config.api().catch(error => {
|
||
console.error(`获取${config.label}数据失败:`, error);
|
||
// 返回空数据而不是模拟数据
|
||
const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30);
|
||
return {
|
||
labels: Array(count).fill(''),
|
||
data: Array(count).fill(0)
|
||
};
|
||
})
|
||
)).then(results => {
|
||
// 创建数据集
|
||
const datasets = results.map((data, index) => ({
|
||
label: datasetsConfig[index].label,
|
||
data: data.data,
|
||
borderColor: datasetsConfig[index].color,
|
||
backgroundColor: datasetsConfig[index].fillColor,
|
||
tension: 0.4,
|
||
fill: false, // 混合视图不填充
|
||
borderWidth: 2
|
||
}));
|
||
|
||
// 创建或更新图表
|
||
if (dnsRequestsChart) {
|
||
// 使用第一个数据集的标签,但确保每个数据集使用自己的数据
|
||
dnsRequestsChart.data.labels = results[0].labels;
|
||
dnsRequestsChart.data.datasets = datasets;
|
||
dnsRequestsChart.options.plugins.legend.display = showLegend;
|
||
// 使用平滑过渡动画更新图表
|
||
dnsRequestsChart.update({
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
});
|
||
} else {
|
||
dnsRequestsChart = new Chart(chartContext, {
|
||
type: 'line',
|
||
data: {
|
||
labels: results[0].labels,
|
||
datasets: datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: {
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: showLegend,
|
||
position: 'top'
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.1)'
|
||
}
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}).catch(error => {
|
||
console.error('绘制混合视图图表失败:', error);
|
||
});
|
||
} else {
|
||
// 普通视图
|
||
// 根据当前时间范围选择API函数和对应的颜色
|
||
let apiFunction;
|
||
let chartColor;
|
||
let chartFillColor;
|
||
|
||
switch (currentTimeRange) {
|
||
case '7d':
|
||
apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] }));
|
||
chartColor = '#22c55e'; // 绿色,与混合视图中的7天数据颜色一致
|
||
chartFillColor = 'rgba(34, 197, 94, 0.1)';
|
||
break;
|
||
case '30d':
|
||
apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] }));
|
||
chartColor = '#a855f7'; // 紫色,与混合视图中的30天数据颜色一致
|
||
chartFillColor = 'rgba(168, 85, 247, 0.1)';
|
||
break;
|
||
default: // 24h
|
||
apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] }));
|
||
chartColor = '#3b82f6'; // 蓝色,与混合视图中的24小时数据颜色一致
|
||
chartFillColor = 'rgba(59, 130, 246, 0.1)';
|
||
}
|
||
|
||
// 获取统计数据
|
||
apiFunction().then(data => {
|
||
// 创建或更新图表
|
||
if (dnsRequestsChart) {
|
||
dnsRequestsChart.data.labels = data.labels;
|
||
dnsRequestsChart.data.datasets = [{
|
||
label: 'DNS请求数量',
|
||
data: data.data,
|
||
borderColor: chartColor,
|
||
backgroundColor: chartFillColor,
|
||
tension: 0.4,
|
||
fill: true
|
||
}];
|
||
dnsRequestsChart.options.plugins.legend.display = false;
|
||
// 使用平滑过渡动画更新图表
|
||
dnsRequestsChart.update({
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
});
|
||
} else {
|
||
dnsRequestsChart = new Chart(chartContext, {
|
||
type: 'line',
|
||
data: {
|
||
labels: data.labels,
|
||
datasets: [{
|
||
label: 'DNS请求数量',
|
||
data: data.data,
|
||
borderColor: chartColor,
|
||
backgroundColor: chartFillColor,
|
||
tension: 0.4,
|
||
fill: true
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: {
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.1)'
|
||
}
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}).catch(error => {
|
||
console.error('绘制DNS请求图表失败:', error);
|
||
// 错误处理:使用空数据而不是模拟数据
|
||
const count = currentTimeRange === '24h' ? 24 : (currentTimeRange === '7d' ? 7 : 30);
|
||
const emptyData = {
|
||
labels: Array(count).fill(''),
|
||
data: Array(count).fill(0)
|
||
};
|
||
|
||
if (dnsRequestsChart) {
|
||
dnsRequestsChart.data.labels = emptyData.labels;
|
||
dnsRequestsChart.data.datasets[0].data = emptyData.data;
|
||
dnsRequestsChart.update();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 更新图表数据
|
||
function updateCharts(stats, queryTypeStats) {
|
||
console.log('更新图表,收到统计数据:', stats);
|
||
console.log('查询类型统计数据:', queryTypeStats);
|
||
|
||
// 空值检查
|
||
if (!stats) {
|
||
console.error('更新图表失败: 未提供统计数据');
|
||
return;
|
||
}
|
||
|
||
// 更新比例图表
|
||
if (ratioChart) {
|
||
let allowed = '---', blocked = '---', error = '---';
|
||
|
||
// 尝试从stats数据中提取
|
||
if (stats.dns) {
|
||
allowed = stats.dns.Allowed || allowed;
|
||
blocked = stats.dns.Blocked || blocked;
|
||
error = stats.dns.Errors || error;
|
||
} else if (stats.totalQueries !== undefined) {
|
||
allowed = stats.allowedQueries || allowed;
|
||
blocked = stats.blockedQueries || blocked;
|
||
error = stats.errorQueries || error;
|
||
}
|
||
|
||
ratioChart.data.datasets[0].data = [allowed, blocked, error];
|
||
// 使用自定义动画配置更新图表,确保平滑过渡
|
||
ratioChart.update({
|
||
duration: 500,
|
||
easing: 'easeInOutQuart'
|
||
});
|
||
}
|
||
|
||
// 更新解析类型统计饼图
|
||
if (queryTypeChart && queryTypeStats && Array.isArray(queryTypeStats)) {
|
||
const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e'];
|
||
|
||
// 检查是否有有效的数据项
|
||
const validData = queryTypeStats.filter(item => item && item.count > 0);
|
||
|
||
if (validData.length > 0) {
|
||
// 准备标签和数据
|
||
const labels = validData.map(item => item.type);
|
||
const data = validData.map(item => item.count);
|
||
|
||
// 为每个解析类型分配颜色
|
||
const colors = labels.map((_, index) => queryTypeColors[index % queryTypeColors.length]);
|
||
|
||
// 更新图表数据
|
||
queryTypeChart.data.labels = labels;
|
||
queryTypeChart.data.datasets[0].data = data;
|
||
queryTypeChart.data.datasets[0].backgroundColor = colors;
|
||
} else {
|
||
// 如果没有数据,显示默认值
|
||
queryTypeChart.data.labels = ['暂无数据'];
|
||
queryTypeChart.data.datasets[0].data = [1];
|
||
queryTypeChart.data.datasets[0].backgroundColor = [queryTypeColors[0]];
|
||
}
|
||
|
||
// 使用自定义动画配置更新图表,确保平滑过渡
|
||
queryTypeChart.update({
|
||
duration: 500,
|
||
easing: 'easeInOutQuart'
|
||
});
|
||
}
|
||
}
|
||
|
||
// 更新统计卡片折线图
|
||
function updateStatCardCharts(stats) {
|
||
if (!stats || Object.keys(statCardCharts).length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 更新查询总量图表
|
||
if (statCardCharts['query-chart']) {
|
||
let queryCount = 0;
|
||
if (stats.dns) {
|
||
queryCount = stats.dns.Queries || 0;
|
||
} else if (stats.totalQueries !== undefined) {
|
||
queryCount = stats.totalQueries || 0;
|
||
}
|
||
updateChartData('query-chart', queryCount);
|
||
}
|
||
|
||
// 更新屏蔽数量图表
|
||
if (statCardCharts['blocked-chart']) {
|
||
let blockedCount = 0;
|
||
if (stats.dns) {
|
||
blockedCount = stats.dns.Blocked || 0;
|
||
} else if (stats.blockedQueries !== undefined) {
|
||
blockedCount = stats.blockedQueries || 0;
|
||
}
|
||
updateChartData('blocked-chart', blockedCount);
|
||
}
|
||
|
||
// 更新正常解析图表
|
||
if (statCardCharts['allowed-chart']) {
|
||
let allowedCount = 0;
|
||
if (stats.dns) {
|
||
allowedCount = stats.dns.Allowed || 0;
|
||
} else if (stats.allowedQueries !== undefined) {
|
||
allowedCount = stats.allowedQueries || 0;
|
||
} else if (stats.dns && stats.dns.Queries && stats.dns.Blocked) {
|
||
allowedCount = stats.dns.Queries - stats.dns.Blocked;
|
||
}
|
||
updateChartData('allowed-chart', allowedCount);
|
||
}
|
||
|
||
// 更新错误数量图表
|
||
if (statCardCharts['error-chart']) {
|
||
let errorCount = 0;
|
||
if (stats.dns) {
|
||
errorCount = stats.dns.Errors || 0;
|
||
} else if (stats.errorQueries !== undefined) {
|
||
errorCount = stats.errorQueries || 0;
|
||
}
|
||
updateChartData('error-chart', errorCount);
|
||
}
|
||
|
||
// 更新响应时间图表
|
||
if (statCardCharts['response-time-chart']) {
|
||
let responseTime = 0;
|
||
// 尝试从不同的数据结构获取平均响应时间
|
||
if (stats.dns && stats.dns.AvgResponseTime) {
|
||
responseTime = stats.dns.AvgResponseTime;
|
||
} else if (stats.avgResponseTime !== undefined) {
|
||
responseTime = stats.avgResponseTime;
|
||
} else if (stats.responseTime) {
|
||
responseTime = stats.responseTime;
|
||
}
|
||
// 限制小数位数为两位
|
||
responseTime = parseFloat(responseTime).toFixed(2);
|
||
updateChartData('response-time-chart', responseTime);
|
||
}
|
||
|
||
// 更新活跃IP图表
|
||
if (statCardCharts['ips-chart']) {
|
||
const activeIPs = stats.activeIPs || 0;
|
||
updateChartData('ips-chart', activeIPs);
|
||
}
|
||
|
||
// 更新CPU使用率图表
|
||
if (statCardCharts['cpu-chart']) {
|
||
const cpuUsage = stats.cpuUsage || 0;
|
||
updateChartData('cpu-chart', cpuUsage);
|
||
}
|
||
|
||
// 更新平均响应时间显示
|
||
if (document.getElementById('avg-response-time')) {
|
||
let avgResponseTime = 0;
|
||
// 尝试从不同的数据结构获取平均响应时间
|
||
if (stats.dns && stats.dns.AvgResponseTime) {
|
||
avgResponseTime = stats.dns.AvgResponseTime;
|
||
} else if (stats.avgResponseTime !== undefined) {
|
||
avgResponseTime = stats.avgResponseTime;
|
||
} else if (stats.responseTime) {
|
||
avgResponseTime = stats.responseTime;
|
||
}
|
||
document.getElementById('avg-response-time').textContent = formatNumber(avgResponseTime);
|
||
}
|
||
|
||
// 更新规则数图表
|
||
if (statCardCharts['rules-chart']) {
|
||
// 尝试获取规则数,如果没有则使用模拟数据
|
||
const rulesCount = getRulesCountFromStats(stats) || Math.floor(Math.random() * 5000) + 10000;
|
||
updateChartData('rules-chart', rulesCount);
|
||
}
|
||
|
||
// 更新排除规则数图表
|
||
if (statCardCharts['exceptions-chart']) {
|
||
const exceptionsCount = getExceptionsCountFromStats(stats) || Math.floor(Math.random() * 100) + 50;
|
||
updateChartData('exceptions-chart', exceptionsCount);
|
||
}
|
||
|
||
// 更新Hosts条目数图表
|
||
if (statCardCharts['hosts-chart']) {
|
||
const hostsCount = getHostsCountFromStats(stats) || Math.floor(Math.random() * 1000) + 2000;
|
||
updateChartData('hosts-chart', hostsCount);
|
||
}
|
||
}
|
||
|
||
// 更新单个图表的数据
|
||
function updateChartData(chartId, newValue) {
|
||
const chart = statCardCharts[chartId];
|
||
const historyData = statCardHistoryData[chartId];
|
||
|
||
if (!chart || !historyData) {
|
||
return;
|
||
}
|
||
|
||
// 添加新数据,移除最旧的数据
|
||
historyData.push(newValue);
|
||
if (historyData.length > 12) {
|
||
historyData.shift();
|
||
}
|
||
|
||
// 更新图表数据
|
||
chart.data.datasets[0].data = historyData;
|
||
chart.data.labels = generateTimeLabels(historyData.length);
|
||
|
||
// 使用自定义动画配置更新图表,确保平滑过渡,避免空白区域
|
||
chart.update({
|
||
duration: 300, // 增加动画持续时间
|
||
easing: 'easeInOutQuart', // 使用平滑的缓动函数
|
||
transition: {
|
||
duration: 300,
|
||
easing: 'easeInOutQuart'
|
||
}
|
||
});
|
||
}
|
||
|
||
// 从统计数据中获取规则数
|
||
function getRulesCountFromStats(stats) {
|
||
// 尝试从stats中获取规则数
|
||
if (stats.shield && stats.shield.rules) {
|
||
return stats.shield.rules;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 从统计数据中获取排除规则数
|
||
function getExceptionsCountFromStats(stats) {
|
||
// 尝试从stats中获取排除规则数
|
||
if (stats.shield && stats.shield.exceptions) {
|
||
return stats.shield.exceptions;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 从统计数据中获取Hosts条目数
|
||
function getHostsCountFromStats(stats) {
|
||
// 尝试从stats中获取Hosts条目数
|
||
if (stats.shield && stats.shield.hosts) {
|
||
return stats.shield.hosts;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 初始化统计卡片折线图
|
||
function initStatCardCharts() {
|
||
console.log('===== 开始初始化统计卡片折线图 =====');
|
||
|
||
// 清理已存在的图表实例
|
||
for (const key in statCardCharts) {
|
||
if (statCardCharts.hasOwnProperty(key)) {
|
||
statCardCharts[key].destroy();
|
||
}
|
||
}
|
||
statCardCharts = {};
|
||
statCardHistoryData = {};
|
||
|
||
// 检查Chart.js是否加载
|
||
console.log('Chart.js是否可用:', typeof Chart !== 'undefined');
|
||
|
||
// 统计卡片配置信息
|
||
const cardConfigs = [
|
||
{ id: 'query-chart', color: '#9b59b6', label: '查询总量' },
|
||
{ id: 'blocked-chart', color: '#e74c3c', label: '屏蔽数量' },
|
||
{ id: 'allowed-chart', color: '#2ecc71', label: '正常解析' },
|
||
{ id: 'error-chart', color: '#f39c12', label: '错误数量' },
|
||
{ id: 'response-time-chart', color: '#3498db', label: '响应时间' },
|
||
{ id: 'ips-chart', color: '#1abc9c', label: '活跃IP' },
|
||
{ id: 'cpu-chart', color: '#e67e22', label: 'CPU使用率' },
|
||
{ id: 'rules-chart', color: '#95a5a6', label: '屏蔽规则数' },
|
||
{ id: 'exceptions-chart', color: '#34495e', label: '排除规则数' },
|
||
{ id: 'hosts-chart', color: '#16a085', label: 'Hosts条目数' }
|
||
];
|
||
|
||
console.log('图表配置:', cardConfigs);
|
||
|
||
cardConfigs.forEach(config => {
|
||
const canvas = document.getElementById(config.id);
|
||
if (!canvas) {
|
||
console.warn(`未找到统计卡片图表元素: ${config.id}`);
|
||
return;
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// 为不同类型的卡片生成更合适的初始数据
|
||
let initialData;
|
||
if (config.id === 'response-time-chart') {
|
||
// 响应时间图表使用空数组,将通过API实时数据更新
|
||
initialData = Array(12).fill(null);
|
||
} else if (config.id === 'cpu-chart') {
|
||
initialData = generateMockData(12, 0, 10);
|
||
} else {
|
||
initialData = generateMockData(12, 0, 100);
|
||
}
|
||
|
||
// 初始化历史数据数组
|
||
statCardHistoryData[config.id] = [...initialData];
|
||
|
||
// 创建图表
|
||
statCardCharts[config.id] = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: generateTimeLabels(12),
|
||
datasets: [{
|
||
label: config.label,
|
||
data: initialData,
|
||
borderColor: config.color,
|
||
backgroundColor: `${config.color}20`, // 透明度20%
|
||
borderWidth: 2,
|
||
tension: 0.4,
|
||
fill: true,
|
||
pointRadius: 0, // 隐藏数据点
|
||
pointHoverRadius: 4, // 鼠标悬停时显示数据点
|
||
pointBackgroundColor: config.color
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
// 添加动画配置,确保平滑过渡
|
||
animation: {
|
||
duration: 800,
|
||
easing: 'easeInOutQuart'
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||
titleColor: '#fff',
|
||
bodyColor: '#fff',
|
||
borderColor: config.color,
|
||
borderWidth: 1,
|
||
padding: 8,
|
||
displayColors: false,
|
||
cornerRadius: 4,
|
||
titleFont: {
|
||
size: 12,
|
||
weight: 'normal'
|
||
},
|
||
bodyFont: {
|
||
size: 11
|
||
},
|
||
// 确保HTML渲染正确
|
||
useHTML: true,
|
||
filter: function(tooltipItem) {
|
||
return tooltipItem.datasetIndex === 0;
|
||
},
|
||
callbacks: {
|
||
title: function(tooltipItems) {
|
||
// 简化时间显示格式
|
||
return tooltipItems[0].label;
|
||
},
|
||
label: function(context) {
|
||
const value = context.parsed.y;
|
||
// 格式化大数字
|
||
const formattedValue = formatNumber(value);
|
||
|
||
// 使用CSS类显示变化趋势
|
||
let trendInfo = '';
|
||
const data = context.dataset.data;
|
||
const currentIndex = context.dataIndex;
|
||
|
||
if (currentIndex > 0) {
|
||
const prevValue = data[currentIndex - 1];
|
||
const change = value - prevValue;
|
||
|
||
if (change !== 0) {
|
||
const changeSymbol = change > 0 ? '↑' : '↓';
|
||
// 取消颜色显示,简化显示
|
||
trendInfo = (changeSymbol + Math.abs(change));
|
||
}
|
||
}
|
||
|
||
// 简化标签格式
|
||
return `${config.label}: ${formattedValue}${trendInfo}`;
|
||
},
|
||
// 移除平均值显示
|
||
afterLabel: function(context) {
|
||
return '';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
display: false // 隐藏X轴
|
||
},
|
||
y: {
|
||
display: false, // 隐藏Y轴
|
||
beginAtZero: true
|
||
}
|
||
},
|
||
interaction: {
|
||
intersect: false,
|
||
mode: 'index'
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 生成模拟数据
|
||
function generateMockData(count, min, max) {
|
||
const data = [];
|
||
for (let i = 0; i < count; i++) {
|
||
data.push(Math.floor(Math.random() * (max - min + 1)) + min);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// 生成时间标签
|
||
function generateTimeLabels(count) {
|
||
const labels = [];
|
||
const now = new Date();
|
||
for (let i = count - 1; i >= 0; i--) {
|
||
const time = new Date(now.getTime() - i * 5 * 60 * 1000); // 每5分钟一个点
|
||
labels.push(`${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}`);
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
// 格式化数字显示(使用K/M后缀)
|
||
function formatNumber(num) {
|
||
// 显示完整数字的最大长度阈值
|
||
const MAX_FULL_LENGTH = 5;
|
||
|
||
// 先获取完整数字字符串
|
||
const fullNumStr = num.toString();
|
||
|
||
// 如果数字长度小于等于阈值,直接返回完整数字
|
||
if (fullNumStr.length <= MAX_FULL_LENGTH) {
|
||
return fullNumStr;
|
||
}
|
||
|
||
// 否则使用缩写格式
|
||
if (num >= 1000000) {
|
||
return (num / 1000000).toFixed(1) + 'M';
|
||
} else if (num >= 1000) {
|
||
return (num / 1000).toFixed(1) + 'K';
|
||
}
|
||
|
||
return fullNumStr;
|
||
}
|
||
|
||
// 更新运行状态
|
||
function updateUptime() {
|
||
// 实现更新运行时间的逻辑
|
||
// 这里应该调用API获取当前运行时间并更新到UI
|
||
// 由于API暂时没有提供运行时间,我们先使用模拟数据
|
||
const uptimeElement = document.getElementById('uptime');
|
||
if (uptimeElement) {
|
||
uptimeElement.textContent = '---';
|
||
}
|
||
}
|
||
|
||
// 格式化数字(添加千位分隔符)
|
||
function formatWithCommas(num) {
|
||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
}
|
||
|
||
// 格式化时间
|
||
function formatTime(timestamp) {
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diff = now - date;
|
||
|
||
// 如果是今天,显示时间
|
||
if (date.toDateString() === now.toDateString()) {
|
||
return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
|
||
}
|
||
|
||
// 否则显示日期和时间
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
// 根据颜色代码获取对应的CSS类名(兼容方式)
|
||
function getColorClassName(colorCode) {
|
||
// 优先使用配置文件中的颜色处理
|
||
if (COLOR_CONFIG.getColorClassName) {
|
||
return COLOR_CONFIG.getColorClassName(colorCode);
|
||
}
|
||
|
||
// 备用颜色映射
|
||
const colorMap = {
|
||
'#1890ff': 'blue',
|
||
'#52c41a': 'green',
|
||
'#fa8c16': 'orange',
|
||
'#f5222d': 'red',
|
||
'#722ed1': 'purple',
|
||
'#13c2c2': 'cyan',
|
||
'#36cfc9': 'teal'
|
||
};
|
||
|
||
// 返回映射的类名,如果没有找到则返回默认的blue
|
||
return colorMap[colorCode] || 'blue';
|
||
}
|
||
|
||
// 显示通知
|
||
function showNotification(message, type = 'info') {
|
||
// 移除已存在的通知
|
||
const existingNotification = document.getElementById('notification');
|
||
if (existingNotification) {
|
||
existingNotification.remove();
|
||
}
|
||
|
||
// 创建通知元素
|
||
const notification = document.createElement('div');
|
||
notification.id = 'notification';
|
||
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-y-0 opacity-0`;
|
||
|
||
// 设置样式和内容
|
||
let bgColor, textColor, icon;
|
||
switch (type) {
|
||
case 'success':
|
||
bgColor = 'bg-success';
|
||
textColor = 'text-white';
|
||
icon = 'fa-check-circle';
|
||
break;
|
||
case 'error':
|
||
bgColor = 'bg-danger';
|
||
textColor = 'text-white';
|
||
icon = 'fa-exclamation-circle';
|
||
break;
|
||
case 'warning':
|
||
bgColor = 'bg-warning';
|
||
textColor = 'text-white';
|
||
icon = 'fa-exclamation-triangle';
|
||
break;
|
||
default:
|
||
bgColor = 'bg-primary';
|
||
textColor = 'text-white';
|
||
icon = 'fa-info-circle';
|
||
}
|
||
|
||
notification.className += ` ${bgColor} ${textColor}`;
|
||
notification.innerHTML = `
|
||
<div class="flex items-center">
|
||
<i class="fa ${icon} mr-3"></i>
|
||
<span>${message}</span>
|
||
</div>
|
||
`;
|
||
|
||
// 添加到页面
|
||
document.body.appendChild(notification);
|
||
|
||
// 显示通知
|
||
setTimeout(() => {
|
||
notification.classList.remove('translate-y-0', 'opacity-0');
|
||
notification.classList.add('-translate-y-2', 'opacity-100');
|
||
}, 10);
|
||
|
||
// 自动关闭
|
||
setTimeout(() => {
|
||
notification.classList.add('translate-y-0', 'opacity-0');
|
||
setTimeout(() => {
|
||
notification.remove();
|
||
}, 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// 页面切换处理
|
||
function handlePageSwitch() {
|
||
const menuItems = document.querySelectorAll('nav a');
|
||
|
||
// 页面切换逻辑
|
||
function switchPage(targetId, menuItem) {
|
||
// 隐藏所有内容
|
||
document.querySelectorAll('[id$="-content"]').forEach(content => {
|
||
content.classList.add('hidden');
|
||
});
|
||
|
||
// 显示目标内容
|
||
document.getElementById(`${targetId}-content`).classList.remove('hidden');
|
||
|
||
// 更新页面标题
|
||
document.getElementById('page-title').textContent = menuItem.querySelector('span').textContent;
|
||
|
||
// 更新活动菜单项
|
||
menuItems.forEach(item => {
|
||
item.classList.remove('sidebar-item-active');
|
||
});
|
||
menuItem.classList.add('sidebar-item-active');
|
||
|
||
// 侧边栏切换(移动端)
|
||
if (window.innerWidth < 1024) {
|
||
toggleSidebar();
|
||
}
|
||
}
|
||
|
||
// 处理hash变化
|
||
function handleHashChange() {
|
||
let hash = window.location.hash;
|
||
|
||
// 如果没有hash,默认设置为#dashboard
|
||
if (!hash) {
|
||
hash = '#dashboard';
|
||
window.location.hash = hash;
|
||
return;
|
||
}
|
||
|
||
const targetId = hash.substring(1);
|
||
|
||
// 查找对应的菜单项
|
||
let targetMenuItem = null;
|
||
menuItems.forEach(item => {
|
||
if (item.getAttribute('href') === hash) {
|
||
targetMenuItem = item;
|
||
}
|
||
});
|
||
|
||
// 如果找到了对应的菜单项,切换页面
|
||
if (targetMenuItem) {
|
||
switchPage(targetId, targetMenuItem);
|
||
} else {
|
||
// 如果没有找到对应的菜单项,尝试显示对应的内容
|
||
const contentElement = document.getElementById(`${targetId}-content`);
|
||
if (contentElement) {
|
||
// 隐藏所有内容
|
||
document.querySelectorAll('[id$="-content"]').forEach(content => {
|
||
content.classList.add('hidden');
|
||
});
|
||
|
||
// 显示目标内容
|
||
contentElement.classList.remove('hidden');
|
||
|
||
// 查找对应的菜单项并更新活动状态
|
||
menuItems.forEach(item => {
|
||
item.classList.remove('sidebar-item-active');
|
||
if (item.getAttribute('href') === hash) {
|
||
item.classList.add('sidebar-item-active');
|
||
// 更新页面标题
|
||
document.getElementById('page-title').textContent = item.querySelector('span').textContent;
|
||
}
|
||
});
|
||
} else {
|
||
// 如果没有找到对应的内容,默认显示dashboard
|
||
window.location.hash = '#dashboard';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化hash路由
|
||
function initHashRoute() {
|
||
handleHashChange();
|
||
}
|
||
|
||
// 监听hash变化事件
|
||
window.addEventListener('hashchange', handleHashChange);
|
||
|
||
menuItems.forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
// 允许默认的hash变化
|
||
// 页面切换会由hashchange事件处理
|
||
});
|
||
});
|
||
|
||
// 初始化hash路由
|
||
initHashRoute();
|
||
}
|
||
|
||
// 侧边栏切换
|
||
function toggleSidebar() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
sidebar.classList.toggle('-translate-x-full');
|
||
}
|
||
|
||
// 响应式处理
|
||
function handleResponsive() {
|
||
const toggleBtn = document.getElementById('toggle-sidebar');
|
||
const sidebar = document.getElementById('sidebar');
|
||
|
||
toggleBtn.addEventListener('click', toggleSidebar);
|
||
|
||
// 初始状态处理
|
||
function updateSidebarState() {
|
||
if (window.innerWidth < 1024) {
|
||
sidebar.classList.add('-translate-x-full');
|
||
} else {
|
||
sidebar.classList.remove('-translate-x-full');
|
||
}
|
||
}
|
||
|
||
updateSidebarState();
|
||
|
||
// 窗口大小改变时处理
|
||
window.addEventListener('resize', () => {
|
||
updateSidebarState();
|
||
|
||
// 更新所有图表大小
|
||
if (dnsRequestsChart) {
|
||
dnsRequestsChart.update();
|
||
}
|
||
if (ratioChart) {
|
||
ratioChart.update();
|
||
}
|
||
if (queryTypeChart) {
|
||
queryTypeChart.update();
|
||
}
|
||
if (detailedDnsRequestsChart) {
|
||
detailedDnsRequestsChart.update();
|
||
}
|
||
// 更新统计卡片图表
|
||
Object.values(statCardCharts).forEach(chart => {
|
||
if (chart) {
|
||
chart.update();
|
||
}
|
||
});
|
||
});
|
||
|
||
// 添加触摸事件支持,用于移动端
|
||
let touchStartX = 0;
|
||
let touchEndX = 0;
|
||
|
||
document.addEventListener('touchstart', (e) => {
|
||
touchStartX = e.changedTouches[0].screenX;
|
||
}, false);
|
||
|
||
document.addEventListener('touchend', (e) => {
|
||
touchEndX = e.changedTouches[0].screenX;
|
||
handleSwipe();
|
||
}, false);
|
||
|
||
function handleSwipe() {
|
||
// 从左向右滑动,打开侧边栏
|
||
if (touchEndX - touchStartX > 50 && window.innerWidth < 1024) {
|
||
sidebar.classList.remove('-translate-x-full');
|
||
}
|
||
// 从右向左滑动,关闭侧边栏
|
||
if (touchStartX - touchEndX > 50 && window.innerWidth < 1024) {
|
||
sidebar.classList.add('-translate-x-full');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 添加重试功能
|
||
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', () => {
|
||
// 初始化页面切换
|
||
handlePageSwitch();
|
||
|
||
// 初始化响应式
|
||
handleResponsive();
|
||
|
||
// 初始化仪表盘
|
||
initDashboard();
|
||
|
||
// 添加重试事件监听器
|
||
addRetryEventListeners();
|
||
|
||
// 页面卸载时清理定时器
|
||
window.addEventListener('beforeunload', () => {
|
||
if (intervalId) {
|
||
clearInterval(intervalId);
|
||
}
|
||
});
|
||
}); |