mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
nodelist now also works with API
This commit is contained in:
@@ -231,4 +231,4 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
+164
-119
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user