Add detailed analytics and charts for command usage trends
Integrate new '/command-analytics' endpoint to display daily trends, hourly activity, and top users in the dashboard, alongside updating the existing '/analytics' data fetching to support concurrent requests. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 0275c4be-7268-43b8-a8d0-8f97a0077139 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/4ZEVdt6 Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
bc8a04825b
commit
cd6f84fd4d
1 changed files with 454 additions and 13 deletions
|
|
@ -1502,6 +1502,59 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Analytics Charts (Database-backed) -->
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
|
||||
Daily Command Trends
|
||||
</h3>
|
||||
<span class="badge badge-info">Last 7 days</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="dailyLineChart" style="min-height: 120px;">
|
||||
<div class="empty-state" style="padding: 1rem;">
|
||||
<p style="color: var(--text-muted);">Loading chart data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Hourly Activity (24h)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="hourlyBarChart" style="min-height: 100px;">
|
||||
<div class="empty-state" style="padding: 1rem;">
|
||||
<p style="color: var(--text-muted);">Loading chart data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></svg>
|
||||
Top Command Users
|
||||
</h3>
|
||||
<span class="badge badge-info">Last 7 days</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="topUsersChart" style="min-height: 150px;">
|
||||
<div class="empty-state" style="padding: 1rem;">
|
||||
<p style="color: var(--text-muted);">No user data available yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Leaderboard Section -->
|
||||
|
|
@ -2541,8 +2594,12 @@
|
|||
// Fetch analytics
|
||||
async function fetchAnalytics() {
|
||||
try {
|
||||
const response = await fetch('/analytics');
|
||||
const data = await response.json();
|
||||
const [analyticsRes, cmdAnalyticsRes] = await Promise.all([
|
||||
fetch('/analytics'),
|
||||
fetch('/command-analytics?days=7')
|
||||
]);
|
||||
const data = await analyticsRes.json();
|
||||
const cmdAnalytics = await cmdAnalyticsRes.json();
|
||||
|
||||
document.getElementById('commandsToday').textContent = data.commandsToday || 0;
|
||||
document.getElementById('xpToday').textContent = formatNumber(data.xpDistributed || 0);
|
||||
|
|
@ -2562,22 +2619,52 @@
|
|||
document.getElementById('banBar').style.width = `${((mods.bans || 0) / maxMod) * 100}%`;
|
||||
document.getElementById('timeoutBar').style.width = `${((mods.timeouts || 0) / maxMod) * 100}%`;
|
||||
|
||||
// Use database command analytics if available
|
||||
const commandBars = document.getElementById('commandBars');
|
||||
if (data.commandUsage && data.commandUsage.length > 0) {
|
||||
const maxCmd = data.commandUsage[0].count || 1;
|
||||
commandBars.innerHTML = data.commandUsage.slice(0, 8).map(cmd => `
|
||||
<div class="chart-bar-row">
|
||||
<span class="chart-bar-label">/${cmd.name}</span>
|
||||
<div class="chart-bar-track">
|
||||
<div class="chart-bar-fill" style="width: ${(cmd.count / maxCmd) * 100}%"></div>
|
||||
const commands = cmdAnalytics.commands && cmdAnalytics.commands.length > 0
|
||||
? cmdAnalytics.commands
|
||||
: (data.commandUsage || []);
|
||||
|
||||
if (commands.length > 0) {
|
||||
const maxCmd = Math.max(...commands.map(c => parseInt(c.count) || 0), 1);
|
||||
commandBars.innerHTML = commands.slice(0, 8).map(cmd => {
|
||||
const name = cmd.command_name || cmd.name || 'unknown';
|
||||
const count = parseInt(cmd.count) || 0;
|
||||
const successRate = cmd.success_count ? Math.round((parseInt(cmd.success_count) / count) * 100) : 100;
|
||||
const avgTime = cmd.avg_time ? Math.round(parseFloat(cmd.avg_time)) : 0;
|
||||
return `
|
||||
<div class="chart-bar-row">
|
||||
<span class="chart-bar-label" title="Avg: ${avgTime}ms, Success: ${successRate}%">/${name}</span>
|
||||
<div class="chart-bar-track">
|
||||
<div class="chart-bar-fill" style="width: ${(count / maxCmd) * 100}%"></div>
|
||||
</div>
|
||||
<span class="chart-bar-value">${count}</span>
|
||||
</div>
|
||||
<span class="chart-bar-value">${cmd.count}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
commandBars.innerHTML = '<div class="empty-state" style="padding: 1rem;"><p>No command usage yet</p></div>';
|
||||
}
|
||||
|
||||
// Render daily line chart from database
|
||||
const dailyChartEl = document.getElementById('dailyLineChart');
|
||||
if (dailyChartEl && cmdAnalytics.daily && cmdAnalytics.daily.length > 0) {
|
||||
renderDailyLineChart(dailyChartEl, cmdAnalytics.daily);
|
||||
}
|
||||
|
||||
// Render hourly chart from database
|
||||
const hourlyChartEl = document.getElementById('hourlyBarChart');
|
||||
if (hourlyChartEl && cmdAnalytics.hourly && cmdAnalytics.hourly.length > 0) {
|
||||
renderHourlyChart(hourlyChartEl, cmdAnalytics.hourly);
|
||||
}
|
||||
|
||||
// Render top users
|
||||
const topUsersEl = document.getElementById('topUsersChart');
|
||||
if (topUsersEl && cmdAnalytics.topUsers && cmdAnalytics.topUsers.length > 0) {
|
||||
renderTopUsersChart(topUsersEl, cmdAnalytics.topUsers);
|
||||
}
|
||||
|
||||
// Fallback heatmap from in-memory data
|
||||
const heatmapGrid = document.getElementById('heatmapGrid');
|
||||
if (data.hourlyActivity && data.hourlyActivity.length === 24) {
|
||||
const maxHour = Math.max(...data.hourlyActivity, 1);
|
||||
|
|
@ -2601,6 +2688,112 @@
|
|||
console.error('Failed to fetch analytics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Render daily line chart (SVG-based)
|
||||
function renderDailyLineChart(container, data) {
|
||||
const width = container.clientWidth || 400;
|
||||
const height = 120;
|
||||
const padding = 30;
|
||||
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--text-muted); text-align: center;">No data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const counts = data.map(d => parseInt(d.count) || 0);
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
const minCount = 0;
|
||||
|
||||
const xStep = (width - padding * 2) / Math.max(data.length - 1, 1);
|
||||
const yScale = (height - padding * 2) / (maxCount - minCount || 1);
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: padding + i * xStep,
|
||||
y: height - padding - (parseInt(d.count) || 0) * yScale,
|
||||
date: d.date,
|
||||
count: parseInt(d.count) || 0
|
||||
}));
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
||||
const areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding} L ${points[0].x} ${height - padding} Z`;
|
||||
|
||||
container.innerHTML = `
|
||||
<svg width="100%" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:var(--accent);stop-opacity:0.3"/>
|
||||
<stop offset="100%" style="stop-color:var(--accent);stop-opacity:0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="${areaPath}" fill="url(#areaGradient)"/>
|
||||
<path d="${linePath}" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||
${points.map(p => `
|
||||
<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--accent)" stroke="var(--bg-secondary)" stroke-width="2">
|
||||
<title>${p.date}: ${p.count} commands</title>
|
||||
</circle>
|
||||
`).join('')}
|
||||
<text x="${padding}" y="${height - 5}" fill="var(--text-muted)" font-size="10">${data[0]?.date?.split('-').slice(1).join('/') || ''}</text>
|
||||
<text x="${width - padding}" y="${height - 5}" fill="var(--text-muted)" font-size="10" text-anchor="end">${data[data.length - 1]?.date?.split('-').slice(1).join('/') || ''}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render hourly bar chart
|
||||
function renderHourlyChart(container, data) {
|
||||
const hours = Array(24).fill(0);
|
||||
data.forEach(d => {
|
||||
const h = parseInt(d.hour);
|
||||
if (h >= 0 && h < 24) hours[h] = parseInt(d.count) || 0;
|
||||
});
|
||||
|
||||
const maxCount = Math.max(...hours, 1);
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display: flex; align-items: flex-end; height: 80px; gap: 2px;">
|
||||
${hours.map((count, h) => {
|
||||
const heightPct = 10 + (count / maxCount) * 90;
|
||||
return `
|
||||
<div style="flex: 1; height: ${heightPct}%; background: linear-gradient(180deg, var(--accent), #a855f7); border-radius: 2px 2px 0 0; min-width: 8px;" title="${h}:00 - ${count} commands"></div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-top: 4px; font-size: 0.7rem; color: var(--text-muted);">
|
||||
<span>0:00</span>
|
||||
<span>6:00</span>
|
||||
<span>12:00</span>
|
||||
<span>18:00</span>
|
||||
<span>23:00</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render top users chart
|
||||
function renderTopUsersChart(container, data) {
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--text-muted);">No user data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...data.map(d => parseInt(d.command_count) || 0), 1);
|
||||
|
||||
container.innerHTML = data.slice(0, 5).map((user, i) => {
|
||||
const count = parseInt(user.command_count) || 0;
|
||||
const pct = (count / maxCount) * 100;
|
||||
const rankClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
|
||||
const tag = user.user_tag || `User ${user.user_id?.slice(-4) || '????'}`;
|
||||
|
||||
return `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<span class="leaderboard-rank ${rankClass}" style="width: 24px; height: 24px; font-size: 0.75rem;">${i + 1}</span>
|
||||
<span style="width: 100px; font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${tag}</span>
|
||||
<div style="flex: 1; height: 16px; background: var(--bg-card); border-radius: 4px; overflow: hidden;">
|
||||
<div style="width: ${pct}%; height: 100%; background: linear-gradient(90deg, var(--accent), #a855f7); border-radius: 4px;"></div>
|
||||
</div>
|
||||
<span style="width: 40px; text-align: right; font-size: 0.8rem; font-weight: 600;">${count}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Fetch leaderboard
|
||||
async function fetchLeaderboard() {
|
||||
|
|
@ -3504,6 +3697,251 @@
|
|||
}, 3000);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WEBSOCKET CONNECTION FOR REAL-TIME UPDATES
|
||||
// =============================================================================
|
||||
|
||||
let ws = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const WS_RECONNECT_DELAY = 3000;
|
||||
let wsConnected = false;
|
||||
|
||||
function initWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WebSocket] Connected');
|
||||
wsConnected = true;
|
||||
wsReconnectAttempts = 0;
|
||||
updateConnectionStatus(true);
|
||||
|
||||
// Reduce polling interval since we have real-time updates
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = setInterval(refreshData, 60000); // Slower polling as backup
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
handleWebSocketMessage(message);
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected');
|
||||
wsConnected = false;
|
||||
updateConnectionStatus(false);
|
||||
|
||||
// Immediately restore polling while disconnected
|
||||
if (!refreshInterval) {
|
||||
console.log('[WebSocket] Restoring polling while disconnected');
|
||||
refreshInterval = setInterval(refreshData, 30000);
|
||||
}
|
||||
|
||||
// Attempt reconnection with exponential backoff
|
||||
if (wsReconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
wsReconnectAttempts++;
|
||||
const backoffDelay = WS_RECONNECT_DELAY * Math.pow(1.5, wsReconnectAttempts - 1);
|
||||
console.log(`[WebSocket] Reconnecting in ${Math.round(backoffDelay/1000)}s (attempt ${wsReconnectAttempts})...`);
|
||||
setTimeout(initWebSocket, backoffDelay);
|
||||
} else {
|
||||
console.log('[WebSocket] Max reconnect attempts reached, continuing with polling');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Failed to connect:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'stats':
|
||||
updateStatsFromWebSocket(message.data);
|
||||
break;
|
||||
case 'activity':
|
||||
addActivityFromWebSocket(message.data);
|
||||
break;
|
||||
case 'command':
|
||||
updateCommandFromWebSocket(message.data);
|
||||
break;
|
||||
case 'threat':
|
||||
addThreatFromWebSocket(message.data);
|
||||
break;
|
||||
case 'ticket':
|
||||
updateTicketFromWebSocket(message.data);
|
||||
break;
|
||||
default:
|
||||
console.log('[WebSocket] Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatsFromWebSocket(data) {
|
||||
if (data.guilds !== undefined) {
|
||||
document.getElementById('serverCount').textContent = data.guilds;
|
||||
}
|
||||
if (data.totalMembers !== undefined) {
|
||||
document.getElementById('memberCount').textContent = formatNumber(data.totalMembers);
|
||||
}
|
||||
if (data.uptime !== undefined) {
|
||||
document.getElementById('uptime').textContent = formatUptime(data.uptime);
|
||||
document.getElementById('systemUptime').textContent = formatUptime(data.uptime);
|
||||
}
|
||||
if (data.heatMapSize !== undefined) {
|
||||
document.getElementById('heatMapSize').textContent = data.heatMapSize;
|
||||
document.getElementById('sentinelHeatMap').textContent = data.heatMapSize;
|
||||
}
|
||||
if (data.activeTickets !== undefined) {
|
||||
document.getElementById('activeTickets').textContent = data.activeTickets;
|
||||
document.getElementById('ticketBadge').textContent = data.activeTickets;
|
||||
}
|
||||
if (data.federationLinks !== undefined) {
|
||||
document.getElementById('federationCount').textContent = data.federationLinks;
|
||||
document.getElementById('sentinelFederation').textContent = data.federationLinks;
|
||||
}
|
||||
if (data.commandsExecuted !== undefined) {
|
||||
document.getElementById('commandCount').textContent = data.commandsExecuted;
|
||||
}
|
||||
|
||||
// Update last update time
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function addActivityFromWebSocket(data) {
|
||||
const activityFeed = document.getElementById('activityFeed');
|
||||
if (!activityFeed) return;
|
||||
|
||||
const iconClass = data.type === 'command' ? 'purple' :
|
||||
data.type === 'moderation' ? 'red' :
|
||||
data.type === 'member' ? 'green' : 'blue';
|
||||
const iconSvg = data.type === 'command' ? '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>' :
|
||||
data.type === 'moderation' ? '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>' :
|
||||
data.type === 'member' ? '<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>' :
|
||||
'<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>';
|
||||
|
||||
const newActivity = document.createElement('div');
|
||||
newActivity.className = 'activity-item';
|
||||
newActivity.innerHTML = `
|
||||
<div class="activity-icon stat-icon ${iconClass}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${iconSvg}</svg>
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-text">${data.message || 'Activity'}</div>
|
||||
<div class="activity-time">Just now</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add animation
|
||||
newActivity.style.animation = 'slideIn 0.3s ease';
|
||||
|
||||
// Insert at top
|
||||
if (activityFeed.firstChild) {
|
||||
activityFeed.insertBefore(newActivity, activityFeed.firstChild);
|
||||
} else {
|
||||
activityFeed.appendChild(newActivity);
|
||||
}
|
||||
|
||||
// Limit to 20 items
|
||||
while (activityFeed.children.length > 20) {
|
||||
activityFeed.removeChild(activityFeed.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCommandFromWebSocket(data) {
|
||||
// Update command analytics if on that section
|
||||
if (data.commandName && data.success !== undefined) {
|
||||
// Could update command usage chart here
|
||||
console.log(`[WebSocket] Command executed: ${data.commandName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addThreatFromWebSocket(data) {
|
||||
const threatFeed = document.getElementById('threatsFeed');
|
||||
if (!threatFeed) return;
|
||||
|
||||
const levelClass = data.level === 'critical' ? 'danger' :
|
||||
data.level === 'high' ? 'warning' : 'info';
|
||||
|
||||
const newThreat = document.createElement('div');
|
||||
newThreat.className = 'activity-item';
|
||||
newThreat.innerHTML = `
|
||||
<div class="activity-icon stat-icon ${levelClass === 'danger' ? 'red' : levelClass === 'warning' ? 'orange' : 'blue'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-text">${data.message || 'Security alert'}</div>
|
||||
<div class="activity-time">Just now - <span class="badge badge-${levelClass}">${data.level || 'unknown'}</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
newThreat.style.animation = 'slideIn 0.3s ease';
|
||||
|
||||
if (threatFeed.firstChild) {
|
||||
threatFeed.insertBefore(newThreat, threatFeed.firstChild);
|
||||
} else {
|
||||
threatFeed.appendChild(newThreat);
|
||||
}
|
||||
|
||||
// Update threat badge
|
||||
const badge = document.getElementById('threatBadge');
|
||||
if (badge) {
|
||||
badge.textContent = parseInt(badge.textContent || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTicketFromWebSocket(data) {
|
||||
if (data.action === 'create') {
|
||||
const badge = document.getElementById('ticketBadge');
|
||||
if (badge) {
|
||||
badge.textContent = parseInt(badge.textContent || 0) + 1;
|
||||
}
|
||||
document.getElementById('activeTickets').textContent =
|
||||
parseInt(document.getElementById('activeTickets').textContent || 0) + 1;
|
||||
} else if (data.action === 'close') {
|
||||
const badge = document.getElementById('ticketBadge');
|
||||
if (badge) {
|
||||
const current = parseInt(badge.textContent || 0);
|
||||
badge.textContent = Math.max(0, current - 1);
|
||||
}
|
||||
const activeEl = document.getElementById('activeTickets');
|
||||
activeEl.textContent = Math.max(0, parseInt(activeEl.textContent || 0) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
if (connected) {
|
||||
// Add WebSocket indicator
|
||||
if (!document.getElementById('wsIndicator')) {
|
||||
const indicator = document.createElement('span');
|
||||
indicator.id = 'wsIndicator';
|
||||
indicator.style.cssText = 'margin-left: 8px; font-size: 0.7rem; color: var(--success);';
|
||||
indicator.textContent = '(Live)';
|
||||
statusText.parentNode.appendChild(indicator);
|
||||
}
|
||||
} else {
|
||||
const indicator = document.getElementById('wsIndicator');
|
||||
if (indicator) indicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load theme
|
||||
|
|
@ -3511,6 +3949,9 @@
|
|||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
|
||||
// Initialize WebSocket connection
|
||||
initWebSocket();
|
||||
|
||||
// Initial fetch
|
||||
refreshData();
|
||||
fetchExtendedStatus();
|
||||
|
|
@ -3518,7 +3959,7 @@
|
|||
fetchFoundationStats();
|
||||
fetchIntegrationStatus();
|
||||
|
||||
// Auto-refresh every 30s
|
||||
// Auto-refresh every 30s (will be reduced when WebSocket connects)
|
||||
refreshInterval = setInterval(refreshData, 30000);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue