mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-06 08:53:20 +02:00
Added API pages and moved chat to API/JS
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 we’ve 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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user