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:
sirpiglr 2025-12-08 06:40:51 +00:00
parent bc8a04825b
commit cd6f84fd4d

View file

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