Compare commits

...

8 Commits

Author SHA1 Message Date
l5y e91ad24cf9 Fix sqlite3 native extension on Alpine (#146) 2025-09-22 08:12:48 +02:00
l5y 2e543b7cd4 Allow binding to all interfaces in app.sh (#147) 2025-09-22 08:11:36 +02:00
l5y db4353ccdc Force building sqlite3 gem on Alpine (#145) 2025-09-22 08:10:00 +02:00
l5y 5a610cf08a Support mock serial interface in CI (#143) 2025-09-21 10:00:30 +02:00
l5y 71b854998c Fix Docker workflow to build linux images (#142) 2025-09-21 09:39:09 +02:00
l5y 0a70ae4b3e Add clickable role filters to the map legend (#140)
* Make map legend role entries filter nodes

* Adjust map legend spacing and toggle text
2025-09-21 09:33:48 +02:00
l5y 6e709b0b67 Rebuild chat log on each refresh (#139) 2025-09-21 09:19:07 +02:00
l5y a4256cee83 fix: retain runtime libs for alpine production (#138) 2025-09-21 09:18:55 +02:00
7 changed files with 256 additions and 88 deletions
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}>?&nbsp;&nbsp;&nbsp;</span>`;
}
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, '&nbsp;');
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();