Enhance command execution and analytics features. Update CommandManager to allow DM-only commands in public channels to silently ignore errors if the channel is not configured. Refactor BaseCommand to improve execution checks based on channel permissions. In the web viewer, add time window parameters for analytics in the database stats API, and implement Chart.js for visualizing activity trends over the last 30 days. Update dashboard templates to include time window selectors for top users, commands, paths, and channels, improving user experience and data accessibility.

This commit is contained in:
agessaman committed 2025-11-30 20:04:20 -08:00
1 parent b1c39edbb8
commit 71d157a974
6 files changed
+572 -161

No files matched your search

+8 -2
View File
@@ -513,9 +513,15 @@ class CommandManager:
# Check if command can execute (cooldown, DM requirements, etc.)
if not command.can_execute_now(message):
# For DM-only commands in public channels, only show error if channel is allowed
# (i.e., channel is in monitor_channels or command's allowed_channels)
# This prevents prompting users in channels where the command shouldn't work at all
if command.requires_dm and not message.is_dm:
error_msg = command.translate('errors.dm_only', command=command_name)
await self.send_response(message, error_msg)
# Only prompt if channel is allowed (configured channels)
if command.is_channel_allowed(message):
error_msg = command.translate('errors.dm_only', command=command_name)
await self.send_response(message, error_msg)
# Otherwise, silently ignore (channel not configured for this command)
elif command.requires_admin_access():
error_msg = command.translate('errors.access_denied', command=command_name)
await self.send_response(message, error_msg)
+15 -1
View File
@@ -310,7 +310,21 @@ class BaseCommand(ABC):
def should_execute(self, message: MeshMessage) -> bool:
"""Check if this command should execute for the given message"""
return (self.matches_keyword(message) or self.matches_custom_syntax(message))
# First check if keyword matches
if not (self.matches_keyword(message) or self.matches_custom_syntax(message)):
return False
# For DM-only commands, only consider them if:
# 1. Message is a DM, OR
# 2. Channel is in allowed_channels (or monitor_channels if no override)
# This prevents DM-only commands from being processed in public channels
if self.requires_dm and not message.is_dm:
# Check if channel is allowed for this command
if not self.is_channel_allowed(message):
# Channel not allowed - don't even consider this command
return False
return True
def can_execute_now(self, message: MeshMessage) -> bool:
"""Check if this command can execute right now (permissions, cooldown, etc.)"""
+185 -17
View File
@@ -250,7 +250,17 @@ class BotDataViewer:
def api_stats():
"""Get comprehensive database statistics for dashboard"""
try:
stats = self._get_database_stats()
# Get optional time window parameters for analytics
top_users_window = request.args.get('top_users_window', 'all')
top_commands_window = request.args.get('top_commands_window', 'all')
top_paths_window = request.args.get('top_paths_window', 'all')
top_channels_window = request.args.get('top_channels_window', 'all')
stats = self._get_database_stats(
top_users_window=top_users_window,
top_commands_window=top_commands_window,
top_paths_window=top_paths_window,
top_channels_window=top_channels_window
)
return jsonify(stats)
except Exception as e:
self.logger.error(f"Error getting stats: {e}")
@@ -795,7 +805,8 @@ class BotDataViewer:
except Exception as e:
self.logger.error(f"Error cleaning up old packet stream data: {e}")
def _get_database_stats(self):
def _get_database_stats(self, top_users_window='all', top_commands_window='all',
top_paths_window='all', top_channels_window='all'):
"""Get comprehensive database statistics for dashboard"""
conn = None
try:
@@ -971,14 +982,25 @@ class BotDataViewer:
cursor.execute("SELECT COUNT(DISTINCT channel) FROM message_stats WHERE channel IS NOT NULL")
stats['unique_channels_total'] = cursor.fetchone()[0]
# Top users (most frequent message senders)
cursor.execute("""
# Top users (most frequent message senders) - filter by time window
if top_users_window == '24h':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')"
elif top_users_window == '7d':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')"
elif top_users_window == '30d':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')"
else: # 'all'
time_filter = ""
query = f"""
SELECT sender_id, COUNT(*) as count
FROM message_stats
{time_filter}
GROUP BY sender_id
ORDER BY count DESC
LIMIT 15
""")
"""
cursor.execute(query)
stats['top_users'] = [{'user': row[0], 'count': row[1]} for row in cursor.fetchall()]
if 'command_stats' in tables:
@@ -991,14 +1013,25 @@ class BotDataViewer:
""")
stats['commands_24h'] = cursor.fetchone()[0]
# Top commands
cursor.execute("""
# Top commands - filter by time window
if top_commands_window == '24h':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')"
elif top_commands_window == '7d':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')"
elif top_commands_window == '30d':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')"
else: # 'all'
time_filter = ""
query = f"""
SELECT command_name, COUNT(*) as count
FROM command_stats
{time_filter}
GROUP BY command_name
ORDER BY count DESC
LIMIT 15
""")
"""
cursor.execute(query)
stats['top_commands'] = [{'command': row[0], 'count': row[1]} for row in cursor.fetchall()]
# Bot reply rate (commands that got responses)
@@ -1012,15 +1045,25 @@ class BotDataViewer:
else:
stats['bot_reply_rate'] = 0
# Top channels by message count
cursor.execute("""
# Top channels by message count - filter by time window
if top_channels_window == '24h':
time_filter = "AND timestamp > strftime('%s', 'now', '-24 hours')"
elif top_channels_window == '7d':
time_filter = "AND timestamp > strftime('%s', 'now', '-7 days')"
elif top_channels_window == '30d':
time_filter = "AND timestamp > strftime('%s', 'now', '-30 days')"
else: # 'all'
time_filter = ""
query = f"""
SELECT channel, COUNT(*) as message_count, COUNT(DISTINCT sender_id) as unique_users
FROM message_stats
WHERE channel IS NOT NULL
WHERE channel IS NOT NULL {time_filter}
GROUP BY channel
ORDER BY message_count DESC
LIMIT 10
""")
"""
cursor.execute(query)
stats['top_channels'] = [
{'channel': row[0], 'messages': row[1], 'users': row[2]}
for row in cursor.fetchall()
@@ -1043,13 +1086,24 @@ class BotDataViewer:
'timestamp': longest_path[3]
}
# Top paths (longest paths)
cursor.execute("""
# Top paths (longest paths) - filter by time window
if top_paths_window == '24h':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')"
elif top_paths_window == '7d':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')"
elif top_paths_window == '30d':
time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')"
else: # 'all'
time_filter = ""
query = f"""
SELECT sender_id, path_length, path_string, timestamp
FROM path_stats
{time_filter}
ORDER BY path_length DESC
LIMIT 5
""")
"""
cursor.execute(query)
stats['top_paths'] = [
{
'user': row[0],
@@ -1373,23 +1427,137 @@ class BotDataViewer:
server_stats['total_advertisements'] = cursor.fetchone()[0] or 0
# Nodes per day statistics
# Calculate today's unique nodes from complete_contact_tracking
# (last_heard in last 24 hours) since daily_stats might not have today's data yet
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date = date('now')
SELECT COUNT(DISTINCT public_key) FROM complete_contact_tracking
WHERE last_heard >= datetime('now', '-24 hours')
""")
server_stats['nodes_24h'] = cursor.fetchone()[0] or 0
# Get today's unique nodes by role for the stacked chart
cursor.execute("""
SELECT role, COUNT(DISTINCT public_key) as count
FROM complete_contact_tracking
WHERE last_heard >= datetime('now', '-24 hours')
AND role IS NOT NULL AND role != ''
GROUP BY role
""")
today_by_role = {}
for row in cursor.fetchall():
role = row[0].lower() if row[0] else 'unknown'
count = row[1]
today_by_role[role] = count
server_stats['nodes_24h_by_role'] = {
'companion': today_by_role.get('companion', 0),
'repeater': today_by_role.get('repeater', 0),
'roomserver': today_by_role.get('roomserver', 0),
'sensor': today_by_role.get('sensor', 0),
'other': sum(v for k, v in today_by_role.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor'])
}
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', '-7 days') AND date < date('now')
""")
server_stats['nodes_7d'] = cursor.fetchone()[0] or 0
# Calculate day-over-day and period-over-period comparisons
# Today vs 7 days ago (single day comparison)
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date = date('now', '-7 days')
""")
result = cursor.fetchone()
server_stats['nodes_7d_ago'] = result[0] if result and result[0] else 0
# Last 7 days vs previous 7 days (days 8-14 ago)
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', '-14 days') AND date < date('now', '-7 days')
""")
result = cursor.fetchone()
server_stats['nodes_prev_7d'] = result[0] if result and result[0] else 0
# Last 30 days vs previous 30 days (days 31-60 ago)
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', '-60 days') AND date < date('now', '-30 days')
""")
result = cursor.fetchone()
server_stats['nodes_prev_30d'] = result[0] if result and result[0] else 0
# Also get current period totals for comparison
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', '-7 days')
""")
server_stats['nodes_7d'] = cursor.fetchone()[0] or 0
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', '-30 days')
""")
server_stats['nodes_30d'] = cursor.fetchone()[0] or 0
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
""")
server_stats['nodes_all'] = cursor.fetchone()[0] or 0
# Get daily unique node counts by role for the last 30 days for the stacked graph
# Join daily_stats with complete_contact_tracking to get role information
# This gives us accurate historical daily counts by role
cursor.execute("""
SELECT ds.date, c.role, COUNT(DISTINCT ds.public_key) as daily_count
FROM daily_stats ds
LEFT JOIN complete_contact_tracking c ON ds.public_key = c.public_key
WHERE ds.date >= date('now', '-30 days') AND ds.date <= date('now')
AND (c.role IS NOT NULL AND c.role != '')
GROUP BY ds.date, c.role
ORDER BY ds.date ASC, c.role ASC
""")
daily_data_by_role = cursor.fetchall()
# Organize data by date and role
daily_by_role = {}
for row in daily_data_by_role:
date_str = row[0]
role = (row[1] or 'unknown').lower()
count = row[2]
if date_str not in daily_by_role:
daily_by_role[date_str] = {}
daily_by_role[date_str][role] = count
# Convert to array format with all roles for each date
server_stats['daily_nodes_30d_by_role'] = []
for date_str in sorted(daily_by_role.keys()):
roles_data = daily_by_role[date_str]
server_stats['daily_nodes_30d_by_role'].append({
'date': date_str,
'companion': roles_data.get('companion', 0),
'repeater': roles_data.get('repeater', 0),
'roomserver': roles_data.get('roomserver', 0),
'sensor': roles_data.get('sensor', 0),
'other': sum(v for k, v in roles_data.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor'])
})
# Also keep the total count for backward compatibility
cursor.execute("""
SELECT date, COUNT(DISTINCT public_key) as daily_count
FROM daily_stats
WHERE date >= date('now', '-30 days') AND date <= date('now')
GROUP BY date
ORDER BY date ASC
""")
daily_data = cursor.fetchall()
server_stats['daily_nodes_30d'] = [
{'date': row[0], 'count': row[1]}
for row in daily_data
]
except Exception as e:
self.logger.debug(f"Could not get server stats: {e}")
+3
View File
@@ -193,6 +193,9 @@
<!-- Moment.js for date formatting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>
<!-- Chart.js for graphs -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Base JavaScript -->
<script>
// Global connection management
+184 -48
View File
@@ -68,6 +68,7 @@
<div class="card h-100">
<div class="card-header">
<i class="fas fa-plus-circle"></i> New Devices
<small class="text-muted d-block" style="font-size: 0.75em; font-weight: normal; margin-top: 2px;">Last 7 days</small>
</div>
<div class="card-body d-flex align-items-center">
<div class="row text-center w-100">
@@ -92,21 +93,10 @@
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-chart-line"></i> Activity Trends
<i class="fas fa-chart-line"></i> Activity Trends (30 Days)
</div>
<div class="card-body d-flex align-items-center">
<div class="row text-center w-100">
<div class="col-6">
<div id="trend-today">
<div class="loading">Loading...</div>
</div>
</div>
<div class="col-6">
<div id="trend-week">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<div class="card-body">
<canvas id="activityTrendsChart" style="max-height: 250px;"></canvas>
</div>
</div>
</div>
@@ -189,6 +179,7 @@ class ModernContactsManager {
this.searchTerm = '';
this.sortColumn = 'last_seen';
this.sortDirection = 'desc';
this.activityChart = null; // Chart.js instance for activity trends
this.initializeContacts();
}
@@ -560,45 +551,190 @@ class ModernContactsManager {
document.getElementById('new-repeaters').textContent = newRepeaters;
document.getElementById('new-room-servers').textContent = newRoomServers;
// Update activity trends
this.updateActivityTrends(contacts24h, contacts7d, contactsAll);
// Update activity trends chart
this.updateActivityTrendsChart();
}
updateActivityTrends(contacts24h, contacts7d, contactsAll) {
// Today's trend (24h vs 7d average)
const dailyAverage = contacts7d / 7;
const todayTrend = dailyAverage > 0 ? Math.round((contacts24h / dailyAverage) * 100) : 0;
const todayTrendIcon = todayTrend > 100 ? '📈' : todayTrend < 100 ? '📉' : '➡️';
updateActivityTrendsChart() {
const serverStats = this.contactsData.server_stats || {};
const dailyDataByRole = serverStats.daily_nodes_30d_by_role || [];
const nodesTodayByRole = serverStats.nodes_24h_by_role || {};
document.getElementById('trend-today').innerHTML = `
<div class="text-center">
<h5 class="mb-1">${contacts24h}</h5>
<div class="d-flex justify-content-center align-items-center">
<span class="me-1">${todayTrendIcon}</span>
<span class="badge bg-${todayTrend > 100 ? 'success' : todayTrend < 100 ? 'warning' : 'secondary'} small">
${todayTrend}%
</span>
</div>
<small class="text-muted">Today</small>
</div>
`;
// Destroy existing chart if it exists
if (this.activityChart) {
this.activityChart.destroy();
}
// Week's trend (7d vs all time)
const weekTrend = contactsAll > 0 ? Math.round((contacts7d / contactsAll) * 100) : 0;
const weekTrendIcon = weekTrend > 50 ? '📈' : weekTrend < 30 ? '📉' : '➡️';
// Prepare data for the last 30 days by role
// Create a map of date -> role counts for quick lookup
const dataMapByRole = {};
dailyDataByRole.forEach(item => {
dataMapByRole[item.date] = {
companion: item.companion || 0,
repeater: item.repeater || 0,
roomserver: item.roomserver || 0,
sensor: item.sensor || 0,
other: item.other || 0
};
});
document.getElementById('trend-week').innerHTML = `
<div class="text-center">
<h5 class="mb-1">${contacts7d}</h5>
<div class="d-flex justify-content-center align-items-center">
<span class="me-1">${weekTrendIcon}</span>
<span class="badge bg-${weekTrend > 50 ? 'success' : weekTrend < 30 ? 'warning' : 'secondary'} small">
${weekTrend}%
</span>
</div>
<small class="text-muted">Week</small>
</div>
`;
// Generate labels and data arrays for each role
const labels = [];
const companionData = [];
const repeaterData = [];
const roomserverData = [];
const sensorData = [];
const otherData = [];
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format for today
for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD format
labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
// Use today's data from server stats if this is today, otherwise use dataMapByRole
if (dateStr === todayStr) {
companionData.push(nodesTodayByRole.companion || 0);
repeaterData.push(nodesTodayByRole.repeater || 0);
roomserverData.push(nodesTodayByRole.roomserver || 0);
sensorData.push(nodesTodayByRole.sensor || 0);
otherData.push(nodesTodayByRole.other || 0);
} else {
const dayData = dataMapByRole[dateStr] || {};
companionData.push(dayData.companion || 0);
repeaterData.push(dayData.repeater || 0);
roomserverData.push(dayData.roomserver || 0);
sensorData.push(dayData.sensor || 0);
otherData.push(dayData.other || 0);
}
}
// Get canvas context
const ctx = document.getElementById('activityTrendsChart');
if (!ctx) return;
// Create the stacked chart
this.activityChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Companions',
data: companionData,
borderColor: 'rgb(255, 193, 7)', // Yellow/warning color
backgroundColor: 'rgba(255, 193, 7, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Repeaters',
data: repeaterData,
borderColor: 'rgb(13, 110, 253)', // Blue/primary color
backgroundColor: 'rgba(13, 110, 253, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Room Servers',
data: roomserverData,
borderColor: 'rgb(25, 135, 84)', // Green/success color
backgroundColor: 'rgba(25, 135, 84, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Sensors',
data: sensorData,
borderColor: 'rgb(13, 202, 240)', // Light blue/info color
backgroundColor: 'rgba(13, 202, 240, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Other',
data: otherData,
borderColor: 'rgb(108, 117, 125)', // Gray/secondary color
backgroundColor: 'rgba(108, 117, 125, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11
}
}
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
footer: function(tooltipItems) {
let total = 0;
tooltipItems.forEach(item => {
total += item.parsed.y || 0;
});
return 'Total: ' + total;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
maxRotation: 45,
minRotation: 45,
font: {
size: 9
}
}
},
y: {
stacked: true,
beginAtZero: true,
ticks: {
precision: 0
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
}
},
interaction: {
mode: 'index',
intersect: false
}
}
});
}
formatLastSeen(lastSeen) {
+177 -93
View File
@@ -58,7 +58,7 @@
<!-- Network Activity Overview -->
<div class="row mb-4">
<div class="col-md-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="fas fa-users"></i> Active Contacts
@@ -82,31 +82,7 @@
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<i class="fas fa-broadcast-tower"></i> Advertisements
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-4">
<h4 id="advertisements-24h" class="text-primary">0</h4>
<small class="text-muted">24h</small>
</div>
<div class="col-4">
<h4 id="advertisements-7d" class="text-info">0</h4>
<small class="text-muted">7d</small>
</div>
<div class="col-4">
<h4 id="total-advertisements" class="text-success">0</h4>
<small class="text-muted">All</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="fas fa-network-wired"></i> Network Health
@@ -131,29 +107,9 @@
</div>
</div>
<!-- Network Performance - Three Columns -->
<!-- Network Performance - Two Columns -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-signal"></i> Signal Quality
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h4 id="avg-snr-24h" class="text-success">0</h4>
<small class="text-muted">Avg SNR (24h)</small>
</div>
<div class="col-6">
<h4 id="avg-signal-strength-24h" class="text-info">0</h4>
<small class="text-muted">Avg Signal (24h)</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-globe"></i> Geographic Coverage
@@ -177,7 +133,7 @@
</div>
</div>
<div class="col-md-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-hashtag"></i> Channel Statistics
@@ -203,7 +159,7 @@
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-users"></i> User Statistics
<i class="fas fa-users"></i> Bot User Statistics
</div>
<div class="card-body text-center">
<div class="row text-center">
@@ -265,8 +221,16 @@
<div class="row mb-4" id="analytics-section">
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-trophy"></i> Most Popular Commands
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-trophy"></i> Most Popular Commands
</div>
<select id="top-commands-time-window" class="form-select form-select-sm" style="width: auto; font-size: 0.75rem;">
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
<option value="all" selected>All</option>
</select>
</div>
<div class="card-body">
<div id="top-commands-list">
@@ -278,8 +242,16 @@
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-users"></i> Most Active Users
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-users"></i> Most Active Users
</div>
<select id="top-users-time-window" class="form-select form-select-sm" style="width: auto; font-size: 0.75rem;">
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
<option value="all" selected>All</option>
</select>
</div>
<div class="card-body">
<div id="top-users-list">
@@ -291,8 +263,16 @@
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-route"></i> Longest Network Paths
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-route"></i> Longest Paths
</div>
<select id="longest-paths-time-window" class="form-select form-select-sm" style="width: auto; font-size: 0.75rem;">
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
<option value="all" selected>All</option>
</select>
</div>
<div class="card-body">
<div id="longest-paths-list">
@@ -304,8 +284,16 @@
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-hashtag"></i> Top Channels
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-hashtag"></i> Top Channels
</div>
<select id="top-channels-time-window" class="form-select form-select-sm" style="width: auto; font-size: 0.75rem;">
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
<option value="all" selected>All</option>
</select>
</div>
<div class="card-body">
<div id="top-channels-list">
@@ -336,6 +324,41 @@ class ModernDashboard {
this.startAutoRefresh();
this.updateUptime();
this.updateTimestamp();
this.setupTimeWindowSelector();
}
setupTimeWindowSelector() {
// Setup selector for Most Active Users
const usersSelector = document.getElementById('top-users-time-window');
if (usersSelector) {
usersSelector.addEventListener('change', (e) => {
this.loadTopUsers(e.target.value);
});
}
// Setup selector for Most Popular Commands
const commandsSelector = document.getElementById('top-commands-time-window');
if (commandsSelector) {
commandsSelector.addEventListener('change', (e) => {
this.loadTopCommands(e.target.value);
});
}
// Setup selector for Longest Paths
const pathsSelector = document.getElementById('longest-paths-time-window');
if (pathsSelector) {
pathsSelector.addEventListener('change', (e) => {
this.loadLongestPaths(e.target.value);
});
}
// Setup selector for Top Channels
const channelsSelector = document.getElementById('top-channels-time-window');
if (channelsSelector) {
channelsSelector.addEventListener('change', (e) => {
this.loadTopChannels(e.target.value);
});
}
}
async loadDashboardData() {
@@ -369,6 +392,17 @@ class ModernDashboard {
// Update all metrics
this.updateMetrics(statsData);
// Load analytics with default time windows
const usersTimeWindow = document.getElementById('top-users-time-window')?.value || 'all';
const commandsTimeWindow = document.getElementById('top-commands-time-window')?.value || 'all';
const pathsTimeWindow = document.getElementById('longest-paths-time-window')?.value || 'all';
const channelsTimeWindow = document.getElementById('top-channels-time-window')?.value || 'all';
this.loadTopUsers(usersTimeWindow);
this.loadTopCommands(commandsTimeWindow);
this.loadLongestPaths(pathsTimeWindow);
this.loadTopChannels(channelsTimeWindow);
// Update database status
this.updateDatabaseStatus('Connected', 'success');
@@ -397,16 +431,9 @@ class ModernDashboard {
this.updateElement('contacts-7d', data.contacts_7d);
this.updateElement('tracked-contacts', data.tracked_contacts);
this.updateElement('total-advertisements', data.total_advertisements);
this.updateElement('advertisements-24h', data.advertisements_24h);
this.updateElement('advertisements-7d', data.advertisements_7d);
this.updateElement('avg-hop-count', data.avg_hop_count);
this.updateElement('max-hop-count', data.max_hop_count);
this.updateElement('avg-snr-24h', data.avg_snr_24h);
this.updateElement('avg-signal-strength-24h', data.avg_signal_strength_24h);
this.updateElement('countries', data.countries);
this.updateElement('states', data.states);
this.updateElement('cities', data.cities);
@@ -427,34 +454,7 @@ class ModernDashboard {
this.updateElement('unique-channels-total', data.unique_channels_total);
this.updateElement('bot-reply-rate', data.bot_reply_rate ? `${data.bot_reply_rate}%` : '0%');
// Analytics section - show if any analytics have data
let hasAnalytics = false;
// Top commands
if (data.top_commands && data.top_commands.length > 0) {
this.updateTopCommands(data.top_commands);
hasAnalytics = true;
}
// Top users
if (data.top_users && data.top_users.length > 0) {
this.updateTopUsers(data.top_users);
hasAnalytics = true;
}
// Longest paths
if (data.top_paths && data.top_paths.length > 0) {
this.updateLongestPaths(data.top_paths);
hasAnalytics = true;
}
// Top channels
if (data.top_channels && data.top_channels.length > 0) {
this.updateTopChannels(data.top_channels);
hasAnalytics = true;
}
// Analytics section is always visible - no need to control display
// Analytics are loaded separately with time window selectors
}
updateElement(elementId, value) {
@@ -466,6 +466,27 @@ class ModernDashboard {
}
}
async loadTopCommands(timeWindow = 'all') {
try {
const response = await fetch(`/api/stats?top_commands_window=${timeWindow}`);
const data = await response.json();
if (data.error) {
console.error('Error loading top commands:', data.error);
return;
}
if (data.top_commands && data.top_commands.length > 0) {
this.updateTopCommands(data.top_commands);
} else {
const container = document.getElementById('top-commands-list');
container.innerHTML = '<div class="text-muted text-center">No commands found</div>';
}
} catch (error) {
console.error('Error loading top commands:', error);
}
}
updateTopCommands(commands) {
const container = document.getElementById('top-commands-list');
container.innerHTML = commands.map((cmd, index) => `
@@ -479,6 +500,27 @@ class ModernDashboard {
`).join('');
}
async loadTopUsers(timeWindow = 'all') {
try {
const response = await fetch(`/api/stats?top_users_window=${timeWindow}`);
const data = await response.json();
if (data.error) {
console.error('Error loading top users:', data.error);
return;
}
if (data.top_users && data.top_users.length > 0) {
this.updateTopUsers(data.top_users);
} else {
const container = document.getElementById('top-users-list');
container.innerHTML = '<div class="text-muted text-center">No users found</div>';
}
} catch (error) {
console.error('Error loading top users:', error);
}
}
updateTopUsers(users) {
const container = document.getElementById('top-users-list');
container.innerHTML = users.map((user, index) => `
@@ -492,6 +534,27 @@ class ModernDashboard {
`).join('');
}
async loadLongestPaths(timeWindow = 'all') {
try {
const response = await fetch(`/api/stats?top_paths_window=${timeWindow}`);
const data = await response.json();
if (data.error) {
console.error('Error loading longest paths:', data.error);
return;
}
if (data.top_paths && data.top_paths.length > 0) {
this.updateLongestPaths(data.top_paths);
} else {
const container = document.getElementById('longest-paths-list');
container.innerHTML = '<div class="text-muted text-center">No paths found</div>';
}
} catch (error) {
console.error('Error loading longest paths:', error);
}
}
updateLongestPaths(paths) {
const container = document.getElementById('longest-paths-list');
container.innerHTML = paths.map((path, index) => `
@@ -511,6 +574,27 @@ class ModernDashboard {
`).join('');
}
async loadTopChannels(timeWindow = 'all') {
try {
const response = await fetch(`/api/stats?top_channels_window=${timeWindow}`);
const data = await response.json();
if (data.error) {
console.error('Error loading top channels:', data.error);
return;
}
if (data.top_channels && data.top_channels.length > 0) {
this.updateTopChannels(data.top_channels);
} else {
const container = document.getElementById('top-channels-list');
container.innerHTML = '<div class="text-muted text-center">No channels found</div>';
}
} catch (error) {
console.error('Error loading top channels:', error);
}
}
updateTopChannels(channels) {
const container = document.getElementById('top-channels-list');
container.innerHTML = channels.map((channel, index) => `