mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 08:53:41 +02:00
allow filtering chat and logs by node name (#417)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user