Added API pages and moved chat to API/JS

This commit is contained in:
Pablo Revilla
2025-09-10 22:04:02 -07:00
parent 1e7fccae82
commit 868a1658ce
5 changed files with 546 additions and 194 deletions
+127
View File
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Chat</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body {
margin: 0;
background: #ffffff;
color: #000000;
}
#swagger-ui { background: #ffffff; color: #000; }
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description { color: #000 !important; }
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
.swagger-ui .opblock-section-header { color: #000 !important; }
.swagger-ui .parameters,
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
.swagger-ui table { color: #000 !important; }
.swagger-ui a { color: #1a0dab !important; }
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Chat API",
version: "1.0.0",
description: "API for retrieving chat messages from the mesh network. Supports optional time-based filtering and result limits."
},
paths: {
"/api/chat": {
get: {
summary: "Get chat messages",
description: "Returns a list of chat messages. You can optionally filter by a timestamp (`since`) and limit the number of results (`limit`).",
parameters: [
{
name: "since",
in: "query",
required: false,
description: "ISO 8601 timestamp; only return messages newer than this.",
schema: { type: "string", format: "date-time", example: "2025-09-10T15:29:33.307782" }
},
{
name: "limit",
in: "query",
required: false,
description: "Maximum number of messages to return (1-200). Default is 100.",
schema: { type: "integer", minimum: 1, maximum: 200, default: 100 }
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
packets: {
type: "array",
description: "Array of chat messages",
items: {
type: "object",
properties: {
id: { type: "integer", example: 4285655910 },
import_time: { type: "string", format: "date-time", example: "2025-09-10T15:29:33.307782" },
channel: { type: "string", example: "MediumSlow" },
from_node_id: { type: "integer", example: 2695230252 },
long_name: { type: "string", example: "Fight The Empire With Sticks And Rocks" },
payload: { type: "string", example: "sunny in SF" },
reply_id: { type: "integer", example: 670731015, nullable: true }
}
}
},
latest_import_time: {
type: "string",
format: "date-time",
description: "Timestamp of the most recent message returned",
example: "2025-09-10T16:54:14.124001"
}
}
}
}
}
},
"500": {
description: "Server error",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Edges</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body {
margin: 0;
background: #ffffff;
color: #000000;
}
#swagger-ui { background: #ffffff; color: #000; }
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description { color: #000 !important; }
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
.swagger-ui .opblock-section-header { color: #000 !important; }
.swagger-ui .parameters,
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
.swagger-ui table { color: #000 !important; }
.swagger-ui a { color: #1a0dab !important; }
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Network Edges API",
version: "1.0.0",
description: "API for retrieving network edges derived from traceroutes and neighbor info packets, with optional type filtering."
},
paths: {
"/api/edges": {
get: {
summary: "Get network edges",
description: "Returns edges between nodes in the network. Optionally filter by type (`traceroute` or `neighbor`).",
parameters: [
{
name: "type",
in: "query",
required: false,
description: "Optional filter to only return edges of this type (`traceroute` or `neighbor`).",
schema: { type: "string", enum: ["traceroute", "neighbor"] }
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
edges: {
type: "array",
items: {
type: "object",
properties: {
from: { type: "integer", example: 101 },
to: { type: "integer", example: 102 },
type: { type: "string", example: "traceroute" }
}
}
}
}
}
}
}
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>
+134
View File
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Nodes</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body { margin: 0; background: #ffffff; color: #000; }
#swagger-ui { background: #ffffff; color: #000; }
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description { color: #000 !important; }
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
.swagger-ui .opblock-section-header { color: #000 !important; }
.swagger-ui .parameters,
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
.swagger-ui table { color: #000 !important; }
.swagger-ui a { color: #1a0dab !important; }
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Network Nodes API",
version: "1.0.0",
description: "API for retrieving nodes in the network with optional filters by last seen time."
},
paths: {
"/api/nodes": {
get: {
summary: "Get network nodes",
description: "Returns a list of nodes with optional filtering by recent activity.",
parameters: [
{
name: "hours",
in: "query",
required: false,
description: "Return nodes seen in the last X hours.",
schema: { type: "integer", example: 24 }
},
{
name: "days",
in: "query",
required: false,
description: "Return nodes seen in the last X days.",
schema: { type: "integer", example: 7 }
},
{
name: "last_seen_after",
in: "query",
required: false,
description: "Return nodes last seen after this ISO8601 timestamp.",
schema: { type: "string", format: "date-time", example: "2025-08-25T14:00:00" }
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
nodes: {
type: "array",
items: {
type: "object",
properties: {
node_id: { type: "integer", example: 101 },
long_name: { type: "string", example: "Node Alpha" },
short_name: { type: "string", example: "A" },
channel: { type: "string", example: "2" },
last_seen: { type: "string", format: "date-time", example: "2025-08-25T12:00:00" },
last_lat: { type: "number", format: "float", example: 37.7749 },
last_long: { type: "number", format: "float", example: -122.4194 },
hardware: { type: "string", example: "Heltec V3" },
firmware: { type: "string", example: "1.0.5" },
role: { type: "string", example: "router" }
}
}
}
}
}
}
}
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
},
"500": {
description: "Server error",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>
+123 -106
View File
@@ -23,135 +23,152 @@
animation: flash 3.5s ease-out;
}
/* New CSS for smaller Replying to text */
/* Nested reply style below the message */
.replying-to {
font-size: 0.85em;
color: #ccc;
background-color: #444;
padding: 4px 8px;
margin-top: 4px;
border-left: 3px solid #ffe066;
border-radius: 4px;
font-size: 0.9em;
color: #fff;
}
{% endblock %}
{% block body %}
<div id="chat-container">
<div class="container" id="chat-log">
{% for packet in packets %}
<div
class="row chat-packet"
data-packet-id="{{ packet.id }}"
role="article"
aria-label="Chat message from {{ packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }}"
>
<span class="col-2 timestamp" title="{{ packet.import_time.isoformat() }}">
{{ packet.import_time.strftime('%-I:%M:%S %p - %m/%d/%Y') }}
</span>
<span class="col-2 channel">
<a href="/packet/{{ packet.id }}" title="View packet details">✉️</a>
{{ packet.from_node.channel }}
</span>
<span class="col-3 username">
<a href="/packet_list/{{ packet.from_node_id }}" title="View all packets from this node">
{{ packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }}
</a>
</span>
<span class="col-5 message">
{{ packet.payload }}
{% if packet.raw_mesh_packet.decoded.reply_id %}
<span class="replying-to">
<i>(Replying to: <a href="/packet/{{ packet.raw_mesh_packet.decoded.reply_id }}">{{ packet.raw_mesh_packet.decoded.reply_id }}</a>)</i>
</span>
{% endif %}
</span>
</div>
{% else %}
<p class="text-muted">No packets found.</p>
{% endfor %}
</div>
</div>
<script>
let lastTime = null;
const chatContainer = document.querySelector("#chat-log");
// Initialize lastTime from DOM
const firstPacket = chatContainer.querySelector(".chat-packet");
if (firstPacket) {
lastTime = firstPacket.querySelector(".timestamp")?.getAttribute("title");
}
// Track rendered packet IDs
let lastTime = null;
const renderedPacketIds = new Set();
document.querySelectorAll(".chat-packet").forEach(div => {
const id = div.dataset.packetId;
if (id) renderedPacketIds.add(id);
});
const packetMap = new Map(); // store all packets weve seen
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
const div = document.createElement("div");
div.textContent = text == null ? "" : text;
return div.innerHTML;
}
function renderPacket(packet, highlight = false) {
// prevent duplicates
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
packetMap.set(packet.id, packet);
const date = new Date(packet.import_time);
const formattedTime = date.toLocaleTimeString([], {
hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true
});
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2,"0")}/` +
`${date.getDate().toString().padStart(2,"0")}/` +
`${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
// Try to resolve the reply target
let replyHtml = "";
if (packet.reply_id) {
const parent = packetMap.get(packet.reply_id);
if (parent) {
replyHtml = `
<div class="replying-to">
<div class="reply-preview">
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
${escapeHtml(parent.payload || "")}
</div>
</div>
`;
} else {
// fallback if parent not loaded yet
replyHtml = `
<div class="replying-to">
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>
`;
}
}
const div = document.createElement("div");
div.className = "row chat-packet" + (highlight ? " flash" : "");
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time}">
${formattedTimestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" title="View packet details">✉️</a> ${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/packet_list/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
</a>
</span>
<span class="col-5 message">
${escapeHtml(packet.payload)}
${replyHtml}
</span>
`;
// Prepend so newest messages are at the top.
chatContainer.prepend(div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
}
function renderPacketsEnsureDescending(packets, highlight = false) {
if (!Array.isArray(packets) || packets.length === 0) return;
const sortedDesc = packets.slice().sort((a, b) =>
new Date(b.import_time) - new Date(a.import_time)
);
for (let i = sortedDesc.length - 1; i >= 0; i--) {
renderPacket(sortedDesc[i], highlight);
}
}
async function fetchInitial() {
try {
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit", "100");
const resp = await fetch(url);
const data = await resp.json();
if (data && data.packets && data.packets.length > 0) {
renderPacketsEnsureDescending(data.packets, false);
if (data.latest_import_time) lastTime = data.latest_import_time;
}
} catch (err) {
console.error("Initial fetch error:", err);
}
}
async function fetchUpdates() {
try {
const url = new URL("/chat/updates", window.location.origin);
if (lastTime) {
url.searchParams.set("last_time", lastTime);
}
try {
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit", "100");
if (lastTime) url.searchParams.set("since", lastTime);
const response = await fetch(url);
const data = await response.json();
const resp = await fetch(url);
const data = await resp.json();
if (data.packets && data.packets.length > 0) {
for (const packet of data.packets) {
if (renderedPacketIds.has(packet.id)) continue;
renderedPacketIds.add(packet.id);
const div = document.createElement("div");
div.className = "row chat-packet flash";
div.dataset.packetId = packet.id;
const date = new Date(packet.import_time);
const formattedTime = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
});
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}/${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time}">
${formattedTimestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" title="View packet details">✉️</a> ${packet.channel}
</span>
<span class="col-3 username">
<a href="/packet_list/${packet.from_node_id}" title="View all packets from this node">
${packet.long_name || (packet.from_node_id || '').toString(16)}
</a>
</span>
<span class="col-5 message">
${escapeHtml(packet.payload)}
${packet.reply_id
? `<span class="replying-to">(Replying to: <a href="/packet/${packet.reply_id}">${packet.reply_id}</a>)</span>`
: ''}
</span>
`;
chatContainer.prepend(div);
setTimeout(() => div.classList.remove("flash"), 2500);
}
}
if (data.latest_import_time) {
lastTime = data.latest_import_time;
}
} catch (err) {
console.error("Fetch error:", err);
if (data && data.packets && data.packets.length > 0) {
renderPacketsEnsureDescending(data.packets, true);
if (data.latest_import_time) lastTime = data.latest_import_time;
}
} catch (err) {
console.error("Fetch updates error:", err);
}
}
// initial load
fetchInitial();
setInterval(fetchUpdates, 5000);
</script>
{% endblock %}
+53 -88
View File
@@ -1279,18 +1279,9 @@ async def top(request):
@routes.get("/chat")
async def chat(request):
try:
packets = await store.get_packets(
node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, limit=100
)
ui_packets = [Packet.from_model(p) for p in packets]
filtered_packets = [
p for p in ui_packets if not re.fullmatch(r"seq \d+", p.payload)
]
template = env.get_template("chat.html")
return web.Response(
text=template.render(
packets=filtered_packets,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE
),
@@ -1307,63 +1298,6 @@ async def chat(request):
)
return web.Response(text=rendered, status=500, content_type="text/html")
@routes.get("/chat/updates")
async def chat_updates(request):
try:
last_time_str = request.query.get("last_time", None)
if last_time_str:
try:
last_time = datetime.datetime.fromisoformat(last_time_str)
except Exception as e:
print(f"Failed to parse last_time '{last_time_str}': {e}")
last_time = datetime.datetime.min
else:
last_time = datetime.datetime.min
packets = await store.get_packets(
node_id=0xFFFFFFFF,
portnum=PortNum.TEXT_MESSAGE_APP,
limit=5,
)
ui_packets = [Packet.from_model(p) for p in packets]
filtered_packets = [p for p in ui_packets if not SEQ_REGEX.fullmatch(p.payload)]
new_packets = [p for p in filtered_packets if p.import_time > last_time]
packets_data = []
for p in new_packets:
reply_id = None
if getattr(p, 'raw_mesh_packet', None):
decoded = getattr(p.raw_mesh_packet, 'decoded', None)
if decoded:
reply_id = getattr(decoded, 'reply_id', None)
packet_dict = {
"id": p.id,
"import_time": p.import_time.isoformat(),
"channel": p.from_node.channel if p.from_node else "",
"from_node_id": p.from_node_id,
"long_name": p.from_node.long_name if p.from_node else "",
"payload": p.payload,
}
if reply_id:
packet_dict["reply_id"] = reply_id
packets_data.append(packet_dict)
response = {
"packets": packets_data
}
if new_packets:
latest_import_time = max(p.import_time for p in new_packets)
response["latest_import_time"] = latest_import_time.isoformat()
return web.json_response(response)
except Exception as e:
print("Error in /chat/updates:", e)
return web.json_response({"error": "Failed to fetch updates"}, status=500)
# Assuming the route URL structure is /nodegraph
@routes.get("/nodegraph")
@@ -1476,41 +1410,48 @@ async def get_config(request):
# The response includes "latest_import_time" for frontend to keep track of the newest message timestamp.
# The backend fetches extra packets (limit*5) to account for filtering messages like "seq N" and since filtering.
@routes.get("/api/chat")
async def api_chat(request):
try:
# Parse query params
limit_str = request.query.get("limit", "100")
since_str = request.query.get("since", None)
limit_str = request.query.get("limit", "20")
since_str = request.query.get("since")
# Clamp limit between 1 and 200
try:
limit = min(max(int(limit_str), 1), 200) # Limit between 1 and 200
limit = min(max(int(limit_str), 1), 100)
except ValueError:
limit = 100
limit = 50
# Parse "since" timestamp if provided
since = None
if since_str:
try:
since = datetime.datetime.fromisoformat(since_str)
except Exception as e:
print(f"Failed to parse since '{since_str}': {e}")
since = None
else:
since = None
# Fetch packets from store
packets = await store.get_packets(
node_id=0xFFFFFFFF,
portnum=PortNum.TEXT_MESSAGE_APP,
limit=limit*5, # Fetch extra to filter out seq messages and since filter
limit=limit,
)
SEQ_REGEX = re.compile(r"seq \d+")
ui_packets = [Packet.from_model(p) for p in packets]
filtered_packets = [p for p in ui_packets if p.payload and not SEQ_REGEX.fullmatch(p.payload)]
# Filter by 'since' timestamp if provided
# Filter out "seq N" and missing payloads
filtered_packets = [
p for p in ui_packets
if p.payload and not SEQ_REGEX.fullmatch(p.payload)
]
# Apply "since" filter
if since:
filtered_packets = [p for p in filtered_packets if p.import_time > since]
filtered_packets = [
p for p in filtered_packets if p.import_time > since
]
# Sort by import_time descending (latest first)
filtered_packets.sort(key=lambda p: p.import_time, reverse=True)
@@ -1518,16 +1459,36 @@ async def api_chat(request):
# Trim to requested limit
filtered_packets = filtered_packets[:limit]
packets_data = [{
"id": p.id,
"import_time": p.import_time.isoformat(),
"channel": getattr(p.from_node, "channel", ""),
"from_node_id": p.from_node_id,
"long_name": getattr(p.from_node, "long_name", ""),
"payload": p.payload,
} for p in filtered_packets]
# Build response data
packets_data = []
for p in filtered_packets:
reply_id = getattr(
getattr(getattr(p, "raw_mesh_packet", None), "decoded", None),
"reply_id",
None
)
latest_import_time = filtered_packets[0].import_time.isoformat() if filtered_packets else since_str or None
packet_dict = {
"id": p.id,
"import_time": p.import_time.isoformat(),
"channel": getattr(p.from_node, "channel", ""),
"from_node_id": p.from_node_id,
"long_name": getattr(p.from_node, "long_name", ""),
"payload": p.payload,
}
if reply_id: # ✅ only include if not None/0
packet_dict["reply_id"] = reply_id
packets_data.append(packet_dict)
# Pick latest import time for clients to use in next request
if filtered_packets:
latest_import_time = filtered_packets[0].import_time.isoformat()
elif since:
latest_import_time = since.isoformat()
else:
latest_import_time = None
return web.json_response({
"packets": packets_data,
@@ -1536,7 +1497,11 @@ async def api_chat(request):
except Exception as e:
print("Error in /api/chat:", e)
return web.json_response({"error": "Failed to fetch chat data"}, status=500)
return web.json_response(
{"error": "Failed to fetch chat data", "details": str(e)},
status=500
)
# Client to pass ?hours=1 or ?days=7 to filter