fix(dm): move retry counter below message, show delivery info visually

Move the attempt counter (e.g. "Attempt 15/24") from next to the status
icon to below the message text, left of the Resend button. Add visible
delivery meta line for delivered/failed messages showing attempt count
and path used. Store attempt info for failed messages too. Replace
Polish abbreviations (ŚK, ŚD, ŚG) with English in all log messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-28 12:52:00 +01:00
parent 7dbbba57b9
commit 677036a831
3 changed files with 60 additions and 38 deletions
+16 -15
View File
@@ -1275,8 +1275,8 @@ class DeviceManager:
4-scenario matrix based on (has_path × has_configured_paths):
- Scenario 1: No path, no configured paths → FLOOD only
- Scenario 2: Has path, no configured paths → DIRECT + optional FLOOD
- Scenario 3: No path, has configured paths → FLOOD first, then ŚD rotation
- Scenario 4: Has path, has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD
- Scenario 3: No path, has configured paths → FLOOD first, then configured path rotation
- Scenario 4: Has path, has configured paths → DIRECT on current path, configured path rotation, optional FLOOD
The no_auto_flood per-contact flag prevents automatic DIRECT→FLOOD reset
in Scenarios 2 and 4. Ignored in Scenarios 1 and 3.
@@ -1405,7 +1405,7 @@ class DeviceManager:
# Scenario 2: Has path, no configured paths → DIRECT + optional FLOOD
# ════════════════════════════════════════════════════════════
elif has_path and not has_configured_paths:
# Phase 1: Direct retries on current ŚK
# Phase 1: Direct retries on current path
for _ in range(cfg['direct_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['direct_interval'])):
@@ -1425,7 +1425,7 @@ class DeviceManager:
return
# ════════════════════════════════════════════════════════════
# Scenario 3: No path, has configured paths → FLOOD first, then ŚD rotation
# Scenario 3: No path, has configured paths → FLOOD first, then configured path rotation
# ════════════════════════════════════════════════════════════
elif not has_path and has_configured_paths:
# Phase 1: FLOOD retries per NoPath settings (discover new path)
@@ -1433,9 +1433,9 @@ class DeviceManager:
for _ in range(cfg['flood_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])):
return # Firmware sets discovered path as ŚK
return # Firmware sets discovered path automatically
# Phase 2: ŚD rotation (primary first, then others by sort_order)
# Phase 2: Configured path rotation (primary first, then others by sort_order)
logger.info("DM retry: FLOOD exhausted, rotating through configured paths")
direct_interval = float(cfg['direct_interval'])
@@ -1455,28 +1455,28 @@ class DeviceManager:
await self._restore_primary_path(contact, contact_pubkey)
return
# Restore ŚG regardless of outcome
# Restore primary path regardless of outcome
await self._restore_primary_path(contact, contact_pubkey)
# ════════════════════════════════════════════════════════════
# Scenario 4: Has path + has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD
# Scenario 4: Has path + has configured paths → DIRECT on current path, configured path rotation, optional FLOOD
# ════════════════════════════════════════════════════════════
else: # has_path and has_configured_paths
# Phase 1: Direct retries on current ŚK
# Phase 1: Direct retries on current path
for _ in range(cfg['direct_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['direct_interval'])):
return # Delivered on ŚK, no path change needed
return # Delivered on current path, no change needed
# Phase 2: ŚD rotation with dedup
logger.info("DM retry: direct on ŚK exhausted, rotating through configured paths")
# Phase 2: Configured path rotation with dedup
logger.info("DM retry: direct retries exhausted, rotating through configured paths")
direct_interval = float(cfg['direct_interval'])
for path_info in rotation_order:
# Dedup: skip if this configured path matches original ŚK
# Dedup: skip if this configured path matches original device path
if self._paths_match(original_out_path, original_out_path_len, path_info):
logger.debug(f"DM retry: skipping path '{path_info.get('label', '')}' "
f"({path_info['path_hex']}) — matches current ŚK")
f"({path_info['path_hex']}) — matches current device path")
continue
try:
@@ -1508,10 +1508,11 @@ class DeviceManager:
await self._restore_primary_path(contact, contact_pubkey)
return
# Restore ŚG regardless of outcome
# Restore primary path regardless of outcome
await self._restore_primary_path(contact, contact_pubkey)
# ── 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_status(dm_id, 'failed')
self._emit_retry_failed(dm_id, initial_ack)
logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, scenario={scenario}) "
+9 -2
View File
@@ -739,9 +739,15 @@ main {
}
.dm-retry-info {
font-size: 0.6rem;
font-size: 0.65rem;
color: var(--text-meta);
margin-left: 0.15rem;
margin-right: auto;
}
.dm-delivery-meta {
font-size: 0.65rem;
color: var(--text-meta);
margin-top: 0.1rem;
}
.dm-delivery-popup {
@@ -763,6 +769,7 @@ main {
.dm-actions {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 0.25rem;
}
+35 -21
View File
@@ -112,14 +112,14 @@ function connectChatSocket() {
if (data.snr != null) tooltip.push(`SNR: ${data.snr}`);
if (data.route_type) tooltip.push(`Route: ${data.route_type}`);
statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : 'Delivered';
// Remove retry counter if present
// Unwrap status icon from wrapper span
const wrapper = statusEl.closest('[data-dm-id]');
if (wrapper) {
const info = wrapper.querySelector('.dm-retry-info');
if (info) info.remove();
// Unwrap: replace wrapper span with just the icon
wrapper.replaceWith(statusEl);
}
// Clear retry counter in actions area
const retryInfo = el.querySelector('.dm-retry-info');
if (retryInfo) retryInfo.textContent = '';
}
});
});
@@ -127,27 +127,27 @@ function connectChatSocket() {
// Real-time DM retry progress
chatSocket.on('dm_retry_status', (data) => {
if (!data.dm_id) return;
const wrapper = document.querySelector(`[data-dm-id="${data.dm_id}"]`);
if (!wrapper) return;
const info = wrapper.querySelector('.dm-retry-info');
if (info) info.textContent = `${data.attempt}/${data.max_attempts}`;
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
});
// DM retry exhausted — mark as failed
chatSocket.on('dm_retry_failed', (data) => {
if (!data.dm_id) return;
const wrapper = document.querySelector(`[data-dm-id="${data.dm_id}"]`);
if (!wrapper) return;
const icon = wrapper.querySelector('.dm-status');
if (icon) {
icon.className = 'bi bi-x-circle dm-status timeout';
icon.title = 'Delivery failed — all retries exhausted';
// Update status icon
const wrapper = document.querySelector(`.dm-status-unknown[data-dm-id="${data.dm_id}"]`);
if (wrapper) {
const icon = wrapper.querySelector('.dm-status');
if (icon) {
icon.className = 'bi bi-x-circle dm-status timeout';
icon.title = 'Delivery failed — all retries exhausted';
}
wrapper.removeAttribute('onclick');
wrapper.classList.remove('dm-status-unknown');
}
const info = wrapper.querySelector('.dm-retry-info');
if (info) info.remove();
// Remove onclick
wrapper.removeAttribute('onclick');
wrapper.classList.remove('dm-status-unknown');
// Clear retry counter
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) info.textContent = '';
});
// Real-time device status
@@ -1141,7 +1141,7 @@ function displayMessages(messages) {
statusIcon = `<i class="bi bi-clock dm-status pending"${ackAttr} title="Sending..."></i>`;
} else {
// No ACK received — show clickable "?" with retry counter
statusIcon = `<span class="dm-status-unknown"${dmIdAttr} onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i><span class="dm-retry-info"></span></span>`;
statusIcon = `<span class="dm-status-unknown"${dmIdAttr} onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
}
}
@@ -1157,9 +1157,22 @@ function displayMessages(messages) {
}
}
// Resend button for own messages
// Delivery info for delivered/failed messages (attempt count + path)
let deliveryMeta = '';
if (msg.is_own && (msg.status === 'delivered' || msg.status === 'failed')
&& (msg.delivery_attempt || msg.delivery_path)) {
const parts = [];
if (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}`);
deliveryMeta = `<div class="dm-delivery-meta">${parts.join(', ')}</div>`;
}
// Resend button for own messages (with retry counter placeholder)
const resendBtn = msg.is_own ? `
<div class="dm-actions">
<span class="dm-retry-info" data-dm-id="${msg.id || ''}"></span>
<button class="btn btn-outline-secondary btn-sm dm-action-btn" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Resend">
<i class="bi bi-arrow-repeat"></i>
</button>
@@ -1172,6 +1185,7 @@ function displayMessages(messages) {
${statusIcon}
</div>
<div>${processMessageContent(msg.content)}</div>
${deliveryMeta}
${meta}
${resendBtn}
`;