Added self updating firehose.html and chat.html

This commit is contained in:
Pablo Revilla
2025-05-22 10:19:50 -07:00
parent 56fef8312b
commit f08f6f9b93
6 changed files with 405 additions and 143 deletions
+122 -17
View File
@@ -5,33 +5,138 @@
min-width:10em;
}
.chat-packet:nth-of-type(odd){
background-color: #272b2f; /* Lighter than #2a2a2a */
background-color: #3a3a3a; /* Lighter than #2a2a2a */
}
.chat-packet {
border: 1px solid #474b4e;
border-bottom: 1px solid #555;
padding: 8px;
margin-bottom: 4px;
border-radius: 8px; /* Adjust the value to make the corners more or less rounded */
}
.chat-packet:nth-of-type(even){
background-color: #212529; /* Slightly lighter than the previous #181818 */
background-color: #333333; /* Slightly lighter than the previous #181818 */
}
@keyframes flash {
0% { background-color: #ffe066; }
100% { background-color: inherit; }
}
.chat-packet.flash {
animation: flash 3.5s ease-out;
}
{% endblock %}
{% block body %}
<script>
setTimeout(function() {
location.reload();
}, 10000); // 10 secondd
</script>
<div class="container" id="chat">
{% 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-2 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 }}
</span>
</div>
{% else %}
No packets found.
{% endfor %}
</div>
<div class="container" >
{% for packet in packets %}
{% include 'chat_packet.html' %}
{% else %}
No packets found.
{% endfor %}
</div>
<script>
let lastTime = null;
// Initialize lastTime from DOM
const firstPacket = document.querySelector(".chat-packet");
if (firstPacket) {
lastTime = firstPacket.querySelector(".timestamp")?.getAttribute("title");
console.log("Initialized lastTime from DOM:", lastTime);
}
// Track rendered packets
const renderedPacketIds = new Set();
document.querySelectorAll(".chat-packet").forEach(div => {
const id = div.dataset.packetId;
if (id) renderedPacketIds.add(id);
});
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
async function fetchUpdates() {
try {
const url = new URL("/chat/updates", window.location.origin);
if (lastTime) {
url.searchParams.set("last_time", lastTime);
}
const response = await fetch(url);
const data = await response.json();
const chatContainer = document.querySelector(".container");
if (!chatContainer) return;
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";
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time}">
${new Date(packet.import_time).toLocaleString()}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" title="View packet details">✉️</a> ${packet.channel}
</span>
<span class="col-2 username">
<a href="/packet_list/${packet.from_node_id}" title="View all packets from this node">
${packet.long_name ? packet.long_name : parseInt(packet.from_node_id).toString(16)}
</a>
</span>
<span class="col-5 message">${escapeHtml(packet.payload)}</span>
`;
chatContainer.prepend(div);
chatContainer.prepend(div);
// Trigger flash animation
div.classList.add("flash");
// Optionally remove the class after animation ends
setTimeout(() => div.classList.remove("flash"), 2500);
}
}
if (data.latest_import_time) {
lastTime = data.latest_import_time;
}
} catch (err) {
console.error("Fetch error:", err);
}
}
setInterval(fetchUpdates, 5000); // Poll every 5 seconds
</script>
{% endblock %}
+81 -69
View File
@@ -1,80 +1,33 @@
{% extends "base.html" %}
{% block css %}
/* Set the maximum width of the page to 900px */
{% block css %}
.container {
max-width: 900px;
margin: 0 auto; /* Center the content horizontally */
margin: 0 auto;
}
.packet-fade-in {
animation: fadeIn 0.5s ease forwards;
opacity: 0;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
#pause-button {
white-space: nowrap;
padding: 2px 8px;
font-size: 0.85rem;
}
{% endblock %}
{% block body %}
<script>
let refreshInterval;
function updateURLWithPort() {
let selectedPort = document.querySelector('select[name="portnum"]').value;
let url = new URL(window.location.href);
url.searchParams.set('portnum', selectedPort);
// Save scroll position before refreshing
localStorage.setItem("scrollPosition", window.scrollY);
window.location.href = url.toString();
}
function startAutoRefresh() {
refreshInterval = setInterval(updateURLWithPort, 5000);
localStorage.setItem("autoRefresh", "true");
updateButtonState(true);
}
function stopAutoRefresh() {
clearInterval(refreshInterval);
localStorage.setItem("autoRefresh", "false");
updateButtonState(false);
}
function toggleAutoRefresh() {
let isEnabled = localStorage.getItem("autoRefresh") === "true";
if (isEnabled) {
stopAutoRefresh();
} else {
startAutoRefresh();
}
}
function updateButtonState(isEnabled) {
let button = document.getElementById("auto-refresh-button");
button.innerText = isEnabled ? "Disable Auto-Refresh" : "Enable Auto-Refresh";
}
function restoreScrollPosition() {
let scrollPosition = localStorage.getItem("scrollPosition");
if (scrollPosition !== null) {
window.scrollTo(0, parseInt(scrollPosition, 10));
}
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelector('select[name="portnum"]').addEventListener('change', updateURLWithPort);
document.getElementById("auto-refresh-button").addEventListener('click', toggleAutoRefresh);
// Restore auto-refresh state
let isEnabled = localStorage.getItem("autoRefresh") === "true";
updateButtonState(isEnabled);
if (isEnabled) {
startAutoRefresh();
}
// Restore scroll position
restoreScrollPosition();
});
</script>
<div class="container">
<form class="row">
<form class="d-flex align-items-center justify-content-between mb-2">
{% set options = {
1: "Text Message",
3: "Position",
@@ -84,14 +37,16 @@
70: "Trace Route",
}
%}
<select name="portnum" class="col-2 m-2">
<select name="portnum" class="form-select form-select-sm w-auto">
<option value="" {% if portnum not in options %}selected{% endif %}>All</option>
{% for value, name in options.items() %}
<option value="{{ value }}" {% if value == portnum %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<button type="button" id="auto-refresh-button">Enable Auto-Refresh</button>
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary">Pause</button>
</form>
<div class="row">
<div class="col-xs" id="packet_list">
{% for packet in packets %}
@@ -103,4 +58,61 @@
</div>
</div>
<script>
let lastTime = null;
let portnum = "{{ portnum if portnum is not none else '' }}";
let updatesPaused = false;
function fetchUpdates() {
if (updatesPaused) return;
const url = new URL("/firehose/updates", window.location.origin);
if (lastTime) url.searchParams.set("last_time", lastTime);
if (portnum) url.searchParams.set("portnum", portnum);
fetch(url)
.then(res => res.json())
.then(data => {
if (data.packets && data.packets.length > 0) {
lastTime = data.latest_import_time;
const list = document.getElementById("packet_list");
for (const html of data.packets.reverse()) {
list.insertAdjacentHTML("afterbegin", html);
const firstChild = list.firstElementChild;
if (firstChild) {
firstChild.classList.add("packet-fade-in");
firstChild.addEventListener("animationend", () => {
firstChild.classList.remove("packet-fade-in");
}, { once: true });
}
}
}
})
.catch(err => {
console.error("Update fetch failed:", err);
});
}
document.addEventListener("DOMContentLoaded", () => {
const pauseBtn = document.getElementById("pause-button");
document.querySelector('select[name="portnum"]').addEventListener("change", (e) => {
const selected = e.target.value;
const url = new URL(window.location.href);
url.searchParams.set("portnum", selected);
window.location.href = url;
});
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
});
// Start fetching updates
fetchUpdates();
setInterval(fetchUpdates, 1000);
});
</script>
{% endblock %}
+26 -6
View File
@@ -24,10 +24,30 @@
<div class="container" > {{ site_config["site"]["weekly_net_message"] }} <br><br>
</div>
<div class="container">
{% for packet in packets %}
{% include 'chat_packet.html' %}
{% else %}
No packets found.
{% endfor %}
</div>
{% 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">
{{ packet.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y') }}
</span>
<span class="col-1 timestamp">
<a href="/packet/{{ packet.id }}" title="View packet details">✉️</a> {{ packet.from_node.channel }}
</span>
<span class="col-2 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 }}
</span>
</div>
{% else %}
No packets found.
{% endfor %}
</div>
{% endblock %}
+1 -1
View File
@@ -5,7 +5,7 @@
<span {% if from_me %} class="fw-bold" {% endif %}>
{{packet.from_node.long_name}}(
{%- if not from_me -%}
<a hx-target="#node" href="/node_search?q={{packet.from_node_id|node_id_to_hex}}">
<a href="/node_search?q={{packet.from_node_id|node_id_to_hex}}">
{%- endif -%}
{{packet.from_node_id|node_id_to_hex}}
{%- if not from_me -%}
+75 -48
View File
@@ -1,67 +1,94 @@
{% extends "base.html" %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block body %}
<div id="mynetwork" style="width: 100%; height: 600px;"></div>
<div id="mynetwork" style="width: 100%; height: 800px;"></div>
<script type="text/javascript">
var chart = echarts.init(document.getElementById('mynetwork'));
const chart = echarts.init(document.getElementById('mynetwork'));
// Define the nodes and edges passed from the backend
var nodes = {{ chart_data['nodes'] | tojson }};
var edges = {{ chart_data['edges'] | tojson }};
const rawNodes = {{ chart_data['nodes'] | tojson }};
const rawEdges = {{ chart_data['edges'] | tojson }};
var option = {
backgroundColor: '#ffffff', // Set background color to white
tooltip: {},
series: [
{
type: 'graph',
layout: 'force',
data: nodes,
links: edges,
roam: true,
force: {
repulsion: 500,
edgeLength: [100, 200],
gravity: 0.1
// Build DAG layout
const layers = {};
const nodeDepth = {};
// Organize nodes into layers by hop count
for (const edge of rawEdges) {
const { source, target } = edge;
if (!(source in nodeDepth)) nodeDepth[source] = 0;
const nextDepth = nodeDepth[source] + 1;
nodeDepth[target] = Math.max(nodeDepth[target] || 0, nextDepth);
}
for (const node of rawNodes) {
const depth = nodeDepth[node.name] || 0;
if (!(depth in layers)) layers[depth] = [];
layers[depth].push(node);
}
// Position nodes manually
const chartNodes = [];
const layerKeys = Object.keys(layers).sort((a, b) => +a - +b);
const verticalSpacing = 100;
const horizontalSpacing = 180;
layerKeys.forEach((depth, layerIndex) => {
const layer = layers[depth];
const y = layerIndex * verticalSpacing;
const xStart = -(layer.length - 1) * horizontalSpacing / 2;
layer.forEach((node, i) => {
chartNodes.push({
...node,
x: xStart + i * horizontalSpacing,
y: y,
itemStyle: {
color: '#dddddd',
borderColor: '#222',
borderWidth: 2,
},
label: {
show: true,
position: 'inside',
color: '#000',
padding: [5, 10],
formatter: function(params) { return params.data.value; },
backgroundColor: '#f0f0f0',
borderColor: '#999',
borderWidth: 1,
borderRadius: 5,
z: 5 // Label z-index is now 5, to be below the edges
fontSize: 12,
formatter: node.short_name || node.name,
},
itemStyle: {
normal: {
borderColor: '#1E1E1E',
borderWidth: 2,
}
},
lineStyle: {
width: 2,
color: '#ccc', // Edge color
curveness: 0.1, // Slight curve for edges
type: 'solid',
z: 10 // Edge lines have a higher z-index than the labels
},
edgeSymbol: ['arrow', 'arrow'], // Both ends of the edge will have arrowheads
edgeSymbolSize: [8, 8], // Size of the arrows
z: 15 // Ensure edges (arrows) are on top of both the nodes and labels
}
]
});
});
});
const chartEdges = rawEdges.map(edge => ({
source: edge.source,
target: edge.target,
lineStyle: {
color: edge.originalColor || '#ccc',
width: 2,
type: 'solid',
},
}));
const option = {
backgroundColor: '#fff',
tooltip: {},
animation: false,
series: [{
type: 'graph',
layout: 'none',
coordinateSystem: null,
data: chartNodes,
links: chartEdges,
roam: true,
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: [0, 10],
lineStyle: {
curveness: 0,
},
}],
};
chart.setOption(option);
</script>
{% endblock %}
+100 -2
View File
@@ -1,5 +1,6 @@
import asyncio
import datetime
from datetime import timedelta
import json
import os
import re
@@ -20,7 +21,10 @@ from meshview import models
from meshview import store
from meshview.store import get_total_node_count
from aiohttp import web
SOFTWARE_RELEASE= "2.0.2"
import re
SEQ_REGEX = re.compile(r"seq \d+")
SOFTWARE_RELEASE= "2.0.3"
CONFIG = config.CONFIG
env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape())
@@ -384,6 +388,55 @@ async def packet_details(request):
content_type="text/html",
)
@routes.get("/firehose/updates")
async def firehose_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
portnum = request.query.get("portnum")
if portnum is not None and portnum != "":
portnum = int(portnum)
else:
portnum = None
packets = await store.get_packets(
portnum=portnum,
limit=20,
)
ui_packets = [Packet.from_model(p) for p in packets]
# Filter packets newer than last_time
new_packets = [p for p in ui_packets if p.import_time > last_time]
template = env.get_template("packet.html")
# Replace YOUR_NODE_ID with your current node ID (integer)
YOUR_NODE_ID = 0x12345678 # example, replace with actual
rendered_packets = [template.render(packet=p, node_id=YOUR_NODE_ID) for p in new_packets]
response = {
"packets": rendered_packets,
}
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 /firehose/updates:", e)
return web.json_response({"error": "Failed to fetch updates"}, status=500)
@routes.get("/packet/{packet_id}")
async def packet(request):
packet = await store.get_packet(int(request.match_info["packet_id"]))
@@ -1000,7 +1053,7 @@ async def net(request):
try:
# Fetch packets for the given node ID and port number
packets = await store.get_packets(
node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, limit=1000
node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, since=timedelta(days=3)
)
# Convert packets to UI packets
@@ -1139,6 +1192,51 @@ async def chat(request):
status=500,
content_type="text/plain",
)
@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 = [{
"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,
} for p in new_packets]
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")