mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Merge branch 'master' into 10-15-25-bugs2
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"67": "Telemetry",
|
||||
"70": "Traceroute",
|
||||
"71": "Neighbor Info"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"replying_to": "Replying to:",
|
||||
|
||||
@@ -24,14 +24,7 @@ async def get_fuzzy_nodes(query):
|
||||
return result.scalars()
|
||||
|
||||
|
||||
async def get_packets(
|
||||
node_id=None,
|
||||
portnum=None,
|
||||
after=None,
|
||||
before=None,
|
||||
limit=None,
|
||||
channel: str | list[str] | tuple[str, ...] | None = None,
|
||||
):
|
||||
async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None):
|
||||
async with database.async_session() as session:
|
||||
q = select(Packet)
|
||||
|
||||
@@ -43,13 +36,6 @@ async def get_packets(
|
||||
q = q.where(Packet.import_time > after)
|
||||
if before:
|
||||
q = q.where(Packet.import_time < before)
|
||||
if channel:
|
||||
if isinstance(channel, (list, tuple, set)):
|
||||
lowered = [c.lower() for c in channel if isinstance(c, str) and c]
|
||||
if lowered:
|
||||
q = q.where(func.lower(Packet.channel).in_(lowered))
|
||||
elif isinstance(channel, str):
|
||||
q = q.where(func.lower(Packet.channel) == channel.lower())
|
||||
|
||||
q = q.order_by(Packet.import_time.desc())
|
||||
|
||||
@@ -161,21 +147,17 @@ async def get_mqtt_neighbors(since):
|
||||
|
||||
# We count the total amount of packages
|
||||
# This is to be used by /stats in web.py
|
||||
async def get_total_packet_count(channel: str | None = None) -> int:
|
||||
async def get_total_packet_count():
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(Packet.id)) # Use SQLAlchemy's func to count packets
|
||||
if channel:
|
||||
q = q.where(func.lower(Packet.channel) == channel.lower())
|
||||
result = await session.execute(q)
|
||||
return result.scalar() # Return the total count of packets
|
||||
|
||||
|
||||
# We count the total amount of seen packets
|
||||
async def get_total_packet_seen_count(channel: str | None = None) -> int:
|
||||
async def get_total_packet_seen_count():
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(PacketSeen.node_id)) # Use SQLAlchemy's func to count nodes
|
||||
if channel:
|
||||
q = q.where(func.lower(PacketSeen.channel) == channel.lower())
|
||||
result = await session.execute(q)
|
||||
return result.scalar() # Return the` total count of seen packets
|
||||
|
||||
@@ -275,13 +257,7 @@ async def get_node_traffic(node_id: int):
|
||||
return []
|
||||
|
||||
|
||||
async def get_nodes(
|
||||
role=None,
|
||||
channel=None,
|
||||
hw_model=None,
|
||||
days_active=None,
|
||||
active_within: timedelta | None = None,
|
||||
):
|
||||
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
|
||||
"""
|
||||
Fetches nodes from the database based on optional filtering criteria.
|
||||
|
||||
@@ -289,8 +265,6 @@ async def get_nodes(
|
||||
role (str, optional): The role of the node (converted to uppercase for consistency).
|
||||
channel (str, optional): The communication channel associated with the node.
|
||||
hw_model (str, optional): The hardware model of the node.
|
||||
days_active (int, optional): Legacy support for filtering by a number of days.
|
||||
active_within (timedelta, optional): Filter nodes seen within the provided window.
|
||||
|
||||
Returns:
|
||||
list: A list of Node objects that match the given criteria.
|
||||
@@ -310,12 +284,8 @@ async def get_nodes(
|
||||
if hw_model is not None:
|
||||
query = query.where(Node.hw_model == hw_model)
|
||||
|
||||
window = active_within
|
||||
if window is None and days_active is not None:
|
||||
window = timedelta(days=days_active)
|
||||
|
||||
if window is not None:
|
||||
query = query.where(Node.last_update > datetime.now() - window)
|
||||
if days_active is not None:
|
||||
query = query.where(Node.last_update > datetime.now() - timedelta(days_active))
|
||||
|
||||
# Exclude nodes where last_update is an empty string
|
||||
query = query.where(Node.last_update != "")
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ site_config.get('general', {}).get('language', 'en') }}" data-bs-theme="dark">
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<title>
|
||||
Meshview - {{ site_config.get("site", {}).get("title", "") }}
|
||||
{% if node and node.short_name %}-- {{ node.short_name }}{% endif %}
|
||||
</title>
|
||||
<title>Meshview</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@@ -29,27 +26,13 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% set site = site_config.get("site", {}) %}
|
||||
|
||||
<br>
|
||||
<div style="text-align:center">
|
||||
<strong>{{ site.get("title", "") }} {{ site.get("domain", "") }}</strong>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
{{ site.get("message", "") }}
|
||||
</div>
|
||||
<!-- Dynamic Header -->
|
||||
<div style="text-align:center" id="site-header"></div>
|
||||
<div style="text-align:center" id="site-message"></div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div style="text-align:center">
|
||||
{% if site.get("nodes") == "True" %}<a href="/nodelist" id="nav-nodes" data-translate-lang="nodes">Nodes</a>{% endif %}
|
||||
{% if site.get("conversations") == "True" %} - <a href="/chat" id="nav-conversations" data-translate-lang="conversations">Conversations</a>{% endif %}
|
||||
{% if site.get("everything") == "True" %} - <a href="/firehose" id="nav-everything" data-translate-lang="everything">See Everything</a>{% endif %}
|
||||
{% if site.get("graphs") == "True" %} - <a href="/nodegraph" id="nav-graph" data-translate-lang="graph">Mesh Graphs</a>{% endif %}
|
||||
{% if site.get("net") == "True" %} - <a href="/net" id="nav-net" data-translate-lang="net">Weekly Net</a>{% endif %}
|
||||
{% if site.get("map") == "True" %} - <a href="/map" id="nav-map" data-translate-lang="map">Live Map</a>{% endif %}
|
||||
{% if site.get("stats") == "True" %} - <a href="/stats" id="nav-stats" data-translate-lang="stats">Stats</a>{% endif %}
|
||||
{% if site.get("top") == "True" %} - <a href="/top" id="nav-top" data-translate-lang="top">Top Traffic Nodes</a>{% endif %}
|
||||
</div>
|
||||
<!-- Dynamic Menu -->
|
||||
<div style="text-align:center" id="site-menu"></div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form class="container p-2 sticky-top mx-auto" id="search_form" action="/node_search">
|
||||
@@ -75,7 +58,7 @@
|
||||
</datalist>
|
||||
|
||||
<select name="portnum" class="col-2 m-2" id="portnum_select">
|
||||
<!-- Options will be populated dynamically -->
|
||||
<!-- Options populated dynamically -->
|
||||
</select>
|
||||
|
||||
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate-lang="go to node" />
|
||||
@@ -85,15 +68,15 @@
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<br>
|
||||
<div style="text-align:center" id="footer" data-translate-lang="footer">
|
||||
</div><div style="text-align:center"><div><small>ver. {{ SOFTWARE_RELEASE | default("unknown") }}</small></div>
|
||||
<div style="text-align:center" id="footer" data-translate-lang="footer"></div>
|
||||
<div style="text-align:center"><div><small id="site-version">ver. unknown</small></div>
|
||||
<br>
|
||||
|
||||
<!-- Language Loader -->
|
||||
<script>
|
||||
async function loadTranslations() {
|
||||
try {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language', 'en') }}";
|
||||
const langCode = "en";
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=base`);
|
||||
const t = await res.json();
|
||||
|
||||
@@ -132,8 +115,59 @@
|
||||
console.error("Failed to load language:", err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadTranslations);
|
||||
</script>
|
||||
|
||||
<!-- Dynamic Site Config Loader -->
|
||||
<script>
|
||||
async function loadSiteConfig() {
|
||||
try {
|
||||
const res = await fetch("/api/config");
|
||||
const cfg = await res.json();
|
||||
const site = cfg.site || {};
|
||||
|
||||
// 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">Nodes</a>`;
|
||||
if (site.conversations === "true") html += ` - <a href="/chat">Conversations</a>`;
|
||||
if (site.everything === "true") html += ` - <a href="/firehose">See Everything</a>`;
|
||||
if (site.graphs === "true") html += ` - <a href="/nodegraph">Mesh Graphs</a>`;
|
||||
if (site.net === "true") html += ` - <a href="/net">Weekly Net</a>`;
|
||||
if (site.map === "true") html += ` - <a href="/map">Live Map</a>`;
|
||||
if (site.stats === "true") html += ` - <a href="/stats">Stats</a>`;
|
||||
if (site.top === "true") html += ` - <a href="/top">Top Traffic Nodes</a>`;
|
||||
menu.innerHTML = html;
|
||||
}
|
||||
|
||||
// Version
|
||||
const verEl = document.getElementById("site-version");
|
||||
if (verEl) {
|
||||
verEl.textContent = "ver. " + (site.version || "unknown");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load site config:", err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadSiteConfig);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,61 +17,19 @@
|
||||
/* Nested reply style */
|
||||
.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; }
|
||||
.replying-to .reply-preview { color: #aaa; }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#chatChannelSelect {
|
||||
padding: 4px 6px;
|
||||
background: #444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: #bbb;
|
||||
margin-bottom: 12px;
|
||||
display: none;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="chat-container">
|
||||
<div class="filter-bar">
|
||||
<label for="chatChannelSelect" class="filter-label">Channel</label>
|
||||
<select id="chatChannelSelect">
|
||||
<option value="" data-translate-lang="all_channels">All Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="chat-status" class="status-message"></div>
|
||||
<div class="container" id="chat-log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CHANNEL_PRESETS = ["LongFast", "MediumSlow"];
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const chatContainer = document.querySelector("#chat-log");
|
||||
const channelSelect = document.getElementById("chatChannelSelect");
|
||||
const statusMessage = document.getElementById("chat-status");
|
||||
if (!chatContainer) {
|
||||
console.error("#chat-log not found");
|
||||
return;
|
||||
}
|
||||
if (!chatContainer) return console.error("#chat-log not found");
|
||||
|
||||
let lastTime = null;
|
||||
let currentChannel = "";
|
||||
const renderedPacketIds = new Set();
|
||||
const packetMap = new Map();
|
||||
let chatTranslations = {};
|
||||
@@ -94,74 +52,6 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchChannels() {
|
||||
try {
|
||||
const res = await fetch("/api/channels");
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
return Array.isArray(json.channels) ? json.channels : [];
|
||||
} catch (err) {
|
||||
console.error("Channel fetch failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function populateChannelSelect(channels) {
|
||||
if (!channelSelect) return;
|
||||
const unique = Array.from(new Set((channels || []).filter(ch => typeof ch === "string" && ch.trim().length > 0))).sort((a, b) => a.localeCompare(b));
|
||||
const prioritized = [];
|
||||
CHANNEL_PRESETS.forEach(preset => {
|
||||
const idx = unique.indexOf(preset);
|
||||
if (idx >= 0) {
|
||||
prioritized.push(unique[idx]);
|
||||
unique.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
const ordered = [...new Set([...prioritized, ...unique])];
|
||||
channelSelect.innerHTML = "";
|
||||
const allOption = document.createElement("option");
|
||||
allOption.value = "";
|
||||
allOption.textContent = "All Channels";
|
||||
allOption.setAttribute("data-translate-lang", "all_channels");
|
||||
channelSelect.appendChild(allOption);
|
||||
ordered.forEach(ch => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.textContent = ch;
|
||||
channelSelect.appendChild(opt);
|
||||
});
|
||||
let preferred = currentChannel;
|
||||
if (preferred && !ordered.includes(preferred)) {
|
||||
preferred = "";
|
||||
}
|
||||
if (!preferred) {
|
||||
preferred = CHANNEL_PRESETS.find(preset => ordered.includes(preset)) || "";
|
||||
}
|
||||
channelSelect.value = preferred;
|
||||
currentChannel = channelSelect.value;
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
chatContainer.innerHTML = "";
|
||||
renderedPacketIds.clear();
|
||||
packetMap.clear();
|
||||
lastTime = null;
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
function updateEmptyState() {
|
||||
if (!statusMessage) return;
|
||||
if (chatContainer.childElementCount === 0) {
|
||||
statusMessage.textContent = currentChannel
|
||||
? `No messages for ${currentChannel} yet.`
|
||||
: "No messages yet.";
|
||||
statusMessage.style.display = "block";
|
||||
} else {
|
||||
statusMessage.textContent = "";
|
||||
statusMessage.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPacket(packet, highlight = false) {
|
||||
if (renderedPacketIds.has(packet.id)) return;
|
||||
renderedPacketIds.add(packet.id);
|
||||
@@ -217,7 +107,6 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
applyTranslations(chatTranslations, div);
|
||||
|
||||
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
function renderPacketsEnsureDescending(packets, highlight = false) {
|
||||
@@ -226,28 +115,21 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
for (let i = sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight);
|
||||
}
|
||||
|
||||
function buildChatUrl({ since } = {}) {
|
||||
const url = new URL("/api/chat", window.location.origin);
|
||||
url.searchParams.set("limit", "100");
|
||||
if (since) url.searchParams.set("since", since);
|
||||
if (currentChannel) url.searchParams.set("channel", currentChannel);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchInitial() {
|
||||
try {
|
||||
const resp = await fetch(buildChatUrl());
|
||||
const resp = await fetch("/api/chat?limit=100");
|
||||
const data = await resp.json();
|
||||
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
|
||||
lastTime = data?.latest_import_time || lastTime;
|
||||
updateEmptyState();
|
||||
} catch(err) { console.error("Initial fetch error:", err); }
|
||||
}
|
||||
|
||||
async function fetchUpdates() {
|
||||
if (!lastTime) return;
|
||||
try {
|
||||
const resp = await fetch(buildChatUrl({ since: lastTime }));
|
||||
const url = new URL("/api/chat", window.location.origin);
|
||||
url.searchParams.set("limit","100");
|
||||
if(lastTime) url.searchParams.set("since", lastTime);
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets, true);
|
||||
lastTime = data?.latest_import_time || lastTime;
|
||||
@@ -263,21 +145,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
} catch(err){ console.error("Chat translation load failed:", err); }
|
||||
}
|
||||
|
||||
if (channelSelect) {
|
||||
channelSelect.addEventListener("change", async e => {
|
||||
currentChannel = e.target.value;
|
||||
clearChat();
|
||||
await fetchInitial();
|
||||
});
|
||||
}
|
||||
|
||||
await loadTranslations();
|
||||
const channels = await fetchChannels();
|
||||
populateChannelSelect(channels);
|
||||
if (chatTranslations && channelSelect) {
|
||||
applyTranslations(chatTranslations, channelSelect.parentElement || channelSelect);
|
||||
}
|
||||
updateEmptyState();
|
||||
await fetchInitial();
|
||||
setInterval(fetchUpdates, 5000);
|
||||
});
|
||||
|
||||
@@ -11,38 +11,11 @@
|
||||
padding: 2px 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#firehoseChannelSelect {
|
||||
padding: 4px 6px;
|
||||
background: #444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: #bbb;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<form class="d-flex align-items-center justify-content-center flex-wrap gap-2 mb-2">
|
||||
<form class="d-flex align-items-center justify-content-between mb-2">
|
||||
{% set options = {
|
||||
1: "Text Message",
|
||||
3: "Position",
|
||||
@@ -52,155 +25,55 @@
|
||||
70: "Trace Route",
|
||||
}
|
||||
%}
|
||||
<div class="filter-controls">
|
||||
<label for="firehoseChannelSelect" class="filter-label">Channel</label>
|
||||
<select id="firehoseChannelSelect">
|
||||
<option value="" data-translate-lang="all_channels">All Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary">Pause</button>
|
||||
</form>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs">
|
||||
<div id="packet_list">
|
||||
{% for packet in packets %}
|
||||
{% include 'packet.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="firehose-empty-state" class="status-message"{% if packets|length > 0 %} style="display:none;"{% endif %}>
|
||||
<div class="col-xs" id="packet_list">
|
||||
{% for packet in packets %}
|
||||
{% include 'packet.html' %}
|
||||
{% else %}
|
||||
No packets found.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CHANNEL_PRESETS = ["LongFast", "MediumSlow"];
|
||||
|
||||
let lastTime = {{ (last_time or None) | tojson }};
|
||||
let portnum = {{ (portnum if portnum is not none else '') | tojson }};
|
||||
let lastTime = null;
|
||||
let portnum = "{{ portnum if portnum is not none else '' }}";
|
||||
let updatesPaused = false;
|
||||
let channelFilter = {{ (channel or '') | tojson }};
|
||||
|
||||
// Use firehose_interval from config (seconds), default to 3s if not set
|
||||
let firehoseInterval = {{ site_config["site"]["firehose_interval"] | default(3) }};
|
||||
const firehoseInterval = {{ site_config["site"]["firehose_interval"] | default(3) }};
|
||||
if (firehoseInterval < 0) firehoseInterval = 0;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const packetList = document.getElementById("packet_list");
|
||||
const pauseBtn = document.getElementById("pause-button");
|
||||
const channelSelect = document.getElementById("firehoseChannelSelect");
|
||||
const emptyState = document.getElementById("firehose-empty-state");
|
||||
function fetchUpdates() {
|
||||
if (updatesPaused || firehoseInterval === 0) return;
|
||||
|
||||
function updateEmptyState() {
|
||||
if (!emptyState || !packetList) return;
|
||||
if (packetList.children.length > 0) {
|
||||
emptyState.style.display = "none";
|
||||
} else {
|
||||
emptyState.style.display = "block";
|
||||
}
|
||||
}
|
||||
const url = new URL("/firehose/updates", window.location.origin);
|
||||
if (lastTime) url.searchParams.set("last_time", lastTime);
|
||||
if (portnum) url.searchParams.set("portnum", portnum);
|
||||
|
||||
async function fetchChannels() {
|
||||
try {
|
||||
const res = await fetch("/api/channels");
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
return Array.isArray(json.channels) ? json.channels : [];
|
||||
} catch (err) {
|
||||
console.error("Channel fetch failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.packets && data.packets.length > 0) {
|
||||
lastTime = data.last_time;
|
||||
const list = document.getElementById("packet_list");
|
||||
|
||||
function populateChannelSelect(channels) {
|
||||
if (!channelSelect) return;
|
||||
const unique = Array.from(new Set((channels || []).filter(ch => typeof ch === "string" && ch.trim().length > 0))).sort((a, b) => a.localeCompare(b));
|
||||
const prioritized = [];
|
||||
CHANNEL_PRESETS.forEach(preset => {
|
||||
const idx = unique.indexOf(preset);
|
||||
if (idx >= 0) {
|
||||
prioritized.push(unique[idx]);
|
||||
unique.splice(idx, 1);
|
||||
for (const html of data.packets.reverse()) {
|
||||
list.insertAdjacentHTML("afterbegin", html);
|
||||
}
|
||||
}
|
||||
});
|
||||
const ordered = [...new Set([...prioritized, ...unique])];
|
||||
channelSelect.innerHTML = "";
|
||||
const allOption = document.createElement("option");
|
||||
allOption.value = "";
|
||||
allOption.textContent = "All Channels";
|
||||
allOption.setAttribute("data-translate-lang", "all_channels");
|
||||
channelSelect.appendChild(allOption);
|
||||
ordered.forEach(ch => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.textContent = ch;
|
||||
channelSelect.appendChild(opt);
|
||||
});
|
||||
if (channelFilter && !ordered.includes(channelFilter)) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = channelFilter;
|
||||
opt.textContent = channelFilter;
|
||||
channelSelect.appendChild(opt);
|
||||
}
|
||||
channelSelect.value = channelFilter || "";
|
||||
channelFilter = channelSelect.value;
|
||||
}
|
||||
|
||||
function buildUpdatesUrl({ useLastTime = true } = {}) {
|
||||
const url = new URL("/firehose/updates", window.location.origin);
|
||||
if (useLastTime && lastTime) url.searchParams.set("last_time", lastTime);
|
||||
if (portnum) url.searchParams.set("portnum", portnum);
|
||||
if (channelFilter) url.searchParams.set("channel", channelFilter);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchUpdates({ force = false, reset = false } = {}) {
|
||||
if (!force && (updatesPaused || firehoseInterval === 0)) return;
|
||||
try {
|
||||
const url = buildUpdatesUrl({ useLastTime: !reset });
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Fetch failed with status ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (reset && packetList) {
|
||||
packetList.innerHTML = "";
|
||||
}
|
||||
if (Array.isArray(data.packets) && data.packets.length > 0 && packetList) {
|
||||
data.packets.slice().reverse().forEach(html => {
|
||||
packetList.insertAdjacentHTML("afterbegin", html);
|
||||
});
|
||||
if (data.last_time) lastTime = data.last_time;
|
||||
} else if (reset) {
|
||||
lastTime = data.last_time || null;
|
||||
}
|
||||
} catch (err) {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Update fetch failed:", err);
|
||||
} finally {
|
||||
updateEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener("click", () => {
|
||||
updatesPaused = !updatesPaused;
|
||||
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (channelSelect) {
|
||||
channelSelect.addEventListener("change", async e => {
|
||||
channelFilter = e.target.value;
|
||||
lastTime = null;
|
||||
if (packetList) packetList.innerHTML = "";
|
||||
updateEmptyState();
|
||||
await fetchUpdates({ force: true, reset: true });
|
||||
});
|
||||
}
|
||||
|
||||
const channels = await fetchChannels();
|
||||
populateChannelSelect(channels);
|
||||
updateEmptyState();
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const pauseBtn = document.getElementById("pause-button");
|
||||
|
||||
const portnumSelector = document.querySelector('select[name="portnum"]');
|
||||
if (portnumSelector) {
|
||||
@@ -212,12 +85,15 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
}
|
||||
|
||||
pauseBtn.addEventListener("click", () => {
|
||||
updatesPaused = !updatesPaused;
|
||||
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
|
||||
});
|
||||
|
||||
// Start fetching updates with configurable interval
|
||||
await fetchUpdates({ force: true });
|
||||
fetchUpdates();
|
||||
if (firehoseInterval > 0) {
|
||||
setInterval(() => {
|
||||
fetchUpdates();
|
||||
}, firehoseInterval * 1000);
|
||||
setInterval(fetchUpdates, firehoseInterval * 1000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,97 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
<style>
|
||||
.legend {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
line-height: 1.5;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.3);
|
||||
font-size: 14px;
|
||||
color: black;
|
||||
}
|
||||
.legend i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
#filter-container {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-checkbox {
|
||||
margin: 0 10px;
|
||||
}
|
||||
#activity-range {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
#share-button {
|
||||
margin-left: 20px;
|
||||
padding: 5px 15px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
#share-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
#share-button:active {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
#reset-filters-button {
|
||||
margin-left: 10px;
|
||||
padding: 5px 15px;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
#reset-filters-button:hover {
|
||||
background-color: #da190b;
|
||||
}
|
||||
#reset-filters-button:active {
|
||||
background-color: #c41e0d;
|
||||
}
|
||||
.blinking-tooltip {
|
||||
background: white;
|
||||
color: black;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
.legend { background:white;padding:8px;line-height:1.5;border-radius:5px;box-shadow:0 0 10px rgba(0,0,0,0.3);font-size:14px;color:black; }
|
||||
.legend i { width:12px;height:12px;display:inline-block;margin-right:6px;border-radius:50%; }
|
||||
#filter-container { text-align:center;margin-top:10px; }
|
||||
.filter-checkbox { margin:0 10px; }
|
||||
#share-button, #reset-filters-button {
|
||||
padding:5px 15px;border:none;border-radius:4px;font-size:14px;cursor:pointer;color:white;
|
||||
}
|
||||
#share-button { margin-left:20px; background-color:#4CAF50; }
|
||||
#share-button:hover { background-color:#45a049; }
|
||||
#share-button:active { background-color:#3d8b40; }
|
||||
#reset-filters-button { margin-left:10px; background-color:#f44336; }
|
||||
#reset-filters-button:hover { background-color:#da190b; }
|
||||
#reset-filters-button:active { background-color:#c41e0d; }
|
||||
.blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="map" style="width: 100%; height: calc(100vh - 270px)"></div>
|
||||
<div id="map" style="width:100%;height:calc(100vh - 270px)"></div>
|
||||
<div id="filter-container">
|
||||
<label for="activity-range" id="activity-range-label">Active in:</label>
|
||||
<select id="activity-range">
|
||||
{% for value, label, _window in activity_filters %}
|
||||
<option value="{{ value }}" {% if value == selected_activity %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> <span id="filter-routers-label">Show Routers Only</span>
|
||||
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 5px;">
|
||||
<button id="share-button">🔗 Share This View</button>
|
||||
<div style="text-align:center;margin-top:5px;">
|
||||
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
|
||||
<button id="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
|
||||
</div>
|
||||
|
||||
@@ -103,480 +40,191 @@
|
||||
crossorigin></script>
|
||||
|
||||
<script>
|
||||
async function loadTranslations() {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
try {
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=map`);
|
||||
window.mapTranslations = await res.json();
|
||||
} catch(err) {
|
||||
console.error("Map translation load failed:", err);
|
||||
window.mapTranslations = {};
|
||||
// ---------------------- Map Initialization ----------------------
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map);
|
||||
|
||||
// ---------------------- Globals ----------------------
|
||||
var nodes=[], markers={}, markerById={}, nodeMap = new Map();
|
||||
var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
|
||||
var activeBlinks = new Map(), lastImportTime = null;
|
||||
var mapInterval = 3;
|
||||
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
|
||||
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
|
||||
const colorMap = new Map(); let nextColorIndex = 0;
|
||||
const channelSet = new Set();
|
||||
|
||||
// ---------------------- Helpers ----------------------
|
||||
function timeAgo(date){ const diff=Date.now()-new Date(date), s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24); return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s"; }
|
||||
function hashToColor(str){ if(colorMap.has(str)) return colorMap.get(str); const c=palette[nextColorIndex++%palette.length]; colorMap.set(str,c); return c; }
|
||||
function isInvalidCoord(n){ return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long); }
|
||||
|
||||
// ---------------------- Load Config ----------------------
|
||||
fetch('/api/config').then(r=>r.json()).then(cfg=>{
|
||||
const site = cfg.site || {};
|
||||
mapInterval = parseInt(site.map_interval||3,10);
|
||||
const topLeft = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
|
||||
const bottomRight = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
|
||||
if(topLeft && bottomRight){
|
||||
map.fitBounds([topLeft,bottomRight]);
|
||||
window.configBoundsApplied = true; // prevent nodes from overriding
|
||||
setTimeout(()=>map.invalidateSize(),100);
|
||||
}
|
||||
}).catch(console.error);
|
||||
|
||||
// ---------------------- Load Nodes + Edges ----------------------
|
||||
fetch('/api/nodes?days_active=3').then(r=>r.json()).then(data=>{
|
||||
if(!data.nodes) return;
|
||||
nodes = data.nodes.map(n=>({
|
||||
key: n.node_id!==null?n.node_id:n.id,
|
||||
id: n.id,
|
||||
node_id: n.node_id,
|
||||
lat: n.last_lat?n.last_lat/1e7:null,
|
||||
long: n.last_long?n.last_long/1e7:null,
|
||||
long_name: n.long_name||"",
|
||||
short_name: n.short_name||"",
|
||||
channel: n.channel||"",
|
||||
hw_model: n.hw_model||"",
|
||||
role: n.role||"",
|
||||
firmware: n.firmware||"",
|
||||
last_update: n.last_update||"",
|
||||
isRouter: n.role? n.role.toLowerCase().includes("router"):false
|
||||
}));
|
||||
nodes.forEach(n=>{ nodeMap.set(n.key,n); if(n.channel) channelSet.add(n.channel); });
|
||||
renderNodesOnMap();
|
||||
createChannelFilters();
|
||||
return fetch('/api/edges');
|
||||
}).then(r=>r?r.json():null).then(data=>{
|
||||
if(data && data.edges) edgesData=data.edges;
|
||||
if(mapInterval>0) startPacketFetcher();
|
||||
}).catch(console.error);
|
||||
|
||||
// ---------------------- Render Nodes ----------------------
|
||||
function renderNodesOnMap(){
|
||||
const bounds = L.latLngBounds();
|
||||
nodes.forEach(node=>{
|
||||
if(isInvalidCoord(node)) return;
|
||||
const color = hashToColor(node.channel);
|
||||
const opts = { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 };
|
||||
const marker = L.circleMarker([node.lat,node.long],opts).addTo(map);
|
||||
marker.nodeId = node.key;
|
||||
marker.originalColor = color;
|
||||
markerById[node.key] = marker;
|
||||
const popup = `<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
|
||||
<b>Channel:</b> ${node.channel}<br>
|
||||
<b>Model:</b> ${node.hw_model}<br>
|
||||
<b>Role:</b> ${node.role}<br>
|
||||
${node.last_update? `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`:""}
|
||||
${node.firmware? `<b>Firmware:</b> ${node.firmware}<br>`:""}`;
|
||||
marker.on('click',()=>{ onNodeClick(node); marker.bindPopup(popup).openPopup(); setTimeout(()=>marker.closePopup(),3000); });
|
||||
bounds.extend(marker.getLatLng());
|
||||
});
|
||||
|
||||
// Only fit to nodes if no config bounds were applied
|
||||
if(!window.configBoundsApplied && bounds.isValid()){
|
||||
map.fitBounds(bounds);
|
||||
setTimeout(()=>map.invalidateSize(),100);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map AFTER translations are loaded
|
||||
loadTranslations().then(async () => {
|
||||
const t = window.mapTranslations || {};
|
||||
const activitySelect = document.getElementById("activity-range");
|
||||
const activityLabel = document.getElementById("activity-range-label");
|
||||
if (activityLabel) {
|
||||
activityLabel.textContent = t.active_within || "Active in:";
|
||||
}
|
||||
if (activitySelect) {
|
||||
activitySelect.addEventListener("change", () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("active", activitySelect.value);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Map Setup ----
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
{% if custom_view %}
|
||||
var customView = { lat: {{ custom_view.lat }}, lng: {{ custom_view.lng }}, zoom: {{ custom_view.zoom }} };
|
||||
{% else %}
|
||||
var customView = null;
|
||||
{% endif %}
|
||||
|
||||
// ---- Node Data ----
|
||||
var markers = {};
|
||||
var markerById = {};
|
||||
var nodes = [
|
||||
{% for node in nodes %}
|
||||
{
|
||||
lat: {{ ((node.last_lat / 10**7) + (range(-9,9) | random) / 10000) | round(7) }},
|
||||
long: {{ ((node.last_long / 10**7) + (range(-9,9) | random) / 10000) | round(7) if node.last_long is not none else "null" }},
|
||||
long_name: {{ (node.long_name or "") | tojson }},
|
||||
short_name: {{ (node.short_name or "") | tojson }},
|
||||
channel: {{ (node.channel or "") | tojson }},
|
||||
hw_model: {{ (node.hw_model or "") | tojson }},
|
||||
role: {{ (node.role or "") | tojson }},
|
||||
last_update: {{ node.last_update | default("", true) | tojson }},
|
||||
firmware: {{ (node.firmware or "") | tojson }},
|
||||
id: {{ (node.node_id or "") | tojson }},
|
||||
isRouter: {{ 'true' if 'router' in (node.role or '').lower() else 'false' }}
|
||||
}{{ "," if not loop.last else "" }}
|
||||
{% endfor %}
|
||||
];
|
||||
const channelSet = new Set();
|
||||
let channelList = [];
|
||||
|
||||
const portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"};
|
||||
|
||||
function timeAgo(date) {
|
||||
const now = Date.now();
|
||||
const diff = now - new Date(date);
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return days + "d";
|
||||
if (hours > 0) return hours + "h";
|
||||
if (minutes > 0) return minutes + "m";
|
||||
return seconds + "s";
|
||||
}
|
||||
|
||||
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
|
||||
const colorMap = new Map();
|
||||
let nextColorIndex = 0;
|
||||
function hashToColor(str) {
|
||||
if (colorMap.has(str)) return colorMap.get(str);
|
||||
const color = palette[nextColorIndex % palette.length];
|
||||
colorMap.set(str, color);
|
||||
nextColorIndex++;
|
||||
return color;
|
||||
}
|
||||
|
||||
function channelKey(channel) {
|
||||
if (typeof channel === 'string' && channel.trim().length > 0) {
|
||||
return channel;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
async function fetchAdditionalChannels() {
|
||||
try {
|
||||
const res = await fetch('/api/channels?period_type=day&length=30');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!data || !Array.isArray(data.channels)) return [];
|
||||
return data.channels.filter(ch => typeof ch === 'string' && ch.trim().length > 0);
|
||||
} catch (err) {
|
||||
console.error('Channel list fetch failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const nodeMap = new Map();
|
||||
nodes.forEach(n => nodeMap.set(n.id, n));
|
||||
function isInvalidCoord(node) { return !node || !node.lat || !node.long || node.lat===0 || node.long===0 || Number.isNaN(node.lat) || Number.isNaN(node.long); }
|
||||
|
||||
// ---- Marker Plotting ----
|
||||
var bounds = L.latLngBounds();
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!isInvalidCoord(node)) {
|
||||
let category = channelKey(node.channel);
|
||||
channelSet.add(category);
|
||||
let color = hashToColor(category);
|
||||
|
||||
let popupContent = `<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
|
||||
<b>${t.channel||'Channel:'}</b> ${node.channel}<br>
|
||||
<b>${t.model||'Model:'}</b> ${node.hw_model}<br>
|
||||
<b>${t.role||'Role:'}</b> ${node.role}<br>`;
|
||||
if (node.last_update) popupContent += `<b>${t.last_seen||'Last seen:'}</b> ${timeAgo(node.last_update)}<br>`;
|
||||
if (node.firmware) popupContent += `<b>${t.firmware||'Firmware:'}</b> ${node.firmware}<br>`;
|
||||
|
||||
var marker = L.circleMarker([node.lat, node.long], { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 }).addTo(map);
|
||||
marker.nodeId = node.id;
|
||||
marker.originalColor = color;
|
||||
markerById[node.id] = marker;
|
||||
|
||||
marker.on('click', e => {
|
||||
e.originalEvent.stopPropagation();
|
||||
marker.bindPopup(popupContent).openPopup();
|
||||
setTimeout(() => marker.closePopup(), 3000);
|
||||
onNodeClick(node);
|
||||
});
|
||||
|
||||
if (!markers[category]) markers[category]=[];
|
||||
markers[category].push({marker,isRouter:node.isRouter});
|
||||
bounds.extend(marker.getLatLng());
|
||||
// ---------------------- Render Edges ----------------------
|
||||
function onNodeClick(node){
|
||||
selectedNodeId = node.key;
|
||||
edgeLayer.clearLayers();
|
||||
edgesData.forEach(edge=>{
|
||||
if(edge.from!==node.key && edge.to!==node.key) return;
|
||||
const f=nodeMap.get(edge.from), t=nodeMap.get(edge.to);
|
||||
if(!f||!t||isInvalidCoord(f)||isInvalidCoord(t)) return;
|
||||
const color=edge.type==="neighbor"?"gray":"orange";
|
||||
const l=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
|
||||
if(edge.type==="traceroute"){
|
||||
L.polylineDecorator(l,{patterns:[{offset:'100%',repeat:0,symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{stroke:true,color}})}]}).addTo(edgeLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
map.on('click',e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; } });
|
||||
|
||||
// ---- Map bounds ----
|
||||
var areaBounds = [
|
||||
[{{ site_config["site"]["map_top_left_lat"] }}, {{ site_config["site"]["map_top_left_lon"] }}],
|
||||
[{{ site_config["site"]["map_bottom_right_lat"] }}, {{ site_config["site"]["map_bottom_right_lon"] }}]
|
||||
];
|
||||
if (customView) map.setView([customView.lat,customView.lng],customView.zoom);
|
||||
else map.fitBounds(areaBounds);
|
||||
// ---------------------- Packet Blinking ----------------------
|
||||
function blinkNode(marker,longName,portnum){
|
||||
if(!map.hasLayer(marker)) return;
|
||||
if(activeBlinks.has(marker)){ clearInterval(activeBlinks.get(marker)); marker.setStyle({fillColor:marker.originalColor}); if(marker.tooltip) map.removeLayer(marker.tooltip); }
|
||||
let blinkCount=0;
|
||||
const portName = portMap[portnum]||`Port ${portnum}`;
|
||||
const tooltip = L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
|
||||
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng()).addTo(map);
|
||||
marker.tooltip = tooltip;
|
||||
const interval = setInterval(()=>{
|
||||
if(map.hasLayer(marker)){ marker.setStyle({fillColor: blinkCount%2===0?'yellow':marker.originalColor}); marker.bringToFront(); }
|
||||
blinkCount++;
|
||||
if(blinkCount>7){ clearInterval(interval); marker.setStyle({fillColor:marker.originalColor}); map.removeLayer(tooltip); activeBlinks.delete(marker); }
|
||||
},500);
|
||||
activeBlinks.set(marker,interval);
|
||||
}
|
||||
|
||||
channelList = Array.from(channelSet).sort();
|
||||
|
||||
// ---- LocalStorage for Filter Preferences ----
|
||||
const FILTER_STORAGE_KEY = 'meshview_map_filters';
|
||||
|
||||
function getDefaultFilters() {
|
||||
return {
|
||||
routersOnly: false,
|
||||
channels: {}
|
||||
};
|
||||
}
|
||||
|
||||
function saveFiltersToLocalStorage() {
|
||||
const filters = {
|
||||
routersOnly: document.getElementById("filter-routers-only").checked,
|
||||
channels: {}
|
||||
};
|
||||
|
||||
channelList.forEach(channel => {
|
||||
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
let checkbox = document.getElementById(filterId);
|
||||
if (checkbox) {
|
||||
filters.channels[channel] = checkbox.checked;
|
||||
}
|
||||
// ---------------------- Packet Fetching ----------------------
|
||||
function fetchLatestPacket(){ fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{ lastImportTime=data.packets?.[0]?.import_time||new Date().toISOString(); }).catch(console.error); }
|
||||
function fetchNewPackets(){
|
||||
if(!lastImportTime) return;
|
||||
fetch(`/api/packets?since=${encodeURIComponent(lastImportTime)}`).then(r=>r.json()).then(data=>{
|
||||
if(!data.packets||data.packets.length===0) return;
|
||||
let latest = lastImportTime;
|
||||
data.packets.forEach(pkt=>{
|
||||
if(pkt.import_time>latest) latest=pkt.import_time;
|
||||
const marker = markerById[pkt.from_node_id];
|
||||
const nodeData = nodeMap.get(pkt.from_node_id);
|
||||
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
|
||||
});
|
||||
lastImportTime=latest;
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters));
|
||||
}
|
||||
// ---------------------- Polling ----------------------
|
||||
let packetInterval=null;
|
||||
function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } }
|
||||
function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
|
||||
document.addEventListener("visibilitychange",()=>{ document.hidden?stopPacketFetcher():startPacketFetcher(); });
|
||||
|
||||
function loadFiltersFromLocalStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading filters from localStorage:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ---------------------- Channel Filters ----------------------
|
||||
function createChannelFilters(){
|
||||
const filterContainer = document.getElementById("filter-container");
|
||||
channelSet.forEach(channel=>{
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "filter-checkbox";
|
||||
checkbox.id = `filter-channel-${channel}`;
|
||||
checkbox.checked = true;
|
||||
checkbox.addEventListener("change", updateNodeVisibility);
|
||||
filterContainer.appendChild(checkbox);
|
||||
|
||||
function renderChannelFilters(savedFilters) {
|
||||
const filterContainer = document.getElementById("filter-container");
|
||||
filterContainer.querySelectorAll('label[data-channel-filter="true"]').forEach(el => el.remove());
|
||||
channelList.forEach(channel => {
|
||||
let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
|
||||
let color = hashToColor(channel);
|
||||
let label = document.createElement('label');
|
||||
label.style.color = color;
|
||||
label.setAttribute('data-channel-filter', 'true');
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'filter-checkbox';
|
||||
checkbox.id = filterId;
|
||||
const shouldCheck = savedFilters ? savedFilters.channels?.[channel] !== false : true;
|
||||
checkbox.checked = shouldCheck;
|
||||
checkbox.addEventListener("change", updateMarkers);
|
||||
label.appendChild(checkbox);
|
||||
label.append(` ${channel}`);
|
||||
filterContainer.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function resetFiltersToDefaults() {
|
||||
localStorage.removeItem(FILTER_STORAGE_KEY);
|
||||
document.getElementById("filter-routers-only").checked = false;
|
||||
renderChannelFilters(null);
|
||||
updateMarkers();
|
||||
|
||||
const button = document.getElementById('reset-filters-button');
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '✓ Filters Reset!';
|
||||
button.style.backgroundColor = '#2196F3';
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.backgroundColor = '#f44336';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.resetFiltersToDefaults = resetFiltersToDefaults;
|
||||
|
||||
// ---- Filters ----
|
||||
const filterLabel = document.getElementById("filter-routers-label");
|
||||
filterLabel.textContent = t.show_routers_only || "Show Routers Only";
|
||||
const routersOnlyCheckbox = document.getElementById("filter-routers-only");
|
||||
const savedFilters = loadFiltersFromLocalStorage();
|
||||
if (savedFilters) {
|
||||
routersOnlyCheckbox.checked = savedFilters.routersOnly || false;
|
||||
}
|
||||
routersOnlyCheckbox.addEventListener("change", updateMarkers);
|
||||
renderChannelFilters(savedFilters);
|
||||
|
||||
function updateMarkers() {
|
||||
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
|
||||
nodes.forEach(node => {
|
||||
let category = channelKey(node.channel);
|
||||
let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
|
||||
let shouldShow=(!checkbox || checkbox.checked) && (!showRoutersOnly || node.isRouter);
|
||||
let marker=markerById[node.id];
|
||||
if(marker) marker.setStyle({fillOpacity:shouldShow?1:0});
|
||||
});
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
saveFiltersToLocalStorage();
|
||||
|
||||
if (!document.hidden) {
|
||||
restartPacketFetcher(true);
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveChannels() {
|
||||
return channelList.filter(channel => {
|
||||
if (channel === 'Unknown') return false;
|
||||
let checkbox = document.getElementById(`filter-${channel.replace(/\s+/g,'-').toLowerCase()}`);
|
||||
return checkbox ? checkbox.checked : true;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply initial filters (from localStorage or defaults)
|
||||
updateMarkers();
|
||||
|
||||
// ---- Share button ----
|
||||
const shareBtn = document.getElementById("share-button");
|
||||
shareBtn.textContent = `🔗 ${t.share_view || "Share This View"}`;
|
||||
shareBtn.onclick = function() {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const lat = center.lat.toFixed(6);
|
||||
const lng = center.lng.toFixed(6);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('lat', lat);
|
||||
url.searchParams.set('lng', lng);
|
||||
url.searchParams.set('zoom', zoom);
|
||||
const shareUrl = url.toString();
|
||||
navigator.clipboard.writeText(shareUrl).then(()=>{
|
||||
const orig = shareBtn.textContent;
|
||||
shareBtn.textContent = '✓ Link Copied!';
|
||||
shareBtn.style.backgroundColor='#2196F3';
|
||||
setTimeout(()=>{ shareBtn.textContent=orig; shareBtn.style.backgroundColor='#4CAF50'; },2000);
|
||||
}).catch(()=>{ alert('Share this link:\n'+shareUrl); });
|
||||
};
|
||||
|
||||
// ---- Edges ----
|
||||
var edgeLayer = L.layerGroup().addTo(map);
|
||||
var edgesData = null;
|
||||
let selectedNodeId = null;
|
||||
|
||||
fetch('/api/edges')
|
||||
.then(r => r.json())
|
||||
.then(data => edgesData = data.edges)
|
||||
.catch(err => console.error(err));
|
||||
|
||||
function onNodeClick(node) {
|
||||
if (selectedNodeId != node.id) {
|
||||
selectedNodeId = node.id;
|
||||
edgeLayer.clearLayers();
|
||||
if (!edgesData) return;
|
||||
if (!map.hasLayer(edgeLayer)) edgeLayer.addTo(map);
|
||||
|
||||
edgesData.forEach(edge => {
|
||||
if (edge.from !== node.id && edge.to !== node.id) return;
|
||||
const fromNode = nodeMap.get(edge.from);
|
||||
const toNode = nodeMap.get(edge.to);
|
||||
if (!fromNode || !toNode) return;
|
||||
if (isInvalidCoord(fromNode) || isInvalidCoord(toNode)) return;
|
||||
|
||||
const lineColor = edge.type === "neighbor" ? "gray" : "orange";
|
||||
const weight = 3;
|
||||
|
||||
const polyline = L.polyline(
|
||||
[[fromNode.lat, fromNode.long], [toNode.lat, toNode.long]],
|
||||
{ color: lineColor, weight, opacity: 1 }
|
||||
).addTo(edgeLayer).bringToFront();
|
||||
|
||||
// ✅ Show tooltip right where the user clicks
|
||||
polyline.on('click', e => {
|
||||
const tooltip = L.tooltip({
|
||||
permanent: false,
|
||||
direction: 'top',
|
||||
offset: [0, -5],
|
||||
className: 'blinking-tooltip'
|
||||
})
|
||||
.setContent(edge.type.charAt(0).toUpperCase() + edge.type.slice(1))
|
||||
.setLatLng(e.latlng)
|
||||
.addTo(map);
|
||||
|
||||
setTimeout(() => map.removeLayer(tooltip), 3000);
|
||||
});
|
||||
|
||||
if (edge.type === "traceroute") {
|
||||
L.polylineDecorator(polyline, {
|
||||
patterns: [{
|
||||
offset: '100%',
|
||||
repeat: 0,
|
||||
symbol: L.Symbol.arrowHead({
|
||||
pixelSize: 5,
|
||||
polygon: false,
|
||||
pathOptions: { stroke: true, color: lineColor }
|
||||
})
|
||||
}]
|
||||
}).addTo(edgeLayer);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
map.on('click', e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; }});
|
||||
|
||||
// ---- Blinking ----
|
||||
var activeBlinks=new Map();
|
||||
function blinkNode(marker,longName,portnum){
|
||||
if(!map.hasLayer(marker)) return;
|
||||
if(activeBlinks.has(marker)){
|
||||
clearInterval(activeBlinks.get(marker));
|
||||
marker.setStyle({fillColor:marker.originalColor});
|
||||
if(marker.tooltip) map.removeLayer(marker.tooltip);
|
||||
}
|
||||
let blinkCount=0;
|
||||
let portName=portMap[portnum]||`Port ${portnum}`;
|
||||
let tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
|
||||
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
|
||||
tooltip.addTo(map);
|
||||
marker.tooltip=tooltip;
|
||||
let interval=setInterval(()=>{
|
||||
if(map.hasLayer(marker)){
|
||||
marker.setStyle({fillColor:blinkCount%2===0?'yellow':marker.originalColor});
|
||||
marker.bringToFront();
|
||||
}
|
||||
blinkCount++;
|
||||
if(blinkCount>7){
|
||||
clearInterval(interval);
|
||||
marker.setStyle({fillColor:marker.originalColor});
|
||||
map.removeLayer(tooltip);
|
||||
activeBlinks.delete(marker);
|
||||
}
|
||||
},500);
|
||||
activeBlinks.set(marker,interval);
|
||||
}
|
||||
|
||||
// ---- Packet fetching ----
|
||||
let lastImportTime=null;
|
||||
const mapInterval={{ site_config["site"]["map_interval"]|default(3) }};
|
||||
function buildPacketsUrl(base){
|
||||
const active = getActiveChannels();
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.delete('channel');
|
||||
if (active.length) {
|
||||
active.forEach(ch => url.searchParams.append('channel', ch));
|
||||
}
|
||||
if (url.origin === window.location.origin) {
|
||||
return url.pathname + (url.search || '') + (url.hash || '');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
function fetchLatestPacket(){
|
||||
return fetch(buildPacketsUrl(`/api/packets?limit=1`))
|
||||
.then(r=>r.json())
|
||||
.then(data=>{
|
||||
if(data.packets && data.packets.length>0){
|
||||
lastImportTime=data.packets[0].import_time;
|
||||
} else {
|
||||
lastImportTime=new Date().toISOString();
|
||||
}
|
||||
})
|
||||
.catch(err=>{
|
||||
console.error('fetchLatestPacket failed:', err);
|
||||
});
|
||||
}
|
||||
function fetchNewPackets(){
|
||||
if(!lastImportTime) return;
|
||||
const baseUrl = `/api/packets?since=${encodeURIComponent(lastImportTime)}`;
|
||||
return fetch(buildPacketsUrl(baseUrl))
|
||||
.then(r=>r.json())
|
||||
.then(data=>{
|
||||
if(!data.packets||data.packets.length===0) return;
|
||||
let latestSeen=lastImportTime;
|
||||
data.packets.forEach(packet=>{
|
||||
if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time;
|
||||
let marker=markerById[packet.from_node_id];
|
||||
if(marker){
|
||||
let nodeData=nodeMap.get(packet.from_node_id);
|
||||
if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
|
||||
}
|
||||
});
|
||||
if(latestSeen) lastImportTime=latestSeen;
|
||||
})
|
||||
.catch(err=>{
|
||||
console.error('fetchNewPackets failed:', err);
|
||||
});
|
||||
}
|
||||
let packetInterval=null;
|
||||
async function startPacketFetcher(resetImportTime=true){
|
||||
if (mapInterval <= 0) return;
|
||||
stopPacketFetcher();
|
||||
if (resetImportTime) {
|
||||
lastImportTime = null;
|
||||
}
|
||||
if (!lastImportTime) {
|
||||
await fetchLatestPacket();
|
||||
}
|
||||
await fetchNewPackets();
|
||||
packetInterval = setInterval(()=>{ fetchNewPackets(); }, mapInterval*1000);
|
||||
}
|
||||
function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
|
||||
async function restartPacketFetcher(resetImportTime=false){
|
||||
if(mapInterval<=0) return;
|
||||
if(document.hidden) return;
|
||||
await startPacketFetcher(resetImportTime);
|
||||
}
|
||||
document.addEventListener("visibilitychange",function(){
|
||||
if(document.hidden) stopPacketFetcher();
|
||||
else restartPacketFetcher(false);
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = checkbox.id;
|
||||
label.innerText = channel;
|
||||
// Set the label color to match the channel
|
||||
label.style.color = hashToColor(channel);
|
||||
filterContainer.appendChild(label);
|
||||
});
|
||||
if(mapInterval>0) startPacketFetcher(true);
|
||||
});
|
||||
document.getElementById("filter-routers-only").addEventListener("change", updateNodeVisibility);
|
||||
}
|
||||
|
||||
|
||||
function updateNodeVisibility(){
|
||||
const showRoutersOnly = document.getElementById("filter-routers-only").checked;
|
||||
const activeChannels = Array.from(channelSet).filter(ch=>document.getElementById(`filter-channel-${ch}`).checked);
|
||||
nodes.forEach(n=>{
|
||||
const marker = markerById[n.key];
|
||||
if(marker){
|
||||
const visible = (!showRoutersOnly || n.isRouter) && activeChannels.includes(n.channel);
|
||||
if(visible) map.addLayer(marker); else map.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------- Share / Reset ----------------------
|
||||
function shareCurrentView(){ alert("Sharing is not implemented yet."); }
|
||||
function resetFiltersToDefaults(){
|
||||
document.getElementById("filter-routers-only").checked = false;
|
||||
channelSet.forEach(ch=>document.getElementById(`filter-channel-${ch}`).checked = true);
|
||||
updateNodeVisibility();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -87,12 +87,6 @@
|
||||
<div id="mynetwork"></div>
|
||||
|
||||
<div class="search-container">
|
||||
<label for="activity-range" style="color:#333;">Active in:</label>
|
||||
<select id="activity-range">
|
||||
{% for value, label, _window in activity_filters %}
|
||||
<option value="{{ value }}" {% if value == selected_activity %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="channel-select" style="color:#333;">Channel:</label>
|
||||
<select id="channel-select" onchange="filterByChannel()"></select>
|
||||
<input type="text" id="node-search" placeholder="Search node...">
|
||||
@@ -129,23 +123,6 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const activityRangeSelect = document.getElementById('activity-range');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let selectedChannel = urlParams.get('channel') || '';
|
||||
const hasChannelSelection = value => value !== null && value !== undefined && value !== '';
|
||||
if (activityRangeSelect) {
|
||||
activityRangeSelect.addEventListener('change', () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('active', activityRangeSelect.value);
|
||||
if (hasChannelSelection(selectedChannel)) {
|
||||
url.searchParams.set('channel', selectedChannel);
|
||||
} else {
|
||||
url.searchParams.delete('channel');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
}
|
||||
|
||||
const chart = echarts.init(document.getElementById('mynetwork'));
|
||||
|
||||
const colors = {
|
||||
@@ -228,101 +205,28 @@ const edges = [
|
||||
|
||||
let filteredNodes = [];
|
||||
let filteredEdges = [];
|
||||
let selectedChannel = 'LongFast';
|
||||
let lastSelectedNode = null;
|
||||
|
||||
const normalizeChannel = (value) => {
|
||||
const trimmed = (value || '').toString().trim();
|
||||
return trimmed || 'Unknown';
|
||||
};
|
||||
|
||||
const channelByNodeId = new Map();
|
||||
nodes.forEach(n=>{
|
||||
const key = String(n.name ?? n.node_id ?? '');
|
||||
if(key){
|
||||
channelByNodeId.set(key, normalizeChannel(n.channel));
|
||||
}
|
||||
});
|
||||
|
||||
const channelCounts = new Map();
|
||||
edges.forEach(edge=>{
|
||||
const weight = typeof edge.weight === 'number' ? edge.weight : 1;
|
||||
const fromChannel = channelByNodeId.get(String(edge.source));
|
||||
const toChannel = channelByNodeId.get(String(edge.target));
|
||||
if(fromChannel){
|
||||
channelCounts.set(fromChannel, (channelCounts.get(fromChannel) || 0) + weight);
|
||||
}
|
||||
if(toChannel){
|
||||
channelCounts.set(toChannel, (channelCounts.get(toChannel) || 0) + weight);
|
||||
}
|
||||
});
|
||||
|
||||
let channelOptions = [];
|
||||
if (channelCounts.size) {
|
||||
channelOptions = Array.from(
|
||||
[...channelCounts.entries()]
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([channel]) => channel)
|
||||
).sort();
|
||||
}
|
||||
|
||||
if (!channelOptions.length) {
|
||||
channelOptions = Array.from(new Set(nodes.map(n=>normalizeChannel(n.channel)))).sort();
|
||||
}
|
||||
|
||||
if (hasChannelSelection(selectedChannel) && channelOptions.length && !channelOptions.includes(selectedChannel)) {
|
||||
channelOptions.push(selectedChannel);
|
||||
channelOptions.sort();
|
||||
}
|
||||
|
||||
if (!hasChannelSelection(selectedChannel) && channelOptions.length) {
|
||||
selectedChannel = channelOptions[0];
|
||||
}
|
||||
|
||||
function populateChannelDropdown() {
|
||||
const sel = document.getElementById('channel-select');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = 'All Channels';
|
||||
allOpt.setAttribute('data-translate-lang','all_channels');
|
||||
sel.appendChild(allOpt);
|
||||
channelOptions.forEach(ch=>{
|
||||
const unique = [...new Set(nodes.map(n=>n.channel).filter(Boolean))].sort();
|
||||
unique.forEach(ch=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ch;
|
||||
opt.text = ch;
|
||||
if (selectedChannel === ch) {
|
||||
opt.selected = true;
|
||||
}
|
||||
opt.value=ch; opt.text=ch;
|
||||
if(ch==='LongFast') opt.selected=true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!hasChannelSelection(selectedChannel)) {
|
||||
sel.value = '';
|
||||
}
|
||||
selectedChannel = sel.value;
|
||||
filterByChannel();
|
||||
}
|
||||
|
||||
function filterByChannel(isInitial=false) {
|
||||
const sel = document.getElementById('channel-select');
|
||||
if (sel) {
|
||||
selectedChannel = sel.value;
|
||||
}
|
||||
if (hasChannelSelection(selectedChannel)) {
|
||||
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
|
||||
} else {
|
||||
filteredNodes = [...nodes];
|
||||
}
|
||||
function filterByChannel() {
|
||||
selectedChannel = document.getElementById('channel-select').value;
|
||||
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
|
||||
const nodeSet = new Set(filteredNodes.map(n=>n.name));
|
||||
filteredEdges = edges.filter(e=>nodeSet.has(e.source) && nodeSet.has(e.target));
|
||||
lastSelectedNode=null;
|
||||
if (!isInitial) {
|
||||
const url = new URL(window.location.href);
|
||||
if (hasChannelSelection(selectedChannel)) {
|
||||
url.searchParams.set('channel', selectedChannel);
|
||||
} else {
|
||||
url.searchParams.delete('channel');
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
updateChart();
|
||||
}
|
||||
|
||||
@@ -387,7 +291,6 @@ function searchNode(){
|
||||
}
|
||||
|
||||
populateChannelDropdown();
|
||||
filterByChannel(true);
|
||||
window.addEventListener('resize', ()=>chart.resize());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -77,90 +77,14 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
border: 1px solid #3a3d42;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.stats-table th,
|
||||
.stats-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #3a3d42;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.stats-table th.sorted-asc::after,
|
||||
.stats-table th.sorted-desc::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
}
|
||||
|
||||
.stats-table th.sorted-asc::after {
|
||||
border-bottom: 6px solid #66bb6a;
|
||||
transform: translateY(-75%);
|
||||
}
|
||||
|
||||
.stats-table th.sorted-desc::after {
|
||||
border-top: 6px solid #66bb6a;
|
||||
transform: translateY(-25%);
|
||||
}
|
||||
|
||||
.stats-table tbody tr:hover {
|
||||
background-color: #2f3338;
|
||||
}
|
||||
|
||||
.empty-table {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#channelSelect {
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 6px;
|
||||
background:#444;
|
||||
color:#fff;
|
||||
border:none;
|
||||
border-radius:4px;
|
||||
}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
@@ -171,25 +95,18 @@
|
||||
<div class="main-container">
|
||||
<h2 class="main-header" data-translate-lang="mesh_stats_summary">Mesh Statistics - Summary (all available in Database)</h2>
|
||||
|
||||
<div class="filter-bar">
|
||||
<label for="channelSelect" class="filter-label">Channel</label>
|
||||
<select id="channelSelect">
|
||||
<option value="" data-translate-lang="all_channels">All Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p data-translate-lang="total_nodes">Total Nodes</p>
|
||||
<div class="summary-count" id="summaryTotalNodes">{{ "{:,}".format(total_nodes) }}</div>
|
||||
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p data-translate-lang="total_packets">Total Packets</p>
|
||||
<div class="summary-count" id="summaryTotalPackets">{{ "{:,}".format(total_packets) }}</div>
|
||||
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
|
||||
<div class="summary-count" id="summaryTotalPacketsSeen">{{ "{:,}".format(total_packets_seen) }}</div>
|
||||
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,6 +122,9 @@
|
||||
<!-- Packet Types Pie Chart with Channel Selector -->
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packet_types_last_24h">Packet Types - Last 24 Hours</p>
|
||||
<select id="channelSelect">
|
||||
<option value="" data-translate-lang="all_channels">All Channels</option>
|
||||
</select>
|
||||
<button class="expand-btn" data-chart="chart_packet_types" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_packet_types" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_packet_types" class="chart"></div>
|
||||
@@ -253,29 +173,9 @@
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="channel_breakdown">Channel Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_channel" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
|
||||
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_channel" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="nodes_table_title">Nodes Overview</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="stats-table" id="nodesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort-key="long_name">Long Name</th>
|
||||
<th data-sort-key="short_name">Short Name</th>
|
||||
<th data-sort-key="role">Role</th>
|
||||
<th data-sort-key="hw_model">Hardware</th>
|
||||
<th data-sort-key="channel">Channel</th>
|
||||
<th data-sort-key="last_update">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty-table" id="nodesTableEmpty" style="display:none;">No nodes found for the selected channel.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for expanded charts -->
|
||||
@@ -301,28 +201,12 @@ const PORTNUM_LABELS = {
|
||||
71: "Neighbor Info"
|
||||
};
|
||||
|
||||
const PORT_CONFIG = [
|
||||
{ port: 1, color: '#ff5722', domId: 'chart_portnum_1', totalId: 'total_portnum_1' },
|
||||
{ port: 3, color: '#2196f3', domId: 'chart_portnum_3', totalId: 'total_portnum_3' },
|
||||
{ port: 4, color: '#9c27b0', domId: 'chart_portnum_4', totalId: 'total_portnum_4' },
|
||||
{ port: 67, color: '#ffeb3b', domId: 'chart_portnum_67', totalId: 'total_portnum_67' },
|
||||
{ port: 70, color: '#795548', domId: 'chart_portnum_70', totalId: 'total_portnum_70' },
|
||||
{ port: 71, color: '#4caf50', domId: 'chart_portnum_71', totalId: 'total_portnum_71' },
|
||||
];
|
||||
|
||||
const CHANNEL_PRESETS = ["LongFast", "MediumSlow"];
|
||||
|
||||
let currentChannel = "";
|
||||
let nodeTableData = [];
|
||||
let nodeTableSortKey = "last_update";
|
||||
let nodeTableSortDirection = "desc";
|
||||
|
||||
// --- Fetch & Processing ---
|
||||
async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
try{
|
||||
let url=`/api/stats?period_type=${period_type}&length=${length}`;
|
||||
if(portnum!==null) url+=`&portnum=${portnum}`;
|
||||
if(channel) url+=`&channel=${encodeURIComponent(channel)}`;
|
||||
if(channel) url+=`&channel=${channel}`;
|
||||
const res=await fetch(url);
|
||||
if(!res.ok) return [];
|
||||
const json=await res.json();
|
||||
@@ -330,212 +214,28 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
}catch{return [];}
|
||||
}
|
||||
|
||||
async function fetchNodes(channel=null){
|
||||
try{
|
||||
const base="/api/nodes";
|
||||
const url=channel?`${base}?channel=${encodeURIComponent(channel)}`:base;
|
||||
const res=await fetch(url);
|
||||
const json=await res.json();
|
||||
return json.nodes||[];
|
||||
}catch{return [];}
|
||||
}
|
||||
async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} }
|
||||
async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
|
||||
|
||||
async function fetchChannels(){
|
||||
try{
|
||||
const res = await fetch("/api/channels");
|
||||
const json = await res.json();
|
||||
return json.channels || [];
|
||||
}catch{return [];}
|
||||
}
|
||||
|
||||
async function fetchSummary(channel=null){
|
||||
try{
|
||||
const base="/api/stats/summary";
|
||||
const url=channel?`${base}?channel=${encodeURIComponent(channel)}`:base;
|
||||
const res=await fetch(url);
|
||||
if(!res.ok) return null;
|
||||
return await res.json();
|
||||
}catch{return null;}
|
||||
}
|
||||
|
||||
function processCountField(nodes,field){
|
||||
const counts={};
|
||||
(nodes||[]).forEach(n=>{
|
||||
const key=n?.[field]||"Unknown";
|
||||
counts[key]=(counts[key]||0)+1;
|
||||
});
|
||||
return Object.entries(counts).map(([name,value])=>({name,value}));
|
||||
}
|
||||
|
||||
function updateTotalCount(domId,data){
|
||||
const el=document.getElementById(domId);
|
||||
if(!el) return;
|
||||
const dataset=Array.isArray(data)?data:[];
|
||||
const total=dataset.reduce((acc,d)=>acc+(d?.count??d?.packet_count??0),0);
|
||||
el.textContent=`Total: ${total.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function prepareTopN(data=[],n=20){
|
||||
const sorted=[...(data||[])].sort((a,b)=>b.value-a.value);
|
||||
const top=sorted.slice(0,n);
|
||||
if(sorted.length>n){
|
||||
const otherValue=sorted.slice(n).reduce((sum,item)=>sum+item.value,0);
|
||||
top.push({name:"Other", value:otherValue});
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
function formatDateString(value){
|
||||
if(!value) return "—";
|
||||
const date = new Date(value);
|
||||
if(Number.isNaN(date.getTime())) return value;
|
||||
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
function normalizeString(value){
|
||||
return (value ?? "").toString().toLowerCase();
|
||||
}
|
||||
|
||||
function applyNodeTableSort(render=true){
|
||||
const dir = nodeTableSortDirection === "asc" ? 1 : -1;
|
||||
nodeTableData.sort((a,b)=>{
|
||||
let lhs=a[nodeTableSortKey];
|
||||
let rhs=b[nodeTableSortKey];
|
||||
if(nodeTableSortKey==="last_update"){
|
||||
lhs = lhs ?? -Infinity;
|
||||
rhs = rhs ?? -Infinity;
|
||||
return (lhs - rhs) * dir;
|
||||
}
|
||||
const left = normalizeString(lhs);
|
||||
const right = normalizeString(rhs);
|
||||
if(left===right) return 0;
|
||||
return left > right ? dir : -dir;
|
||||
});
|
||||
if(render) renderNodeTableRows();
|
||||
}
|
||||
|
||||
function renderNodeTableRows(){
|
||||
const tbody=document.querySelector("#nodesTable tbody");
|
||||
const emptyMessage=document.getElementById("nodesTableEmpty");
|
||||
if(!tbody) return;
|
||||
tbody.innerHTML="";
|
||||
if(!nodeTableData.length){
|
||||
if(emptyMessage) emptyMessage.style.display="block";
|
||||
return;
|
||||
}
|
||||
if(emptyMessage) emptyMessage.style.display="none";
|
||||
nodeTableData.forEach(node=>{
|
||||
const tr=document.createElement("tr");
|
||||
tr.innerHTML=`
|
||||
<td>${node.long_name}</td>
|
||||
<td>${node.short_name}</td>
|
||||
<td>${node.role}</td>
|
||||
<td>${node.hw_model}</td>
|
||||
<td>${node.channel || "—"}</td>
|
||||
<td>${node.last_update_display}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
updateSortIndicators();
|
||||
}
|
||||
|
||||
function setNodeTableData(rawNodes){
|
||||
nodeTableData = (rawNodes||[]).map(n=>{
|
||||
const lastUpdateRaw = n?.last_update ?? null;
|
||||
const lastUpdateDate = lastUpdateRaw ? new Date(lastUpdateRaw) : null;
|
||||
return {
|
||||
long_name: n?.long_name || "—",
|
||||
short_name: n?.short_name || "—",
|
||||
role: n?.role || "Unknown",
|
||||
hw_model: n?.hw_model || "Unknown",
|
||||
channel: n?.channel || "",
|
||||
last_update: lastUpdateDate ? lastUpdateDate.getTime() : null,
|
||||
last_update_display: formatDateString(lastUpdateRaw),
|
||||
};
|
||||
});
|
||||
applyNodeTableSort(false);
|
||||
renderNodeTableRows();
|
||||
}
|
||||
|
||||
function updateSortIndicators(){
|
||||
document.querySelectorAll("#nodesTable thead th[data-sort-key]").forEach(th=>{
|
||||
th.classList.remove("sorted-asc","sorted-desc");
|
||||
if(th.dataset.sortKey === nodeTableSortKey){
|
||||
th.classList.add(nodeTableSortDirection === "asc" ? "sorted-asc" : "sorted-desc");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleNodeTableSort(event){
|
||||
const key=event.currentTarget?.dataset?.sortKey;
|
||||
if(!key) return;
|
||||
if(nodeTableSortKey===key){
|
||||
nodeTableSortDirection = nodeTableSortDirection === "asc" ? "desc" : "asc";
|
||||
}else{
|
||||
nodeTableSortKey = key;
|
||||
nodeTableSortDirection = key === "last_update" ? "desc" : "asc";
|
||||
}
|
||||
applyNodeTableSort();
|
||||
}
|
||||
function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); }
|
||||
function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; }
|
||||
function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; }
|
||||
|
||||
// --- Chart Rendering ---
|
||||
function renderChart(domId,data,type,color){
|
||||
const el=document.getElementById(domId);
|
||||
if(!el) return null;
|
||||
const existing=echarts.getInstanceByDom(el);
|
||||
if(existing) existing.dispose();
|
||||
const chart=echarts.init(el);
|
||||
const source=Array.isArray(data)?data:[];
|
||||
const periods=source.map(d=>{
|
||||
const periodValue=d?.period;
|
||||
return (periodValue||periodValue===0) ? String(periodValue) : '';
|
||||
});
|
||||
const counts=source.map(d=>d?.count??d?.packet_count??0);
|
||||
chart.setOption({
|
||||
backgroundColor:'#272b2f',
|
||||
tooltip:{trigger:'axis'},
|
||||
grid:{left:'6%', right:'6%', bottom:'18%'},
|
||||
xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}},
|
||||
yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}},
|
||||
series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]
|
||||
});
|
||||
return chart;
|
||||
}
|
||||
function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; }
|
||||
|
||||
function renderPieChart(elId,data,name){
|
||||
const el=document.getElementById(elId);
|
||||
if(!el) return null;
|
||||
const existing=echarts.getInstanceByDom(el);
|
||||
if(existing) existing.dispose();
|
||||
const chart=echarts.init(el);
|
||||
const top20=prepareTopN(data,20);
|
||||
chart.setOption({
|
||||
backgroundColor:"#272b2f",
|
||||
tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`},
|
||||
series:[{
|
||||
name:name,
|
||||
type:"pie",
|
||||
radius:["30%","70%"],
|
||||
center:["50%","50%"],
|
||||
avoidLabelOverlap:true,
|
||||
itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2},
|
||||
label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10},
|
||||
labelLine:{show:true,length:10,length2:6},
|
||||
data:top20
|
||||
}]
|
||||
});
|
||||
return chart;
|
||||
}
|
||||
function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; }
|
||||
|
||||
// --- Packet Type Pie Chart ---
|
||||
async function fetchPacketTypeBreakdown(channel=null) {
|
||||
const requests = PORT_CONFIG.map(async ({port}) => {
|
||||
const data = await fetchStats('hour',24,port,channel);
|
||||
const total = (data || []).reduce((sum,d)=>sum+(d?.count??d?.packet_count??0),0);
|
||||
return {portnum: port, count: total};
|
||||
const portnums = [1,3,4,67,70,71];
|
||||
const requests = portnums.map(async pn => {
|
||||
const data = await fetchStats('hour',24,pn,channel);
|
||||
const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
|
||||
return {portnum: pn, count: total};
|
||||
});
|
||||
const allData = await fetchStats('hour',24,null,channel);
|
||||
const totalAll = (allData||[]).reduce((sum,d)=>sum+(d?.count??d?.packet_count??0),0);
|
||||
const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
|
||||
const results = await Promise.all(requests);
|
||||
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
|
||||
const other = Math.max(totalAll - trackedTotal,0);
|
||||
@@ -543,137 +243,44 @@ async function fetchPacketTypeBreakdown(channel=null) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function updateSummaryCards(summary){
|
||||
if(!summary) return;
|
||||
const nodesEl=document.getElementById("summaryTotalNodes");
|
||||
const packetsEl=document.getElementById("summaryTotalPackets");
|
||||
const packetsSeenEl=document.getElementById("summaryTotalPacketsSeen");
|
||||
if(nodesEl) nodesEl.textContent=Number(summary.total_nodes||0).toLocaleString();
|
||||
if(packetsEl) packetsEl.textContent=Number(summary.total_packets||0).toLocaleString();
|
||||
if(packetsSeenEl) packetsSeenEl.textContent=Number(summary.total_packets_seen||0).toLocaleString();
|
||||
}
|
||||
|
||||
function populateChannelSelect(channels){
|
||||
const select=document.getElementById("channelSelect");
|
||||
if(!select) return;
|
||||
const unique=Array.from(new Set((channels||[]).filter(ch=>typeof ch==="string" && ch.trim().length>0))).sort((a,b)=>a.localeCompare(b));
|
||||
const prioritized=[];
|
||||
CHANNEL_PRESETS.forEach(preset=>{
|
||||
const idx=unique.indexOf(preset);
|
||||
if(idx>=0){
|
||||
prioritized.push(unique[idx]);
|
||||
unique.splice(idx,1);
|
||||
}
|
||||
});
|
||||
const ordered=[...new Set([...prioritized, ...unique])];
|
||||
select.innerHTML="";
|
||||
const allOption=document.createElement("option");
|
||||
allOption.value="";
|
||||
allOption.textContent="All Channels";
|
||||
allOption.setAttribute("data-translate-lang","all_channels");
|
||||
select.appendChild(allOption);
|
||||
ordered.forEach(ch=>{
|
||||
const opt=document.createElement("option");
|
||||
opt.value=ch;
|
||||
opt.textContent=ch;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
let preferred=currentChannel;
|
||||
if(!preferred){
|
||||
preferred=CHANNEL_PRESETS.find(preset=>ordered.includes(preset))||"";
|
||||
}
|
||||
if(preferred && !ordered.includes(preferred)){
|
||||
preferred="";
|
||||
}
|
||||
select.value=preferred;
|
||||
currentChannel=select.value;
|
||||
}
|
||||
|
||||
function setPortChart(port, chart){
|
||||
switch(port){
|
||||
case 1: chartPortnum1 = chart; break;
|
||||
case 3: chartPortnum3 = chart; break;
|
||||
case 4: chartPortnum4 = chart; break;
|
||||
case 67: chartPortnum67 = chart; break;
|
||||
case 70: chartPortnum70 = chart; break;
|
||||
case 71: chartPortnum71 = chart; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
|
||||
let chartDailyAll, chartDailyPortnum1;
|
||||
let chartHwModel, chartRole, chartChannel;
|
||||
let chartPacketTypes;
|
||||
let isRefreshing=false;
|
||||
|
||||
async function refreshDashboard(){
|
||||
if(isRefreshing) return;
|
||||
isRefreshing=true;
|
||||
const channel=currentChannel||null;
|
||||
try{
|
||||
const [summary, dailyAllData, dailyPort1Data, hourlyAllData, portDataSets, nodes, packetTypesData] = await Promise.all([
|
||||
fetchSummary(channel),
|
||||
fetchStats('day',14,null,channel),
|
||||
fetchStats('day',14,1,channel),
|
||||
fetchStats('hour',24,null,channel),
|
||||
Promise.all(PORT_CONFIG.map(cfg=>fetchStats('hour',24,cfg.port,channel))),
|
||||
fetchNodes(channel),
|
||||
fetchPacketTypeBreakdown(channel)
|
||||
]);
|
||||
|
||||
updateSummaryCards(summary);
|
||||
|
||||
updateTotalCount('total_daily_all',dailyAllData);
|
||||
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
|
||||
|
||||
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
|
||||
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
|
||||
|
||||
updateTotalCount('total_hourly_all',hourlyAllData);
|
||||
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
|
||||
|
||||
PORT_CONFIG.forEach((cfg,idx)=>{
|
||||
const data=portDataSets?.[idx]||[];
|
||||
updateTotalCount(cfg.totalId,data);
|
||||
const chart=renderChart(cfg.domId,data,'bar',cfg.color);
|
||||
setPortChart(cfg.port,chart);
|
||||
});
|
||||
|
||||
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
|
||||
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
|
||||
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
|
||||
setNodeTableData(nodes);
|
||||
|
||||
const formatted=(packetTypesData||[]).filter(d=>d.count>0).map(d=>({
|
||||
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
|
||||
value: d.count
|
||||
}));
|
||||
chartPacketTypes=renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
|
||||
} finally {
|
||||
isRefreshing=false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init(){
|
||||
const channels = await fetchChannels();
|
||||
populateChannelSelect(channels);
|
||||
const select=document.getElementById("channelSelect");
|
||||
if(select && !select.dataset.listenerAttached){
|
||||
select.addEventListener("change",async e=>{
|
||||
currentChannel=e.target.value;
|
||||
await refreshDashboard();
|
||||
});
|
||||
select.dataset.listenerAttached="true";
|
||||
}
|
||||
document.querySelectorAll("#nodesTable thead th[data-sort-key]").forEach(th=>{
|
||||
if(!th.dataset.listenerAttached){
|
||||
th.addEventListener("click",handleNodeTableSort);
|
||||
th.dataset.listenerAttached="true";
|
||||
}
|
||||
});
|
||||
await refreshDashboard();
|
||||
const select = document.getElementById("channelSelect");
|
||||
channels.forEach(ch=>{ const opt = document.createElement("option"); opt.value = ch; opt.textContent = ch; select.appendChild(opt); });
|
||||
|
||||
const dailyAllData=await fetchStats('day',14);
|
||||
updateTotalCount('total_daily_all',dailyAllData);
|
||||
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
|
||||
|
||||
const dailyPort1Data=await fetchStats('day',14,1);
|
||||
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
|
||||
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
|
||||
|
||||
const hourlyAllData=await fetchStats('hour',24);
|
||||
updateTotalCount('total_hourly_all',hourlyAllData);
|
||||
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
|
||||
|
||||
const portnums=[1,3,4,67,70,71];
|
||||
const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50'];
|
||||
const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71'];
|
||||
const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71'];
|
||||
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
|
||||
for(let i=0;i<portnums.length;i++){ updateTotalCount(totalIds[i],allData[i]); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]); }
|
||||
|
||||
const nodes=await fetchNodes();
|
||||
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
|
||||
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
|
||||
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
|
||||
|
||||
const packetTypesData = await fetchPacketTypeBreakdown();
|
||||
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
|
||||
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
|
||||
}
|
||||
|
||||
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
|
||||
@@ -735,6 +342,14 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
|
||||
const channel = e.target.value;
|
||||
const packetTypesData = await fetchPacketTypeBreakdown(channel);
|
||||
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
|
||||
chartPacketTypes?.dispose();
|
||||
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
|
||||
});
|
||||
|
||||
init();
|
||||
|
||||
// --- Translation Loader ---
|
||||
|
||||
343
meshview/web.py
343
meshview/web.py
@@ -30,20 +30,9 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SEQ_REGEX = re.compile(r"seq \d+")
|
||||
SOFTWARE_RELEASE = "2.0.7 ~ 09-17-25"
|
||||
SOFTWARE_RELEASE = "2.0.7 ~ 10-17-25"
|
||||
CONFIG = config.CONFIG
|
||||
|
||||
ACTIVITY_FILTERS = [
|
||||
("1h", "Last 1 hour", timedelta(hours=1)),
|
||||
("8h", "Last 8 hours", timedelta(hours=8)),
|
||||
("1d", "Last 1 day", timedelta(days=1)),
|
||||
("3d", "Last 3 days", timedelta(days=3)),
|
||||
("7d", "Last 7 days", timedelta(days=7)),
|
||||
("total", "All time", None),
|
||||
]
|
||||
ACTIVITY_OPTIONS = {value: window for value, _label, window in ACTIVITY_FILTERS}
|
||||
DEFAULT_ACTIVITY_OPTION = "1d"
|
||||
|
||||
env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape())
|
||||
|
||||
# Start Database
|
||||
@@ -197,17 +186,6 @@ def format_timestamp(timestamp):
|
||||
env.filters["node_id_to_hex"] = node_id_to_hex
|
||||
env.filters["format_timestamp"] = format_timestamp
|
||||
|
||||
|
||||
def resolve_activity_window(
|
||||
raw_value: str | None, default_key: str = DEFAULT_ACTIVITY_OPTION
|
||||
):
|
||||
default_key = default_key if default_key in ACTIVITY_OPTIONS else DEFAULT_ACTIVITY_OPTION
|
||||
if raw_value:
|
||||
normalized = raw_value.strip().lower()
|
||||
if normalized in ACTIVITY_OPTIONS:
|
||||
return normalized, ACTIVITY_OPTIONS[normalized]
|
||||
return default_key, ACTIVITY_OPTIONS[default_key]
|
||||
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
|
||||
@@ -447,27 +425,15 @@ async def packet_details(request):
|
||||
|
||||
@routes.get("/firehose")
|
||||
async def packet_details_firehose(request):
|
||||
portnum_value = request.query.get("portnum")
|
||||
channel = request.query.get("channel")
|
||||
|
||||
portnum = None
|
||||
if portnum_value:
|
||||
try:
|
||||
portnum = int(portnum_value)
|
||||
except ValueError:
|
||||
logger.warning("Invalid portnum '%s' provided to /firehose", portnum_value)
|
||||
|
||||
packets = await store.get_packets(portnum=portnum, limit=10, channel=channel)
|
||||
ui_packets = [Packet.from_model(p) for p in packets]
|
||||
latest_time = ui_packets[0].import_time.isoformat() if ui_packets else None
|
||||
|
||||
portnum = request.query.get("portnum")
|
||||
if portnum:
|
||||
portnum = int(portnum)
|
||||
packets = await store.get_packets(portnum=portnum, limit=10)
|
||||
template = env.get_template("firehose.html")
|
||||
return web.Response(
|
||||
text=template.render(
|
||||
packets=ui_packets,
|
||||
packets=(Packet.from_model(p) for p in packets),
|
||||
portnum=portnum,
|
||||
channel=channel,
|
||||
last_time=latest_time,
|
||||
site_config=CONFIG,
|
||||
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
|
||||
),
|
||||
@@ -488,18 +454,8 @@ async def firehose_updates(request):
|
||||
logger.error(f"Failed to parse last_time '{last_time_str}': {e}")
|
||||
last_time = None
|
||||
|
||||
portnum_value = request.query.get("portnum")
|
||||
channel = request.query.get("channel")
|
||||
|
||||
portnum = None
|
||||
if portnum_value:
|
||||
try:
|
||||
portnum = int(portnum_value)
|
||||
except ValueError:
|
||||
logger.warning("Invalid portnum '%s' provided to /firehose/updates", portnum_value)
|
||||
|
||||
# Query packets after last_time (microsecond precision)
|
||||
packets = await store.get_packets(after=last_time, limit=10, portnum=portnum, channel=channel)
|
||||
packets = await store.get_packets(after=last_time, limit=10)
|
||||
|
||||
# Convert to UI model
|
||||
ui_packets = [Packet.from_model(p) for p in packets]
|
||||
@@ -1203,24 +1159,6 @@ async def net(request):
|
||||
@routes.get("/map")
|
||||
async def map(request):
|
||||
try:
|
||||
activity_param = request.query.get("active")
|
||||
if not activity_param:
|
||||
legacy_days = request.query.get("days_active")
|
||||
if legacy_days and legacy_days.isdigit():
|
||||
activity_param = "total" if legacy_days == "0" else f"{legacy_days}d"
|
||||
|
||||
selected_activity, activity_window = resolve_activity_window(activity_param)
|
||||
|
||||
nodes = await store.get_nodes(active_within=activity_window)
|
||||
|
||||
# Filter out nodes with no latitude
|
||||
nodes = [node for node in nodes if node.last_lat is not None]
|
||||
|
||||
# Optional datetime formatting
|
||||
for node in nodes:
|
||||
if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime):
|
||||
node.last_update = node.last_update.isoformat()
|
||||
|
||||
# Parse optional URL parameters for custom view
|
||||
map_center_lat = request.query.get("lat")
|
||||
map_center_lng = request.query.get("lng")
|
||||
@@ -1242,21 +1180,18 @@ async def map(request):
|
||||
|
||||
return web.Response(
|
||||
text=template.render(
|
||||
nodes=nodes,
|
||||
custom_view=custom_view,
|
||||
activity_filters=ACTIVITY_FILTERS,
|
||||
selected_activity=selected_activity,
|
||||
site_config=CONFIG,
|
||||
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
|
||||
custom_view=custom_view,
|
||||
),
|
||||
content_type="text/html",
|
||||
)
|
||||
except Exception:
|
||||
return web.Response(
|
||||
text="An error occurred while processing your request.",
|
||||
status=500,
|
||||
content_type="text/plain",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"/map route error: {e}")
|
||||
return web.Response(
|
||||
text="An error occurred while processing your request.",
|
||||
status=500,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@routes.get("/stats")
|
||||
@@ -1384,26 +1319,22 @@ async def chat(request):
|
||||
# Assuming the route URL structure is /nodegraph
|
||||
@routes.get("/nodegraph")
|
||||
async def nodegraph(request):
|
||||
activity_param = request.query.get("active")
|
||||
if not activity_param:
|
||||
legacy_days = request.query.get("days_active")
|
||||
if legacy_days and legacy_days.isdigit():
|
||||
activity_param = "total" if legacy_days == "0" else f"{legacy_days}d"
|
||||
|
||||
selected_activity, activity_window = resolve_activity_window(activity_param)
|
||||
|
||||
nodes = await store.get_nodes(active_within=activity_window)
|
||||
|
||||
active_node_ids = {node.node_id for node in nodes}
|
||||
nodes = await store.get_nodes(days_active=3) # Fetch nodes for the given channel
|
||||
node_ids = set()
|
||||
edges_map = defaultdict(
|
||||
lambda: {"weight": 0, "type": None}
|
||||
) # weight is based on the number of traceroutes and neighbor info packets
|
||||
used_nodes = set() # This will track nodes involved in edges (including traceroutes)
|
||||
since = datetime.timedelta(hours=48)
|
||||
traceroutes = []
|
||||
|
||||
# Fetch traceroutes
|
||||
async for tr in store.get_traceroutes(since):
|
||||
node_ids.add(tr.gateway_node_id)
|
||||
node_ids.add(tr.packet.from_node_id)
|
||||
node_ids.add(tr.packet.to_node_id)
|
||||
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
||||
node_ids.update(route.route)
|
||||
|
||||
path = [tr.packet.from_node_id]
|
||||
path.extend(route.route)
|
||||
@@ -1419,12 +1350,18 @@ async def nodegraph(request):
|
||||
edge_pair = (path[i], path[i + 1])
|
||||
edges_map[edge_pair]["weight"] += 1
|
||||
edges_map[edge_pair]["type"] = "traceroute"
|
||||
used_nodes.add(path[i]) # Add all nodes in the traceroute path
|
||||
used_nodes.add(path[i + 1]) # Add all nodes in the traceroute path
|
||||
|
||||
# Fetch NeighborInfo packets
|
||||
for packet in await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since):
|
||||
try:
|
||||
_, neighbor_info = decode_payload.decode(packet)
|
||||
node_ids.add(packet.from_node_id)
|
||||
used_nodes.add(packet.from_node_id)
|
||||
for node in neighbor_info.neighbors:
|
||||
node_ids.add(node.node_id)
|
||||
used_nodes.add(node.node_id)
|
||||
|
||||
edge_pair = (node.node_id, packet.from_node_id)
|
||||
edges_map[edge_pair]["weight"] += 1
|
||||
@@ -1433,16 +1370,7 @@ async def nodegraph(request):
|
||||
logger.error(f"Error decoding NeighborInfo packet: {e}")
|
||||
|
||||
# Convert edges_map to a list of dicts with colors
|
||||
filtered_edge_items = [
|
||||
((frm, to), info)
|
||||
for (frm, to), info in edges_map.items()
|
||||
if frm in active_node_ids and to in active_node_ids
|
||||
]
|
||||
max_weight = (
|
||||
max(info["weight"] for _, info in filtered_edge_items)
|
||||
if filtered_edge_items
|
||||
else 1
|
||||
)
|
||||
max_weight = max(i['weight'] for i in edges_map.values()) if edges_map else 1
|
||||
edges = [
|
||||
{
|
||||
"from": frm,
|
||||
@@ -1450,53 +1378,23 @@ async def nodegraph(request):
|
||||
"type": info["type"],
|
||||
"weight": max([info['weight'] / float(max_weight) * 10, 1]),
|
||||
}
|
||||
for (frm, to), info in filtered_edge_items
|
||||
for (frm, to), info in edges_map.items()
|
||||
]
|
||||
|
||||
# Filter nodes to only include those involved in edges (including traceroutes)
|
||||
connected_node_ids = {
|
||||
node_id for edge in edges for node_id in (edge["from"], edge["to"])
|
||||
}
|
||||
nodes_with_edges = [node for node in nodes if node.node_id in connected_node_ids]
|
||||
nodes_with_edges = [node for node in nodes if node.node_id in used_nodes]
|
||||
|
||||
template = env.get_template("nodegraph.html")
|
||||
return web.Response(
|
||||
text=template.render(
|
||||
nodes=nodes_with_edges,
|
||||
edges=edges, # Pass edges with color info
|
||||
activity_filters=ACTIVITY_FILTERS,
|
||||
selected_activity=selected_activity,
|
||||
site_config=CONFIG,
|
||||
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
|
||||
),
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
|
||||
# Show basic details about the site on the site
|
||||
@routes.get("/config")
|
||||
async def get_config(request):
|
||||
try:
|
||||
site = CONFIG.get("site", {})
|
||||
mqtt = CONFIG.get("mqtt", {})
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"Server": site.get("domain", ""),
|
||||
"Title": site.get("title", ""),
|
||||
"Message": site.get("message", ""),
|
||||
"MQTT Server": mqtt.get("server", ""),
|
||||
"Topics": json.loads(mqtt.get("topics", "[]")),
|
||||
"Release": SOFTWARE_RELEASE,
|
||||
"Time": datetime.datetime.now().isoformat(),
|
||||
},
|
||||
dumps=lambda obj: json.dumps(obj, indent=2),
|
||||
)
|
||||
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return web.json_response({"error": "Invalid configuration format"}, status=500)
|
||||
|
||||
|
||||
# API Section
|
||||
#######################################################################
|
||||
# How this works
|
||||
@@ -1544,7 +1442,6 @@ async def api_chat(request):
|
||||
# Parse query params
|
||||
limit_str = request.query.get("limit", "20")
|
||||
since_str = request.query.get("since")
|
||||
channel_filter = request.query.get("channel")
|
||||
|
||||
# Clamp limit between 1 and 200
|
||||
try:
|
||||
@@ -1564,9 +1461,7 @@ async def api_chat(request):
|
||||
packets = await store.get_packets(
|
||||
node_id=0xFFFFFFFF,
|
||||
portnum=PortNum.TEXT_MESSAGE_APP,
|
||||
after=since,
|
||||
limit=limit,
|
||||
channel=channel_filter,
|
||||
)
|
||||
|
||||
ui_packets = [Packet.from_model(p) for p in packets]
|
||||
@@ -1636,20 +1531,17 @@ async def api_nodes(request):
|
||||
role = request.query.get("role")
|
||||
channel = request.query.get("channel")
|
||||
hw_model = request.query.get("hw_model")
|
||||
activity_param = request.query.get("active")
|
||||
if not activity_param:
|
||||
legacy_days = request.query.get("days_active")
|
||||
if legacy_days and legacy_days.isdigit():
|
||||
activity_param = "total" if legacy_days == "0" else f"{legacy_days}d"
|
||||
days_active = request.query.get("days_active")
|
||||
|
||||
_, activity_window = resolve_activity_window(activity_param, default_key="total")
|
||||
if days_active:
|
||||
try:
|
||||
days_active = int(days_active)
|
||||
except ValueError:
|
||||
days_active = None
|
||||
|
||||
# Fetch nodes from database using your get_nodes function
|
||||
nodes = await store.get_nodes(
|
||||
role=role,
|
||||
channel=channel,
|
||||
hw_model=hw_model,
|
||||
active_within=activity_window,
|
||||
role=role, channel=channel, hw_model=hw_model, days_active=days_active
|
||||
)
|
||||
|
||||
# Prepare the JSON response
|
||||
@@ -1685,19 +1577,6 @@ async def api_packets(request):
|
||||
limit = int(request.query.get("limit", 50))
|
||||
since_str = request.query.get("since")
|
||||
since_time = None
|
||||
channel_values = []
|
||||
|
||||
# Support repeated ?channel=foo&channel=bar and comma-separated values
|
||||
if "channel" in request.query:
|
||||
raw_channels = request.query.getall("channel", [])
|
||||
if not raw_channels:
|
||||
raw_value = request.query.get("channel")
|
||||
if raw_value:
|
||||
raw_channels = [raw_value]
|
||||
for raw in raw_channels:
|
||||
if raw:
|
||||
parts = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
channel_values.extend(parts)
|
||||
|
||||
# Parse 'since' timestamp if provided
|
||||
if since_str:
|
||||
@@ -1707,14 +1586,7 @@ async def api_packets(request):
|
||||
logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}")
|
||||
|
||||
# Fetch last N packets
|
||||
if not channel_values:
|
||||
channel_filter = None
|
||||
elif len(channel_values) == 1:
|
||||
channel_filter = channel_values[0]
|
||||
else:
|
||||
channel_filter = channel_values
|
||||
|
||||
packets = await store.get_packets(limit=limit, after=since_time, channel=channel_filter)
|
||||
packets = await store.get_packets(limit=limit, after=since_time)
|
||||
packets = [Packet.from_model(p) for p in packets]
|
||||
|
||||
# Build JSON response (no raw_payload)
|
||||
@@ -1790,41 +1662,6 @@ async def api_stats(request):
|
||||
return web.json_response(stats)
|
||||
|
||||
|
||||
@routes.get("/api/stats/summary")
|
||||
async def api_stats_summary(request):
|
||||
channel = request.query.get("channel") or None
|
||||
total_packets = await store.get_total_packet_count(channel=channel)
|
||||
total_nodes = await store.get_total_node_count(channel=channel)
|
||||
total_packets_seen = await store.get_total_packet_seen_count(channel=channel)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"total_packets": total_packets,
|
||||
"total_nodes": total_nodes,
|
||||
"total_packets_seen": total_packets_seen,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@routes.get("/api/config")
|
||||
async def api_config(request):
|
||||
try:
|
||||
site = CONFIG.get("site", {})
|
||||
safe_site = {
|
||||
"map_interval": site.get("map_interval", 3),
|
||||
"firehose_interval": site.get("firehose_interval", 3),
|
||||
"map_top_left_lat": site.get("map_top_left_lat", 3),
|
||||
"map_top_left_lon": site.get("map_top_left_lon", 3),
|
||||
"map_bottom_right_lat": site.get("map_bottom_right_lat", 3),
|
||||
"map_bottom_right_lon": site.get("map_bottom_right_lon", 3),
|
||||
}
|
||||
safe_config = {"site": safe_site}
|
||||
|
||||
return web.json_response(safe_config)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.get("/api/edges")
|
||||
async def api_edges(request):
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=48)
|
||||
@@ -1867,6 +1704,106 @@ async def api_edges(request):
|
||||
|
||||
return web.json_response({"edges": edges_list})
|
||||
|
||||
@routes.get("/api/config")
|
||||
async def api_config(request):
|
||||
try:
|
||||
# ------------------ Helpers ------------------
|
||||
def get(section, key, default=None):
|
||||
"""Safe getter for both dict and ConfigParser."""
|
||||
if isinstance(section, dict):
|
||||
return section.get(key, default)
|
||||
return section.get(key, fallback=default)
|
||||
|
||||
def get_bool(section, key, default=False):
|
||||
val = get(section, key, default)
|
||||
if isinstance(val, bool):
|
||||
return "true" if val else "false"
|
||||
if isinstance(val, str):
|
||||
return "true" if val.lower() in ("1", "true", "yes", "on") else "false"
|
||||
return "true" if bool(val) else "false"
|
||||
|
||||
def get_float(section, key, default=0.0):
|
||||
try:
|
||||
return float(get(section, key, default))
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
def get_int(section, key, default=0):
|
||||
try:
|
||||
return int(get(section, key, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def get_str(section, key, default=""):
|
||||
val = get(section, key, default)
|
||||
return str(val) if val is not None else str(default)
|
||||
|
||||
# ------------------ SITE ------------------
|
||||
site = CONFIG.get("site", {})
|
||||
safe_site = {
|
||||
"domain": get_str(site, "domain", ""),
|
||||
"language": get_str(site, "language", "en"),
|
||||
"title": get_str(site, "title", ""),
|
||||
"message": get_str(site, "message", ""),
|
||||
"starting": get_str(site, "starting", "/chat"),
|
||||
"nodes": get_bool(site, "nodes", True),
|
||||
"conversations": get_bool(site, "conversations", True),
|
||||
"everything": get_bool(site, "everything", True),
|
||||
"graphs": get_bool(site, "graphs", True),
|
||||
"stats": get_bool(site, "stats", True),
|
||||
"net": get_bool(site, "net", True),
|
||||
"map": get_bool(site, "map", True),
|
||||
"top": get_bool(site, "top", True),
|
||||
"map_top_left_lat": get_float(site, "map_top_left_lat", 39.0),
|
||||
"map_top_left_lon": get_float(site, "map_top_left_lon", -123.0),
|
||||
"map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
|
||||
"map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
|
||||
"map_interval": get_int(site, "map_interval", 3),
|
||||
"firehose_interval": get_int(site, "firehose_interval", 3),
|
||||
"weekly_net_message": get_str(site, "weekly_net_message", "Weekly Mesh check-in message."),
|
||||
"net_tag": get_str(site, "net_tag", "#BayMeshNet"),
|
||||
"version": str(SOFTWARE_RELEASE),
|
||||
}
|
||||
|
||||
# ------------------ MQTT ------------------
|
||||
mqtt = CONFIG.get("mqtt", {})
|
||||
topics_raw = get(mqtt, "topics", [])
|
||||
import json
|
||||
if isinstance(topics_raw, str):
|
||||
try:
|
||||
topics = json.loads(topics_raw)
|
||||
except Exception:
|
||||
topics = [topics_raw]
|
||||
elif isinstance(topics_raw, list):
|
||||
topics = topics_raw
|
||||
else:
|
||||
topics = []
|
||||
|
||||
safe_mqtt = {
|
||||
"server": get_str(mqtt, "server", ""),
|
||||
"topics": topics,
|
||||
}
|
||||
|
||||
# ------------------ CLEANUP ------------------
|
||||
cleanup = CONFIG.get("cleanup", {})
|
||||
safe_cleanup = {
|
||||
"enabled": get_bool(cleanup, "enabled", False),
|
||||
"days_to_keep": get_str(cleanup, "days_to_keep", "14"),
|
||||
"hour": get_str(cleanup, "hour", "2"),
|
||||
"minute": get_str(cleanup, "minute", "0"),
|
||||
"vacuum": get_bool(cleanup, "vacuum", False),
|
||||
}
|
||||
|
||||
safe_config = {
|
||||
"site": safe_site,
|
||||
"mqtt": safe_mqtt,
|
||||
"cleanup": safe_cleanup,
|
||||
}
|
||||
|
||||
return web.json_response(safe_config)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.get("/api/lang")
|
||||
async def api_lang(request):
|
||||
|
||||
Reference in New Issue
Block a user