mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-11 18:14:50 +00:00
f525d2e1d0
- Updated MessageHandler to extract and store path information from packet_info and routing_info, improving data tracking. - Added a new API endpoint in the web viewer for decoding path hex strings to repeater names. - Enhanced the contacts template to display path information with tooltips, improving user experience. - Implemented tooltip functionality for path data in the web viewer, allowing users to view detailed repeater information on hover.
1558 lines
62 KiB
HTML
1558 lines
62 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Contacts - MeshCore Bot Data Viewer{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h1 class="mb-4">
|
|
<i class="fas fa-users"></i> Contacts
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tracking Statistics -->
|
|
<div class="row mb-4">
|
|
<!-- Active Contacts -->
|
|
<div class="col-md-3">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<i class="fas fa-users"></i> Active Contacts
|
|
</div>
|
|
<div class="card-body d-flex align-items-center">
|
|
<div class="row text-center w-100">
|
|
<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="contacts-all" class="text-success">0</h4>
|
|
<small class="text-muted">All</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advertisements -->
|
|
<div class="col-md-3">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<i class="fas fa-broadcast-tower"></i> Advertisements
|
|
</div>
|
|
<div class="card-body d-flex align-items-center">
|
|
<div class="row text-center w-100">
|
|
<div class="col-4">
|
|
<h4 id="adverts-24h" class="text-primary">0</h4>
|
|
<small class="text-muted">24h</small>
|
|
</div>
|
|
<div class="col-4">
|
|
<h4 id="adverts-7d" class="text-info">0</h4>
|
|
<small class="text-muted">7d</small>
|
|
</div>
|
|
<div class="col-4">
|
|
<h4 id="adverts-all" class="text-success">0</h4>
|
|
<small class="text-muted">All</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New Devices -->
|
|
<div class="col-md-3">
|
|
<div class="card h-100">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>
|
|
<i class="fas fa-plus-circle"></i> New Devices
|
|
</span>
|
|
<small class="text-muted" style="font-size: 0.75em; font-weight: normal;">Last 7 days</small>
|
|
</div>
|
|
<div class="card-body d-flex align-items-center">
|
|
<div class="row text-center w-100">
|
|
<div class="col-4">
|
|
<h4 id="new-companions" class="text-primary">0</h4>
|
|
<small class="text-muted">Companions</small>
|
|
</div>
|
|
<div class="col-4">
|
|
<h4 id="new-repeaters" class="text-info">0</h4>
|
|
<small class="text-muted">Repeaters</small>
|
|
</div>
|
|
<div class="col-4">
|
|
<h4 id="new-room-servers" class="text-success">0</h4>
|
|
<small class="text-muted">Room Servers</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Activity Trends -->
|
|
<div class="col-md-3">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<i class="fas fa-chart-line"></i> Activity Trends (30 Days)
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="activityTrendsChart" style="max-height: 250px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tracking Table -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<i class="fas fa-list"></i> Contact Activity
|
|
<div class="float-end d-flex gap-2">
|
|
<div class="input-group" style="width: 300px;">
|
|
<span class="input-group-text">
|
|
<i class="fas fa-search"></i>
|
|
</span>
|
|
<input type="text" class="form-control" id="search-contacts" placeholder="Search contacts..." autocomplete="off">
|
|
</div>
|
|
<button class="btn btn-sm btn-primary" id="refresh-contacts">
|
|
<i class="fas fa-sync"></i> Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th class="sortable" data-sort="username">
|
|
Name <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="device_type">
|
|
Device Type <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="location">
|
|
Location <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="distance">
|
|
Distance <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="snr">
|
|
SNR <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="hop_count">
|
|
Hops <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="first_heard">
|
|
First Heard <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th class="sortable sort-active" data-sort="last_seen">
|
|
Last Heard <i class="fas fa-sort-down"></i>
|
|
</th>
|
|
<th class="sortable" data-sort="advert_count">
|
|
Adverts <i class="fas fa-sort"></i>
|
|
</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="contacts-table-body">
|
|
<tr>
|
|
<td colspan="10" class="text-center">
|
|
<div class="loading">Loading contacts data...</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
class ModernContactsManager {
|
|
constructor() {
|
|
this.contactsData = [];
|
|
this.filteredData = [];
|
|
this.searchTerm = '';
|
|
this.sortColumn = 'last_seen';
|
|
this.sortDirection = 'desc';
|
|
this.activityChart = null; // Chart.js instance for activity trends
|
|
this.initializeContacts();
|
|
}
|
|
|
|
async initializeContacts() {
|
|
await this.loadContactsData();
|
|
this.setupEventHandlers();
|
|
this.applySort();
|
|
this.updateSortIcons();
|
|
this.renderContactsData();
|
|
this.updateStatistics();
|
|
}
|
|
|
|
async loadContactsData() {
|
|
try {
|
|
const response = await fetch('/api/contacts');
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
this.showError('Failed to load contacts data: ' + data.error);
|
|
return;
|
|
}
|
|
|
|
this.contactsData = {
|
|
tracking_data: data.tracking_data || [],
|
|
server_stats: data.server_stats || {}
|
|
};
|
|
this.filteredData = [...this.contactsData.tracking_data];
|
|
|
|
} catch (error) {
|
|
console.error('Error loading contacts data:', error);
|
|
this.showError('Failed to load contacts data: ' + error.message);
|
|
}
|
|
}
|
|
|
|
setupEventHandlers() {
|
|
document.getElementById('refresh-contacts').addEventListener('click', () => {
|
|
this.loadContactsData().then(() => {
|
|
this.applySearch();
|
|
this.applySort();
|
|
this.renderContactsData();
|
|
this.updateStatistics();
|
|
});
|
|
});
|
|
|
|
document.getElementById('search-contacts').addEventListener('input', (e) => {
|
|
this.searchTerm = e.target.value.toLowerCase().trim();
|
|
this.applySearch();
|
|
this.applySort();
|
|
this.renderContactsData();
|
|
this.updateStatistics();
|
|
});
|
|
|
|
// Add click handlers for sortable columns
|
|
document.querySelectorAll('.sortable').forEach(header => {
|
|
header.addEventListener('click', () => {
|
|
const column = header.dataset.sort;
|
|
this.setSort(column);
|
|
this.applySort();
|
|
this.renderContactsData();
|
|
this.updateSortIcons();
|
|
});
|
|
});
|
|
}
|
|
|
|
setSort(column) {
|
|
if (this.sortColumn === column) {
|
|
// Toggle direction if same column
|
|
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
// New column, default to ascending
|
|
this.sortColumn = column;
|
|
this.sortDirection = 'asc';
|
|
}
|
|
}
|
|
|
|
applySort() {
|
|
this.filteredData.sort((a, b) => {
|
|
let aValue = this.getSortValue(a, this.sortColumn);
|
|
let bValue = this.getSortValue(b, this.sortColumn);
|
|
|
|
// Handle null/undefined values
|
|
if (aValue === null || aValue === undefined) aValue = '';
|
|
if (bValue === null || bValue === undefined) bValue = '';
|
|
|
|
// Handle different data types
|
|
if (this.sortColumn === 'last_seen' || this.sortColumn === 'first_heard') {
|
|
// Date sorting
|
|
aValue = new Date(aValue);
|
|
bValue = new Date(bValue);
|
|
} else if (this.sortColumn === 'snr' || this.sortColumn === 'hop_count' || this.sortColumn === 'advert_count' || this.sortColumn === 'distance') {
|
|
// Numeric sorting
|
|
aValue = parseFloat(aValue) || 0;
|
|
bValue = parseFloat(bValue) || 0;
|
|
} else {
|
|
// String sorting
|
|
aValue = String(aValue).toLowerCase();
|
|
bValue = String(bValue).toLowerCase();
|
|
}
|
|
|
|
let comparison = 0;
|
|
if (aValue < bValue) comparison = -1;
|
|
else if (aValue > bValue) comparison = 1;
|
|
|
|
return this.sortDirection === 'desc' ? -comparison : comparison;
|
|
});
|
|
}
|
|
|
|
getSortValue(contact, column) {
|
|
switch (column) {
|
|
case 'username':
|
|
return contact.username || '';
|
|
case 'device_type':
|
|
return contact.device_type || '';
|
|
case 'location':
|
|
return this.getLocationString(contact);
|
|
case 'snr':
|
|
return contact.snr;
|
|
case 'hop_count':
|
|
return contact.hop_count;
|
|
case 'first_heard':
|
|
return contact.first_heard;
|
|
case 'last_seen':
|
|
return contact.last_seen;
|
|
case 'advert_count':
|
|
return contact.advert_count;
|
|
case 'distance':
|
|
return contact.distance || 0;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
getLocationString(contact) {
|
|
if (contact.city && contact.state) {
|
|
return `${contact.city}, ${contact.state}`;
|
|
} else if (contact.city) {
|
|
return contact.city;
|
|
} else if (contact.latitude && contact.longitude) {
|
|
return `${contact.latitude}, ${contact.longitude}`;
|
|
} else {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
updateSortIcons() {
|
|
// Remove all sort icons
|
|
document.querySelectorAll('.sortable i').forEach(icon => {
|
|
icon.className = 'fas fa-sort';
|
|
});
|
|
|
|
// Remove active class from all headers
|
|
document.querySelectorAll('.sortable').forEach(header => {
|
|
header.classList.remove('sort-active');
|
|
});
|
|
|
|
// Add active class and correct icon to current sort column
|
|
const activeHeader = document.querySelector(`[data-sort="${this.sortColumn}"]`);
|
|
if (activeHeader) {
|
|
activeHeader.classList.add('sort-active');
|
|
const icon = activeHeader.querySelector('i');
|
|
if (icon) {
|
|
icon.className = this.sortDirection === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
|
|
}
|
|
}
|
|
}
|
|
|
|
applySearch() {
|
|
if (!this.searchTerm) {
|
|
this.filteredData = [...this.contactsData.tracking_data];
|
|
return;
|
|
}
|
|
|
|
this.filteredData = this.contactsData.tracking_data.filter(contact => {
|
|
// Search in all text fields (case-insensitive)
|
|
const searchableFields = [
|
|
contact.username || '',
|
|
contact.role || '',
|
|
contact.device_type || '',
|
|
contact.city || '',
|
|
contact.state || '',
|
|
contact.country || ''
|
|
];
|
|
|
|
// For public key, only match from the beginning
|
|
const publicKey = contact.user_id || '';
|
|
const publicKeyMatch = publicKey.toLowerCase().startsWith(this.searchTerm);
|
|
|
|
// For other fields, match anywhere in the string
|
|
const otherFieldsMatch = searchableFields.some(field =>
|
|
field.toLowerCase().includes(this.searchTerm)
|
|
);
|
|
|
|
return publicKeyMatch || otherFieldsMatch;
|
|
});
|
|
}
|
|
|
|
renderContactsData() {
|
|
const tbody = document.getElementById('contacts-table-body');
|
|
|
|
if (this.filteredData.length === 0) {
|
|
const message = this.searchTerm ?
|
|
`No contacts match "${this.searchTerm}"` :
|
|
'No contacts data available';
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="10" class="text-center text-muted">
|
|
${message}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = this.filteredData.map(contact => `
|
|
<tr>
|
|
<td>
|
|
<strong>${contact.username || 'Unknown'}</strong>
|
|
<br>
|
|
<small class="text-muted">${contact.user_id ? contact.user_id.substring(0, 16) + '...' : 'Unknown'}</small>
|
|
</td>
|
|
<td>${this.formatDeviceType(contact)}</td>
|
|
<td>${this.formatLocation(contact)}</td>
|
|
<td>${this.formatDistance(contact)}</td>
|
|
<td>${this.formatSignal(contact)}</td>
|
|
<td>${this.formatHops(contact)}</td>
|
|
<td>${this.formatTimestamp(contact.first_heard)}</td>
|
|
<td>${this.formatTimeAgo(contact.last_seen)}</td>
|
|
<td><span class="badge bg-success">${contact.advert_count || 0}</span></td>
|
|
<td>
|
|
<div class="btn-group" role="group">
|
|
${contact.role && (contact.role.toLowerCase() === 'repeater' || contact.role.toLowerCase() === 'roomserver') ?
|
|
`<button class="btn btn-sm ${contact.is_starred ? 'btn-warning' : 'btn-outline-warning'}" onclick="contactsManager.toggleStar('${contact.user_id.replace(/'/g, "\\'")}', this)" title="${contact.is_starred ? 'Unstar contact (removes path bias)' : 'Star contact (adds strong path bias)'}">
|
|
<i class="fas ${contact.is_starred ? 'fa-star' : 'fa-star'}"></i>
|
|
</button>` :
|
|
''
|
|
}
|
|
<button class="btn btn-sm btn-outline-info" onclick="contactsManager.viewAdvertData('${contact.user_id}')" title="View Advertisement Data">
|
|
<i class="fas fa-info-circle"></i>
|
|
</button>
|
|
${contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0 ?
|
|
`<button class="btn btn-sm btn-outline-primary" onclick="contactsManager.geocodeContact('${contact.user_id.replace(/'/g, "\\'")}', this)" title="Geocode Location">
|
|
<i class="fas fa-map-marker-alt"></i>
|
|
</button>` :
|
|
''
|
|
}
|
|
<button class="btn btn-sm btn-outline-danger" onclick="contactsManager.showDeleteConfirmation('${contact.user_id.replace(/'/g, "\\'")}', '${(contact.username || 'Unknown').replace(/'/g, "\\'")}')" title="Delete Contact">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
// Setup path tooltips after rendering
|
|
this.setupPathTooltips();
|
|
}
|
|
|
|
updateStatistics() {
|
|
const now = new Date();
|
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
// Calculate time-based metrics
|
|
const contacts24h = this.filteredData.filter(contact => {
|
|
const lastSeen = new Date(contact.last_seen);
|
|
return lastSeen >= oneDayAgo;
|
|
}).length;
|
|
|
|
const contacts7d = this.filteredData.filter(contact => {
|
|
const lastSeen = new Date(contact.last_seen);
|
|
return lastSeen >= oneWeekAgo;
|
|
}).length;
|
|
|
|
const contactsAll = this.filteredData.length;
|
|
|
|
// Calculate advertisement metrics using daily tracking data
|
|
// Note: These should now be provided by the server from the daily_advertisements table
|
|
// Fallback to client-side calculation if server data not available
|
|
|
|
// Try to get server-provided daily stats first
|
|
const serverStats = this.contactsData.server_stats || {};
|
|
console.log('Contacts data structure:', typeof this.contactsData, 'has server_stats:', 'server_stats' in this.contactsData);
|
|
console.log('Server stats received:', serverStats);
|
|
const adverts24h = (serverStats.advertisements_24h !== undefined) ? serverStats.advertisements_24h : this.filteredData.filter(contact => {
|
|
const lastSeen = new Date(contact.last_seen);
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const contactDate = new Date(lastSeen.getFullYear(), lastSeen.getMonth(), lastSeen.getDate());
|
|
return contactDate.getTime() === today.getTime();
|
|
}).reduce((sum, contact) => sum + (contact.advert_count || 0), 0);
|
|
|
|
const adverts7d = (serverStats.advertisements_7d !== undefined) ? serverStats.advertisements_7d : this.filteredData.filter(contact => {
|
|
const lastSeen = new Date(contact.last_seen);
|
|
return lastSeen >= oneWeekAgo && lastSeen < oneDayAgo;
|
|
}).reduce((sum, contact) => sum + (contact.advert_count || 0), 0);
|
|
|
|
const advertsAll = (serverStats.total_advertisements !== undefined) ? serverStats.total_advertisements : this.filteredData.reduce((sum, contact) => sum + (contact.advert_count || 0), 0);
|
|
|
|
console.log('Final advertisement counts:', {
|
|
'24h': adverts24h,
|
|
'7d': adverts7d,
|
|
'All': advertsAll,
|
|
'using_server_stats': (serverStats.advertisements_24h !== undefined) &&
|
|
(serverStats.advertisements_7d !== undefined) &&
|
|
(serverStats.total_advertisements !== undefined)
|
|
});
|
|
|
|
// Calculate new device metrics (devices first seen in the last 7 days)
|
|
// Debug: Log device types to see what we're working with
|
|
const deviceTypes = [...new Set(this.filteredData.map(contact => contact.device_type))];
|
|
console.log('Available device types:', deviceTypes);
|
|
console.log('Sample contact data:', this.filteredData[0]);
|
|
|
|
// Also check for any devices first heard in the last week regardless of type
|
|
const anyNewDevices = this.filteredData.filter(contact => {
|
|
const firstHeard = new Date(contact.first_heard);
|
|
return firstHeard >= oneWeekAgo;
|
|
});
|
|
console.log('Any new devices in last week:', anyNewDevices.length);
|
|
console.log('Sample new device:', anyNewDevices[0]);
|
|
|
|
// Try different possible device type values
|
|
const newCompanions = this.filteredData.filter(contact => {
|
|
const firstHeard = new Date(contact.first_heard);
|
|
const deviceType = (contact.device_type || '').toLowerCase();
|
|
return firstHeard >= oneWeekAgo && (
|
|
deviceType === 'companion' ||
|
|
deviceType === 'companions' ||
|
|
deviceType.includes('companion')
|
|
);
|
|
}).length;
|
|
|
|
const newRepeaters = this.filteredData.filter(contact => {
|
|
const firstHeard = new Date(contact.first_heard);
|
|
const deviceType = (contact.device_type || '').toLowerCase();
|
|
return firstHeard >= oneWeekAgo && (
|
|
deviceType === 'repeater' ||
|
|
deviceType === 'repeaters' ||
|
|
deviceType.includes('repeater')
|
|
);
|
|
}).length;
|
|
|
|
const newRoomServers = this.filteredData.filter(contact => {
|
|
const firstHeard = new Date(contact.first_heard);
|
|
const deviceType = (contact.device_type || '').toLowerCase();
|
|
return firstHeard >= oneWeekAgo && (
|
|
deviceType === 'room_server' ||
|
|
deviceType === 'roomserver' ||
|
|
deviceType === 'room_servers' ||
|
|
deviceType.includes('room') ||
|
|
deviceType.includes('server')
|
|
);
|
|
}).length;
|
|
|
|
// Debug: Log the counts
|
|
console.log('New companions:', newCompanions);
|
|
console.log('New repeaters:', newRepeaters);
|
|
console.log('New room servers:', newRoomServers);
|
|
|
|
// Update contact metrics
|
|
document.getElementById('contacts-24h').textContent = contacts24h;
|
|
document.getElementById('contacts-7d').textContent = contacts7d;
|
|
document.getElementById('contacts-all').textContent = contactsAll;
|
|
|
|
// Update advertisement metrics
|
|
document.getElementById('adverts-24h').textContent = adverts24h;
|
|
document.getElementById('adverts-7d').textContent = adverts7d;
|
|
document.getElementById('adverts-all').textContent = advertsAll;
|
|
|
|
// Update new device metrics
|
|
document.getElementById('new-companions').textContent = newCompanions;
|
|
document.getElementById('new-repeaters').textContent = newRepeaters;
|
|
document.getElementById('new-room-servers').textContent = newRoomServers;
|
|
|
|
// Update activity trends chart
|
|
this.updateActivityTrendsChart();
|
|
}
|
|
|
|
updateActivityTrendsChart() {
|
|
const serverStats = this.contactsData.server_stats || {};
|
|
const dailyDataByRole = serverStats.daily_nodes_30d_by_role || [];
|
|
const nodesTodayByRole = serverStats.nodes_24h_by_role || {};
|
|
|
|
// Destroy existing chart if it exists
|
|
if (this.activityChart) {
|
|
this.activityChart.destroy();
|
|
}
|
|
|
|
// 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
|
|
};
|
|
});
|
|
|
|
// 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) {
|
|
if (!lastSeen) return 'Never';
|
|
|
|
const date = new Date(lastSeen);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
const diffMonths = Math.floor(diffDays / 30);
|
|
|
|
if (diffMonths > 0) {
|
|
return `${diffMonths}mo ago`;
|
|
} else if (diffWeeks > 0) {
|
|
return `${diffWeeks}w ago`;
|
|
} else if (diffDays > 0) {
|
|
return `${diffDays}d ago`;
|
|
} else if (diffHours > 0) {
|
|
return `${diffHours}h ago`;
|
|
} else if (diffMinutes > 0) {
|
|
return `${diffMinutes}m ago`;
|
|
} else {
|
|
return 'Just now';
|
|
}
|
|
}
|
|
|
|
formatLastMessage(lastMessage) {
|
|
if (!lastMessage) return 'Never';
|
|
|
|
const date = new Date(lastMessage);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
const diffMonths = Math.floor(diffDays / 30);
|
|
|
|
if (diffMonths > 0) {
|
|
return `${diffMonths}mo ago`;
|
|
} else if (diffWeeks > 0) {
|
|
return `${diffWeeks}w ago`;
|
|
} else if (diffDays > 0) {
|
|
return `${diffDays}d ago`;
|
|
} else if (diffHours > 0) {
|
|
return `${diffHours}h ago`;
|
|
} else if (diffMinutes > 0) {
|
|
return `${diffMinutes}m ago`;
|
|
} else {
|
|
return 'Just now';
|
|
}
|
|
}
|
|
|
|
getStatusBadge(lastSeen) {
|
|
if (!lastSeen) return '<span class="badge bg-secondary">Unknown</span>';
|
|
|
|
const date = new Date(lastSeen);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
|
|
if (diffHours < 1) {
|
|
return '<span class="badge bg-success">Online</span>';
|
|
} else if (diffHours < 24) {
|
|
return '<span class="badge bg-warning">Recent</span>';
|
|
} else {
|
|
return '<span class="badge bg-secondary">Offline</span>';
|
|
}
|
|
}
|
|
|
|
formatLocation(contact) {
|
|
if (contact.city && contact.state) {
|
|
return `${contact.city}, ${contact.state}`;
|
|
} else if (contact.city) {
|
|
return contact.city;
|
|
} else if (contact.latitude && contact.longitude) {
|
|
return `${contact.latitude.toFixed(4)}, ${contact.longitude.toFixed(4)}`;
|
|
} else {
|
|
return '<span class="text-muted">Unknown</span>';
|
|
}
|
|
}
|
|
|
|
formatDistance(contact) {
|
|
if (contact.distance !== undefined && contact.distance !== null) {
|
|
if (contact.distance < 1) {
|
|
return `${(contact.distance * 1000).toFixed(0)}m`;
|
|
} else {
|
|
return `${contact.distance.toFixed(1)}km`;
|
|
}
|
|
} else {
|
|
return '<span class="text-muted">N/A</span>';
|
|
}
|
|
}
|
|
|
|
formatDeviceType(contact) {
|
|
const deviceType = contact.device_type || 'Unknown';
|
|
const role = contact.role || '';
|
|
|
|
// Color code based on role
|
|
let badgeClass = 'bg-secondary'; // Default for unknown
|
|
let displayText = deviceType;
|
|
|
|
if (role.toLowerCase() === 'repeater') {
|
|
badgeClass = 'bg-primary'; // Blue for Repeater
|
|
} else if (role.toLowerCase() === 'roomserver') {
|
|
badgeClass = 'bg-success'; // Green for RoomServer
|
|
} else if (role.toLowerCase() === 'companion') {
|
|
badgeClass = 'bg-warning'; // Yellow for Companion
|
|
} else if (role.toLowerCase() === 'sensor') {
|
|
badgeClass = 'bg-info'; // Light blue for Sensor
|
|
}
|
|
|
|
return `<span class="badge ${badgeClass}">${displayText}</span>`;
|
|
}
|
|
|
|
formatSignal(contact) {
|
|
if (contact.snr !== null && contact.snr !== undefined) {
|
|
const snr = parseFloat(contact.snr);
|
|
if (snr > 0) {
|
|
return `<span class="badge bg-success">${snr.toFixed(1)} dB</span>`;
|
|
} else {
|
|
return `<span class="badge bg-warning">${snr.toFixed(1)} dB</span>`;
|
|
}
|
|
} else {
|
|
return '<span class="text-muted">N/A</span>';
|
|
}
|
|
}
|
|
|
|
formatHops(contact) {
|
|
const hopCount = contact.hop_count || 0;
|
|
const outPath = contact.out_path || '';
|
|
const outPathLen = contact.out_path_len || -1;
|
|
|
|
// Escape the path for use in HTML attributes
|
|
const escapedPath = outPath.replace(/"/g, '"');
|
|
const escapedUserId = contact.user_id.replace(/"/g, '"');
|
|
|
|
// If we have path data, add tooltip functionality
|
|
if (outPath && outPathLen > 0) {
|
|
// Create a unique ID for this tooltip
|
|
const tooltipId = `hops-tooltip-${contact.user_id.substring(0, 16).replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
return `<span class="badge bg-primary path-badge"
|
|
id="${tooltipId}"
|
|
data-path-hex="${escapedPath}"
|
|
data-path-len="${outPathLen}"
|
|
data-user-id="${escapedUserId}"
|
|
style="cursor: help; position: relative;">
|
|
${hopCount}
|
|
</span>`;
|
|
} else if (outPathLen === 0) {
|
|
// Direct connection
|
|
return `<span class="badge bg-primary"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-placement="top"
|
|
title="Direct connection (0 hops)">
|
|
${hopCount}
|
|
</span>`;
|
|
} else {
|
|
// No path data
|
|
return `<span class="badge bg-primary">${hopCount}</span>`;
|
|
}
|
|
}
|
|
|
|
setupPathTooltips() {
|
|
// Create a global custom tooltip element if it doesn't exist
|
|
let customTooltip = document.getElementById('custom-path-tooltip');
|
|
if (!customTooltip) {
|
|
customTooltip = document.createElement('div');
|
|
customTooltip.id = 'custom-path-tooltip';
|
|
customTooltip.className = 'custom-path-tooltip';
|
|
customTooltip.style.cssText = 'position: absolute; z-index: 1070; display: none; padding: 0.5rem; background-color: rgba(0, 0, 0, 0.9); color: white; border-radius: 0.25rem; font-size: 0.875rem; max-width: 300px; pointer-events: none;';
|
|
document.body.appendChild(customTooltip);
|
|
}
|
|
|
|
// Setup event listeners for path tooltips after rendering
|
|
document.querySelectorAll('.path-badge').forEach(badge => {
|
|
const pathHex = badge.dataset.pathHex;
|
|
if (!pathHex) return;
|
|
|
|
// Check if tooltip is already set up
|
|
if (badge.dataset.tooltipSetup === 'true') return;
|
|
|
|
badge.dataset.tooltipSetup = 'true';
|
|
|
|
// Load path data on first hover
|
|
let pathLoaded = false;
|
|
let pathContent = 'Loading path...';
|
|
|
|
const showTooltip = (event) => {
|
|
const rect = badge.getBoundingClientRect();
|
|
customTooltip.innerHTML = pathContent;
|
|
customTooltip.style.display = 'block';
|
|
customTooltip.style.left = (rect.left + rect.width / 2 - customTooltip.offsetWidth / 2) + 'px';
|
|
customTooltip.style.top = (rect.top - customTooltip.offsetHeight - 8) + 'px';
|
|
|
|
// Adjust if tooltip goes off screen
|
|
const tooltipRect = customTooltip.getBoundingClientRect();
|
|
if (tooltipRect.left < 10) {
|
|
customTooltip.style.left = '10px';
|
|
}
|
|
if (tooltipRect.right > window.innerWidth - 10) {
|
|
customTooltip.style.left = (window.innerWidth - customTooltip.offsetWidth - 10) + 'px';
|
|
}
|
|
if (tooltipRect.top < 10) {
|
|
customTooltip.style.top = (rect.bottom + 8) + 'px';
|
|
}
|
|
};
|
|
|
|
const hideTooltip = () => {
|
|
customTooltip.style.display = 'none';
|
|
};
|
|
|
|
badge.addEventListener('mouseenter', async (event) => {
|
|
showTooltip(event);
|
|
|
|
// If path is already loaded, we're done
|
|
if (pathLoaded) {
|
|
return;
|
|
}
|
|
|
|
// If we're already loading, don't start another request
|
|
if (badge.dataset.loading === 'true') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
badge.dataset.loading = 'true';
|
|
|
|
// Fetch decoded path from API
|
|
const response = await fetch('/api/decode-path', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
path_hex: pathHex
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error || 'Failed to decode path');
|
|
}
|
|
|
|
if (!data.path || !Array.isArray(data.path)) {
|
|
throw new Error('Invalid path data format');
|
|
}
|
|
|
|
// Format the path response
|
|
const pathLines = data.path.map(node => {
|
|
if (node.found) {
|
|
// Escape HTML to prevent XSS
|
|
const name = this.escapeHtml(node.name);
|
|
// Add ❓ emoji if this is a geographic guess (collision)
|
|
const guessIndicator = node.geographic_guess ? ' ❓' : '';
|
|
return `${node.node_id}: ${name}${guessIndicator}`;
|
|
} else {
|
|
return `${node.node_id}: Unknown`;
|
|
}
|
|
});
|
|
|
|
pathContent = pathLines.length > 0 ? pathLines.join('<br>') : 'No path data';
|
|
pathLoaded = true;
|
|
badge.dataset.loading = 'false';
|
|
|
|
// Update tooltip content if still hovering
|
|
if (badge.matches(':hover')) {
|
|
showTooltip(event);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error loading path tooltip:', error);
|
|
pathContent = 'Error loading path: ' + error.message;
|
|
badge.dataset.loading = 'false';
|
|
|
|
// Update tooltip content if still hovering
|
|
if (badge.matches(':hover')) {
|
|
showTooltip(event);
|
|
}
|
|
}
|
|
});
|
|
|
|
badge.addEventListener('mouseleave', hideTooltip);
|
|
badge.addEventListener('mousemove', (event) => {
|
|
if (badge.matches(':hover')) {
|
|
showTooltip(event);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
formatTimestamp(timestamp) {
|
|
if (!timestamp) return '<span class="text-muted">Never</span>';
|
|
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
|
}
|
|
|
|
formatTimeAgo(timestamp) {
|
|
if (!timestamp) return '<span class="text-muted">Never</span>';
|
|
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
const diffMonths = Math.floor(diffDays / 30);
|
|
|
|
if (diffMonths > 0) {
|
|
return `${diffMonths}mo ago`;
|
|
} else if (diffWeeks > 0) {
|
|
return `${diffWeeks}w ago`;
|
|
} else if (diffDays > 0) {
|
|
return `${diffDays}d ago`;
|
|
} else if (diffHours > 0) {
|
|
return `${diffHours}h ago`;
|
|
} else if (diffMinutes > 0) {
|
|
return `${diffMinutes}m ago`;
|
|
} else {
|
|
return '<span class="text-success">Just now</span>';
|
|
}
|
|
}
|
|
|
|
viewAdvertData(userId) {
|
|
// Find the contact data
|
|
const contact = this.filteredData.find(c => c.user_id === userId);
|
|
if (!contact) {
|
|
alert('Contact data not found');
|
|
return;
|
|
}
|
|
|
|
// Create a modal to display the advertisement data
|
|
const modalHtml = `
|
|
<div class="modal fade" id="advertModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Advertisement Data - ${contact.username || 'Unknown'}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<pre class="bg-light p-3 rounded" style="background-color: var(--bg-secondary); color: var(--text-color);">${JSON.stringify(contact.raw_advert_data_parsed || contact.raw_advert_data || 'No advertisement data available', null, 2)}</pre>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Remove existing modal if any
|
|
const existingModal = document.getElementById('advertModal');
|
|
if (existingModal) {
|
|
existingModal.remove();
|
|
}
|
|
|
|
// Add modal to page
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
|
|
// Show modal
|
|
const modal = new bootstrap.Modal(document.getElementById('advertModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async geocodeContact(userId, buttonElement = null) {
|
|
// Find the contact data
|
|
const contact = this.filteredData.find(c => c.user_id === userId);
|
|
if (!contact) {
|
|
this.showError('Contact data not found');
|
|
return;
|
|
}
|
|
|
|
// Check if contact has valid coordinates
|
|
if (!contact.latitude || !contact.longitude || contact.latitude === 0 || contact.longitude === 0) {
|
|
this.showError('Contact does not have valid coordinates');
|
|
return;
|
|
}
|
|
|
|
// Get the button element - use passed element or find it
|
|
const button = buttonElement || document.querySelector(`button[onclick*="${userId.substring(0, 16)}"]`);
|
|
if (!button) {
|
|
this.showError('Button not found');
|
|
return;
|
|
}
|
|
|
|
// Disable button and show loading state
|
|
const originalHTML = button.innerHTML;
|
|
button.disabled = true;
|
|
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
button.classList.remove('btn-outline-primary');
|
|
button.classList.add('btn-secondary');
|
|
|
|
try {
|
|
// Call the geocoding API
|
|
const response = await fetch('/api/geocode-contact', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
public_key: userId
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error || 'Geocoding failed');
|
|
}
|
|
|
|
// Check if we got any location data
|
|
if (!data.location || (!data.location.city && !data.location.state && !data.location.country)) {
|
|
throw new Error('Geocoding returned no location data');
|
|
}
|
|
|
|
// Update the contact data in our local array
|
|
const contactIndex = this.filteredData.findIndex(c => c.user_id === userId);
|
|
if (contactIndex !== -1) {
|
|
this.filteredData[contactIndex].city = data.location.city || this.filteredData[contactIndex].city;
|
|
this.filteredData[contactIndex].state = data.location.state || this.filteredData[contactIndex].state;
|
|
this.filteredData[contactIndex].country = data.location.country || this.filteredData[contactIndex].country;
|
|
}
|
|
|
|
// Also update in the main contacts data
|
|
const mainContactIndex = this.contactsData.tracking_data.findIndex(c => c.user_id === userId);
|
|
if (mainContactIndex !== -1) {
|
|
this.contactsData.tracking_data[mainContactIndex].city = data.location.city || this.contactsData.tracking_data[mainContactIndex].city;
|
|
this.contactsData.tracking_data[mainContactIndex].state = data.location.state || this.contactsData.tracking_data[mainContactIndex].state;
|
|
this.contactsData.tracking_data[mainContactIndex].country = data.location.country || this.contactsData.tracking_data[mainContactIndex].country;
|
|
}
|
|
|
|
// Re-render the table to show updated location
|
|
this.renderContactsData();
|
|
|
|
// Show success message
|
|
this.showSuccess(data.message || 'Location geocoded successfully');
|
|
|
|
} catch (error) {
|
|
console.error('Error geocoding contact:', error);
|
|
this.showError('Failed to geocode contact: ' + error.message);
|
|
} finally {
|
|
// Restore button state
|
|
button.disabled = false;
|
|
button.innerHTML = originalHTML;
|
|
button.classList.remove('btn-secondary');
|
|
button.classList.add('btn-outline-primary');
|
|
}
|
|
}
|
|
|
|
async toggleStar(userId, buttonElement = null) {
|
|
// Find the contact data
|
|
const contact = this.filteredData.find(c => c.user_id === userId);
|
|
if (!contact) {
|
|
this.showError('Contact data not found');
|
|
return;
|
|
}
|
|
|
|
// Get the button element - use passed element or find it
|
|
const button = buttonElement || document.querySelector(`button[onclick*="toggleStar('${userId.substring(0, 16)}"]`);
|
|
if (!button) {
|
|
this.showError('Button not found');
|
|
return;
|
|
}
|
|
|
|
// Disable button and show loading state
|
|
const originalHTML = button.innerHTML;
|
|
button.disabled = true;
|
|
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
|
|
try {
|
|
// Call the toggle star API
|
|
const response = await fetch('/api/toggle-star-contact', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
public_key: userId
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error || 'Failed to toggle star status');
|
|
}
|
|
|
|
// Update the contact data in our local array
|
|
const contactIndex = this.filteredData.findIndex(c => c.user_id === userId);
|
|
if (contactIndex !== -1) {
|
|
this.filteredData[contactIndex].is_starred = data.is_starred;
|
|
}
|
|
|
|
// Also update in the main contacts data
|
|
const mainContactIndex = this.contactsData.tracking_data.findIndex(c => c.user_id === userId);
|
|
if (mainContactIndex !== -1) {
|
|
this.contactsData.tracking_data[mainContactIndex].is_starred = data.is_starred;
|
|
}
|
|
|
|
// Re-render the table to show updated star status
|
|
this.renderContactsData();
|
|
|
|
// Show success message
|
|
const action = data.is_starred ? 'starred' : 'unstarred';
|
|
this.showSuccess(`Contact ${action} successfully. Path command will ${data.is_starred ? 'strongly prefer' : 'no longer prefer'} this repeater.`);
|
|
|
|
} catch (error) {
|
|
console.error('Error toggling star status:', error);
|
|
this.showError('Failed to toggle star status: ' + error.message);
|
|
} finally {
|
|
// Restore button state
|
|
button.disabled = false;
|
|
button.innerHTML = originalHTML;
|
|
}
|
|
}
|
|
|
|
showDeleteConfirmation(userId, username) {
|
|
// Find the contact data
|
|
const contact = this.filteredData.find(c => c.user_id === userId);
|
|
if (!contact) {
|
|
this.showError('Contact data not found');
|
|
return;
|
|
}
|
|
|
|
// Create a modal for delete confirmation
|
|
const modalHtml = `
|
|
<div class="modal fade" id="deleteContactModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header bg-danger text-white">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-exclamation-triangle"></i> Delete Contact
|
|
</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="mb-3">
|
|
<strong>Are you sure you want to delete this contact?</strong>
|
|
</p>
|
|
<div class="alert alert-warning">
|
|
<strong>Contact Information:</strong><br>
|
|
<strong>Name:</strong> ${this.escapeHtml(username || 'Unknown')}<br>
|
|
<strong>Public Key:</strong> <code>${userId.substring(0, 16)}...</code>
|
|
</div>
|
|
<div class="alert alert-danger">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<strong>Warning:</strong> This will permanently delete all tracking data for this contact, including:
|
|
<ul class="mb-0 mt-2">
|
|
<li>Contact history and statistics</li>
|
|
<li>Location information</li>
|
|
<li>Advertisement data</li>
|
|
<li>Daily statistics</li>
|
|
</ul>
|
|
<p class="mb-0 mt-2">
|
|
<strong>This action cannot be undone.</strong> The contact is not banned and may reappear if heard again.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
<i class="fas fa-trash"></i> Delete Contact
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Remove existing modal if any
|
|
const existingModal = document.getElementById('deleteContactModal');
|
|
if (existingModal) {
|
|
existingModal.remove();
|
|
}
|
|
|
|
// Add modal to page
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
|
|
// Get the modal element
|
|
const modalElement = document.getElementById('deleteContactModal');
|
|
const modal = new bootstrap.Modal(modalElement);
|
|
|
|
// Set up delete button handler
|
|
const confirmBtn = document.getElementById('confirmDeleteBtn');
|
|
confirmBtn.addEventListener('click', () => {
|
|
this.deleteContact(userId, username, modalElement);
|
|
});
|
|
|
|
// Show modal
|
|
modal.show();
|
|
|
|
// Clean up modal when hidden
|
|
modalElement.addEventListener('hidden.bs.modal', () => {
|
|
modalElement.remove();
|
|
});
|
|
}
|
|
|
|
async deleteContact(userId, username, modalElement) {
|
|
// Disable the delete button and show loading state
|
|
const confirmBtn = document.getElementById('confirmDeleteBtn');
|
|
const originalHTML = confirmBtn.innerHTML;
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
|
|
|
try {
|
|
// Call the delete API
|
|
const response = await fetch('/api/delete-contact', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
public_key: userId
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error || 'Failed to delete contact');
|
|
}
|
|
|
|
// Close the modal
|
|
const modal = bootstrap.Modal.getInstance(modalElement);
|
|
if (modal) {
|
|
modal.hide();
|
|
}
|
|
|
|
// Remove contact from local data
|
|
this.contactsData.tracking_data = this.contactsData.tracking_data.filter(c => c.user_id !== userId);
|
|
this.filteredData = this.filteredData.filter(c => c.user_id !== userId);
|
|
|
|
// Re-render the table
|
|
this.renderContactsData();
|
|
this.updateStatistics();
|
|
|
|
// Show success message
|
|
this.showSuccess(`Contact "${username || 'Unknown'}" has been deleted successfully.`);
|
|
|
|
} catch (error) {
|
|
console.error('Error deleting contact:', error);
|
|
this.showError('Failed to delete contact: ' + error.message);
|
|
|
|
// Restore button state
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = originalHTML;
|
|
}
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
showError(message) {
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
|
|
errorDiv.setAttribute('role', 'alert');
|
|
errorDiv.innerHTML = `
|
|
<strong>Error:</strong> ${message}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
`;
|
|
|
|
const content = document.querySelector('.container-fluid');
|
|
if (content) {
|
|
content.insertBefore(errorDiv, content.firstChild);
|
|
|
|
setTimeout(() => {
|
|
if (errorDiv.parentNode) {
|
|
errorDiv.parentNode.removeChild(errorDiv);
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
showSuccess(message) {
|
|
const successDiv = document.createElement('div');
|
|
successDiv.className = 'alert alert-success alert-dismissible fade show';
|
|
successDiv.setAttribute('role', 'alert');
|
|
successDiv.innerHTML = `
|
|
<strong>Success:</strong> ${message}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
`;
|
|
|
|
const content = document.querySelector('.container-fluid');
|
|
if (content) {
|
|
content.insertBefore(successDiv, content.firstChild);
|
|
|
|
setTimeout(() => {
|
|
if (successDiv.parentNode) {
|
|
successDiv.parentNode.removeChild(successDiv);
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize contacts manager when page loads
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.contactsManager = new ModernContactsManager();
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* Sortable column styling - subtle and clean */
|
|
.sortable {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.sortable i {
|
|
margin-left: 5px;
|
|
opacity: 0.5;
|
|
transition: opacity 0.2s ease;
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.sortable:hover i {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.sort-active i {
|
|
opacity: 1;
|
|
color: #0d6efd !important;
|
|
}
|
|
|
|
/* Dark mode table styling with better contrast */
|
|
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
|
|
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
|
|
}
|
|
|
|
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
|
|
background-color: #2d2d2d; /* Darker gray (card-bg) */
|
|
}
|
|
|
|
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
|
|
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
|
|
}
|
|
|
|
[data-theme="dark"] .table-dark {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table-dark th {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table td {
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
/* Ensure all text in table cells is light in dark mode */
|
|
[data-theme="dark"] .table td,
|
|
[data-theme="dark"] .table td * {
|
|
color: var(--text-color) !important;
|
|
}
|
|
|
|
/* Node names (strong tags) should be light and prominent */
|
|
[data-theme="dark"] .table td strong {
|
|
color: #ffffff !important;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Ensure small text is also light */
|
|
[data-theme="dark"] .table td small {
|
|
color: var(--text-muted) !important;
|
|
}
|
|
|
|
/* Dark mode input group styling */
|
|
[data-theme="dark"] .input-group-text {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .input-group .form-control {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .input-group .form-control:focus {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: #007bff;
|
|
color: var(--text-color);
|
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
|
}
|
|
|
|
/* Custom path tooltip styling */
|
|
.custom-path-tooltip {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
[data-theme="dark"] .custom-path-tooltip {
|
|
background-color: rgba(40, 40, 40, 0.95) !important;
|
|
color: var(--text-color) !important;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
</style>
|
|
{% endblock %}
|