mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-12 10:34:53 +00:00
960 lines
38 KiB
HTML
960 lines
38 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">
|
|
<i class="fas fa-plus-circle"></i> New Devices
|
|
</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
|
|
</div>
|
|
<div class="card-body d-flex align-items-center">
|
|
<div class="row text-center w-100">
|
|
<div class="col-6">
|
|
<div id="trend-today">
|
|
<div class="loading">Loading...</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div id="trend-week">
|
|
<div class="loading">Loading...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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="9" 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.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="11" 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><span class="badge bg-primary">${contact.hop_count || 0}</span></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">
|
|
<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>` :
|
|
''
|
|
}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
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
|
|
this.updateActivityTrends(contacts24h, contacts7d, contactsAll);
|
|
}
|
|
|
|
updateActivityTrends(contacts24h, contacts7d, contactsAll) {
|
|
// Today's trend (24h vs 7d average)
|
|
const dailyAverage = contacts7d / 7;
|
|
const todayTrend = dailyAverage > 0 ? Math.round((contacts24h / dailyAverage) * 100) : 0;
|
|
const todayTrendIcon = todayTrend > 100 ? '📈' : todayTrend < 100 ? '📉' : '➡️';
|
|
|
|
document.getElementById('trend-today').innerHTML = `
|
|
<div class="text-center">
|
|
<h5 class="mb-1">${contacts24h}</h5>
|
|
<div class="d-flex justify-content-center align-items-center">
|
|
<span class="me-1">${todayTrendIcon}</span>
|
|
<span class="badge bg-${todayTrend > 100 ? 'success' : todayTrend < 100 ? 'warning' : 'secondary'} small">
|
|
${todayTrend}%
|
|
</span>
|
|
</div>
|
|
<small class="text-muted">Today</small>
|
|
</div>
|
|
`;
|
|
|
|
// Week's trend (7d vs all time)
|
|
const weekTrend = contactsAll > 0 ? Math.round((contacts7d / contactsAll) * 100) : 0;
|
|
const weekTrendIcon = weekTrend > 50 ? '📈' : weekTrend < 30 ? '📉' : '➡️';
|
|
|
|
document.getElementById('trend-week').innerHTML = `
|
|
<div class="text-center">
|
|
<h5 class="mb-1">${contacts7d}</h5>
|
|
<div class="d-flex justify-content-center align-items-center">
|
|
<span class="me-1">${weekTrendIcon}</span>
|
|
<span class="badge bg-${weekTrend > 50 ? 'success' : weekTrend < 30 ? 'warning' : 'secondary'} small">
|
|
${weekTrend}%
|
|
</span>
|
|
</div>
|
|
<small class="text-muted">Week</small>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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>';
|
|
}
|
|
}
|
|
|
|
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">${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');
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
</style>
|
|
{% endblock %}
|