mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-22 14:01:14 +00:00
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:
1 parent
b1c39edbb8
commit
71d157a974
6 files changed
+572
-161
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => `
|
||||
|
||||
Reference in new issue
Block a user