allow filtering chat and logs by node name (#417)

This commit is contained in:
l5y
2025-11-07 15:55:11 +01:00
committed by GitHub
parent 554b2abd82
commit 9239805129
3 changed files with 435 additions and 14 deletions
@@ -0,0 +1,109 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { CHAT_LOG_ENTRY_TYPES } from '../chat-log-tabs.js';
import {
chatLogEntryMatchesQuery,
chatMessageMatchesQuery,
filterChatModel,
normaliseChatFilterQuery
} from '../chat-search.js';
test('normaliseChatFilterQuery lower-cases and trims user input', () => {
assert.equal(normaliseChatFilterQuery(' MIXED Case '), 'mixed case');
assert.equal(normaliseChatFilterQuery(null), '');
});
test('chatMessageMatchesQuery inspects text and node metadata', () => {
const message = { text: 'Hello Mesh', node: { short_name: 'ALFA', long_name: 'Alpha Node' } };
const helloQuery = normaliseChatFilterQuery('mesh');
assert.equal(chatMessageMatchesQuery(message, helloQuery), true);
const aliasQuery = normaliseChatFilterQuery('alfa');
assert.equal(chatMessageMatchesQuery(message, aliasQuery), true);
const missQuery = normaliseChatFilterQuery('bravo');
assert.equal(chatMessageMatchesQuery(message, missQuery), false);
});
test('chatLogEntryMatchesQuery recognises position highlight values', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.POSITION,
ts: 1,
position: { latitude: 51.5, longitude: 0 },
node: { node_id: '!alpha', short_name: 'Alpha' }
};
const query = normaliseChatFilterQuery('51.50000');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
const missQuery = normaliseChatFilterQuery('bravo');
assert.equal(chatLogEntryMatchesQuery(entry, missQuery), false);
});
test('chatLogEntryMatchesQuery uses enriched node context for lookups', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.TELEMETRY,
nodeId: '!alpha',
telemetry: { voltage: 12.1 },
node: { short_name: 'ALFA', long_name: 'Alpha Node' }
};
const query = normaliseChatFilterQuery('alpha node');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
});
test('chatLogEntryMatchesQuery inspects neighbor node context', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR,
neighborId: '!bravo',
neighborNode: { short_name: 'BRAV', long_name: 'Bravo Station' }
};
const query = normaliseChatFilterQuery('bravo station');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
});
test('filterChatModel filters both log entries and channel messages', () => {
const model = {
logEntries: [
{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!alpha', node: { short_name: 'Alpha' } },
{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!bravo', node: { short_name: 'Bravo' } }
],
channels: [
{
index: 0,
label: '0',
entries: [
{ ts: 1, message: { text: 'Ping Alpha', node: { short_name: 'Alpha' } } },
{ ts: 2, message: { text: 'Ack Bravo', node: { short_name: 'Bravo' } } }
]
}
]
};
const result = filterChatModel(model, 'bravo');
assert.equal(result.logEntries.length, 1);
assert.equal(result.logEntries[0].nodeId, '!bravo');
assert.equal(result.channels.length, 1);
assert.deepEqual(result.channels[0].entries.map(entry => entry.message.text), ['Ack Bravo']);
});
test('filterChatModel returns original references when query is empty', () => {
const model = {
logEntries: [{ type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, nodeId: '!alpha', node: { short_name: 'Alpha' } }],
channels: [{ index: 0, label: '0', entries: [] }]
};
const result = filterChatModel(model, ' ');
assert.strictEqual(result.logEntries, model.logEntries);
assert.strictEqual(result.channels, model.channels);
});
+220
View File
@@ -0,0 +1,220 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CHAT_LOG_ENTRY_TYPES } from './chat-log-tabs.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
const BASE_SEARCH_KEYS = Object.freeze([
'node_id',
'nodeId',
'id',
'node_num',
'nodeNum',
'num',
'short_name',
'shortName',
'long_name',
'longName',
'name',
'role',
'hw_model',
'hwModel',
'neighbor_id',
'neighborId'
]);
const MESSAGE_EXTRA_KEYS = Object.freeze([
'text',
'emoji',
'channel',
'channel_index',
'channelIndex',
'channel_name',
'channelName',
'channel_display',
'channelDisplay',
'from_id',
'fromId',
'to_id',
'toId',
'reply_id',
'replyId'
]);
/**
* Normalise arbitrary input into a comparable, lower-cased string.
*
* @param {*} value User-supplied input.
* @returns {string} Trimmed, lower-cased query string.
*/
export function normaliseChatFilterQuery(value) {
if (value == null) {
return '';
}
const text = String(value).trim().toLowerCase();
return text;
}
/**
* Apply chat filtering to log entries and channel buckets.
*
* @param {{ logEntries?: Array<Object>, channels?: Array<Object> }} model Chat tab model.
* @param {*} query Filter query supplied by the user.
* @returns {{ logEntries: Array<Object>, channels: Array<Object> }} Filtered model.
*/
export function filterChatModel(model = {}, query) {
const logEntries = Array.isArray(model.logEntries) ? model.logEntries : [];
const channels = Array.isArray(model.channels) ? model.channels : [];
const normalisedQuery = normaliseChatFilterQuery(query);
if (!normalisedQuery) {
return { logEntries, channels };
}
const filteredLogs = logEntries.filter(entry => chatLogEntryMatchesQuery(entry, normalisedQuery));
const filteredChannels = channels.map(channel => ({
...channel,
entries: Array.isArray(channel.entries)
? channel.entries.filter(item => chatMessageMatchesQuery(item?.message, normalisedQuery))
: []
}));
return { logEntries: filteredLogs, channels: filteredChannels };
}
/**
* Determine whether a structured chat log entry matches the query.
*
* @param {?Object} entry Chat log entry.
* @param {string} query Normalised filter query.
* @returns {boolean} True when the entry should remain visible.
*/
export function chatLogEntryMatchesQuery(entry, query) {
if (!query) return true;
if (!entry || typeof entry !== 'object') {
return false;
}
const candidates = [];
candidates.push(...collectSearchValues(entry.node));
candidates.push(...collectSearchValues(entry.telemetry));
candidates.push(...collectSearchValues(entry.position));
candidates.push(...collectSearchValues(entry.neighbor));
candidates.push(...collectSearchValues(entry.neighborNode));
if (entry.nodeId) candidates.push(entry.nodeId);
if (entry.nodeNum != null && entry.nodeNum !== '') candidates.push(entry.nodeNum);
if (entry.neighborId) candidates.push(entry.neighborId);
if (entry.type) candidates.push(entry.type);
if (entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED) {
if (entry.message && chatMessageMatchesQuery(entry.message, query)) {
return true;
}
} else if (entry.type === CHAT_LOG_ENTRY_TYPES.TELEMETRY) {
const telemetryHighlights = formatTelemetryHighlights(entry.telemetry || {});
candidates.push(...highlightsToStrings(telemetryHighlights));
} else if (entry.type === CHAT_LOG_ENTRY_TYPES.POSITION) {
const positionHighlights = formatPositionHighlights(entry.position || {});
candidates.push(...highlightsToStrings(positionHighlights));
} else if (entry.type === CHAT_LOG_ENTRY_TYPES.NEIGHBOR) {
if (entry.neighbor && entry.neighbor.neighbor_id) {
candidates.push(entry.neighbor.neighbor_id);
}
}
return candidates.some(value => valueIncludesQuery(value, query));
}
/**
* Determine whether a mesh message matches the active query.
*
* @param {?Object} message Chat message payload.
* @param {string} query Normalised filter query.
* @returns {boolean} True when the message should be shown.
*/
export function chatMessageMatchesQuery(message, query) {
if (!query) return true;
if (!message || typeof message !== 'object') {
return false;
}
const candidates = [
...collectSearchValues(message, MESSAGE_EXTRA_KEYS),
...collectSearchValues(message.node),
];
return candidates.some(value => valueIncludesQuery(value, query));
}
function highlightsToStrings(highlights) {
if (!Array.isArray(highlights)) {
return [];
}
return highlights
.map(entry => {
if (!entry || typeof entry !== 'object') {
return null;
}
const label = entry.label != null ? String(entry.label).trim() : '';
const value = entry.value != null ? String(entry.value).trim() : '';
return `${label} ${value}`.trim();
})
.filter(Boolean);
}
function collectSearchValues(source, extraKeys = []) {
if (!source || typeof source !== 'object') {
return [];
}
const values = [];
const keys = extraKeys.length ? [...BASE_SEARCH_KEYS, ...extraKeys] : BASE_SEARCH_KEYS;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
const value = source[key];
if (value == null || value === '') {
continue;
}
if (typeof value === 'object') {
continue;
}
values.push(value);
}
return values;
}
function valueIncludesQuery(value, query) {
if (!query) return true;
if (value == null) {
return false;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return false;
}
return String(value).toLowerCase().includes(query);
}
if (typeof value === 'boolean') {
return (value ? 'true' : 'false').includes(query);
}
const text = String(value).trim();
if (!text) {
return false;
}
return text.toLowerCase().includes(query);
}
export default {
normaliseChatFilterQuery,
filterChatModel,
chatLogEntryMatchesQuery,
chatMessageMatchesQuery
};
+106 -14
View File
@@ -40,6 +40,7 @@ import { initializeInstanceSelector } from './instance-selector.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js';
import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js';
/**
@@ -124,6 +125,12 @@ export function initializeApp(config) {
let allNodes = [];
/** @type {Array<Object>} */
let allNeighbors = [];
/** @type {Array<Object>} */
let allMessages = [];
/** @type {Array<Object>} */
let allTelemetryEntries = [];
/** @type {Array<Object>} */
let allPositionEntries = [];
/** @type {Map<string, Object>} */
let nodesById = new Map();
let messagesById = new Map();
@@ -2617,11 +2624,85 @@ let messagesById = new Map();
return div;
}
/**
* Attach node context to chat log entries when identifier metadata exists.
*
* @param {Array<Object>} entries Chat log entries.
* @returns {Array<Object>} Enriched entries.
*/
function attachNodeContextToLogEntries(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
return Array.isArray(entries) ? entries : [];
}
return entries.map(entry => {
if (!entry || typeof entry !== 'object') {
return entry;
}
const hasNode = entry.node && typeof entry.node === 'object';
const hasNeighborNode = entry.neighborNode && typeof entry.neighborNode === 'object';
const resolvedNode = hasNode ? entry.node : resolveNodeForLogEntryContext(entry);
const resolvedNeighbor = hasNeighborNode ? entry.neighborNode : resolveNeighborForLogEntry(entry);
if (resolvedNode === entry.node && resolvedNeighbor === entry.neighborNode) {
return entry;
}
const enriched = { ...entry };
if (resolvedNode && !hasNode) {
enriched.node = resolvedNode;
}
if (resolvedNeighbor && !hasNeighborNode) {
enriched.neighborNode = resolvedNeighbor;
}
return enriched;
});
}
/**
* Locate the canonical node associated with a chat log entry for filtering.
*
* @param {Object} entry Chat log entry.
* @returns {?Object} Node payload when available.
*/
function resolveNodeForLogEntryContext(entry) {
if (!entry || typeof entry !== 'object') {
return null;
}
if (nodesById instanceof Map && typeof entry.nodeId === 'string' && nodesById.has(entry.nodeId)) {
return nodesById.get(entry.nodeId);
}
if (nodesByNum instanceof Map && Number.isFinite(entry.nodeNum) && nodesByNum.has(entry.nodeNum)) {
return nodesByNum.get(entry.nodeNum);
}
return null;
}
/**
* Locate the neighbor node metadata for a chat log entry when available.
*
* @param {Object} entry Chat log entry.
* @returns {?Object} Neighbor node payload when available.
*/
function resolveNeighborForLogEntry(entry) {
if (!entry || typeof entry !== 'object' || !(nodesById instanceof Map)) {
return null;
}
const neighborId = typeof entry.neighborId === 'string' ? entry.neighborId : null;
if (neighborId && nodesById.has(neighborId)) {
return nodesById.get(neighborId);
}
return null;
}
/**
* Render the chat history panel with nodes and messages.
*
* @param {Array<Object>} nodes Collection of node payloads.
* @param {Array<Object>} messages Collection of message payloads.
* @param {{
* nodes?: Array<Object>,
* messages?: Array<Object>,
* telemetryEntries?: Array<Object>,
* positionEntries?: Array<Object>,
* neighborEntries?: Array<Object>,
* filterQuery?: string
* }} params Render inputs.
* @returns {void}
*/
function renderChatLog({
@@ -2629,7 +2710,8 @@ let messagesById = new Map();
messages = [],
telemetryEntries = [],
positionEntries = [],
neighborEntries = []
neighborEntries = [],
filterQuery = ''
}) {
if (!CHAT_ENABLED || !chatEl) return;
messagesById = buildMessageIndex(messages);
@@ -2645,13 +2727,19 @@ let messagesById = new Map();
maxChannelIndex: MAX_CHANNEL_INDEX
});
const enrichedLogEntries = attachNodeContextToLogEntries(logEntries);
const { logEntries: filteredLogEntries, channels: filteredChannels } = filterChatModel(
{ logEntries: enrichedLogEntries, channels },
filterQuery
);
const logContent = buildChatFragment({
entries: logEntries,
entries: filteredLogEntries,
renderEntry: createChatLogEntry,
emptyLabel: 'No recent mesh activity.'
});
const channelTabs = channels.map(channel => ({
const channelTabs = filteredChannels.map(channel => ({
id: `channel-${channel.index}`,
label: channel.label,
content: buildChatFragment({
@@ -3472,8 +3560,8 @@ let messagesById = new Map();
*/
function applyFilter() {
updateFilterClearVisibility();
const rawQuery = filterInput ? filterInput.value : '';
const q = rawQuery.trim().toLowerCase();
const filterQuery = filterInput ? filterInput.value : '';
const q = normaliseChatFilterQuery(filterQuery);
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
const sortedNodes = sortNodes(filteredNodes);
const nowSec = Date.now()/1000;
@@ -3482,6 +3570,14 @@ let messagesById = new Map();
updateCount(sortedNodes, nowSec);
updateRefreshInfo(sortedNodes, nowSec);
updateSortIndicators();
renderChatLog({
nodes: allNodes,
messages: allMessages,
telemetryEntries: allTelemetryEntries,
positionEntries: allPositionEntries,
neighborEntries: allNeighbors,
filterQuery
});
}
if (filterInput) {
@@ -3540,13 +3636,9 @@ let messagesById = new Map();
allNodes = nodes;
rebuildNodeIndex(allNodes);
const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById);
renderChatLog({
nodes,
messages: chatMessages,
telemetryEntries,
positionEntries: positions,
neighborEntries: neighborTuples
});
allMessages = Array.isArray(chatMessages) ? chatMessages : [];
allTelemetryEntries = Array.isArray(telemetryEntries) ? telemetryEntries : [];
allPositionEntries = Array.isArray(positions) ? positions : [];
allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];
applyFilter();
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();