fix(dm): show delivery route as hex path, add real-time delivery info

Store actual hex path instead of DIRECT/FLOOD labels in delivery_path.
Format route as AB→CD→EF (same as channel messages, truncated if >4
hops). Add dm_delivered_info WebSocket event so delivery meta appears
in real-time without needing page reload. Remove path info from failed
messages since it's not meaningful for undelivered messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-28 13:21:53 +01:00
parent 677036a831
commit 885a967348
2 changed files with 74 additions and 11 deletions

View File

@@ -1355,8 +1355,17 @@ class DeviceManager:
if not no_auto_flood: if not no_auto_flood:
max_attempts += cfg['flood_max_retries'] max_attempts += cfg['flood_max_retries']
# Track current path description for delivery info # Track current path hex for delivery info (actual route, not label)
path_desc = "FLOOD" if not has_path else "DIRECT" def _extract_path_hex(out_path, out_path_len):
"""Extract meaningful hex portion from device path."""
if out_path_len <= 0 or not out_path:
return ''
hop_count = out_path_len & 0x3F
hash_size = (out_path_len >> 6) + 1
meaningful_len = hop_count * hash_size * 2
return out_path[:meaningful_len].lower() if meaningful_len > 0 else ''
path_desc = _extract_path_hex(original_out_path, original_out_path_len) if has_path else ''
logger.info(f"DM retry task started: dm_id={dm_id}, scenario={scenario}, " logger.info(f"DM retry task started: dm_id={dm_id}, scenario={scenario}, "
f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, " f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, "
@@ -1373,6 +1382,13 @@ class DeviceManager:
if delivered: if delivered:
self.db.update_dm_delivery_info( self.db.update_dm_delivery_info(
dm_id, display, max_attempts, path_desc) dm_id, display, max_attempts, path_desc)
if self.socketio:
self.socketio.emit('dm_delivered_info', {
'dm_id': dm_id,
'attempt': display,
'max_attempts': max_attempts,
'path': path_desc,
}, namespace='/chat')
return delivered return delivered
# ── Emit status for initial send (attempt 1) and wait for ACK ── # ── Emit status for initial send (attempt 1) and wait for ACK ──
@@ -1387,6 +1403,11 @@ class DeviceManager:
if ack_event: if ack_event:
self._confirm_delivery(dm_id, initial_ack, ack_event) self._confirm_delivery(dm_id, initial_ack, ack_event)
self.db.update_dm_delivery_info(dm_id, 1, max_attempts, path_desc) self.db.update_dm_delivery_info(dm_id, 1, max_attempts, path_desc)
if self.socketio:
self.socketio.emit('dm_delivered_info', {
'dm_id': dm_id, 'attempt': 1,
'max_attempts': max_attempts, 'path': path_desc,
}, namespace='/chat')
return return
logger.debug(f"DM retry: initial ACK not received (timeout)") logger.debug(f"DM retry: initial ACK not received (timeout)")
@@ -1418,7 +1439,7 @@ class DeviceManager:
logger.info("DM retry: direct exhausted, resetting to FLOOD") logger.info("DM retry: direct exhausted, resetting to FLOOD")
except Exception: except Exception:
pass pass
path_desc = "FLOOD" path_desc = ''
for _ in range(cfg['direct_flood_retries']): for _ in range(cfg['direct_flood_retries']):
attempt += 1 attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])): if await _retry(attempt, float(cfg['flood_interval'])):
@@ -1443,7 +1464,7 @@ class DeviceManager:
try: try:
await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size']) await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size'])
label = path_info.get('label', '') label = path_info.get('label', '')
path_desc = f"{label} ({path_info['path_hex']})" if label else path_info['path_hex'] path_desc = path_info['path_hex']
logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})") logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})")
except Exception as e: except Exception as e:
logger.warning(f"DM retry: failed to switch path: {e}") logger.warning(f"DM retry: failed to switch path: {e}")
@@ -1482,7 +1503,7 @@ class DeviceManager:
try: try:
await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size']) await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size'])
label = path_info.get('label', '') label = path_info.get('label', '')
path_desc = f"{label} ({path_info['path_hex']})" if label else path_info['path_hex'] path_desc = path_info['path_hex']
logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})") logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})")
except Exception as e: except Exception as e:
logger.warning(f"DM retry: failed to switch path: {e}") logger.warning(f"DM retry: failed to switch path: {e}")
@@ -1501,7 +1522,7 @@ class DeviceManager:
logger.info("DM retry: all paths exhausted, falling back to FLOOD") logger.info("DM retry: all paths exhausted, falling back to FLOOD")
except Exception: except Exception:
pass pass
path_desc = "FLOOD" path_desc = ''
for _ in range(cfg['flood_max_retries']): for _ in range(cfg['flood_max_retries']):
attempt += 1 attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])): if await _retry(attempt, float(cfg['flood_interval'])):
@@ -1512,7 +1533,7 @@ class DeviceManager:
await self._restore_primary_path(contact, contact_pubkey) await self._restore_primary_path(contact, contact_pubkey)
# ── Common epilogue: mark failed, grace period for late ACKs ── # ── Common epilogue: mark failed, grace period for late ACKs ──
self.db.update_dm_delivery_info(dm_id, attempt + 1, max_attempts, path_desc) self.db.update_dm_delivery_info(dm_id, attempt + 1, max_attempts, '')
self.db.update_dm_delivery_status(dm_id, 'failed') self.db.update_dm_delivery_status(dm_id, 'failed')
self._emit_retry_failed(dm_id, initial_ack) self._emit_retry_failed(dm_id, initial_ack)
logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, scenario={scenario}) " logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, scenario={scenario}) "

View File

@@ -150,6 +150,31 @@ function connectChatSocket() {
if (info) info.textContent = ''; if (info) info.textContent = '';
}); });
// Real-time delivery info — show attempt count + route after successful delivery
chatSocket.on('dm_delivered_info', (data) => {
if (!data.dm_id) return;
// Find the message element containing this dm_id
const retryEl = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (!retryEl) return;
retryEl.textContent = '';
const msgDiv = retryEl.closest('.dm-message');
if (!msgDiv) return;
// Build delivery meta text
const parts = [];
if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`);
if (data.path) parts.push(`Route: ${formatDmRoute(data.path)}`);
if (parts.length > 0) {
let metaEl = msgDiv.querySelector('.dm-delivery-meta');
if (!metaEl) {
metaEl = document.createElement('div');
metaEl.className = 'dm-delivery-meta';
const contentDiv = msgDiv.querySelector('div:nth-child(2)');
if (contentDiv) contentDiv.after(metaEl);
}
metaEl.textContent = parts.join(', ');
}
});
// Real-time device status // Real-time device status
chatSocket.on('device_status', (data) => { chatSocket.on('device_status', (data) => {
updateStatus(data.connected ? 'connected' : 'disconnected'); updateStatus(data.connected ? 'connected' : 'disconnected');
@@ -1129,7 +1154,7 @@ function displayMessages(messages) {
if (msg.delivery_attempt && msg.delivery_max_attempts) { if (msg.delivery_attempt && msg.delivery_max_attempts) {
title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`; title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`;
} }
if (msg.delivery_path) title += `, Path: ${msg.delivery_path}`; if (msg.delivery_path) title += `, Route: ${formatDmRoute(msg.delivery_path)}`;
if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) { if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) {
title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`; title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`;
} }
@@ -1157,15 +1182,18 @@ function displayMessages(messages) {
} }
} }
// Delivery info for delivered/failed messages (attempt count + path) // Delivery info for delivered/failed messages (attempt count + route)
let deliveryMeta = ''; let deliveryMeta = '';
if (msg.is_own && (msg.status === 'delivered' || msg.status === 'failed') if (msg.is_own && (msg.status === 'delivered' || msg.status === 'failed')
&& (msg.delivery_attempt || msg.delivery_path)) { && msg.delivery_attempt) {
const parts = []; const parts = [];
if (msg.delivery_attempt && msg.delivery_max_attempts) { if (msg.delivery_attempt && msg.delivery_max_attempts) {
parts.push(`Attempt ${msg.delivery_attempt}/${msg.delivery_max_attempts}`); parts.push(`Attempt ${msg.delivery_attempt}/${msg.delivery_max_attempts}`);
} }
if (msg.delivery_path) parts.push(`Path: ${msg.delivery_path}`); // Show route only for delivered messages (not failed)
if (msg.status === 'delivered' && msg.delivery_path) {
parts.push(`Route: ${formatDmRoute(msg.delivery_path)}`);
}
deliveryMeta = `<div class="dm-delivery-meta">${parts.join(', ')}</div>`; deliveryMeta = `<div class="dm-delivery-meta">${parts.join(', ')}</div>`;
} }
@@ -1335,6 +1363,20 @@ function resendMessage(content) {
input.focus(); input.focus();
} }
/**
* Format a hex path as route string (e.g. "5e34e761" → "5e→34→e7→61")
* Truncates if more than 4 segments.
*/
function formatDmRoute(hexPath) {
if (!hexPath) return '';
const segments = hexPath.match(/.{1,2}/g) || [];
if (segments.length === 0) return '';
if (segments.length > 4) {
return `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`;
}
return segments.join('\u2192');
}
/** /**
* Show delivery info popup (mobile-friendly, same pattern as showPathPopup) * Show delivery info popup (mobile-friendly, same pattern as showPathPopup)
*/ */