mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-06 01:41:27 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user