Add channel-aware activity filters and API-driven dashboards

This commit is contained in:
Nathan
2025-10-14 21:34:52 -07:00
parent b7752bc315
commit b35acde821
5 changed files with 380 additions and 138 deletions
+14 -15
View File
@@ -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
View File
@@ -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 %}
+42 -23
View File
@@ -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 %}
+187 -1
View File
@@ -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
View File
@@ -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)