Fix review items 001/003/005 for decoder, channel labels, and node filters

This commit is contained in:
yellowcooln
2026-03-04 20:07:37 -05:00
parent c22274c4e5
commit 2a380f88b4
7 changed files with 65 additions and 54 deletions

View File

@@ -55,8 +55,6 @@ async def list_nodes(
Node.adv_type == "repeater", Node.adv_type == "repeater",
Node.adv_type.ilike("%repeater%"), Node.adv_type.ilike("%repeater%"),
Node.adv_type.ilike("%relay%"), Node.adv_type.ilike("%relay%"),
Node.name.ilike("%repeater%"),
Node.name.ilike("%relay%"),
) )
) )
elif normalized_adv_type == "companion": elif normalized_adv_type == "companion":
@@ -65,8 +63,6 @@ async def list_nodes(
Node.adv_type == "companion", Node.adv_type == "companion",
Node.adv_type.ilike("%companion%"), Node.adv_type.ilike("%companion%"),
Node.adv_type.ilike("%observer%"), Node.adv_type.ilike("%observer%"),
Node.name.ilike("%companion%"),
Node.name.ilike("%observer%"),
) )
) )
elif normalized_adv_type == "room": elif normalized_adv_type == "room":
@@ -74,7 +70,6 @@ async def list_nodes(
or_( or_(
Node.adv_type == "room", Node.adv_type == "room",
Node.adv_type.ilike("%room%"), Node.adv_type.ilike("%room%"),
Node.name.ilike("%room%"),
) )
) )
elif normalized_adv_type == "chat": elif normalized_adv_type == "chat":

View File

@@ -183,6 +183,9 @@ class LetsMeshPacketDecoder:
clean_hex = raw_hex.strip() clean_hex = raw_hex.strip()
if not clean_hex: if not clean_hex:
return None return None
if not self._is_hex(clean_hex):
logger.debug("LetsMesh decoder skipped non-hex raw payload")
return None
cached = self._decode_cache.get(clean_hex) cached = self._decode_cache.get(clean_hex)
if clean_hex in self._decode_cache: if clean_hex in self._decode_cache:
return cached return cached

View File

@@ -22,6 +22,34 @@ export function getConfig() {
return window.__APP_CONFIG__ || {}; return window.__APP_CONFIG__ || {};
} }
/**
* Build channel label map from app config.
* Keys are numeric channel indexes and values are non-empty labels.
*
* @param {Object} [config]
* @returns {Map<number, string>}
*/
export function getChannelLabelsMap(config = getConfig()) {
return new Map(
Object.entries(config.channel_labels || {})
.map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : ''])
.filter(([idx, label]) => Number.isInteger(idx) && label.length > 0),
);
}
/**
* Resolve a channel label from a numeric index.
*
* @param {number|string} channelIdx
* @param {Map<number, string>} [channelLabels]
* @returns {string|null}
*/
export function resolveChannelLabel(channelIdx, channelLabels = getChannelLabelsMap()) {
const parsed = parseInt(String(channelIdx), 10);
if (!Number.isInteger(parsed)) return null;
return channelLabels.get(parsed) || null;
}
/** /**
* Parse API datetime strings reliably. * Parse API datetime strings reliably.
* MeshCore API often returns UTC timestamps without an explicit timezone suffix. * MeshCore API often returns UTC timestamps without an explicit timezone suffix.

View File

@@ -1,35 +1,17 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { import {
html, litRender, nothing, html, litRender, nothing,
getConfig, typeEmoji, errorAlert, pageColors, t, formatDateTime, getConfig, getChannelLabelsMap, resolveChannelLabel,
typeEmoji, errorAlert, pageColors, t, formatDateTime,
} from '../components.js'; } from '../components.js';
import { import {
iconNodes, iconAdvertisements, iconMessages, iconChannel, iconNodes, iconAdvertisements, iconMessages, iconChannel,
} from '../icons.js'; } from '../icons.js';
function knownChannelLabel(channelIdx) { function channelLabel(channel, channelLabels) {
const config = getConfig();
const configuredChannelLabels = new Map(
Object.entries(config.channel_labels || {})
.map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : ''])
.filter(([idx, label]) => Number.isInteger(idx) && label.length > 0),
);
const builtInChannelLabels = new Map([
[17, 'Public'],
[217, '#test'],
[202, '#bot'],
[184, '#chat'],
[159, '#jokes'],
[221, '#sports'],
[104, '#emergency'],
]);
return configuredChannelLabels.get(channelIdx) || builtInChannelLabels.get(channelIdx) || null;
}
function channelLabel(channel) {
const idx = parseInt(String(channel), 10); const idx = parseInt(String(channel), 10);
if (Number.isInteger(idx)) { if (Number.isInteger(idx)) {
return knownChannelLabel(idx) || `Ch ${idx}`; return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`;
} }
return String(channel); return String(channel);
} }
@@ -84,11 +66,11 @@ function renderRecentAds(ads) {
</div>`; </div>`;
} }
function renderChannelMessages(channelMessages) { function renderChannelMessages(channelMessages, channelLabels) {
if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing; if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing;
const channels = Object.entries(channelMessages).map(([channel, messages]) => { const channels = Object.entries(channelMessages).map(([channel, messages]) => {
const label = channelLabel(channel); const label = channelLabel(channel, channelLabels);
const msgLines = messages.map(msg => html` const msgLines = messages.map(msg => html`
<div class="text-sm"> <div class="text-sm">
<span class="text-xs opacity-50">${formatTimeShort(msg.received_at)}</span> <span class="text-xs opacity-50">${formatTimeShort(msg.received_at)}</span>
@@ -127,6 +109,7 @@ function gridCols(count) {
export async function render(container, params, router) { export async function render(container, params, router) {
try { try {
const config = getConfig(); const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
const features = config.features || {}; const features = config.features || {};
const showNodes = features.nodes !== false; const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false; const showAdverts = features.advertisements !== false;
@@ -242,7 +225,7 @@ ${bottomCount > 0 ? html`
</div> </div>
</div>` : nothing} </div>` : nothing}
${showMessages ? renderChannelMessages(stats.channel_messages) : nothing} ${showMessages ? renderChannelMessages(stats.channel_messages, channelLabels) : nothing}
</div>` : nothing}`, container); </div>` : nothing}`, container);
window.initDashboardCharts( window.initDashboardCharts(

View File

@@ -2,6 +2,7 @@ import { apiGet } from '../api.js';
import { import {
html, litRender, nothing, t, html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort, getConfig, formatDateTime, formatDateTimeShort,
getChannelLabelsMap, resolveChannelLabel,
truncateKey, errorAlert, truncateKey, errorAlert,
pagination, timezoneIndicator, pagination, timezoneIndicator,
createFilterHandler, autoSubmit, submitOnEnter createFilterHandler, autoSubmit, submitOnEnter
@@ -16,27 +17,10 @@ export async function render(container, params, router) {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const config = getConfig(); const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
const tz = config.timezone || ''; const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing; const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url); const navigate = (url) => router.navigate(url);
const configuredChannelLabels = new Map(
Object.entries(config.channel_labels || {})
.map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : ''])
.filter(([idx, label]) => Number.isInteger(idx) && label.length > 0),
);
const builtInChannelLabels = new Map([
[17, 'Public'],
[217, '#test'],
[202, '#bot'],
[184, '#chat'],
[159, '#jokes'],
[221, '#sports'],
[104, '#emergency'],
]);
function knownChannelLabel(channelIdx) {
return configuredChannelLabels.get(channelIdx) || builtInChannelLabels.get(channelIdx) || null;
}
function channelInfo(msg) { function channelInfo(msg) {
if (msg.message_type !== 'channel') { if (msg.message_type !== 'channel') {
@@ -45,7 +29,7 @@ export async function render(container, params, router) {
const rawText = msg.text || ''; const rawText = msg.text || '';
const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/); const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/);
if (msg.channel_idx !== null && msg.channel_idx !== undefined) { if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
const knownLabel = knownChannelLabel(msg.channel_idx); const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
if (knownLabel) { if (knownLabel) {
return { return {
label: knownLabel, label: knownLabel,
@@ -63,7 +47,7 @@ export async function render(container, params, router) {
}; };
} }
if (msg.channel_idx !== null && msg.channel_idx !== undefined) { if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
const knownLabel = knownChannelLabel(msg.channel_idx); const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || '-' }; return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || '-' };
} }
return { label: t('messages.type_channel'), text: rawText || '-' }; return { label: t('messages.type_channel'), text: rawText || '-' };

View File

@@ -105,25 +105,28 @@ class TestListNodesFilters:
def test_filter_by_adv_type_matches_legacy_labels( def test_filter_by_adv_type_matches_legacy_labels(
self, client_no_auth, api_db_session self, client_no_auth, api_db_session
): ):
"""Canonical adv_type filters match legacy LetsMesh values and names.""" """Canonical adv_type filters match legacy LetsMesh adv_type values only."""
from datetime import datetime, timezone from datetime import datetime, timezone
from meshcore_hub.common.models import Node from meshcore_hub.common.models import Node
repeater_node = Node( repeater_node = Node(
public_key="ab" * 32, public_key="ab" * 32,
name="Car Relay",
adv_type="PyMC-Repeater", adv_type="PyMC-Repeater",
first_seen=datetime.now(timezone.utc), first_seen=datetime.now(timezone.utc),
) )
companion_node = Node( companion_node = Node(
public_key="cd" * 32, public_key="cd" * 32,
name="YC-Observer", adv_type="offline companion",
adv_type="offline",
first_seen=datetime.now(timezone.utc), first_seen=datetime.now(timezone.utc),
) )
room_node = Node( room_node = Node(
public_key="ef" * 32, public_key="ef" * 32,
adv_type="room server",
first_seen=datetime.now(timezone.utc),
)
name_only_room_node = Node(
public_key="12" * 32,
name="WAL-SE Room Server", name="WAL-SE Room Server",
adv_type="unknown", adv_type="unknown",
first_seen=datetime.now(timezone.utc), first_seen=datetime.now(timezone.utc),
@@ -131,6 +134,7 @@ class TestListNodesFilters:
api_db_session.add(repeater_node) api_db_session.add(repeater_node)
api_db_session.add(companion_node) api_db_session.add(companion_node)
api_db_session.add(room_node) api_db_session.add(room_node)
api_db_session.add(name_only_room_node)
api_db_session.commit() api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?adv_type=repeater") response = client_no_auth.get("/api/v1/nodes?adv_type=repeater")
@@ -147,6 +151,7 @@ class TestListNodesFilters:
assert response.status_code == 200 assert response.status_code == 200
room_keys = {item["public_key"] for item in response.json()["items"]} room_keys = {item["public_key"] for item in response.json()["items"]}
assert room_node.public_key in room_keys assert room_node.public_key in room_keys
assert name_only_room_node.public_key not in room_keys
def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag): def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag):
"""Test filtering nodes by member_id tag.""" """Test filtering nodes by member_id tag."""

View File

@@ -12,6 +12,19 @@ def test_decode_payload_returns_none_without_raw() -> None:
assert decoder.decode_payload({"packet_type": 5}) is None assert decoder.decode_payload({"packet_type": 5}) is None
def test_decode_payload_rejects_non_hex_raw_without_invoking_decoder() -> None:
"""Decoder returns None and does not execute subprocess for invalid raw hex."""
decoder = LetsMeshPacketDecoder(enabled=True, command="meshcore-decoder")
with (
patch("meshcore_hub.collector.letsmesh_decoder.shutil.which", return_value="1"),
patch("meshcore_hub.collector.letsmesh_decoder.subprocess.run") as mock_run,
):
assert decoder.decode_payload({"raw": "ZZ-not-hex"}) is None
mock_run.assert_not_called()
def test_decode_payload_invokes_decoder_with_keys() -> None: def test_decode_payload_invokes_decoder_with_keys() -> None:
"""Decoder command includes channel keys and returns parsed JSON.""" """Decoder command includes channel keys and returns parsed JSON."""
decoder = LetsMeshPacketDecoder( decoder = LetsMeshPacketDecoder(