mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Fix review items 001/003/005 for decoder, channel labels, and node filters
This commit is contained in:
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 || '-' };
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user