diff --git a/.gitignore b/.gitignore index 03c6fd3..8773115 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ ai_docs/ # Generated credentials for the instance web/.config + +# JavaScript dependencies +node_modules/ +web/node_modules/ diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js index 6c6d33b..e9d0c17 100644 --- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -40,6 +40,8 @@ function fixtureNodes() { function fixtureMessages() { return [ { id: 'recent-default', rx_time: NOW - 5, channel: 0, channel_name: ' MediumFast ' }, + { id: 'primary-preset', rx_time: NOW - 8, channel: 0, modem_preset: ' ShortFast ' }, + { id: 'env-default', rx_time: NOW - 12, channel: 0 }, { id: 'recent-alt', rx_time: NOW - 10, channel_index: '1', channel_name: ' BerlinMesh ' }, { id: 'stale', rx_time: NOW - WINDOW - 5, channel: 2 }, { id: 'encrypted', rx_time: NOW - 20, channel: 3, encrypted: true }, @@ -55,6 +57,7 @@ function buildModel(overrides = {}) { messages: fixtureMessages(), nowSeconds: NOW, windowSeconds: WINDOW, + primaryChannelFallbackLabel: '#EnvDefault', ...overrides }); } @@ -72,23 +75,39 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => { ['recent-node', 'iso-node', 'encrypted'] ); - assert.equal(model.channels.length, 3); - const [fallbackChannel, namedPrimaryChannel, secondaryChannel] = model.channels; + assert.equal(model.channels.length, 5); + assert.deepEqual(model.channels.map(channel => channel.label), [ + 'EnvDefault', + 'Fallback', + 'MediumFast', + 'ShortFast', + 'BerlinMesh' + ]); + const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel])); + + const envChannel = channelByLabel.EnvDefault; + assert.equal(envChannel.index, 0); + assert.equal(envChannel.id, 'channel-0-envdefault'); + assert.deepEqual(envChannel.entries.map(entry => entry.message.id), ['env-default']); + + const fallbackChannel = channelByLabel.Fallback; assert.equal(fallbackChannel.index, 0); - assert.equal(fallbackChannel.label, 'Fallback'); assert.equal(fallbackChannel.id, 'channel-0-fallback'); - assert.equal(fallbackChannel.entries.length, 1); assert.deepEqual(fallbackChannel.entries.map(entry => entry.message.id), ['no-index']); + const namedPrimaryChannel = channelByLabel.MediumFast; assert.equal(namedPrimaryChannel.index, 0); - assert.equal(namedPrimaryChannel.label, 'MediumFast'); assert.equal(namedPrimaryChannel.id, 'channel-0-mediumfast'); - assert.equal(namedPrimaryChannel.entries.length, 1); assert.deepEqual(namedPrimaryChannel.entries.map(entry => entry.message.id), ['recent-default']); + const presetChannel = channelByLabel.ShortFast; + assert.equal(presetChannel.index, 0); + assert.equal(presetChannel.id, 'channel-0-shortfast'); + assert.deepEqual(presetChannel.entries.map(entry => entry.message.id), ['primary-preset']); + + const secondaryChannel = channelByLabel.BerlinMesh; assert.equal(secondaryChannel.index, 1); - assert.equal(secondaryChannel.label, 'BerlinMesh'); assert.equal(secondaryChannel.id, 'channel-1'); assert.equal(secondaryChannel.entries.length, 2); assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']); @@ -101,6 +120,19 @@ test('buildChatTabModel always includes channel zero bucket', () => { assert.equal(model.channels[0].entries.length, 0); }); +test('buildChatTabModel falls back to numeric label when no metadata provided', () => { + const model = buildChatTabModel({ + nodes: [], + messages: [{ id: 'plain', rx_time: NOW - 5, channel: 0 }], + nowSeconds: NOW, + windowSeconds: WINDOW, + primaryChannelFallbackLabel: '' + }); + assert.equal(model.channels.length, 1); + assert.equal(model.channels[0].label, '0'); + assert.equal(model.channels[0].id, 'channel-0'); +}); + test('normaliseChannelIndex handles numeric and textual input', () => { assert.equal(normaliseChannelIndex(2.9), 2); assert.equal(normaliseChannelIndex(' 7 '), 7); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index 4b39dac..5f18870 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -14,6 +14,8 @@ * limitations under the License. */ +import { extractModemMetadata } from './node-modem-metadata.js'; + /** * Highest channel index that should be represented within the tab view. * @type {number} @@ -54,7 +56,8 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({ * messages?: Array, * nowSeconds: number, * windowSeconds: number, - * maxChannelIndex?: number + * maxChannelIndex?: number, + * primaryChannelFallbackLabel?: string|null * }} params Aggregation inputs. * @returns {{ * logEntries: Array<{ ts: number, type: string, nodeId?: string, nodeNum?: number }>, @@ -69,11 +72,13 @@ export function buildChatTabModel({ messages = [], nowSeconds, windowSeconds, - maxChannelIndex = MAX_CHANNEL_INDEX + maxChannelIndex = MAX_CHANNEL_INDEX, + primaryChannelFallbackLabel = null }) { const cutoff = (Number.isFinite(nowSeconds) ? nowSeconds : 0) - (Number.isFinite(windowSeconds) ? windowSeconds : 0); const logEntries = []; const channelBuckets = new Map(); + const primaryChannelEnvLabel = normalisePrimaryChannelEnvLabel(primaryChannelFallbackLabel); for (const node of nodes || []) { if (!node) continue; @@ -142,22 +147,32 @@ export function buildChatTabModel({ message.channel_name ?? message.channelName ?? message.channel_display ?? message.channelDisplay ); const safeIndex = channelIndex != null && channelIndex >= 0 ? channelIndex : 0; - const bucketKey = buildChannelBucketKey(safeIndex, channelName); + const modemPreset = safeIndex === 0 ? extractModemMetadata(message).modemPreset : null; + const labelInfo = resolveChannelLabel({ + index: safeIndex, + channelName, + modemPreset, + envFallbackLabel: primaryChannelEnvLabel + }); + const bucketKey = buildChannelBucketKey(safeIndex, safeIndex === 0 && labelInfo.label !== '0' ? labelInfo.label : null); let bucket = channelBuckets.get(bucketKey); if (!bucket) { bucket = { key: bucketKey, id: buildChannelTabId(bucketKey), index: safeIndex, - label: channelName || String(safeIndex), + label: labelInfo.label, entries: [], - hasExplicitName: Boolean(channelName), + labelPriority: labelInfo.priority, isPrimaryFallback: bucketKey === '0' }; channelBuckets.set(bucketKey, bucket); - } else if (channelName && !bucket.hasExplicitName) { - bucket.label = channelName; - bucket.hasExplicitName = true; + } else { + const existingPriority = bucket.labelPriority ?? CHANNEL_LABEL_PRIORITY.INDEX; + if ((labelInfo.priority ?? CHANNEL_LABEL_PRIORITY.INDEX) > existingPriority) { + bucket.label = labelInfo.label; + bucket.labelPriority = labelInfo.priority; + } } bucket.entries.push({ ts, message }); @@ -180,7 +195,7 @@ export function buildChatTabModel({ index: 0, label: '0', entries: [], - hasExplicitName: false, + labelPriority: CHANNEL_LABEL_PRIORITY.INDEX, isPrimaryFallback: true }); } @@ -307,10 +322,13 @@ export function normaliseChannelName(value) { return null; } -function buildChannelBucketKey(index, channelName) { +function buildChannelBucketKey(index, primaryChannelLabel) { const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0; - if (safeIndex === 0 && channelName) { - return `0::${channelName.toLowerCase()}`; + if (safeIndex === 0 && primaryChannelLabel) { + const trimmed = primaryChannelLabel.trim(); + if (trimmed.length > 0 && trimmed !== '0') { + return `0::${trimmed.toLowerCase()}`; + } } return String(safeIndex); } @@ -353,6 +371,42 @@ function hashChannelKey(value) { return hash.toString(36); } +const CHANNEL_LABEL_PRIORITY = Object.freeze({ + INDEX: 0, + ENV: 1, + MODEM: 2, + NAME: 3 +}); + +function resolveChannelLabel({ index, channelName, modemPreset, envFallbackLabel }) { + const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0; + if (safeIndex === 0) { + if (channelName) { + return { label: channelName, priority: CHANNEL_LABEL_PRIORITY.NAME }; + } + if (modemPreset) { + return { label: modemPreset, priority: CHANNEL_LABEL_PRIORITY.MODEM }; + } + if (envFallbackLabel) { + return { label: envFallbackLabel, priority: CHANNEL_LABEL_PRIORITY.ENV }; + } + return { label: '0', priority: CHANNEL_LABEL_PRIORITY.INDEX }; + } + if (channelName) { + return { label: channelName, priority: CHANNEL_LABEL_PRIORITY.NAME }; + } + return { label: String(safeIndex), priority: CHANNEL_LABEL_PRIORITY.INDEX }; +} + +function normalisePrimaryChannelEnvLabel(value) { + const trimmed = normaliseChannelName(value); + if (!trimmed) { + return null; + } + const withoutHash = trimmed.replace(/^#+/, '').trim(); + return withoutHash.length > 0 ? withoutHash : null; +} + export const __test__ = { resolveTimestampSeconds, normaliseChannelIndex, diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 09580fc..bf76d82 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -2727,7 +2727,8 @@ let messagesById = new Map(); messages, nowSeconds, windowSeconds: CHAT_RECENT_WINDOW_SECONDS, - maxChannelIndex: MAX_CHANNEL_INDEX + maxChannelIndex: MAX_CHANNEL_INDEX, + primaryChannelFallbackLabel: config.channel }); const enrichedLogEntries = attachNodeContextToLogEntries(logEntries);