mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-06 17:02:48 +02:00
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:
+140
-109
@@ -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
@@ -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
@@ -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 += ` - <a href="/chat">${base.conversations || "Conversations"}</a>`;
|
||||
if (site.everything === "true") html += ` - <a href="/firehose">${base.everything || "See Everything"}</a>`;
|
||||
if (site.graphs === "true") html += ` - <a href="/nodegraph">${base.graph || "Mesh Graphs"}</a>`;
|
||||
if (site.net === "true") html += ` - <a href="/net">${base.net || "Weekly Net"}</a>`;
|
||||
if (site.map === "true") html += ` - <a href="/map">${base.map || "Live Map"}</a>`;
|
||||
if (site.stats === "true") html += ` - <a href="/stats">${base.stats || "Stats"}</a>`;
|
||||
if (site.top === "true") html += ` - <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}§ion=${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(" - ");
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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}§ion=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 %}
|
||||
|
||||
@@ -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}§ion=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 %}
|
||||
|
||||
@@ -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}§ion=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}§ion=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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user