mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-09 23:04:56 +02:00
Compare commits
8 Commits
v0.2.1-rc2
...
v0.2.1-rc6
| Author | SHA1 | Date | |
|---|---|---|---|
| e91ad24cf9 | |||
| 2e543b7cd4 | |||
| db4353ccdc | |||
| 5a610cf08a | |||
| 71b854998c | |||
| 0a70ae4b3e | |||
| 6e709b0b67 | |||
| a4256cee83 |
@@ -30,14 +30,17 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
service: [web, ingestor]
|
||||
architecture:
|
||||
architecture:
|
||||
- { name: linux-amd64, platform: linux/amd64, label: "Linux x86_64" }
|
||||
- { name: windows-amd64, platform: windows/amd64, label: "Windows x86_64" }
|
||||
- { name: linux-arm64, platform: linux/arm64, label: "Linux ARM64" }
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -127,6 +130,7 @@ jobs:
|
||||
docker run --rm --name ingestor-test \
|
||||
-e POTATOMESH_INSTANCE=http://localhost:41447 \
|
||||
-e API_TOKEN=test-token \
|
||||
-e MESH_SERIAL=mock \
|
||||
-e DEBUG=1 \
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:${{ steps.version.outputs.version }} &
|
||||
sleep 5
|
||||
@@ -156,12 +160,12 @@ jobs:
|
||||
# Web images
|
||||
echo "### 🌐 Web Application" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-windows-amd64:latest\` - Windows x86_64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Ingestor images
|
||||
echo "### 📡 Ingestor Service" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-windows-amd64:latest\` - Windows x86_64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@ COPY data/requirements.txt ./
|
||||
RUN set -eux; \
|
||||
apk add --no-cache \
|
||||
tzdata \
|
||||
curl; \
|
||||
curl \
|
||||
libstdc++ \
|
||||
libgcc; \
|
||||
apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
musl-dev \
|
||||
|
||||
+38
-1
@@ -42,6 +42,43 @@ INSTANCE = os.environ.get("POTATOMESH_INSTANCE", "").rstrip("/")
|
||||
API_TOKEN = os.environ.get("API_TOKEN", "")
|
||||
|
||||
|
||||
# --- Serial interface helpers --------------------------------------------------
|
||||
|
||||
|
||||
class _DummySerialInterface:
|
||||
"""In-memory replacement for ``meshtastic.serial_interface.SerialInterface``.
|
||||
|
||||
The GitHub Actions release tests run the ingestor container without access
|
||||
to a serial device. When ``MESH_SERIAL`` is set to ``"mock"`` (or similar)
|
||||
we provide this stub interface so the daemon can start and exercise its
|
||||
background loop without failing due to missing hardware.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
|
||||
def close(self):
|
||||
"""Mirror the real interface API."""
|
||||
pass
|
||||
|
||||
|
||||
def _create_serial_interface(port: str):
|
||||
"""Return an appropriate serial interface for ``port``.
|
||||
|
||||
Passing ``mock`` (case-insensitive) or an empty value skips hardware access
|
||||
and returns :class:`_DummySerialInterface`. This makes it possible to run
|
||||
the container in CI environments that do not expose serial devices while
|
||||
keeping production behaviour unchanged.
|
||||
"""
|
||||
|
||||
port_value = (port or "").strip()
|
||||
if port_value.lower() in {"", "mock", "none", "null", "disabled"}:
|
||||
if DEBUG:
|
||||
print(f"[debug] using dummy serial interface for port={port_value!r}")
|
||||
return _DummySerialInterface()
|
||||
return SerialInterface(devPath=port_value)
|
||||
|
||||
|
||||
# --- POST queue ----------------------------------------------------------------
|
||||
_POST_QUEUE_LOCK = threading.Lock()
|
||||
_POST_QUEUE = []
|
||||
@@ -422,7 +459,7 @@ def main():
|
||||
# Subscribe to PubSub topics (reliable in current meshtastic)
|
||||
pub.subscribe(on_receive, "meshtastic.receive")
|
||||
|
||||
iface = SerialInterface(devPath=PORT)
|
||||
iface = _create_serial_interface(PORT)
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
|
||||
@@ -102,6 +102,33 @@ def test_snapshot_interval_defaults_to_60_seconds(mesh_module):
|
||||
assert mesh.SNAPSHOT_SECS == 60
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["mock", "Mock", " disabled "])
|
||||
def test_create_serial_interface_allows_mock(mesh_module, value):
|
||||
mesh = mesh_module
|
||||
|
||||
iface = mesh._create_serial_interface(value)
|
||||
|
||||
assert isinstance(iface.nodes, dict)
|
||||
iface.close()
|
||||
|
||||
|
||||
def test_create_serial_interface_uses_serial_module(mesh_module, monkeypatch):
|
||||
mesh = mesh_module
|
||||
created = {}
|
||||
sentinel = object()
|
||||
|
||||
def fake_interface(*, devPath):
|
||||
created["devPath"] = devPath
|
||||
return SimpleNamespace(nodes={"!foo": sentinel}, close=lambda: None)
|
||||
|
||||
monkeypatch.setattr(mesh, "SerialInterface", fake_interface)
|
||||
|
||||
iface = mesh._create_serial_interface("/dev/ttyTEST")
|
||||
|
||||
assert created["devPath"] == "/dev/ttyTEST"
|
||||
assert iface.nodes == {"!foo": sentinel}
|
||||
|
||||
|
||||
def test_node_to_dict_handles_nested_structures(mesh_module):
|
||||
mesh = mesh_module
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,10 @@
|
||||
# Main application builder stage
|
||||
FROM ruby:3.3-alpine AS builder
|
||||
|
||||
# Ensure native extensions are built against musl libc rather than
|
||||
# using glibc precompiled binaries (which fail on Alpine).
|
||||
ENV BUNDLE_FORCE_RUBY_PLATFORM=true
|
||||
|
||||
# Install build dependencies and SQLite3
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
@@ -15,7 +19,8 @@ WORKDIR /app
|
||||
COPY web/Gemfile web/Gemfile.lock* ./
|
||||
|
||||
# Install gems with SQLite3 support
|
||||
RUN bundle config set --local without 'development test' && \
|
||||
RUN bundle config set --local force_ruby_platform true && \
|
||||
bundle config set --local without 'development test' && \
|
||||
bundle install --jobs=4 --retry=3
|
||||
|
||||
# Production stage
|
||||
|
||||
+5
-1
@@ -17,4 +17,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
bundle install
|
||||
exec ruby app.rb -p 41447 -o 127.0.0.1
|
||||
|
||||
PORT=${PORT:-41447}
|
||||
BIND_ADDRESS=${BIND_ADDRESS:-0.0.0.0}
|
||||
|
||||
exec ruby app.rb -p "${PORT}" -o "${BIND_ADDRESS}"
|
||||
|
||||
+169
-80
@@ -91,12 +91,27 @@
|
||||
label { font-size: 14px; color: #333; }
|
||||
input[type="text"] { padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; }
|
||||
.legend { position: relative; background: #fff; padding: 8px 10px 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 12px; line-height: 18px; min-width: 160px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); }
|
||||
.legend-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-weight: 600; }
|
||||
.legend-header { display: flex; align-items: center; justify-content: flex-start; gap: 4px; margin-bottom: 6px; font-weight: 600; }
|
||||
.legend-title { font-size: 13px; }
|
||||
.legend-close { border: none; background: transparent; cursor: pointer; padding: 2px; line-height: 1; font-size: 16px; border-radius: 4px; color: inherit; }
|
||||
.legend-close:hover { background: rgba(0, 0, 0, 0.08); }
|
||||
.legend-items { display: flex; flex-direction: column; gap: 4px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||||
.legend-items { display: flex; flex-direction: column; gap: 2px; }
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.legend-item:hover { background: rgba(0, 0, 0, 0.05); }
|
||||
.legend-item:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; }
|
||||
.legend-item[aria-pressed="true"] { border-color: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.08); }
|
||||
.legend-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 2px; }
|
||||
.legend-hidden { display: none !important; }
|
||||
.legend-toggle { margin-top: 8px; }
|
||||
@@ -193,9 +208,10 @@
|
||||
body.dark label { color: #ddd; }
|
||||
body.dark input[type="text"] { background: #222; color: #eee; border-color: #444; }
|
||||
body.dark .legend { background: #333; border-color: #444; color: #eee; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); }
|
||||
body.dark .legend-close:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
body.dark .legend-toggle-button { background: #333; border-color: #444; color: #eee; }
|
||||
body.dark .legend-toggle-button:hover { background: #444; }
|
||||
body.dark .legend-item:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
body.dark .legend-item[aria-pressed="true"] { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.16); }
|
||||
body.dark .leaflet-popup-content-wrapper,
|
||||
body.dark .leaflet-popup-tip {
|
||||
background: #333;
|
||||
@@ -377,8 +393,6 @@
|
||||
};
|
||||
let allNodes = [];
|
||||
let shortInfoAnchor = null;
|
||||
const seenNodeIds = new Set();
|
||||
const seenMessageIds = new Set();
|
||||
let lastChatDate;
|
||||
const NODE_LIMIT = 1000;
|
||||
const CHAT_LIMIT = 1000;
|
||||
@@ -517,6 +531,28 @@
|
||||
ROUTER: '#E88B94'
|
||||
});
|
||||
|
||||
const activeRoleFilters = new Set();
|
||||
const legendRoleButtons = new Map();
|
||||
|
||||
function normalizeRole(role) {
|
||||
if (role == null) return 'CLIENT';
|
||||
const str = String(role).trim();
|
||||
return str.length ? str : 'CLIENT';
|
||||
}
|
||||
|
||||
function getRoleKey(role) {
|
||||
const normalized = normalizeRole(role);
|
||||
if (roleColors[normalized]) return normalized;
|
||||
const upper = normalized.toUpperCase();
|
||||
if (roleColors[upper]) return upper;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getRoleColor(role) {
|
||||
const key = getRoleKey(role);
|
||||
return roleColors[key] || roleColors.CLIENT || '#3388ff';
|
||||
}
|
||||
|
||||
// --- Map setup ---
|
||||
const map = L.map('map', { worldCopyJump: true });
|
||||
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
|
||||
@@ -641,17 +677,58 @@
|
||||
let legendToggleButton = null;
|
||||
let legendVisible = true;
|
||||
|
||||
function updateLegendToggleState() {
|
||||
if (!legendToggleButton) return;
|
||||
const hasFilters = activeRoleFilters.size > 0;
|
||||
legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false');
|
||||
const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend';
|
||||
const baseText = legendVisible ? 'Hide legend' : 'Show legend';
|
||||
const labelSuffix = hasFilters ? ' (role filters active)' : '';
|
||||
const textSuffix = ' (filters)';
|
||||
legendToggleButton.setAttribute('aria-label', baseLabel + labelSuffix);
|
||||
legendToggleButton.textContent = baseText + textSuffix;
|
||||
if (hasFilters) {
|
||||
legendToggleButton.setAttribute('data-has-active-filters', 'true');
|
||||
} else {
|
||||
legendToggleButton.removeAttribute('data-has-active-filters');
|
||||
}
|
||||
}
|
||||
|
||||
function setLegendVisibility(visible) {
|
||||
legendVisible = visible;
|
||||
if (legendContainer) {
|
||||
legendContainer.classList.toggle('legend-hidden', !visible);
|
||||
legendContainer.setAttribute('aria-hidden', visible ? 'false' : 'true');
|
||||
}
|
||||
if (legendToggleButton) {
|
||||
legendToggleButton.setAttribute('aria-pressed', visible ? 'true' : 'false');
|
||||
legendToggleButton.setAttribute('aria-label', visible ? 'Hide map legend' : 'Show map legend');
|
||||
legendToggleButton.textContent = visible ? 'Hide legend' : 'Show legend';
|
||||
updateLegendToggleState();
|
||||
}
|
||||
|
||||
function updateLegendRoleFiltersUI() {
|
||||
const hasFilters = activeRoleFilters.size > 0;
|
||||
legendRoleButtons.forEach((button, role) => {
|
||||
if (!button) return;
|
||||
const isActive = activeRoleFilters.has(role);
|
||||
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
if (legendContainer) {
|
||||
if (hasFilters) {
|
||||
legendContainer.setAttribute('data-has-active-filters', 'true');
|
||||
} else {
|
||||
legendContainer.removeAttribute('data-has-active-filters');
|
||||
}
|
||||
}
|
||||
updateLegendToggleState();
|
||||
}
|
||||
|
||||
function toggleRoleFilter(role) {
|
||||
if (!role) return;
|
||||
if (activeRoleFilters.has(role)) {
|
||||
activeRoleFilters.delete(role);
|
||||
} else {
|
||||
activeRoleFilters.add(role);
|
||||
}
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
@@ -665,28 +742,35 @@
|
||||
const header = L.DomUtil.create('div', 'legend-header', div);
|
||||
const title = L.DomUtil.create('span', 'legend-title', header);
|
||||
title.textContent = 'Legend';
|
||||
const closeButton = L.DomUtil.create('button', 'legend-close', header);
|
||||
closeButton.type = 'button';
|
||||
closeButton.setAttribute('aria-label', 'Hide legend');
|
||||
closeButton.textContent = '×';
|
||||
closeButton.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setLegendVisibility(false);
|
||||
if (legendToggleButton) {
|
||||
legendToggleButton.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const itemsContainer = L.DomUtil.create('div', 'legend-items', div);
|
||||
legendRoleButtons.clear();
|
||||
for (const [role, color] of Object.entries(roleColors)) {
|
||||
const item = L.DomUtil.create('div', 'legend-item', itemsContainer);
|
||||
const item = L.DomUtil.create('button', 'legend-item', itemsContainer);
|
||||
item.type = 'button';
|
||||
item.setAttribute('aria-pressed', 'false');
|
||||
item.dataset.role = role;
|
||||
const swatch = L.DomUtil.create('span', 'legend-swatch', item);
|
||||
swatch.style.background = color;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
const label = L.DomUtil.create('span', 'legend-label', item);
|
||||
label.textContent = role;
|
||||
item.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const exclusive = event.metaKey || event.ctrlKey;
|
||||
if (exclusive) {
|
||||
activeRoleFilters.clear();
|
||||
activeRoleFilters.add(role);
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
} else {
|
||||
toggleRoleFilter(role);
|
||||
}
|
||||
});
|
||||
legendRoleButtons.set(role, item);
|
||||
}
|
||||
updateLegendRoleFiltersUI();
|
||||
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
L.DomEvent.disableScrollPropagation(div);
|
||||
@@ -701,7 +785,7 @@
|
||||
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
|
||||
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
|
||||
button.type = 'button';
|
||||
button.textContent = 'Hide legend';
|
||||
button.textContent = 'Hide legend (filters)';
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
button.setAttribute('aria-label', 'Hide map legend');
|
||||
button.setAttribute('aria-controls', 'mapLegend');
|
||||
@@ -711,6 +795,7 @@
|
||||
setLegendVisibility(!legendVisible);
|
||||
});
|
||||
legendToggleButton = button;
|
||||
updateLegendToggleState();
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
L.DomEvent.disableScrollPropagation(container);
|
||||
return container;
|
||||
@@ -828,14 +913,14 @@
|
||||
function renderShortHtml(short, role, longName, nodeData = null){
|
||||
const safeTitle = longName ? escapeHtml(String(longName)) : '';
|
||||
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
|
||||
const resolvedRole = role || (nodeData && nodeData.role) || 'CLIENT';
|
||||
const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
|
||||
let infoAttr = '';
|
||||
if (nodeData && typeof nodeData === 'object') {
|
||||
const info = {
|
||||
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
|
||||
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
|
||||
longName: nodeData.long_name ?? longName ?? '',
|
||||
role: resolvedRole,
|
||||
role: roleValue,
|
||||
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
|
||||
battery: nodeData.battery_level ?? nodeData.battery ?? null,
|
||||
voltage: nodeData.voltage ?? null,
|
||||
@@ -849,7 +934,7 @@
|
||||
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>? </span>`;
|
||||
}
|
||||
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
|
||||
const color = roleColors[resolvedRole] || roleColors.CLIENT;
|
||||
const color = getRoleColor(roleValue);
|
||||
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
|
||||
}
|
||||
|
||||
@@ -920,16 +1005,8 @@
|
||||
requestAnimationFrame(positionShortInfoOverlay);
|
||||
}
|
||||
|
||||
function appendChatEntry(div) {
|
||||
chatEl.appendChild(div);
|
||||
while (chatEl.childElementCount > CHAT_LIMIT) {
|
||||
chatEl.removeChild(chatEl.firstChild);
|
||||
}
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
|
||||
function maybeAddDateDivider(ts) {
|
||||
if (!ts) return;
|
||||
function maybeCreateDateDivider(ts) {
|
||||
if (!ts) return null;
|
||||
const d = new Date(ts * 1000);
|
||||
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
if (lastChatDate !== key) {
|
||||
@@ -939,30 +1016,60 @@
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-entry-date';
|
||||
div.textContent = `-- ${formatDate(midnight)} --`;
|
||||
appendChatEntry(div);
|
||||
return div;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addNewNodeChatEntry(n) {
|
||||
maybeAddDateDivider(n.first_heard);
|
||||
function createNodeChatEntry(n) {
|
||||
const div = document.createElement('div');
|
||||
const ts = formatTime(new Date(n.first_heard * 1000));
|
||||
div.className = 'chat-entry-node';
|
||||
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
|
||||
const longName = escapeHtml(n.long_name || '');
|
||||
div.innerHTML = `[${ts}] ${short} <em>New node: ${longName}</em>`;
|
||||
appendChatEntry(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function addNewMessageChatEntry(m) {
|
||||
maybeAddDateDivider(m.rx_time);
|
||||
function createMessageChatEntry(m) {
|
||||
const div = document.createElement('div');
|
||||
const ts = formatTime(new Date(m.rx_time * 1000));
|
||||
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
||||
const text = escapeHtml(m.text || '');
|
||||
div.className = 'chat-entry-msg';
|
||||
div.innerHTML = `[${ts}] ${short} ${text}`;
|
||||
appendChatEntry(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderChatLog(nodes, messages) {
|
||||
if (!chatEl) return;
|
||||
const entries = [];
|
||||
for (const n of nodes || []) {
|
||||
entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
|
||||
}
|
||||
for (const m of messages || []) {
|
||||
entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.ts !== b.ts) return a.ts - b.ts;
|
||||
return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
|
||||
});
|
||||
const frag = document.createDocumentFragment();
|
||||
lastChatDate = null;
|
||||
for (const entry of entries) {
|
||||
const divider = maybeCreateDateDivider(entry.ts);
|
||||
if (divider) frag.appendChild(divider);
|
||||
if (entry.type === 'node') {
|
||||
frag.appendChild(createNodeChatEntry(entry.item));
|
||||
} else {
|
||||
frag.appendChild(createMessageChatEntry(entry.item));
|
||||
}
|
||||
}
|
||||
chatEl.replaceChildren(frag);
|
||||
while (chatEl.childElementCount > CHAT_LIMIT) {
|
||||
chatEl.removeChild(chatEl.firstChild);
|
||||
}
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, "0"); }
|
||||
@@ -1087,7 +1194,7 @@
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (n.distance_km != null && n.distance_km > MAX_NODE_DISTANCE_KM) continue;
|
||||
|
||||
const color = roleColors[n.role] || '#3388ff';
|
||||
const color = getRoleColor(n.role);
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 9,
|
||||
color: '#000',
|
||||
@@ -1116,14 +1223,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
function matchesTextFilter(node, query) {
|
||||
if (!query) return true;
|
||||
return [node?.node_id, node?.short_name, node?.long_name]
|
||||
.filter(value => value != null && value !== '')
|
||||
.some(value => String(value).toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function matchesRoleFilter(node) {
|
||||
if (!activeRoleFilters.size) return true;
|
||||
const roleKey = getRoleKey(node && node.role);
|
||||
return activeRoleFilters.has(roleKey);
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const rawQuery = filterInput ? filterInput.value : '';
|
||||
const q = rawQuery.trim().toLowerCase();
|
||||
const filteredNodes = !q ? allNodes.slice() : allNodes.filter(n => {
|
||||
return [n.node_id, n.short_name, n.long_name]
|
||||
.filter(value => value != null && value !== '')
|
||||
.some(value => String(value).toLowerCase().includes(q));
|
||||
});
|
||||
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
|
||||
const sortedNodes = sortNodes(filteredNodes);
|
||||
const nowSec = Date.now()/1000;
|
||||
renderTable(sortedNodes, nowSec);
|
||||
@@ -1142,35 +1258,8 @@
|
||||
statusEl.textContent = 'refreshing…';
|
||||
const nodes = await fetchNodes();
|
||||
computeDistances(nodes);
|
||||
const newNodes = [];
|
||||
for (const n of nodes) {
|
||||
if (n.node_id && !seenNodeIds.has(n.node_id)) {
|
||||
newNodes.push(n);
|
||||
}
|
||||
}
|
||||
const messages = await fetchMessages();
|
||||
const newMessages = [];
|
||||
for (const m of messages) {
|
||||
if (m.id && !seenMessageIds.has(m.id)) {
|
||||
newMessages.push(m);
|
||||
}
|
||||
}
|
||||
const entries = [];
|
||||
for (const n of newNodes) entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
|
||||
for (const m of newMessages) entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
|
||||
entries.sort((a, b) => {
|
||||
if (a.ts !== b.ts) return a.ts - b.ts;
|
||||
return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
|
||||
});
|
||||
for (const e of entries) {
|
||||
if (e.type === 'node') {
|
||||
addNewNodeChatEntry(e.item);
|
||||
if (e.item.node_id) seenNodeIds.add(e.item.node_id);
|
||||
} else {
|
||||
addNewMessageChatEntry(e.item);
|
||||
if (e.item.id) seenMessageIds.add(e.item.id);
|
||||
}
|
||||
}
|
||||
renderChatLog(nodes, messages);
|
||||
allNodes = nodes;
|
||||
applyFilter();
|
||||
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
|
||||
|
||||
Reference in New Issue
Block a user