mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-01 23:41:28 +02:00
feat(contacts): Add 'Last Seen' timestamp display with activity indicators
Add comprehensive "last seen" tracking for all contact types:
Backend (cli.py):
- New function get_contacts_with_last_seen() using 'apply_to t=1,t=2,t=3,t=4 contact_info'
- Fetches detailed contact metadata including last_advert timestamps
- Returns dictionary indexed by full public_key for efficient lookup
API (api.py):
- Enhanced /api/contacts/detailed endpoint to merge last_seen data
- Matches contacts by public_key_prefix (first 12 chars)
- Graceful fallback if detailed fetch fails (contacts still displayed without last_seen)
Frontend (contacts.js):
- formatRelativeTime() - converts Unix timestamps to human-readable format
("5 minutes ago", "2 hours ago", "3 days ago")
- getActivityStatus() - returns status indicator based on recency:
🟢 Active (< 5 min), 🟡 Recent (< 1 hour), 🔴 Inactive (> 1 hour)
- Contact cards now display "Last seen" with status icon and relative time
- Clean handling of missing last_seen data (shows "Unknown")
This feature helps users identify active vs. inactive contacts at a glance,
using the last_advert field from meshcli's contact_info command.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -524,6 +524,63 @@ def get_all_contacts_detailed() -> Tuple[bool, List[Dict], int, str]:
|
||||
return False, [], 0, str(e)
|
||||
|
||||
|
||||
def get_contacts_with_last_seen() -> Tuple[bool, Dict[str, Dict], str]:
|
||||
"""
|
||||
Get detailed contact information including last_advert timestamps.
|
||||
|
||||
Uses 'apply_to t=1,t=2,t=3,t=4 contact_info' command to fetch metadata
|
||||
for all contact types (CLI, REP, ROOM, SENS).
|
||||
|
||||
Returns:
|
||||
Tuple of (success, contacts_dict, error_message)
|
||||
contacts_dict maps public_key -> contact_details where each detail dict contains:
|
||||
{
|
||||
'public_key': str (full key),
|
||||
'type': int (1=CLI, 2=REP, 3=ROOM, 4=SENS),
|
||||
'flags': int,
|
||||
'out_path_len': int,
|
||||
'out_path': str,
|
||||
'adv_name': str (name with emoji),
|
||||
'last_advert': int (Unix timestamp),
|
||||
'adv_lat': float,
|
||||
'adv_lon': float,
|
||||
'lastmod': int (Unix timestamp)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Execute command to get all contact types
|
||||
# t=1 (CLI), t=2 (REP), t=3 (ROOM), t=4 (SENS)
|
||||
success, stdout, stderr = _run_command(['apply_to', 't=1,t=2,t=3,t=4', 'contact_info'])
|
||||
|
||||
if not success:
|
||||
return False, {}, stderr or 'Failed to get contact details'
|
||||
|
||||
# Parse JSON output
|
||||
try:
|
||||
# The output should be a JSON array
|
||||
contact_list = json.loads(stdout)
|
||||
|
||||
if not isinstance(contact_list, list):
|
||||
return False, {}, 'Unexpected response format (expected JSON array)'
|
||||
|
||||
# Build dictionary indexed by public_key for easy lookup
|
||||
contacts_dict = {}
|
||||
for contact in contact_list:
|
||||
if 'public_key' in contact:
|
||||
# Use full public key as index
|
||||
contacts_dict[contact['public_key']] = contact
|
||||
|
||||
return True, contacts_dict, ""
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse contact_info JSON: {e}")
|
||||
return False, {}, f'JSON parse error: {str(e)}'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting contact details: {e}")
|
||||
return False, {}, str(e)
|
||||
|
||||
|
||||
def delete_contact(selector: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Delete a contact from the device.
|
||||
|
||||
+29
-8
@@ -1221,6 +1221,7 @@ def get_contacts_detailed_api():
|
||||
"public_key_prefix": "df2027d3f2ef",
|
||||
"type_label": "REP",
|
||||
"path_or_mode": "Flood",
|
||||
"last_seen": 1735429453, // Unix timestamp from last_advert
|
||||
"raw_line": "..."
|
||||
},
|
||||
...
|
||||
@@ -1228,16 +1229,10 @@ def get_contacts_detailed_api():
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get basic contacts list
|
||||
success, contacts, total_count, error = cli.get_all_contacts_detailed()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'contacts': contacts,
|
||||
'count': total_count,
|
||||
'limit': 350 # MeshCore device limit
|
||||
}), 200
|
||||
else:
|
||||
if not success:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error or 'Failed to get contacts list',
|
||||
@@ -1246,6 +1241,32 @@ def get_contacts_detailed_api():
|
||||
'limit': 350
|
||||
}), 500
|
||||
|
||||
# Get detailed contact info with last_advert timestamps
|
||||
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
|
||||
|
||||
if success_detailed:
|
||||
# Merge last_advert data with contacts
|
||||
# Match by public_key_prefix (first 12 chars of full public_key)
|
||||
for contact in contacts:
|
||||
prefix = contact.get('public_key_prefix', '')
|
||||
|
||||
# Find matching contact in detailed data
|
||||
for full_key, details in contacts_detailed.items():
|
||||
if full_key.startswith(prefix):
|
||||
# Add last_seen timestamp
|
||||
contact['last_seen'] = details.get('last_advert', None)
|
||||
break
|
||||
else:
|
||||
# If detailed fetch failed, log warning but still return contacts without last_seen
|
||||
logger.warning(f"Failed to get last_seen data: {error_detailed}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'contacts': contacts,
|
||||
'count': total_count,
|
||||
'limit': 350 # MeshCore device limit
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting detailed contacts list: {e}")
|
||||
return jsonify({
|
||||
|
||||
@@ -523,6 +523,93 @@ function renderExistingList(contacts) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Unix timestamp as relative time ("5 minutes ago", "2 hours ago", etc.)
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
|
||||
const now = Math.floor(Date.now() / 1000); // Current time in Unix seconds
|
||||
const diffSeconds = now - timestamp;
|
||||
|
||||
if (diffSeconds < 0) return 'Just now'; // Future timestamp (clock skew)
|
||||
|
||||
// Less than 1 minute
|
||||
if (diffSeconds < 60) {
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
// Less than 1 hour
|
||||
if (diffSeconds < 3600) {
|
||||
const minutes = Math.floor(diffSeconds / 60);
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Less than 1 day
|
||||
if (diffSeconds < 86400) {
|
||||
const hours = Math.floor(diffSeconds / 3600);
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Less than 30 days
|
||||
if (diffSeconds < 2592000) {
|
||||
const days = Math.floor(diffSeconds / 86400);
|
||||
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Less than 1 year
|
||||
if (diffSeconds < 31536000) {
|
||||
const months = Math.floor(diffSeconds / 2592000);
|
||||
return `${months} month${months !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// More than 1 year
|
||||
const years = Math.floor(diffSeconds / 31536000);
|
||||
return `${years} year${years !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity status indicator based on last_seen timestamp
|
||||
* Returns: { icon: string, color: string, title: string }
|
||||
*/
|
||||
function getActivityStatus(timestamp) {
|
||||
if (!timestamp) {
|
||||
return {
|
||||
icon: '⚫',
|
||||
color: '#6c757d',
|
||||
title: 'Never seen'
|
||||
};
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diffSeconds = now - timestamp;
|
||||
|
||||
// Active (< 5 minutes)
|
||||
if (diffSeconds < 300) {
|
||||
return {
|
||||
icon: '🟢',
|
||||
color: '#28a745',
|
||||
title: 'Active (seen recently)'
|
||||
};
|
||||
}
|
||||
|
||||
// Recent (< 1 hour)
|
||||
if (diffSeconds < 3600) {
|
||||
return {
|
||||
icon: '🟡',
|
||||
color: '#ffc107',
|
||||
title: 'Recent activity'
|
||||
};
|
||||
}
|
||||
|
||||
// Inactive (> 1 hour)
|
||||
return {
|
||||
icon: '🔴',
|
||||
color: '#dc3545',
|
||||
title: 'Inactive'
|
||||
};
|
||||
}
|
||||
|
||||
function createExistingContactCard(contact, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'existing-contact-card';
|
||||
@@ -567,6 +654,38 @@ function createExistingContactCard(contact, index) {
|
||||
keyDiv.textContent = contact.public_key_prefix;
|
||||
keyDiv.title = 'Public Key Prefix';
|
||||
|
||||
// Last seen row (with activity status indicator)
|
||||
const lastSeenDiv = document.createElement('div');
|
||||
lastSeenDiv.className = 'text-muted small d-flex align-items-center gap-1';
|
||||
lastSeenDiv.style.marginBottom = '0.25rem';
|
||||
|
||||
if (contact.last_seen) {
|
||||
const status = getActivityStatus(contact.last_seen);
|
||||
const relativeTime = formatRelativeTime(contact.last_seen);
|
||||
|
||||
const statusIcon = document.createElement('span');
|
||||
statusIcon.textContent = status.icon;
|
||||
statusIcon.style.fontSize = '0.9rem';
|
||||
statusIcon.title = status.title;
|
||||
|
||||
const timeText = document.createElement('span');
|
||||
timeText.textContent = `Last seen: ${relativeTime}`;
|
||||
|
||||
lastSeenDiv.appendChild(statusIcon);
|
||||
lastSeenDiv.appendChild(timeText);
|
||||
} else {
|
||||
// No last_seen data available
|
||||
const statusIcon = document.createElement('span');
|
||||
statusIcon.textContent = '⚫';
|
||||
statusIcon.style.fontSize = '0.9rem';
|
||||
|
||||
const timeText = document.createElement('span');
|
||||
timeText.textContent = 'Last seen: Unknown';
|
||||
|
||||
lastSeenDiv.appendChild(statusIcon);
|
||||
lastSeenDiv.appendChild(timeText);
|
||||
}
|
||||
|
||||
// Path/mode (optional)
|
||||
let pathDiv = null;
|
||||
if (contact.path_or_mode && contact.path_or_mode !== 'Flood') {
|
||||
@@ -597,6 +716,7 @@ function createExistingContactCard(contact, index) {
|
||||
// Assemble card
|
||||
card.appendChild(infoRow);
|
||||
card.appendChild(keyDiv);
|
||||
card.appendChild(lastSeenDiv);
|
||||
if (pathDiv) card.appendChild(pathDiv);
|
||||
card.appendChild(actionsDiv);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user