Add channel filter to Messages page, fix channel label display and decoder key parsing

- Add Channel dropdown filter to Messages page (uses existing API channel_idx param)
- Add i18n keys: entities.channel, common.all_channels
- Stop auto-prefixing # on non-hashtag channel labels (Ipswich, IPNet, etc.)
- Strip quotes from decoder key entries to fix .env parsing in Docker Compose
- Add debug logging for decoder initialization and failed decryption
- Fix except syntax (AttributeError, TypeError -> parenthesized tuple)
This commit is contained in:
Louis King
2026-04-14 22:05:00 +01:00
parent aeac44e8c7
commit 0302c0c661
8 changed files with 45 additions and 21 deletions
+11 -8
View File
@@ -48,6 +48,14 @@ class LetsMeshPacketDecoder:
self._decode_cache: dict[str, dict[str, Any] | None] = {}
self._decode_cache_maxsize = 2048
self._key_store = self._build_key_store()
logger.debug(
"LetsMesh decoder initialized: %d channel keys loaded (%s)",
len(self._channel_key_infos),
", ".join(
f"{info.label or 'unlabeled'}=0x{info.channel_hash}"
for info in self._channel_key_infos
),
)
def _build_key_store(self) -> MeshCoreKeyStore:
"""Build a MeshCoreKeyStore from configured channel keys."""
@@ -88,7 +96,7 @@ class LetsMeshPacketDecoder:
if value is None:
return None
candidate = value.strip()
candidate = value.strip().strip('"').strip("'")
if not candidate:
return None
@@ -158,17 +166,12 @@ class LetsMeshPacketDecoder:
if not info.label:
continue
label = info.label.strip()
label = info.label.strip().strip('"').strip("'")
if not label:
continue
if label.lower() == "public":
normalized_label = "Public"
else:
normalized_label = label if label.startswith("#") else f"#{label}"
channel_idx = int(info.channel_hash, 16)
labels.setdefault(channel_idx, normalized_label)
labels.setdefault(channel_idx, label)
return labels
@@ -103,10 +103,18 @@ class LetsMeshNormalizer:
# In LetsMesh compatibility mode, only show messages that decrypt.
text = self._extract_letsmesh_decoder_text(decoded_packet)
if not text:
channel_hash = self._extract_letsmesh_decoder_channel_hash(decoded_packet)
logger.debug(
"Skipping LetsMesh packet %s (type=%s): no decryptable text payload",
"Skipping LetsMesh packet %s (type=%s): no decryptable text payload "
"(channel_hash=%s, decoded_keys=%s)",
packet_hash_text or "unknown",
packet_type,
channel_hash or "N/A",
(
list(decoded_packet.keys())
if isinstance(decoded_packet, dict)
else "N/A"
),
)
return None
@@ -914,10 +922,7 @@ class LetsMeshNormalizer:
) -> str | None:
"""Format a display label for channel messages."""
if channel_name and channel_name.strip():
cleaned = channel_name.strip()
if cleaned.lower() == "public":
return "Public"
return cleaned if cleaned.startswith("#") else f"#{cleaned}"
return channel_name.strip()
if channel_idx is not None:
return f"Ch {channel_idx}"
if channel_hash:
@@ -12,6 +12,7 @@ import { createAutoRefresh } from '../auto-refresh.js';
export async function render(container, params, router) {
const query = params.query || {};
const message_type = query.message_type || '';
const channel_idx = query.channel_idx || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 50;
const offset = (page - 1) * limit;
@@ -185,7 +186,7 @@ ${content}`, container);
async function fetchAndRenderData() {
try {
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
const data = await apiGet('/api/v1/messages', { limit, offset, message_type, channel_idx });
const messages = dedupeBySignature(data.items || []);
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
@@ -277,7 +278,7 @@ ${content}`, container);
});
const paginationBlock = pagination(page, totalPages, '/messages', {
message_type, limit,
message_type, channel_idx, limit,
});
renderPage(html`
@@ -294,6 +295,17 @@ ${content}`, container);
<option value="channel" ?selected=${message_type === 'channel'}>${t('messages.type_channel')}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.channel')}</span>
</label>
<select name="channel_idx" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_channels')}</option>
${[...channelLabels.entries()].map(([idx, label]) =>
html`<option value=${idx} ?selected=${channel_idx === String(idx)}>${label}</option>`
)}
</select>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/messages" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
+3 -1
View File
@@ -14,7 +14,8 @@
"member": "Member",
"admin": "Admin",
"tags": "Tags",
"tag": "Tag"
"tag": "Tag",
"channel": "Channel"
},
"common": {
"filter": "Filter",
@@ -78,6 +79,7 @@
"sign_out": "Sign Out",
"view_details": "View Details",
"all_types": "All Types",
"all_channels": "All Channels",
"node_type": "Node Type",
"show": "Show",
"search_placeholder": "Search by name, ID, or public key...",
@@ -42,6 +42,7 @@ Core entity names used throughout the application. These are referenced by other
| `admin` | Admin | Admin panel |
| `tags` | Tags | Node metadata tags (plural) |
| `tag` | Tag | Single tag |
| `channel` | Channel | Mesh network channel |
**Usage:** These are used with composite patterns. For example, `t('common.add_entity', { entity: t('entities.node') })` produces "Add Node".
@@ -158,6 +159,7 @@ Toast/flash messages after successful operations:
| `updated` | Updated | Last updated timestamp |
| `view_details` | View Details | View details link |
| `all_types` | All Types | "All types" filter option |
| `all_channels` | All Channels | "All channels" filter option |
| `node_type` | Node Type | Node type field |
| `show` | Show | Show/display action |
| `search_placeholder` | Search by name, ID, or public key... | Search input placeholder |
@@ -118,9 +118,9 @@ def test_channel_labels_by_index_includes_labeled_entries() -> None:
labels = decoder.channel_labels_by_index()
assert labels[17] == "Public"
assert labels[217] == "#test"
assert labels[202] == "#bot"
assert labels[184] == "#chat"
assert labels[217] == "test"
assert labels[202] == "bot"
assert labels[184] == "chat"
def test_decode_payload_caches_results() -> None:
+1 -1
View File
@@ -336,7 +336,7 @@ class TestSubscriber:
assert public_key == "a" * 64
assert event_type == "channel_msg_recv"
assert payload["text"] == "decoded hello"
assert payload["channel_name"] == "#test"
assert payload["channel_name"] == "test"
assert payload["sender_timestamp"] == 1771695860
assert payload["txt_type"] == 5
assert payload["path_len"] == 4
+1 -1
View File
@@ -101,4 +101,4 @@ class TestMessagesConfig:
config = json.loads(text[config_start:config_end])
assert config["channel_labels"]["17"] == "Public"
assert config["channel_labels"]["217"] == "#test"
assert config["channel_labels"]["217"] == "test"