Files
meshcore-bot/modules/web_viewer/templates/index.html
T
agessaman fbf39958f1 Enhance multibyte path chart rendering in web viewer
- Refactored the logic for displaying multibyte path statistics in the doughnut chart, improving clarity and responsiveness.
- Introduced a function to disable animations for smoother updates when data changes.
- Updated chart options to dynamically reflect multibyte and other path data, enhancing user experience and visual representation.

These changes improve the accuracy and usability of the multibyte path statistics in the dashboard.
2026-04-05 16:36:58 -07:00

1475 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Bot Statistics - MeshCore Bot Data Viewer{% endblock %}
{% block extra_css %}
<style>
/* Equal-height columns: stacks fill the same vertical space as the path-encoding card (lg+) */
@media (min-width: 992px) {
.dashboard-overview-row > .col-lg-4 {
display: flex;
flex-direction: column;
min-height: 0;
}
.dashboard-overview-stack {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
width: 100%;
}
}
/* Multibyte explainer tooltip — allow wrapping for long copy */
.tooltip.multibyte-capable-hint .tooltip-inner {
max-width: min(22rem, 92vw);
text-align: left;
}
.path-encoding-info-btn:hover,
.path-encoding-info-btn:focus {
text-decoration: none;
color: var(--bs-info, #0dcaf0) !important;
}
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-chart-bar"></i> Bot Statistics
</h1>
</div>
</div>
<!-- System Health Overview -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-gradient-primary text-white">
<div class="d-flex align-items-center">
<i class="fas fa-heartbeat me-2"></i>
<h5 class="mb-0">Bot Health Status</h5>
</div>
</div>
<div class="card-body p-3">
<div class="row text-center health-status-card">
<div class="col-md-3">
<div class="d-flex align-items-center justify-content-center mb-2">
<span class="status-indicator status-connected me-2"></span>
<span id="system-status" class="fw-bold">Online</span>
</div>
<h3 class="text-primary mb-1">
<a href="#" id="connected-clients" class="text-primary text-decoration-none"
data-bs-toggle="modal" data-bs-target="#connectedClientsModal"
onclick="window.dashboard && window.dashboard.loadConnectedClients()">0</a>
</h3>
<small class="text-muted text-uppercase">Connected Clients</small>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center align-items-center">
<div class="mb-2">
<div id="database-status">
<div class="loading">Loading...</div>
</div>
</div>
<small class="text-muted text-uppercase">Database Connection</small>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center align-items-center">
<h3 id="uptime" class="text-success mb-1">0s</h3>
<small class="text-muted text-uppercase">System Uptime</small>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center align-items-center">
<h6 id="last-update" class="text-info mb-1">Loading...</h6>
<small class="text-muted text-uppercase">Last Data Refresh</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Network overview: 3 columns — (Active + Devices&amp;cache) | (Network + Geo) | Path encoding -->
<div class="row mb-4 g-3 align-items-stretch dashboard-overview-row">
<div class="col-12 col-lg-4">
<div class="dashboard-overview-stack">
<div class="card flex-shrink-0">
<div class="card-header">
<i class="fas fa-users"></i> Active Contacts
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-4">
<h4 id="contacts-24h" class="text-primary">0</h4>
<small class="text-muted">24h</small>
</div>
<div class="col-4">
<h4 id="contacts-7d" class="text-info">0</h4>
<small class="text-muted">7d</small>
</div>
<div class="col-4">
<h4 id="total-contacts" class="text-success">0</h4>
<small class="text-muted">All</small>
</div>
</div>
</div>
</div>
<div class="card flex-grow-1 d-flex flex-column">
<div class="card-header">
<i class="fas fa-microchip"></i> Devices &amp; cache
</div>
<div class="card-body flex-grow-1 d-flex align-items-center justify-content-center">
<div class="row text-center w-100">
<div class="col-4">
<h4 id="unique-device-types" class="text-primary">0</h4>
<small class="text-muted">Device types</small>
</div>
<div class="col-4">
<h4 id="unique-roles" class="text-info">0</h4>
<small class="text-muted">Roles</small>
</div>
<div class="col-4">
<h4 id="active-cache-entries" class="text-success">0</h4>
<small class="text-muted">Active cache</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="dashboard-overview-stack">
<div class="card flex-shrink-0">
<div class="card-header">
<i class="fas fa-network-wired"></i> Network Health
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-4">
<h4 id="avg-hop-count" class="text-warning">0</h4>
<small class="text-muted">Avg Hops</small>
</div>
<div class="col-4">
<h4 id="max-hop-count" class="text-danger">0</h4>
<small class="text-muted">Max Hops</small>
</div>
<div class="col-4">
<h4 id="tracked-contacts" class="text-info">0</h4>
<small class="text-muted">Tracked</small>
</div>
</div>
</div>
</div>
<div class="card flex-grow-1 d-flex flex-column">
<div class="card-header">
<i class="fas fa-globe"></i> Geographic Coverage
</div>
<div class="card-body flex-grow-1 d-flex align-items-center justify-content-center">
<div class="row text-center w-100">
<div class="col-4">
<h4 id="countries" class="text-primary">0</h4>
<small class="text-muted">Countries</small>
</div>
<div class="col-4">
<h4 id="states" class="text-info">0</h4>
<small class="text-muted">States</small>
</div>
<div class="col-4">
<h4 id="cities" class="text-success">0</h4>
<small class="text-muted">Cities</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="dashboard-overview-stack">
<div class="card h-100 d-flex flex-column flex-grow-1">
<div class="card-header">
<i class="fas fa-chart-pie"></i> Path encoding (7d)
</div>
<div class="card-body py-3 flex-grow-1">
<div class="row g-3 justify-content-center align-items-start">
<div class="col-12 col-md-6 text-center">
<p class="small text-muted mb-2 d-flex align-items-center justify-content-center gap-1 flex-wrap">
<span>Contacts (last 7 days)</span>
<button type="button" class="btn btn-link p-0 border-0 align-baseline text-muted path-encoding-info-btn"
id="contacts-multibyte-chart-info"
aria-label="How multibyte capable contacts are counted"
data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</p>
<div class="path-encoding-pie-wrap mx-auto" style="max-width: 200px; width: 100%;">
<canvas id="pathEncodingPieChart" aria-label="Multibyte path share among contacts last 7 days"></canvas>
</div>
<p class="small text-muted mt-2 mb-0" id="path-encoding-pie-summary"></p>
</div>
<div class="col-12 col-md-6 text-center">
<p class="small text-muted mb-2">Incoming packets (last 7 days)</p>
<div class="path-encoding-pie-wrap mx-auto" style="max-width: 200px; width: 100%;">
<canvas id="incomingPacketsPieChart" aria-label="Multibyte path share among incoming packets last 7 days"></canvas>
</div>
<p class="small text-muted mt-2 mb-0" id="incoming-packets-pie-summary"></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Network Performance -->
<div class="row mb-4">
<div class="col-12">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-hashtag"></i> Channel Statistics
</div>
<div class="card-body text-center">
<div class="row text-center">
<div class="col-3">
<h4 id="unique-channels-total" class="text-success">0</h4>
<small class="text-muted">Total Channels</small>
</div>
<div class="col-3">
<h4 id="bot-reply-rate-24h" class="text-warning">0%</h4>
<small class="text-muted">24h Reply Rate</small>
</div>
<div class="col-3">
<h4 id="bot-reply-rate-7d" class="text-warning">0%</h4>
<small class="text-muted">7d Reply Rate</small>
</div>
<div class="col-3">
<h4 id="bot-reply-rate-30d" class="text-warning">0%</h4>
<small class="text-muted">30d Reply Rate</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Network Activity - Three Columns -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-users"></i> Bot User Statistics
</div>
<div class="card-body text-center">
<div class="row text-center">
<div class="col-6">
<h4 id="unique-senders-24h" class="text-info">0</h4>
<small class="text-muted">24h Active</small>
</div>
<div class="col-6">
<h4 id="unique-users-total" class="text-primary">0</h4>
<small class="text-muted">Total Users</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-comments"></i> Message Activity
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h4 id="messages-24h" class="text-info">0</h4>
<small class="text-muted">24h Messages</small>
</div>
<div class="col-6">
<h4 id="total-messages" class="text-primary">0</h4>
<small class="text-muted">Total Messages</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-terminal"></i> Command Activity
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h4 id="commands-24h" class="text-warning">0</h4>
<small class="text-muted">24h Commands</small>
</div>
<div class="col-6">
<h4 id="total-commands" class="text-success">0</h4>
<small class="text-muted">Total Commands</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Analytics Section - Four Columns -->
<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 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">
<div class="loading">Loading command statistics...</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<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">
<div class="loading">Loading user statistics...</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<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">
<div class="loading">Loading path statistics...</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card h-100">
<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">
<div class="loading">Loading channel statistics...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Live Activity Feed -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="fas fa-satellite-dish me-2"></i>Live Activity
<span id="live-dot" class="status-indicator status-disconnected ms-2" title="SocketIO connection"></span>
</span>
<div class="d-flex gap-2 align-items-center flex-wrap">
<!-- Type filters -->
<div class="d-flex gap-2 align-items-center" id="live-filters">
<small class="text-muted">Filter:</small>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox" id="filter-packet" data-type="packet" checked>
<label class="form-check-label" for="filter-packet" style="font-size:0.8rem;color:#fd7e14">Packets</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox" id="filter-command" data-type="command" checked>
<label class="form-check-label" for="filter-command" style="font-size:0.8rem;color:#198754">Commands</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox" id="filter-message" data-type="message" checked>
<label class="form-check-label" for="filter-message" style="font-size:0.8rem;color:#0dcaf0">Messages</label>
</div>
</div>
<!-- Scroll buttons -->
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-top" onclick="scrollLiveFeed('top')" title="Scroll to newest">
<i class="fas fa-arrow-up"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-bottom" onclick="scrollLiveFeed('bottom')" title="Scroll to oldest">
<i class="fas fa-arrow-down"></i>
</button>
<span id="live-count" class="badge bg-secondary">0</span>
<button class="btn btn-sm btn-outline-secondary" id="live-pause-btn" onclick="toggleLivePause()">
<i class="fas fa-pause" id="live-pause-icon"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="clearLiveFeed()">
<i class="fas fa-trash-alt"></i>
</button>
<a href="/realtime" class="btn btn-sm btn-outline-primary">
Full Monitor <i class="fas fa-external-link-alt ms-1"></i>
</a>
</div>
</div>
<div class="card-body p-0">
<div id="live-feed" style="height:260px;overflow-y:auto;background:var(--bg-secondary,#f8f9fa);padding:0.5rem;">
<div class="text-muted text-center py-3" id="live-placeholder">
<i class="fas fa-hourglass-half"></i> Connecting…
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Connected Clients Modal -->
<div class="modal fade" id="connectedClientsModal" tabindex="-1" aria-labelledby="connectedClientsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="connectedClientsModalLabel">
<i class="fas fa-users me-2"></i>Connected Clients
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="connected-clients-loading" class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">Loading…</span>
</div>
<div id="connected-clients-empty" class="text-center text-muted py-3" style="display:none">
No clients currently connected.
</div>
<table class="table table-sm mb-0" id="connected-clients-table" style="display:none">
<thead>
<tr>
<th>Client ID</th>
<th>Connected At</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody id="connected-clients-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
class ModernDashboard {
constructor() {
this.botStartTime = null;
this.updateInterval = null;
this.uptimeInterval = null;
this.pathEncodingPieChart = null;
this.incomingPacketsPieChart = null;
this.initializeDashboard();
}
initializeDashboard() {
// Add a small delay to ensure DOM is ready
setTimeout(() => {
this.loadDashboardData();
}, 100);
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() {
try {
console.log('Loading dashboard data...');
// Load system health
const healthResponse = await fetch('/api/health');
const healthData = await healthResponse.json();
console.log('Health data:', healthData);
document.getElementById('connected-clients').textContent = healthData.connected_clients || 0;
// Store bot uptime for display (null when bot not running / no start time in DB)
const rawUptime = healthData.bot_uptime;
this.botStartTime = (rawUptime != null && rawUptime > 0) ? rawUptime : null;
// Update uptime display and restart live counter with fresh data
this.updateUptime();
this.startLiveUptime();
// Load comprehensive statistics
const statsResponse = await fetch('/api/stats');
const statsData = await statsResponse.json();
console.log('Stats data:', statsData);
if (statsData.error) {
this.showError('Failed to load statistics: ' + statsData.error);
return;
}
// 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');
} catch (error) {
console.error('Error loading dashboard data:', error);
this.showError('Failed to load dashboard data: ' + error.message);
this.updateDatabaseStatus('Error', 'danger');
}
}
updateMetrics(data) {
console.log('Updating metrics with data:', data);
// Test with a simple update first
const testElement = document.getElementById('connected-clients');
if (testElement) {
testElement.textContent = data.connected_clients || 0;
console.log('Updated connected-clients to:', data.connected_clients);
} else {
console.error('Element connected-clients not found!');
}
// Update all metrics with error checking
this.updateElement('total-contacts', data.total_contacts);
this.updateElement('contacts-24h', data.contacts_24h);
this.updateElement('contacts-7d', data.contacts_7d);
this.updateElement('tracked-contacts', data.tracked_contacts);
this.updateElement('avg-hop-count', data.avg_hop_count);
this.updateElement('max-hop-count', data.max_hop_count);
this.updateElement('countries', data.countries);
this.updateElement('states', data.states);
this.updateElement('cities', data.cities);
this.updateElement('unique-device-types', data.unique_device_types);
this.updateElement('unique-roles', data.unique_roles);
this.updateElement('active-cache-entries', data.active_cache_entries);
this.updateElement('total-messages', data.total_messages);
this.updateElement('messages-24h', data.messages_24h);
this.updateElement('total-commands', data.total_commands);
this.updateElement('commands-24h', data.commands_24h);
// Additional statistics
this.updateElement('unique-users-total', data.unique_users_total);
this.updateElement('unique-senders-24h', data.unique_senders_24h);
this.updateElement('unique-channels-total', data.unique_channels_total);
this.updateElement('bot-reply-rate-24h', data.bot_reply_rate_24h !== undefined ? `${data.bot_reply_rate_24h}%` : '0%');
this.updateElement('bot-reply-rate-7d', data.bot_reply_rate_7d !== undefined ? `${data.bot_reply_rate_7d}%` : '0%');
this.updateElement('bot-reply-rate-30d', data.bot_reply_rate_30d !== undefined ? `${data.bot_reply_rate_30d}%` : '0%');
this.updatePathEncodingPieChart(data);
this.updateIncomingPacketsPieChart(data);
// Analytics are loaded separately with time window selectors
}
updatePathEncodingPieChart(data) {
const canvas = document.getElementById('pathEncodingPieChart');
const summaryEl = document.getElementById('path-encoding-pie-summary');
if (!canvas || typeof Chart === 'undefined') {
return;
}
const total = Number(data.contacts_7d) || 0;
let mb = data.contacts_7d_multibyte_path;
mb = Number(mb);
if (!Number.isFinite(mb)) {
mb = 0;
}
mb = Math.max(0, Math.min(mb, total));
const other = Math.max(0, total - mb);
const cs = getComputedStyle(document.documentElement);
const legendColor = (cs.getPropertyValue('--text-muted') || '#6c757d').trim();
const mbColor = '#0891b2';
const otherColor = '#a8a29e';
const emptyColor = (cs.getPropertyValue('--bg-tertiary') || '#dee2e6').trim();
const setNoAnimationOptions = (options) => {
options.animation = false;
options.animations = false;
options.transitions = {
active: { animation: { duration: 0 } },
resize: { animation: { duration: 0 } },
show: { animation: { duration: 0 } },
hide: { animation: { duration: 0 } },
};
};
if (total === 0) {
if (summaryEl) {
summaryEl.textContent = 'No contacts in the last 7 days.';
}
if (!this.pathEncodingPieChart) {
this.pathEncodingPieChart = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['—'],
datasets: [{
data: [1],
backgroundColor: [emptyColor],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
cutout: '55%',
},
});
setNoAnimationOptions(this.pathEncodingPieChart.options);
} else {
this.pathEncodingPieChart.data.labels = ['—'];
this.pathEncodingPieChart.data.datasets[0].data = [1];
this.pathEncodingPieChart.data.datasets[0].backgroundColor = [emptyColor];
this.pathEncodingPieChart.options.plugins.legend.display = false;
this.pathEncodingPieChart.options.plugins.tooltip.enabled = false;
this.pathEncodingPieChart.update('none');
}
return;
}
const pct = Math.round((mb / total) * 1000) / 10;
if (summaryEl) {
summaryEl.textContent = `${pct}% multibyte (${mb} of ${total})`;
}
if (!this.pathEncodingPieChart) {
this.pathEncodingPieChart = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Multibyte paths', 'Other'],
datasets: [{
data: [mb, other],
backgroundColor: [mbColor, otherColor],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: legendColor,
boxWidth: 12,
font: { size: 11 },
},
},
tooltip: {
callbacks: {
label: (ctx) => {
const n = ctx.raw;
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
return ` ${n} (${p}%)`;
},
},
},
},
cutout: '55%',
},
});
setNoAnimationOptions(this.pathEncodingPieChart.options);
} else {
this.pathEncodingPieChart.data.labels = ['Multibyte paths', 'Other'];
this.pathEncodingPieChart.data.datasets[0].data = [mb, other];
this.pathEncodingPieChart.data.datasets[0].backgroundColor = [mbColor, otherColor];
this.pathEncodingPieChart.options.plugins.legend.display = true;
this.pathEncodingPieChart.options.plugins.legend.position = 'bottom';
this.pathEncodingPieChart.options.plugins.legend.labels = {
color: legendColor,
boxWidth: 12,
font: { size: 11 },
};
this.pathEncodingPieChart.options.plugins.tooltip.enabled = true;
this.pathEncodingPieChart.options.plugins.tooltip.callbacks = {
label: (ctx) => {
const n = ctx.raw;
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
return ` ${n} (${p}%)`;
},
};
this.pathEncodingPieChart.update('none');
}
}
updateIncomingPacketsPieChart(data) {
const canvas = document.getElementById('incomingPacketsPieChart');
const summaryEl = document.getElementById('incoming-packets-pie-summary');
if (!canvas || typeof Chart === 'undefined') {
return;
}
const total = Number(data.incoming_packets_7d) || 0;
let mb = data.incoming_packets_7d_multibyte_path;
mb = Number(mb);
if (!Number.isFinite(mb)) {
mb = 0;
}
mb = Math.max(0, Math.min(mb, total));
const other = Math.max(0, total - mb);
const cs = getComputedStyle(document.documentElement);
const legendColor = (cs.getPropertyValue('--text-muted') || '#6c757d').trim();
const mbColor = '#0891b2';
const otherColor = '#a8a29e';
const emptyColor = (cs.getPropertyValue('--bg-tertiary') || '#dee2e6').trim();
const setNoAnimationOptions = (options) => {
options.animation = false;
options.animations = false;
options.transitions = {
active: { animation: { duration: 0 } },
resize: { animation: { duration: 0 } },
show: { animation: { duration: 0 } },
hide: { animation: { duration: 0 } },
};
};
if (total === 0) {
if (summaryEl) {
summaryEl.textContent = 'No incoming packets in the last 7 days.';
}
if (!this.incomingPacketsPieChart) {
this.incomingPacketsPieChart = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['—'],
datasets: [{
data: [1],
backgroundColor: [emptyColor],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
cutout: '55%',
},
});
setNoAnimationOptions(this.incomingPacketsPieChart.options);
} else {
this.incomingPacketsPieChart.data.labels = ['—'];
this.incomingPacketsPieChart.data.datasets[0].data = [1];
this.incomingPacketsPieChart.data.datasets[0].backgroundColor = [emptyColor];
this.incomingPacketsPieChart.options.plugins.legend.display = false;
this.incomingPacketsPieChart.options.plugins.tooltip.enabled = false;
this.incomingPacketsPieChart.update('none');
}
return;
}
const pct = Math.round((mb / total) * 1000) / 10;
if (summaryEl) {
summaryEl.textContent = `${pct}% multibyte (${mb} of ${total})`;
}
if (!this.incomingPacketsPieChart) {
this.incomingPacketsPieChart = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Multibyte paths', 'Other'],
datasets: [{
data: [mb, other],
backgroundColor: [mbColor, otherColor],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: legendColor,
boxWidth: 12,
font: { size: 11 },
},
},
tooltip: {
callbacks: {
label: (ctx) => {
const n = ctx.raw;
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
return ` ${n} (${p}%)`;
},
},
},
},
cutout: '55%',
},
});
setNoAnimationOptions(this.incomingPacketsPieChart.options);
} else {
this.incomingPacketsPieChart.data.labels = ['Multibyte paths', 'Other'];
this.incomingPacketsPieChart.data.datasets[0].data = [mb, other];
this.incomingPacketsPieChart.data.datasets[0].backgroundColor = [mbColor, otherColor];
this.incomingPacketsPieChart.options.plugins.legend.display = true;
this.incomingPacketsPieChart.options.plugins.legend.position = 'bottom';
this.incomingPacketsPieChart.options.plugins.legend.labels = {
color: legendColor,
boxWidth: 12,
font: { size: 11 },
};
this.incomingPacketsPieChart.options.plugins.tooltip.enabled = true;
this.incomingPacketsPieChart.options.plugins.tooltip.callbacks = {
label: (ctx) => {
const n = ctx.raw;
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
return ` ${n} (${p}%)`;
},
};
this.incomingPacketsPieChart.update('none');
}
}
updateElement(elementId, value) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = value || 0;
} else {
console.error(`Element ${elementId} not found!`);
}
}
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) => `
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<span class="badge bg-primary me-2">${index + 1}</span>
<span class="fw-bold">${cmd.command}</span>
</div>
<span class="badge bg-secondary">${cmd.count} uses</span>
</div>
`).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) => `
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<span class="badge bg-success me-2">${index + 1}</span>
<span class="fw-bold text-truncate" style="max-width: 200px;" title="${user.user}">${user.user}</span>
</div>
<span class="badge bg-info">${user.count} messages</span>
</div>
`).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) => `
<div class="mb-3 p-2 border rounded">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-warning me-2">${index + 1}</span>
<span class="fw-bold text-truncate" style="max-width: 150px;" title="${path.user}">${path.user}</span>
<span class="badge bg-danger ms-2">${path.path_length} hops</span>
</div>
<div class="small text-muted mb-1">
<code class="text-break" style="word-break: break-all; font-size: 0.75rem;">${path.path_string}</code>
</div>
<div class="small text-muted">
${new Date(path.timestamp * 1000).toLocaleString()}
</div>
</div>
`).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) => `
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<span class="badge bg-primary me-2">${index + 1}</span>
<span class="fw-bold text-truncate" style="max-width: 200px;" title="${channel.channel}">${channel.channel}</span>
</div>
<div class="text-end">
<span class="badge bg-secondary me-1">${channel.messages} msgs</span>
<span class="badge bg-info">${channel.users} users</span>
</div>
</div>
`).join('');
}
updateDatabaseStatus(status, type) {
const statusElement = document.getElementById('database-status');
statusElement.innerHTML = `
<div class="d-flex align-items-center justify-content-center mb-2">
<span class="status-indicator status-${type} me-2"></span>
<span>${status}</span>
</div>
`;
}
updateUptime() {
const uptimeElement = document.getElementById('uptime');
if (!uptimeElement) return;
if (this.botStartTime != null && this.botStartTime > 0) {
this.formatUptime(this.botStartTime);
} else {
// Bot not running or no start time in DB (e.g. viewer run standalone)
uptimeElement.textContent = 'Bot Down?';
}
}
formatUptime(uptimeSeconds) {
const uptimeElement = document.getElementById('uptime');
const totalMinutes = Math.floor(uptimeSeconds / 60);
const totalHours = Math.floor(uptimeSeconds / 3600);
const totalDays = Math.floor(uptimeSeconds / 86400);
const months = Math.floor(totalDays / 30);
const days = totalDays % 30;
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = Math.floor(uptimeSeconds % 60);
const parts = [];
if (months > 0) parts.push(`${months}mo`);
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (parts.length === 0 || totalHours < 1) {
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
}
uptimeElement.textContent = parts.length > 0 ? parts.join(' ') : '0s';
}
startLiveUptime() {
// Clear any existing uptime interval
if (this.uptimeInterval) {
clearInterval(this.uptimeInterval);
this.uptimeInterval = null;
}
// Only run live counter when we have a valid bot uptime (bot is running)
if (this.botStartTime != null && this.botStartTime > 0) {
this.uptimeInterval = setInterval(() => {
if (this.botStartTime != null) {
this.botStartTime += 1;
this.formatUptime(this.botStartTime);
}
}, 1000);
}
}
updateTimestamp() {
const now = new Date();
const timestamp = now.toLocaleTimeString();
document.getElementById('last-update').textContent = timestamp;
}
startAutoRefresh() {
this.updateInterval = setInterval(() => {
this.loadDashboardData();
this.updateTimestamp();
}, 30000); // Refresh every 30 seconds
}
destroy() {
// Clean up intervals when dashboard is destroyed
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
if (this.uptimeInterval) {
clearInterval(this.uptimeInterval);
}
if (this.pathEncodingPieChart) {
this.pathEncodingPieChart.destroy();
this.pathEncodingPieChart = null;
}
if (this.incomingPacketsPieChart) {
this.incomingPacketsPieChart.destroy();
this.incomingPacketsPieChart = null;
}
}
async loadConnectedClients() {
const loading = document.getElementById('connected-clients-loading');
const empty = document.getElementById('connected-clients-empty');
const table = document.getElementById('connected-clients-table');
const tbody = document.getElementById('connected-clients-tbody');
if (!loading) return;
loading.style.display = '';
empty.style.display = 'none';
table.style.display = 'none';
tbody.innerHTML = '';
try {
const resp = await fetch('/api/connected_clients');
const clients = await resp.json();
loading.style.display = 'none';
if (!Array.isArray(clients) || clients.length === 0) {
empty.style.display = '';
return;
}
table.style.display = '';
for (const c of clients) {
const connAt = c.connected_at
? new Date(c.connected_at * 1000).toLocaleTimeString()
: '—';
const lastAct = c.last_activity
? new Date(c.last_activity * 1000).toLocaleTimeString()
: '—';
const tr = document.createElement('tr');
tr.innerHTML = `<td><code>${c.client_id}</code></td><td>${connAt}</td><td>${lastAct}</td>`;
tbody.appendChild(tr);
}
} catch (e) {
loading.style.display = 'none';
empty.style.display = '';
empty.textContent = 'Failed to load client list.';
}
}
showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
errorDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the top of the content
const content = document.querySelector('.container-fluid');
if (content) {
content.insertBefore(errorDiv, content.firstChild);
// Auto-remove after 10 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 10000);
}
}
}
function initMultibyteCapableTooltips() {
if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return;
const dashboardTip =
'Contacts heard in the last 7 days are counted as multibyte capable if any apply: ' +
'stored path encoding (out bytes per hop) is 2 or 3; ' +
'we saw an advert from them in that window on observed paths with multibyte hops (23 bytes per hop); ' +
'or, for repeaters and room servers, their public key prefix matches a hop prefix from a multibyte advert path in that window.';
const el = document.getElementById('contacts-multibyte-chart-info');
if (el) {
const existing = bootstrap.Tooltip.getInstance(el);
if (existing) existing.dispose();
new bootstrap.Tooltip(el, {
title: dashboardTip,
html: false,
customClass: 'multibyte-capable-hint',
});
}
}
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing dashboard...');
window.dashboard = new ModernDashboard();
initMultibyteCapableTooltips();
});
// Clean up when page is unloaded
window.addEventListener('beforeunload', () => {
if (window.dashboard) {
window.dashboard.destroy();
}
});
</script>
<script>
// ── Live Activity Feed (SocketIO) ─────────────────────────────────────────────
(function () {
const feed = document.getElementById('live-feed');
const dot = document.getElementById('live-dot');
const countBadge = document.getElementById('live-count');
const placeholder = document.getElementById('live-placeholder');
let paused = false;
let total = 0;
const MAX_ENTRIES = 100;
const TYPE_COLORS = {
packet: '#fd7e14',
command: '#198754',
message: '#0dcaf0',
};
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Track active type filters
const activeFilters = { packet: true, command: true, message: true };
function applyFilters() {
Array.from(feed.children).forEach(el => {
const t = el.dataset.type;
if (!t) return; // placeholder or unknown
el.style.display = activeFilters[t] ? '' : 'none';
});
}
// Wire filter checkboxes
document.querySelectorAll('.live-filter-cb').forEach(cb => {
cb.addEventListener('change', () => {
activeFilters[cb.dataset.type] = cb.checked;
applyFilters();
});
});
function addEntry(label, text, type) {
if (paused) return;
if (placeholder && placeholder.parentNode) placeholder.remove();
const ts = new Date().toLocaleTimeString();
const color = TYPE_COLORS[type] || '#6c757d';
const el = document.createElement('div');
el.dataset.type = type;
el.style.cssText = `border-left:3px solid ${color};padding:3px 8px;margin-bottom:3px;font-size:0.82rem;border-radius:2px;`;
el.innerHTML = `<span class="text-muted me-2">${ts}</span><strong>${escHtml(label)}</strong> <span class="text-muted">${escHtml(text)}</span>`;
if (!activeFilters[type]) el.style.display = 'none';
feed.insertBefore(el, feed.firstChild);
total++;
countBadge.textContent = total;
// Trim
const items = feed.children;
while (items.length > MAX_ENTRIES) feed.removeChild(feed.lastChild);
}
window.toggleLivePause = function () {
paused = !paused;
const icon = document.getElementById('live-pause-icon');
icon.className = paused ? 'fas fa-play' : 'fas fa-pause';
};
window.clearLiveFeed = function () {
feed.innerHTML = '';
total = 0;
countBadge.textContent = '0';
};
window.scrollLiveFeed = function (dir) {
feed.scrollTop = dir === 'top' ? 0 : feed.scrollHeight;
};
const socket = io({ transports: ['websocket', 'polling'] });
socket.on('connect', () => {
dot.className = 'status-indicator status-connected ms-2';
socket.emit('subscribe_packets');
socket.emit('subscribe_commands');
socket.emit('subscribe_messages');
});
socket.on('disconnect', () => {
dot.className = 'status-indicator status-disconnected ms-2';
});
socket.on('packet_data', d => {
const ptype = d.payload_type_name || d.type_name || 'Packet';
const src = d.from_name || d.pubkey_prefix || '';
addEntry(ptype, src, 'packet');
});
socket.on('command_data', d => {
addEntry('Cmd: ' + (d.command || '?'), (d.user || '') + ' → ' + (d.channel || ''), 'command');
});
socket.on('message_data', d => {
const ch = d.channel ? `[${d.channel}] ` : (d.is_dm ? '[DM] ' : '');
addEntry(d.sender || '?', ch + (d.content || ''), 'message');
});
})();
</script>
<style>
/* Status indicators moved to enhanced section below */
.loading {
color: var(--text-muted);
font-style: italic;
}
.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--shadow-color);
}
.metric-value {
font-weight: 700;
font-size: 1.5rem;
}
.metric-label {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Analytics section uses Bootstrap responsive classes */
/* Health status card styling */
.health-status-card .col-md-3 {
border-right: 1px solid var(--border-color);
padding: 0.75rem 0.5rem;
position: relative;
}
.health-status-card .col-md-3:last-child {
border-right: none;
}
.health-status-card .col-md-3:first-child {
padding-left: 0;
}
.health-status-card .col-md-3:last-child {
padding-right: 0;
}
/* Enhanced card styling */
.card.shadow-sm {
border: none;
border-radius: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card.shadow-sm:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--shadow-color) !important;
}
.bg-gradient-primary {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border: none;
}
.card-header.bg-gradient-primary {
border-radius: 12px 12px 0 0;
padding: 0.75rem 1.25rem;
}
/* Status indicator enhancements */
.status-indicator {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.2);
animation: pulse 2s infinite;
}
.status-connected {
background-color: #28a745;
}
.status-disconnected {
background-color: #dc3545;
}
.status-warning {
background-color: #ffc107;
}
.status-success {
background-color: #28a745;
}
.status-danger {
background-color: #dc3545;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.4); }
70% { box-shadow: 0 0 0 6px rgba(40, 167, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
}
[data-theme="dark"] .status-indicator {
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.4);
animation: pulse-dark 2s infinite;
}
@keyframes pulse-dark {
0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.6); }
70% { box-shadow: 0 0 0 6px rgba(40, 167, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
}
</style>
{% endblock %}