Worked on /api/packet. Needed to modify

- Store.py to read the new time data
- api.py to present the new time data
- firehose.html chat.html and map.html now use the new apis and the time is the browser local time
This commit is contained in:
Pablo Revilla
2025-11-10 21:33:50 -08:00
parent d4f251f1b6
commit 27daa92694
7 changed files with 400 additions and 333 deletions
+140 -109
View File
@@ -1,110 +1,141 @@
{
"base": {
"conversations": "Conversations",
"nodes": "Nodes",
"everything": "See Everything",
"graph": "Mesh Graphs",
"net": "Weekly Net",
"map": "Live Map",
"stats": "Stats",
"top": "Top Traffic Nodes",
"footer": "Visit <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> on GitHub",
"node id": "Node id",
"go to node": "Go to Node",
"all": "All",
"portnum_options": {
"1": "Text Message",
"3": "Position",
"4": "Node Info",
"67": "Telemetry",
"70": "Traceroute",
"71": "Neighbor Info"
}
{
"base": {
"conversations": "Conversations",
"nodes": "Nodes",
"everything": "See Everything",
"graph": "Mesh Graphs",
"net": "Weekly Net",
"map": "Live Map",
"stats": "Stats",
"top": "Top Traffic Nodes",
"footer": "Visit <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> on GitHub",
"node id": "Node id",
"go to node": "Go to Node",
"all": "All",
"portnum_options": {
"1": "Text Message",
"3": "Position",
"4": "Node Info",
"67": "Telemetry",
"70": "Traceroute",
"71": "Neighbor Info"
}
},
"chat": {
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw_models": "All HW Models",
"all_firmware": "All Firmware",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing": "Showing",
"nodes": "nodes",
"short": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_update": "Last Update",
"loading_nodes": "Loading nodes...",
"no_nodes": "No nodes found",
"error_nodes": "Error loading nodes"
},
"chat": {
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw_models": "All HW Models",
"all_firmware": "All Firmware",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing": "Showing",
"nodes": "nodes",
"short": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_update": "Last Update",
"loading_nodes": "Loading nodes...",
"no_nodes": "No nodes found",
"error_nodes": "Error loading nodes"
},
"net": {
"number_of_checkins": "Number of Check-ins:",
"view_packet_details": "View packet details",
"view_all_packets_from_node": "View all packets from this node",
"no_packets_found": "No packets found."
},
"map": {
"channel": "Channel:",
"model": "Model:",
"role": "Role:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"show_routers_only": "Show Routers Only",
"share_view": "Share This View"
},
"stats":
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
"total_nodes": "Total Nodes",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
"packets_per_hour_text": "Packets per Hour - Text Messages (Port 1)",
"packet_types_last_24h": "Packet Types - Last 24 Hours",
"hardware_breakdown": "Hardware Breakdown",
"role_breakdown": "Role Breakdown",
"channel_breakdown": "Channel Breakdown",
"expand_chart": "Expand Chart",
"export_csv": "Export CSV",
"all_channels": "All Channels"
},
"top":
{
"top_traffic_nodes": "Top Traffic Nodes (last 24 hours)",
"chart_description_1": "This chart shows a bell curve (normal distribution) based on the total \"Times Seen\" values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.",
"chart_description_2": "This \"Times Seen\" value is the closest that we can get to Mesh utilization by node.",
"mean_label": "Mean:",
"stddev_label": "Standard Deviation:",
"long_name": "Long Name",
"short_name": "Short Name",
"channel": "Channel",
"packets_sent": "Packets Sent",
"times_seen": "Times Seen",
"seen_percent": "Seen % of Mean",
"no_nodes": "No top traffic nodes available."
},
"nodegraph":
{
"channel_label": "Channel:",
"search_node_placeholder": "Search node...",
"search_button": "Search",
"long_name_label": "Long Name:",
"short_name_label": "Short Name:",
"role_label": "Role:",
"hw_model_label": "Hardware Model:",
"node_not_found": "Node not found in current channel!"
}
}
"net": {
"number_of_checkins": "Number of Check-ins:",
"view_packet_details": "View packet details",
"view_all_packets_from_node": "View all packets from this node",
"no_packets_found": "No packets found."
},
"map": {
"channel": "Channel:",
"model": "Model:",
"role": "Role:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"show_routers_only": "Show Routers Only",
"share_view": "Share This View"
},
"stats":
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
"total_nodes": "Total Nodes",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
"packets_per_hour_text": "Packets per Hour - Text Messages (Port 1)",
"packet_types_last_24h": "Packet Types - Last 24 Hours",
"hardware_breakdown": "Hardware Breakdown",
"role_breakdown": "Role Breakdown",
"channel_breakdown": "Channel Breakdown",
"expand_chart": "Expand Chart",
"export_csv": "Export CSV",
"all_channels": "All Channels",
"node_id": "Node ID"
},
"top":
{
"top_traffic_nodes": "Top Traffic Nodes (last 24 hours)",
"chart_description_1": "This chart shows a bell curve (normal distribution) based on the total \"Times Seen\" values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.",
"chart_description_2": "This \"Times Seen\" value is the closest that we can get to Mesh utilization by node.",
"mean_label": "Mean:",
"stddev_label": "Standard Deviation:",
"long_name": "Long Name",
"short_name": "Short Name",
"channel": "Channel",
"packets_sent": "Packets Sent",
"times_seen": "Times Seen",
"seen_percent": "Seen % of Mean",
"no_nodes": "No top traffic nodes available."
},
"nodegraph":
{
"channel_label": "Channel:",
"search_node_placeholder": "Search node...",
"search_button": "Search",
"long_name_label": "Long Name:",
"short_name_label": "Short Name:",
"role_label": "Role:",
"hw_model_label": "Hardware Model:",
"node_not_found": "Node not found in current channel!"
},
"firehose":
{
"live_feed": "📡 Live Feed",
"pause": "Pause",
"resume": "Resume",
"time": "Time",
"packet_id": "Packet ID",
"from": "From",
"to": "To",
"port": "Port",
"links": "Links",
"unknown_app": "UNKNOWN APP",
"text_message": "Text Message",
"position": "Position",
"node_info": "Node Info",
"routing": "Routing",
"administration": "Administration",
"waypoint": "Waypoint",
"store_forward": "Store Forward",
"telemetry": "Telemetry",
"trace_route": "Trace Route",
"neighbor_info": "Neighbor Info",
"direct_to_mqtt": "direct to MQTT",
"all": "All",
"map": "Map",
"graph": "Graph"
}
}
+31 -1
View File
@@ -108,5 +108,35 @@
"other": "Otro",
"unknown": "Desconocido",
"node_not_found": "¡Nodo no encontrado en el canal actual!"
}
},
"firehose":
{
"live_feed": "📡 Flujo en Vivo",
"pause": "Pausar",
"resume": "Continuar",
"time": "Hora",
"packet_id": "ID del Paquete",
"from": "De",
"to": "Para",
"port": "Puerto",
"links": "Enlaces",
"unknown_app": "APLICACIÓN DESCONOCIDA",
"text_message": "Mensaje de Texto",
"position": "Posición",
"node_info": "Información del Nodo",
"routing": "Enrutamiento",
"administration": "Administración",
"waypoint": "Punto de Ruta",
"store_forward": "Almacenar y Reenviar",
"telemetry": "Telemetría",
"trace_route": "Rastreo de Ruta",
"neighbor_info": "Información de Vecinos",
"direct_to_mqtt": "Directo a MQTT",
"all": "Todos",
"map": "Mapa",
"graph": "Gráfico"
}
}
+135 -132
View File
@@ -10,7 +10,7 @@
<script src="https://unpkg.com/htmx.org@1.9.11/dist/ext/sse.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- Stylesheets -->
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
@@ -22,9 +22,7 @@
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
body.ready {
opacity: 1;
}
body.ready { opacity: 1; }
.htmx-indicator { opacity: 0; transition: opacity 500ms ease-in; }
.htmx-request .htmx-indicator { opacity: 1; }
#search_form { z-index: 4000; }
@@ -46,7 +44,7 @@
id="q"
type="text"
name="q"
data-translate-lang="node id"
data-translate="node id"
placeholder="Node id"
autocomplete="off"
list="node_options"
@@ -60,146 +58,151 @@
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
{% endfor %}
</datalist>
{% set options = {
1: "Text Message",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info",
}
%}
<select name="portnum" class="col-2 m-2">
<option
value = ""
{% if portnum not in options %}selected{% endif %}
>All</option>
{% for value, name in options.items() %}
<option
value="{{value}}"
{% if value == portnum %}selected{% endif %}
>{{ name }}</option>
{% endfor %}
</select>
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate-lang="go to node" />
<!-- portnum select will be translated dynamically -->
<select id="portnum_select" name="portnum" class="col-2 m-2"></select>
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate="go to node" />
</div>
</form>
{% block body %}{% endblock %}
<br>
<div style="text-align:center" id="footer" data-translate-lang="footer"></div>
<div style="text-align:center" id="footer" data-translate="footer"></div>
<div style="text-align:center"><small id="site-version">ver. unknown</small></div>
<br>
<!-- Shared Site Config & Language Loader -->
<script>
// --- Global Promises ---
if (!window._langPromise) {
window._langPromise = (async () => {
try {
const res = await fetch("/api/lang");
const lang = await res.json();
window._lang = lang;
console.log("Loaded language from /api/lang:", lang);
return lang;
} catch (err) {
console.error("Failed to load /api/lang:", err);
return {};
}
})();
}
if (!window._siteConfigPromise) {
window._siteConfigPromise = (async () => {
try {
const res = await fetch("/api/config");
const config = await res.json();
window._siteConfig = config;
console.log("Loaded config from /api/config:", config);
return config;
} catch (err) {
console.error("Failed to load /api/config:", err);
return {};
}
})();
}
// --- Apply Translations ---
function applyTranslations(lang) {
if (!lang || !lang.base) return;
const base = lang.base;
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
const translation = base[key];
if (!translation) return;
if (el.placeholder) {
el.placeholder = translation;
} else if (key === "footer") {
el.innerHTML = translation; // allow HTML links in footer
} else if (el.value && el.tagName === "INPUT") {
el.value = translation;
} else {
el.textContent = translation;
}
});
}
async function initializePage() {
// --- Shared Promises ---
if (!window._siteConfigPromise) {
window._siteConfigPromise = (async () => {
try {
const [lang, cfg] = await Promise.all([
window._langPromise,
window._siteConfigPromise
]);
const site = cfg.site || {};
const base = (lang && lang.base) || {};
// --- Title ---
document.title = "Meshview - " + (site.title || "");
// --- Header ---
const header = document.getElementById("site-header");
if (header)
header.innerHTML = `<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
// --- Message ---
const msg = document.getElementById("site-message");
if (msg) msg.textContent = site.message || "";
// --- Menu ---
const menu = document.getElementById("site-menu");
if (menu) {
let html = "";
if (site.nodes === "true") html += `<a href="/nodelist">${base.nodes || "Nodes"}</a>`;
if (site.conversations === "true") html += `&nbsp;-&nbsp;<a href="/chat">${base.conversations || "Conversations"}</a>`;
if (site.everything === "true") html += `&nbsp;-&nbsp;<a href="/firehose">${base.everything || "See Everything"}</a>`;
if (site.graphs === "true") html += `&nbsp;-&nbsp;<a href="/nodegraph">${base.graph || "Mesh Graphs"}</a>`;
if (site.net === "true") html += `&nbsp;-&nbsp;<a href="/net">${base.net || "Weekly Net"}</a>`;
if (site.map === "true") html += `&nbsp;-&nbsp;<a href="/map">${base.map || "Live Map"}</a>`;
if (site.stats === "true") html += `&nbsp;-&nbsp;<a href="/stats">${base.stats || "Stats"}</a>`;
if (site.top === "true") html += `&nbsp;-&nbsp;<a href="/top">${base.top || "Top Traffic Nodes"}</a>`;
menu.innerHTML = html;
}
// --- Version ---
const verEl = document.getElementById("site-version");
if (verEl) verEl.textContent = "ver. " + (site.version || "unknown");
// --- Apply translations to placeholders and footer ---
applyTranslations(lang);
// --- Fade in ---
document.body.classList.add("ready");
const res = await fetch("/api/config");
const cfg = await res.json();
window._siteConfig = cfg;
console.log("Loaded config:", cfg);
return cfg;
} catch (err) {
console.error("Failed to initialize page:", err);
document.body.classList.add("ready");
console.error("Failed to load /api/config:", err);
return {};
}
}
})();
}
document.addEventListener("DOMContentLoaded", initializePage);
</script>
// --- Wait for config before deciding which language to load ---
if (!window._langPromise) {
window._langPromise = (async () => {
try {
const cfg = await window._siteConfigPromise;
const site = cfg.site || {};
const userLang = site.language || "en"; // <-- ✅ read language from /api/config
const section = "base";
const url = `/api/lang?lang=${userLang}&section=${section}`;
const res = await fetch(url);
const lang = await res.json();
window._lang = lang;
console.log(`Loaded language (${userLang}):`, lang);
return lang;
} catch (err) {
console.error("Failed to load language:", err);
return {};
}
})();
}
// --- Translation Helper ---
function applyTranslations(dict) {
document.querySelectorAll("[data-translate]").forEach(el => {
const key = el.dataset.translate;
const value = dict[key];
if (!value) return;
if (el.placeholder) {
el.placeholder = value;
} else if (el.tagName === "INPUT" && el.value) {
el.value = value;
} else if (key === "footer") {
el.innerHTML = value;
} else {
el.textContent = value;
}
});
}
// --- Fill <select> dynamically using translation data ---
function fillPortnumSelect(dict, selectedValue) {
const sel = document.getElementById("portnum_select");
if (!sel) return;
const portOptions = dict.portnum_options || {};
sel.innerHTML = "";
const allOption = document.createElement("option");
allOption.value = "";
allOption.textContent = dict.all || "All";
if (!selectedValue) allOption.selected = true;
sel.appendChild(allOption);
for (const [val, label] of Object.entries(portOptions)) {
const opt = document.createElement("option");
opt.value = val;
opt.textContent = label;
if (parseInt(val) === parseInt(selectedValue)) opt.selected = true;
sel.appendChild(opt);
}
}
// --- Main Init ---
async function initializePage() {
try {
const [cfg, lang] = await Promise.all([
window._siteConfigPromise,
window._langPromise
]);
const dict = lang || {};
const site = cfg.site || {};
// Title
document.title = "Meshview - " + (site.title || "");
// Header & Message
document.getElementById("site-header").innerHTML =
`<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
document.getElementById("site-message").textContent = site.message || "";
// Menu
const menu = document.getElementById("site-menu");
if (menu) {
const items = [];
const keys = ["nodes","conversations","everything","graph","net","map","stats","top"];
const urls = ["/nodelist","/chat","/firehose","/nodegraph","/net","/map","/stats","/top"];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (site[key] === "true") {
items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`);
}
}
menu.innerHTML = items.join("&nbsp;-&nbsp;");
}
// Version
document.getElementById("site-version").textContent = "ver. " + (site.version || "unknown");
// Apply translations and portnum select
applyTranslations(dict);
fillPortnumSelect(dict, "{{ portnum or '' }}");
document.body.classList.add("ready");
} catch (err) {
console.error("Failed to initialize page:", err);
document.body.classList.add("ready");
}
}
document.addEventListener("DOMContentLoaded", initializePage);
</script>
</body>
</html>
+33 -35
View File
@@ -16,15 +16,14 @@
/* Wider spacing between Bootstrap-style columns */
.chat-packet > [class^="col-"] {
padding-left: 10px !important; /* wider horizontal */
padding-right: 10px !important; /* wider horizontal */
padding-left: 10px !important;
padding-right: 10px !important;
padding-top: 1px !important;
padding-bottom: 1px !important;
}
.chat-packet:nth-of-type(even) { background-color: #333333; }
/* Make the channel italic and slightly lighter */
.channel {
font-style: italic;
color: #bbb;
@@ -49,8 +48,6 @@
.replying-to .reply-preview { color: #aaa; }
{% endblock %}
{% block body %}
<div id="chat-container">
<div class="container" id="chat-log"></div>
@@ -64,7 +61,20 @@ document.addEventListener("DOMContentLoaded", async () => {
let lastTime = null;
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatTranslations = {};
let chatLang = {};
// --- Reuse translation helper from base ---
function applyTranslations(dict, root = document) {
root.querySelectorAll("[data-translate]").forEach(el => {
const key = el.dataset.translate;
const val = dict[key];
if (!val) return;
if (el.placeholder) el.placeholder = val;
else if (el.tagName === "INPUT" && el.value) el.value = val;
else if (key === "footer") el.innerHTML = val;
else el.textContent = val;
});
}
function escapeHtml(text) {
const div = document.createElement("div");
@@ -72,23 +82,11 @@ document.addEventListener("DOMContentLoaded", async () => {
return div.innerHTML;
}
function applyTranslations(translations, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
});
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
const key = el.dataset.translateLangTitle;
if (translations[key]) el.title = translations[key];
});
}
function renderPacket(packet, highlight = false) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
packetMap.set(packet.id, packet);
// Convert microseconds to JS Date
const date = new Date(packet.import_time_us / 1000);
const formattedTime = date.toLocaleTimeString([], { hour:"numeric", minute:"2-digit", second:"2-digit", hour12:true });
const formattedDate = `${(date.getMonth()+1).toString().padStart(2,"0")}/${date.getDate().toString().padStart(2,"0")}/${date.getFullYear()}`;
@@ -97,19 +95,20 @@ document.addEventListener("DOMContentLoaded", async () => {
let replyHtml = "";
if (packet.reply_id) {
const parent = packetMap.get(packet.reply_id);
const replyPrefix = `<i data-translate="replying_to"></i>`;
if (parent) {
replyHtml = `<div class="replying-to">
<div class="reply-preview">
<i data-translate-lang="replying_to"></i>
replyHtml = `
<div class="replying-to">
${replyPrefix}
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
${escapeHtml(parent.payload || "")}
</div>
</div>`;
</div>`;
} else {
replyHtml = `<div class="replying-to">
<i data-translate-lang="replying_to"></i>
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
replyHtml = `
<div class="replying-to">
${replyPrefix}
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
}
}
@@ -119,7 +118,7 @@ document.addEventListener("DOMContentLoaded", async () => {
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time_us}">${formattedTimestamp}</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" data-translate-lang-title="view_packet_details">✉️</a>
<a href="/packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">✉️</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
@@ -130,7 +129,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<span class="col-5 message">${escapeHtml(packet.payload)}${replyHtml}</span>
`;
chatContainer.prepend(div);
applyTranslations(chatTranslations, div);
applyTranslations(chatLang, div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
}
@@ -162,20 +161,19 @@ document.addEventListener("DOMContentLoaded", async () => {
} catch(err){ console.error("Fetch updates error:", err); }
}
async function loadTranslations() {
async function loadChatLang() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatTranslations = await res.json();
applyTranslations(chatTranslations, document);
chatLang = await res.json();
applyTranslations(chatLang);
} catch(err){ console.error("Chat translation load failed:", err); }
}
await loadTranslations();
await fetchInitial();
// --- Main init ---
await Promise.all([loadChatLang(), fetchInitial()]);
setInterval(fetchUpdates, 5000);
});
</script>
{% endblock %}
+39 -33
View File
@@ -38,8 +38,6 @@
font-weight: 500;
color: #fff;
}
/* --- Color-coded port labels --- */
.port-0 { background-color: #6c757d; }
.port-1 { background-color: #007bff; }
.port-3 { background-color: #28a745; }
@@ -51,16 +49,9 @@
.port-70 { background-color: #6f42c1; }
.port-71 { background-color: #fd7e14; }
.to-mqtt {
font-style: italic;
color: #aaa;
}
.to-mqtt { font-style: italic; color: #aaa; }
/* --- Payload rows --- */
.payload-row {
display: none;
background-color: #1b1e22;
}
.payload-row { display: none; background-color: #1b1e22; }
.payload-cell {
padding: 8px 12px;
font-family: monospace;
@@ -68,36 +59,33 @@
color: #b0bec5;
border-top: none;
}
.packet-table tr.expanded + .payload-row {
display: table-row;
}
.packet-table tr.expanded + .payload-row { display: table-row; }
.toggle-btn {
cursor: pointer;
color: #aaa;
margin-right: 6px;
font-weight: bold;
}
.toggle-btn:hover {
color: #fff;
}
.toggle-btn:hover { color: #fff; }
{% endblock %}
{% block body %}
<div class="container">
<form class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0">📡 Live Feed</h5>
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary">Pause</button>
<h5 class="mb-0" data-translate-lang="live_feed">📡 Live Feed</h5>
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary" data-translate-lang="pause">Pause</button>
</form>
<table class="packet-table">
<thead>
<tr>
<th>Time</th>
<th>Packet ID</th>
<th>From</th>
<th>To</th>
<th>Port</th>
<th>Links</th>
<th data-translate-lang="time">Time</th>
<th data-translate-lang="packet_id">Packet ID</th>
<th data-translate-lang="from">From</th>
<th data-translate-lang="to">To</th>
<th data-translate-lang="port">Port</th>
<th data-translate-lang="links">Links</th>
</tr>
</thead>
<tbody id="packet_list"></tbody>
@@ -109,6 +97,27 @@ let lastImportTimeUs = null;
let updatesPaused = false;
let nodeMap = {};
let updateInterval = 3000;
let firehoseTranslations = {};
function applyTranslations(translations, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
});
}
// --- Load translations for firehose section ---
async function loadTranslations() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=firehose`);
firehoseTranslations = await res.json();
applyTranslations(firehoseTranslations, document);
} catch (err) {
console.error("Firehose translation load failed:", err);
}
}
const PORT_MAP = {
0: "UNKNOWN APP",
@@ -171,28 +180,23 @@ function portLabel(portnum, payload) {
<span class="text-secondary">(${portnum})</span>`;
}
// --- Convert import_time_us to local time string ---
function formatLocalTime(importTimeUs) {
const ms = importTimeUs / 1000;
const date = new Date(ms);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
// --- Fetch firehose interval from shared site config ---
async function configureFirehose() {
try {
const cfg = await window._siteConfigPromise;
const intervalSec = cfg?.site?.firehose_interval;
if (intervalSec && !isNaN(intervalSec)) {
updateInterval = parseInt(intervalSec) * 1000;
}
if (intervalSec && !isNaN(intervalSec)) updateInterval = parseInt(intervalSec) * 1000;
console.log("Firehose update interval:", updateInterval, "ms");
} catch (err) {
console.warn("Failed to read firehose interval:", err);
}
}
// --- Fetch and render packets ---
async function fetchUpdates() {
if (updatesPaused) return;
const url = new URL("/api/packets", window.location.origin);
@@ -269,7 +273,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const pauseBtn = document.getElementById("pause-button");
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
pauseBtn.textContent = updatesPaused
? (firehoseTranslations.resume || "Resume")
: (firehoseTranslations.pause || "Pause");
});
document.addEventListener("click", (e) => {
@@ -280,11 +286,11 @@ document.addEventListener("DOMContentLoaded", async () => {
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
});
await loadTranslations();
await configureFirehose();
await loadNodes();
fetchUpdates();
setInterval(fetchUpdates, updateInterval);
});
</script>
{% endblock %}
+22 -9
View File
@@ -352,24 +352,37 @@ document.getElementById("channelSelect").addEventListener("change", async (e)=>{
init();
// --- Translation Loader ---
async function loadTranslations() {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
// --- Load config and translations ---
async function loadConfigAndTranslations() {
let langCode = "en";
try {
const res = await fetch(`/api/lang?lang=${langCode}&section=stats`);
window.statsTranslations = await res.json();
} catch(err){
const resConfig = await fetch("/api/config");
const cfg = await resConfig.json();
window.site_config = cfg;
langCode = cfg?.site?.language || "en";
} catch(err) {
console.error("Failed to load /api/config:", err);
window.site_config = { site: { language: "en" } };
}
try {
const resLang = await fetch(`/api/lang?lang=${langCode}&section=stats`);
window.statsTranslations = await resLang.json();
} catch(err) {
console.error("Stats translation load failed:", err);
window.statsTranslations = {};
}
}
function applyTranslations() {
// Apply translations
const t = window.statsTranslations || {};
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
const key = el.getAttribute("data-translate-lang");
if(t[key]) el.textContent = t[key];
});
}
loadTranslations().then(applyTranslations);
// Call after init
loadConfigAndTranslations();
</script>
{% endblock %}
-14
View File
@@ -8,8 +8,6 @@ import ssl
import traceback
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import timedelta
import pydot
from aiohttp import web
from google.protobuf import text_format
@@ -17,7 +15,6 @@ from google.protobuf.message import Message
from jinja2 import Environment, PackageLoader, Undefined, select_autoescape
from markupsafe import Markup
from pandas import DataFrame
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import config, database, decode_payload, migrations, models, store
from meshview.__version__ import (
@@ -1110,8 +1107,6 @@ async def stats(request):
total_packets=total_packets,
total_nodes=total_nodes,
total_packets_seen=total_packets_seen,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
content_type="text/html",
)
@@ -1280,15 +1275,6 @@ async def nodegraph(request):
)
# API Section
#######################################################################
# How this works
# When your frontend calls /api/chat without since, it returns the most recent limit (default 100) messages.
# When your frontend calls /api/chat?since=ISO_TIMESTAMP, it returns only messages with import_time > since.
# The response includes "latest_import_time" for frontend to keep track of the newest message timestamp.
# The backend fetches extra packets (limit*5) to account for filtering messages like "seq N" and since filtering.
# Generic static HTML route
@routes.get("/{page}")
async def serve_page(request):