mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-10 19:03:14 +02:00
Add channel-aware activity filters and API-driven dashboards
This commit is contained in:
+14
-15
@@ -24,7 +24,14 @@ 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 | None = None):
|
||||
async def get_packets(
|
||||
node_id=None,
|
||||
portnum=None,
|
||||
after=None,
|
||||
before=None,
|
||||
limit=None,
|
||||
channel: str | list[str] | tuple[str, ...] | None = None,
|
||||
):
|
||||
async with database.async_session() as session:
|
||||
q = select(Packet)
|
||||
|
||||
@@ -37,7 +44,12 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit
|
||||
if before:
|
||||
q = q.where(Packet.import_time < before)
|
||||
if channel:
|
||||
q = q.where(func.lower(Packet.channel) == channel.lower())
|
||||
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())
|
||||
|
||||
@@ -372,19 +384,6 @@ async def get_packet_stats(
|
||||
}
|
||||
|
||||
|
||||
async def get_all_channels():
|
||||
async with database.async_session() as session:
|
||||
stmt = (
|
||||
select(Node.channel)
|
||||
.where(Node.channel.is_not(None))
|
||||
.where(Node.channel != "")
|
||||
.distinct()
|
||||
.order_by(Node.channel.asc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return [row[0] for row in result]
|
||||
|
||||
|
||||
async def get_channels_in_period(period_type: str = "hour", length: int = 24):
|
||||
"""
|
||||
Returns a list of distinct channels used in packets over a given period.
|
||||
|
||||
+116
-78
@@ -115,7 +115,7 @@ async function loadTranslations() {
|
||||
}
|
||||
|
||||
// Initialize map AFTER translations are loaded
|
||||
loadTranslations().then(() => {
|
||||
loadTranslations().then(async () => {
|
||||
const t = window.mapTranslations || {};
|
||||
const activitySelect = document.getElementById("activity-range");
|
||||
const activityLabel = document.getElementById("activity-range-label");
|
||||
@@ -163,8 +163,8 @@ loadTranslations().then(() => {
|
||||
}{{ "," if not loop.last else "" }}
|
||||
{% endfor %}
|
||||
];
|
||||
const providedChannels = {{ all_channels | default([], true) | tojson }};
|
||||
const channelSet = new Set(providedChannels.filter(ch => ch));
|
||||
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"};
|
||||
|
||||
@@ -199,6 +199,19 @@ loadTranslations().then(() => {
|
||||
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); }
|
||||
@@ -245,7 +258,9 @@ loadTranslations().then(() => {
|
||||
if (customView) map.setView([customView.lat,customView.lng],customView.zoom);
|
||||
else map.fitBounds(areaBounds);
|
||||
|
||||
const channelList = Array.from(channelSet).sort();
|
||||
const extraChannels = await fetchAdditionalChannels();
|
||||
extraChannels.forEach(raw => channelSet.add(channelKey(raw)));
|
||||
channelList = Array.from(channelSet).sort();
|
||||
|
||||
// ---- LocalStorage for Filter Preferences ----
|
||||
const FILTER_STORAGE_KEY = 'meshview_map_filters';
|
||||
@@ -272,16 +287,13 @@ loadTranslations().then(() => {
|
||||
});
|
||||
|
||||
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters));
|
||||
console.log('Filters saved to localStorage:', filters);
|
||||
}
|
||||
|
||||
function loadFiltersFromLocalStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const filters = JSON.parse(stored);
|
||||
console.log('Filters loaded from localStorage:', filters);
|
||||
return filters;
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading filters from localStorage:', error);
|
||||
@@ -289,25 +301,34 @@ loadTranslations().then(() => {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
console.log('Filters reset to defaults');
|
||||
|
||||
// Reset routers only filter
|
||||
document.getElementById("filter-routers-only").checked = false;
|
||||
|
||||
// Reset all channel filters to checked (default)
|
||||
channelList.forEach(channel => {
|
||||
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
let checkbox = document.getElementById(filterId);
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
renderChannelFilters(null);
|
||||
updateMarkers();
|
||||
|
||||
// Show feedback to user
|
||||
const button = document.getElementById('reset-filters-button');
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '✓ Filters Reset!';
|
||||
@@ -319,35 +340,18 @@ loadTranslations().then(() => {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.resetFiltersToDefaults = resetFiltersToDefaults;
|
||||
|
||||
// ---- Filters ----
|
||||
const filterLabel = document.getElementById("filter-routers-label");
|
||||
filterLabel.textContent = t.show_routers_only || "Show Routers Only";
|
||||
|
||||
let filterContainer = document.getElementById("filter-container");
|
||||
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.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
|
||||
filterContainer.appendChild(label);
|
||||
});
|
||||
|
||||
// Load saved filters from localStorage
|
||||
const routersOnlyCheckbox = document.getElementById("filter-routers-only");
|
||||
const savedFilters = loadFiltersFromLocalStorage();
|
||||
if (savedFilters) {
|
||||
// Apply routers only filter
|
||||
document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false;
|
||||
|
||||
// Apply channel filters
|
||||
channelList.forEach(channel => {
|
||||
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
let checkbox = document.getElementById(filterId);
|
||||
if (checkbox && savedFilters.channels.hasOwnProperty(channel)) {
|
||||
checkbox.checked = savedFilters.channels[channel];
|
||||
}
|
||||
});
|
||||
routersOnlyCheckbox.checked = savedFilters.routersOnly || false;
|
||||
}
|
||||
routersOnlyCheckbox.addEventListener("change", updateMarkers);
|
||||
renderChannelFilters(savedFilters);
|
||||
|
||||
function updateMarkers() {
|
||||
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
|
||||
@@ -363,11 +367,17 @@ loadTranslations().then(() => {
|
||||
saveFiltersToLocalStorage();
|
||||
|
||||
if (!document.hidden) {
|
||||
restartPacketFetcher();
|
||||
restartPacketFetcher(true);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers));
|
||||
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();
|
||||
@@ -497,50 +507,78 @@ loadTranslations().then(() => {
|
||||
// ---- 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(){
|
||||
fetch(`/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(err));
|
||||
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;
|
||||
fetch(`/api/packets?since=${lastImportTime}`).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);
|
||||
}
|
||||
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);
|
||||
});
|
||||
if(latestSeen) lastImportTime=latestSeen;
|
||||
}).catch(err=>console.error(err));
|
||||
}
|
||||
let packetInterval=null;
|
||||
function startPacketFetcher(resetImportTime=true){
|
||||
if(mapInterval<=0) return;
|
||||
if(!packetInterval){
|
||||
if(resetImportTime || !lastImportTime){
|
||||
fetchLatestPacket();
|
||||
}
|
||||
packetInterval=setInterval(fetchNewPackets,mapInterval*1000);
|
||||
if(!resetImportTime && lastImportTime){
|
||||
fetchNewPackets();
|
||||
}
|
||||
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; } }
|
||||
function restartPacketFetcher(){
|
||||
async function restartPacketFetcher(resetImportTime=false){
|
||||
if(mapInterval<=0) return;
|
||||
stopPacketFetcher();
|
||||
if(document.hidden) return;
|
||||
startPacketFetcher(false);
|
||||
await startPacketFetcher(resetImportTime);
|
||||
}
|
||||
document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); });
|
||||
if(mapInterval>0) startPacketFetcher();
|
||||
document.addEventListener("visibilitychange",function(){
|
||||
if(document.hidden) stopPacketFetcher();
|
||||
else restartPacketFetcher(false);
|
||||
});
|
||||
if(mapInterval>0) startPacketFetcher(true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -130,10 +130,8 @@
|
||||
|
||||
<script>
|
||||
const activityRangeSelect = document.getElementById('activity-range');
|
||||
const availableChannelsFromServer = {{ all_channels | default([], true) | tojson }};
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const channelParam = urlParams.get('channel');
|
||||
let selectedChannel = {{ selected_channel | default('', true) | tojson }};
|
||||
let selectedChannel = urlParams.get('channel') || '';
|
||||
const hasChannelSelection = value => value !== null && value !== undefined && value !== '';
|
||||
if (activityRangeSelect) {
|
||||
activityRangeSelect.addEventListener('change', () => {
|
||||
@@ -231,42 +229,60 @@ let filteredNodes = [];
|
||||
let filteredEdges = [];
|
||||
let lastSelectedNode = null;
|
||||
const nodeChannelSet = [...new Set(nodes.map(n => n.channel).filter(Boolean))];
|
||||
const channelOptions = Array.from(new Set([
|
||||
...availableChannelsFromServer.filter(Boolean),
|
||||
...nodeChannelSet,
|
||||
])).sort();
|
||||
let channelOptions = [...nodeChannelSet].sort();
|
||||
|
||||
if (!selectedChannel || !channelOptions.includes(selectedChannel)) {
|
||||
if (channelParam && channelOptions.includes(channelParam)) {
|
||||
selectedChannel = channelParam;
|
||||
} else if (nodeChannelSet.length) {
|
||||
selectedChannel = nodeChannelSet[0];
|
||||
} else if (channelOptions.length) {
|
||||
async function fetchChannelOptionsFromAPI() {
|
||||
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 fetch failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeChannelOptions() {
|
||||
const fetched = await fetchChannelOptionsFromAPI();
|
||||
const merged = new Set([...nodeChannelSet, ...fetched]);
|
||||
if (hasChannelSelection(selectedChannel)) {
|
||||
merged.add(selectedChannel);
|
||||
}
|
||||
channelOptions = Array.from(merged).sort();
|
||||
if (!hasChannelSelection(selectedChannel) && channelOptions.length) {
|
||||
selectedChannel = channelOptions[0];
|
||||
} else {
|
||||
selectedChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 opt = document.createElement('option');
|
||||
opt.value=ch; opt.text=ch;
|
||||
if(selectedChannel && ch === selectedChannel) opt.selected=true;
|
||||
opt.value = ch;
|
||||
opt.text = ch;
|
||||
if (selectedChannel === ch) {
|
||||
opt.selected = true;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!selectedChannel && channelOptions.length) {
|
||||
selectedChannel = channelOptions[0];
|
||||
sel.value = selectedChannel;
|
||||
if (!hasChannelSelection(selectedChannel)) {
|
||||
sel.value = '';
|
||||
}
|
||||
filterByChannel(true);
|
||||
}
|
||||
|
||||
function filterByChannel(isInitial=false) {
|
||||
const sel = document.getElementById('channel-select');
|
||||
if (sel) {
|
||||
selectedChannel = sel.value || selectedChannel;
|
||||
selectedChannel = sel.value;
|
||||
}
|
||||
if (hasChannelSelection(selectedChannel)) {
|
||||
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
|
||||
@@ -348,7 +364,10 @@ function searchNode(){
|
||||
else alert("Node not found in current channel!");
|
||||
}
|
||||
|
||||
populateChannelDropdown();
|
||||
initializeChannelOptions().then(() => {
|
||||
populateChannelDropdown();
|
||||
filterByChannel(true);
|
||||
});
|
||||
window.addEventListener('resize', ()=>chart.resize());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -77,6 +77,69 @@
|
||||
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;
|
||||
@@ -190,9 +253,29 @@
|
||||
<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 -->
|
||||
@@ -230,6 +313,9 @@ const PORT_CONFIG = [
|
||||
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){
|
||||
@@ -299,6 +385,99 @@ function prepareTopN(data=[],n=20){
|
||||
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();
|
||||
}
|
||||
|
||||
// --- Chart Rendering ---
|
||||
function renderChart(domId,data,type,color){
|
||||
const el=document.getElementById(domId);
|
||||
@@ -465,6 +644,7 @@ async function refreshDashboard(){
|
||||
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}`),
|
||||
@@ -487,6 +667,12 @@ async function init(){
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
+21
-21
@@ -1212,7 +1212,6 @@ async def map(request):
|
||||
selected_activity, activity_window = resolve_activity_window(activity_param)
|
||||
|
||||
nodes = await store.get_nodes(active_within=activity_window)
|
||||
all_channels = await store.get_all_channels()
|
||||
|
||||
# Filter out nodes with no latitude
|
||||
nodes = [node for node in nodes if node.last_lat is not None]
|
||||
@@ -1247,8 +1246,6 @@ async def map(request):
|
||||
custom_view=custom_view,
|
||||
activity_filters=ACTIVITY_FILTERS,
|
||||
selected_activity=selected_activity,
|
||||
default_activity=DEFAULT_ACTIVITY_OPTION,
|
||||
all_channels=all_channels,
|
||||
site_config=CONFIG,
|
||||
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
|
||||
),
|
||||
@@ -1396,20 +1393,6 @@ async def nodegraph(request):
|
||||
selected_activity, activity_window = resolve_activity_window(activity_param)
|
||||
|
||||
nodes = await store.get_nodes(active_within=activity_window)
|
||||
all_channels = await store.get_all_channels()
|
||||
channel_param = request.query.get("channel")
|
||||
node_channel_candidates = sorted({node.channel for node in nodes if node.channel})
|
||||
|
||||
if channel_param and channel_param in node_channel_candidates:
|
||||
selected_channel = channel_param
|
||||
elif channel_param and channel_param in all_channels:
|
||||
selected_channel = channel_param
|
||||
elif node_channel_candidates:
|
||||
selected_channel = node_channel_candidates[0]
|
||||
elif all_channels:
|
||||
selected_channel = all_channels[0]
|
||||
else:
|
||||
selected_channel = None
|
||||
|
||||
active_node_ids = {node.node_id for node in nodes}
|
||||
edges_map = defaultdict(
|
||||
@@ -1483,9 +1466,6 @@ async def nodegraph(request):
|
||||
edges=edges, # Pass edges with color info
|
||||
activity_filters=ACTIVITY_FILTERS,
|
||||
selected_activity=selected_activity,
|
||||
default_activity=DEFAULT_ACTIVITY_OPTION,
|
||||
all_channels=all_channels,
|
||||
selected_channel=selected_channel,
|
||||
site_config=CONFIG,
|
||||
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
|
||||
),
|
||||
@@ -1685,6 +1665,19 @@ 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:
|
||||
@@ -1694,7 +1687,14 @@ async def api_packets(request):
|
||||
logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}")
|
||||
|
||||
# Fetch last N packets
|
||||
packets = await store.get_packets(limit=limit, after=since_time)
|
||||
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 = [Packet.from_model(p) for p in packets]
|
||||
|
||||
# Build JSON response (no raw_payload)
|
||||
|
||||
Reference in New Issue
Block a user