nodelist now also works with API

This commit is contained in:
Pablo Revilla
2025-09-11 07:54:00 -07:00
parent d69a7959b8
commit 9c4171928a
3 changed files with 201 additions and 236 deletions
+1 -1
View File
@@ -231,4 +231,4 @@
{% endif %}
{% endblock %}
{% endblock %}
+164 -119
View File
@@ -19,6 +19,9 @@ th {
background-color: #1f1f1f;
color: white;
cursor: pointer;
position: sticky;
top: 0;
z-index: 2;
}
tr:nth-child(even) {
@@ -29,6 +32,10 @@ tr:nth-child(odd) {
background-color: #222;
}
tr:hover {
background-color: #2a2a2a;
}
.search-container {
display: flex;
gap: 10px;
@@ -70,147 +77,185 @@ tr:nth-child(odd) {
{% endblock %}
{% block body %}
<div id="node-list">
<div class="search-container">
<input class="search" placeholder="Search nodes..." />
<div id="node-list">
<div class="search-container">
<input class="search" placeholder="Search nodes..." />
<!-- Filter by Role -->
<select class="filter-role" onchange="applyFilters()">
<option value="">Filter by Role</option>
{% for node in nodes|groupby('role') %}
<option value="{{ node.grouper }}">{{ node.grouper }}</option>
{% endfor %}
</select>
<select class="filter-role" onchange="applyFilters()">
<option value="">Filter by Role</option>
</select>
<!-- Filter by Channel -->
<select class="filter-channel" onchange="applyFilters()">
<option value="">Filter by Channel</option>
{% for node in nodes|groupby('channel') %}
<option value="{{ node.grouper }}">{{ node.grouper }}</option>
{% endfor %}
</select>
<select class="filter-channel" onchange="applyFilters()">
<option value="">Filter by Channel</option>
</select>
<!-- Filter by HW Model -->
<select class="filter-hw_model" onchange="applyFilters()">
<option value="">Filter by HW Model</option>
{% for node in nodes|groupby('hw_model') %}
<option value="{{ node.grouper }}">{{ node.grouper }}</option>
{% endfor %}
</select>
<select class="filter-hw_model" onchange="applyFilters()">
<option value="">Filter by HW Model</option>
</select>
<button class="export-btn" onclick="exportToCSV()">Export to CSV</button>
</div>
<!-- Count Display -->
<div class="count-container">Showing <span id="node-count-value">0</span> nodes</div>
{% if nodes %}
<table id="node-table">
<thead>
<tr>
<th class="sort" data-sort="long_name">Long Name</th>
<th class="sort" data-sort="short_name">Short Name</th>
<th class="sort" data-sort="hw_model">HW Model</th>
<th class="sort" data-sort="firmware">Firmware</th>
<th class="sort" data-sort="role">Role</th>
<th class="sort" data-sort="last_lat">Last Latitude</th>
<th class="sort" data-sort="last_long">Last Longitude</th>
<th class="sort" data-sort="channel">Channel</th>
<th class="sort" data-sort="last_update" data-order="desc">Last Update</th>
</tr>
</thead>
<tbody class="list">
{% for node in nodes %}
<tr>
<td class="long_name"> <a href="/packet_list/{{ node.node_id }}">{{ node.long_name }}</a></td>
<td class="short_name">{{ node.short_name }}</td>
<td class="hw_model">{{ node.hw_model if node.hw_model else "N/A" }}</td>
<td class="firmware">{{ node.firmware if node.firmware else "N/A" }}</td>
<td class="role">{{ node.role if node.role else "N/A" }}</td>
<td class="last_lat">{{ "{:.7f}".format(node.last_lat / 10**7) if node.last_lat else "N/A" }}</td>
<td class="last_long">{{ "{:.7f}".format(node.last_long / 10**7) if node.last_long else "N/A" }}</td>
<td class="channel">{{ node.channel if node.channel else "N/A" }}</td>
<td class="last_update" data-timestamp="{{ node.last_update.timestamp() if node.last_update else 0 }}">
{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No nodes found.</p>
{% endif %}
<button class="export-btn" onclick="exportToCSV()">Export to CSV</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/list.js/2.3.1/list.min.js"></script>
<script>
var nodeList;
<div class="count-container">Showing <span id="node-count-value">0</span> nodes</div>
document.addEventListener("DOMContentLoaded", function () {
var options = {
<table id="node-table">
<thead>
<tr>
<th class="sort" data-sort="long_name">Long Name</th>
<th class="sort" data-sort="short_name">Short Name</th>
<th class="sort" data-sort="hw_model">HW Model</th>
<th class="sort" data-sort="firmware">Firmware</th>
<th class="sort" data-sort="role">Role</th>
<th class="sort" data-sort="last_lat">Last Latitude</th>
<th class="sort" data-sort="last_long">Last Longitude</th>
<th class="sort" data-sort="channel">Channel</th>
<th class="sort" data-sort="last_update" data-order="desc">Last Update</th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/list.js/2.3.1/list.min.js"></script>
<script>
let nodeList;
const searchInput = document.querySelector(".search");
const roleFilter = document.querySelector(".filter-role");
const channelFilter = document.querySelector(".filter-channel");
const hwFilter = document.querySelector(".filter-hw_model");
const countDisplay = document.getElementById("node-count-value");
const tbody = document.querySelector("#node-table tbody");
document.addEventListener("DOMContentLoaded", async function () {
try {
const response = await fetch("/api/nodes?days_active=3");
if (!response.ok) throw new Error("Failed to fetch nodes");
const data = await response.json();
// Insert rows
data.nodes.forEach(node => {
const row = document.createElement("tr");
row.innerHTML = `
<td class="long_name"><a href="/packet_list/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td class="short_name">${node.short_name || "N/A"}</td>
<td class="hw_model">${node.hw_model || "N/A"}</td>
<td class="firmware">${node.firmware || "N/A"}</td>
<td class="role">${node.role || "N/A"}</td>
<td class="last_lat">${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
<td class="last_long">${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td class="channel">${node.channel || "N/A"}</td>
<td class="last_update" data-timestamp="${node.last_update ? Date.parse(node.last_update) / 1000 : 0}">
${node.last_update ? new Date(node.last_update).toLocaleString() : "N/A"}
</td>
`;
tbody.appendChild(row);
});
populateFilters(data.nodes);
// Init list.js
nodeList = new List("node-list", {
valueNames: [
"long_name", "short_name", "hw_model", "firmware", "role",
"last_lat", "last_long", "channel", { name: "last_update", attr: "data-timestamp" }
"last_lat", "last_long", "channel",
{ name: "last_update", attr: "data-timestamp", type: "number" }
]
};
});
nodeList = new List("node-list", options);
// Default sort by last_update DESC
nodeList.sort("last_update", { order: "desc" });
updateCount();
nodeList.on("updated", updateCount);
nodeList.on("updated", function () {
updateCount();
});
searchInput.addEventListener("input", applyFilters);
} catch (err) {
tbody.innerHTML = `<tr><td colspan="9" style="color:red; text-align:center;">Error loading nodes: ${err.message}</td></tr>`;
}
});
function populateFilters(nodes) {
const sets = {
role: new Set(),
channel: new Set(),
hw: new Set()
};
nodes.forEach(n => {
if (n.role) sets.role.add(n.role);
if (n.channel) sets.channel.add(n.channel);
if (n.hw_model) sets.hw.add(n.hw_model);
});
function applyFilters() {
var selectedRole = document.querySelector(".filter-role").value;
var selectedChannel = document.querySelector(".filter-channel").value;
var selectedHWModel = document.querySelector(".filter-hw_model").value;
fillSelect(roleFilter, [...sets.role]);
fillSelect(channelFilter, [...sets.channel]);
fillSelect(hwFilter, [...sets.hw]);
}
nodeList.filter(function (item) {
var matchesRole = selectedRole === "" || item.values().role === selectedRole;
var matchesChannel = selectedChannel === "" || item.values().channel === selectedChannel;
var matchesHWModel = selectedHWModel === "" || item.values().hw_model === selectedHWModel;
return matchesRole && matchesChannel && matchesHWModel;
});
function fillSelect(select, values) {
values.forEach(v => {
const option = document.createElement("option");
option.value = v;
option.textContent = v;
select.appendChild(option);
});
}
updateCount();
}
function applyFilters() {
const selectedRole = roleFilter.value.toLowerCase();
const selectedChannel = channelFilter.value.toLowerCase();
const selectedHW = hwFilter.value.toLowerCase();
const searchText = searchInput.value.toLowerCase();
function updateCount() {
var visibleRows = document.querySelectorAll("#node-table tbody tr:not([style*='display: none'])").length;
document.getElementById("node-count-value").innerText = visibleRows;
}
nodeList.filter(item => {
const values = item.values();
const matchesRole = !selectedRole || (values.role || "").toLowerCase() === selectedRole;
const matchesChannel = !selectedChannel || (values.channel || "").toLowerCase() === selectedChannel;
const matchesHW = !selectedHW || (values.hw_model || "").toLowerCase() === selectedHW;
const matchesSearch = !searchText || Object.values(values).some(v =>
(v || "").toString().toLowerCase().includes(searchText)
);
return matchesRole && matchesChannel && matchesHW && matchesSearch;
});
function exportToCSV() {
var table = document.getElementById("node-table");
var rows = table.querySelectorAll("tr");
var csvContent = [];
updateCount();
}
var headers = [];
table.querySelectorAll("th").forEach(th => headers.push(th.innerText));
csvContent.push(headers.join(","));
function updateCount() {
const visibleRows = document.querySelectorAll("#node-table tbody tr:not([style*='display: none'])").length;
countDisplay.innerText = visibleRows;
}
rows.forEach(row => {
if (row.style.display !== "none") {
var cells = row.querySelectorAll("td");
if (cells.length > 0) {
var rowData = [];
cells.forEach(cell => {
rowData.push('"' + cell.innerText.replace(/"/g, '""') + '"');
});
csvContent.push(rowData.join(","));
}
function exportToCSV() {
const table = document.getElementById("node-table");
const rows = table.querySelectorAll("tr");
const csvContent = [];
const headers = [];
table.querySelectorAll("th").forEach(th => headers.push(th.innerText));
csvContent.push(headers.join(","));
rows.forEach(row => {
if (row.style.display !== "none") {
const cells = row.querySelectorAll("td");
if (cells.length > 0) {
const rowData = [];
cells.forEach(cell => {
rowData.push('"' + cell.innerText.replace(/"/g, '""') + '"');
});
csvContent.push(rowData.join(","));
}
});
}
});
var csvString = csvContent.join("\n");
var blob = new Blob([csvString], { type: "text/csv" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "nodes_list.csv";
a.click();
}
</script>
const csvString = csvContent.join("\n");
const blob = new Blob(["\uFEFF" + csvString], { type: "text/csv;charset=utf-8;" });
const dateStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `nodes_list_${dateStr}.csv`;
a.click();
}
</script>
{% endblock %}
+36 -116
View File
@@ -1054,94 +1054,24 @@ async def graph_network(request):
@routes.get("/nodelist")
async def nodelist(request):
try:
role = request.query.get("role")
channel = request.query.get("channel")
hw_model = request.query.get("hw_model")
nodes= await store.get_nodes(role,channel, hw_model, days_active=3)
template = env.get_template("nodelist.html")
return web.Response(
text=template.render(
nodes=nodes,
site_config = CONFIG,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE
),
content_type="text/html",
)
except Exception as e:
except Exception:
template = env.get_template("error.html")
rendered = template.render(
error_message="An error occurred while processing your request.",
error_message="An error occurred while loading the node list page.",
error_details=traceback.format_exc(),
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
)
return web.Response(text=rendered, status=500, content_type="text/html")
@routes.get("/api/nodes")
async def api_nodes(request):
try:
# Extract optional query parameters
role = request.query.get("role")
channel = request.query.get("channel")
hw_model = request.query.get("hw_model")
# Fetch filtered nodes
nodes = await store.get_nodes(role, channel, hw_model)
# Convert node objects to dictionaries for JSON output
nodes_json = [node.to_dict() for node in nodes]
# Return a pretty-printed JSON response
return web.json_response(
{"nodes": nodes_json},
dumps=lambda obj: json.dumps(obj, indent=2) # Pretty print for development
)
except Exception as e:
# Log error and stack trace to console
print("Error in /api endpoint:", str(e))
# Return a plain-text error response
return web.Response(
text=f"An error occurred: {str(e)}",
status=500,
content_type="text/plain"
)
@routes.get("/api2/packets")
async def api_packets(request):
try:
node_id = request.query.get("node_id")
packets = await store.get_packets(node_id)
packets_json = [
{
"id": packet.id,
"from_node_id": packet.from_node_id,
"from_node": packet.from_node.long_name if packet.from_node else None,
"to_node_id": packet.to_node_id,
"to_node": packet.to_node.long_name if packet.to_node else None,
"portnum": packet.portnum,
"payload": packet.payload,
"import_time": packet.import_time.isoformat(),
}
for packet in packets
]
return web.json_response(
{"packets": packets_json},
dumps=lambda obj: json.dumps(obj, indent=2)
)
except Exception as e:
print("Error in /api/packets endpoint:", str(e))
return web.Response(
text=f"An error occurred: {str(e)}",
status=500,
content_type="text/plain"
)
@routes.get("/net")
@@ -1503,63 +1433,53 @@ async def api_chat(request):
)
# Client to pass ?hours=1 or ?days=7 to filter
@routes.get("/api/nodes")
async def api_nodes(request):
try:
# Query params
hours = request.query.get("hours")
days = request.query.get("days")
last_seen_after = None
# Optional query parameters
role = request.query.get("role")
channel = request.query.get("channel")
hw_model = request.query.get("hw_model")
days_active = request.query.get("days_active")
# Determine cutoff time
if hours:
if days_active:
try:
last_seen_after = datetime.datetime.now() - datetime.timedelta(hours=int(hours))
days_active = int(days_active)
except ValueError:
pass
elif days:
try:
last_seen_after = datetime.datetime.now() - datetime.timedelta(days=int(days))
except ValueError:
pass
else:
# Fallback: if a direct ISO timestamp is provided
last_seen_str = request.query.get("last_seen_after")
if last_seen_str:
try:
last_seen_after = datetime.datetime.fromisoformat(last_seen_str)
except Exception as e:
print(f"Failed to parse last_seen_after '{last_seen_str}': {e}")
days_active = None
# Fetch nodes
nodes = await store.get_nodes()
# Fetch nodes from database using your get_nodes function
nodes = await store.get_nodes(
role=role,
channel=channel,
hw_model=hw_model,
days_active=days_active
)
# Apply filter
if last_seen_after:
nodes = [n for n in nodes if n.last_seen and n.last_seen > last_seen_after]
# Prepare response
nodes_data = [{
"node_id": n.id,
"long_name": n.long_name,
"short_name": n.short_name,
"channel": n.channel,
"last_seen": n.last_seen.isoformat() if n.last_seen else None,
"last_lat": getattr(n, "last_lat", None),
"last_long": getattr(n, "last_long", None),
"hardware": n.hardware,
"firmware": n.firmware,
"role": n.role,
} for n in nodes]
# Prepare the JSON response
nodes_data = []
for n in nodes:
nodes_data.append({
"id": getattr(n, "id", None),
"node_id": n.node_id,
"long_name": n.long_name,
"short_name": n.short_name,
"hw_model": n.hw_model,
"firmware": n.firmware,
"role": n.role,
"last_lat": getattr(n, "last_lat", None),
"last_long": getattr(n, "last_long", None),
"channel": n.channel,
"last_update": n.last_update.isoformat()
})
return web.json_response({"nodes": nodes_data})
except Exception as e:
print("Error in /api/nodes:", e)
return web.json_response({"error": "Failed to fetch nodes"}, status=500)
@routes.get("/api/packets")
async def api_packets(request):
try: