mirror of
https://github.com/lklynet/hypermind.git
synced 2026-05-03 09:30:36 +00:00
feat: add peer listing and graceful shutdown
- Add `/who` command to list active users via new `/api/peers` endpoint - Implement graceful shutdown for server and swarm with proper cleanup - Enhance SSE broadcast with client connection state checks - Expose peer list in stats and add health endpoint - Fix theme color refresh for network visualization
This commit is contained in:
+57
-4
@@ -4,6 +4,8 @@ const totalUniqueEl = document.getElementById("total-unique");
|
||||
const canvas = document.getElementById("network");
|
||||
const ctx = canvas.getContext("2d");
|
||||
let particles = [];
|
||||
let particleColor = "#4ade80";
|
||||
let particleLinkColor = "#22d3ee";
|
||||
|
||||
function getThemeColor(varName) {
|
||||
return getComputedStyle(document.documentElement)
|
||||
@@ -11,6 +13,11 @@ function getThemeColor(varName) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
const refreshThemeColors = () => {
|
||||
particleColor = getThemeColor("--color-particle");
|
||||
particleLinkColor = getThemeColor("--color-particle-link");
|
||||
};
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
@@ -39,7 +46,7 @@ class Particle {
|
||||
draw() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = getThemeColor("--color-particle");
|
||||
ctx.fillStyle = particleColor;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
@@ -62,15 +69,15 @@ const updateParticles = (count) => {
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.strokeStyle = getThemeColor("--color-particle-link");
|
||||
ctx.strokeStyle = particleLinkColor;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
|
||||
if (distance < 150) {
|
||||
if (distanceSquared < 22500) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
@@ -473,6 +480,17 @@ const getColorFromId = (id) => {
|
||||
const seenMessageIds = new Set();
|
||||
const messageIdHistory = [];
|
||||
|
||||
const appendSystemLines = (lines) => {
|
||||
const entries = Array.isArray(lines) ? lines : [lines];
|
||||
for (const line of entries) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "system-message";
|
||||
div.innerText = `[SYSTEM] ${line}`;
|
||||
terminalOutput.appendChild(div);
|
||||
}
|
||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||
};
|
||||
|
||||
const appendMessage = (msg) => {
|
||||
const div = document.createElement("div");
|
||||
|
||||
@@ -631,6 +649,39 @@ terminalInput.addEventListener("keypress", async (e) => {
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if (content === "/who") {
|
||||
try {
|
||||
const res = await fetch("/api/peers");
|
||||
if (!res.ok) {
|
||||
systemStatusBar.innerText = "[SYSTEM] Failed to fetch peer list";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const peers = Array.isArray(data.peers) ? data.peers : [];
|
||||
const mappedPeers = peers.map((peer) => {
|
||||
if (peer.id && peer.screenname) {
|
||||
nameToId.set(peer.screenname, peer.id);
|
||||
}
|
||||
const shortId = peer.id ? peer.id.slice(-8) : "unknown";
|
||||
return `${peer.screenname || "Unknown"} (...${shortId})`;
|
||||
});
|
||||
|
||||
const visiblePeers = mappedPeers.slice(0, 20);
|
||||
const outputLines = [
|
||||
`Active users: ${mappedPeers.length}`,
|
||||
...visiblePeers,
|
||||
];
|
||||
if (mappedPeers.length > visiblePeers.length) {
|
||||
outputLines.push(`...and ${mappedPeers.length - visiblePeers.length} more`);
|
||||
}
|
||||
|
||||
appendSystemLines(outputLines);
|
||||
systemStatusBar.innerText = `[SYSTEM] Listed ${mappedPeers.length} active users`;
|
||||
} catch (err) {
|
||||
systemStatusBar.innerText = "[SYSTEM] Failed to fetch peer list";
|
||||
}
|
||||
return;
|
||||
} else if (content.startsWith("/whisper ")) {
|
||||
const parts = content.split(" ");
|
||||
if (parts.length < 3) {
|
||||
@@ -840,6 +891,7 @@ const initialCount = parseInt(countEl.dataset.initialCount) || 0;
|
||||
countEl.innerText = initialCount;
|
||||
countEl.classList.add("loaded");
|
||||
updateParticles(initialCount);
|
||||
refreshThemeColors();
|
||||
animate();
|
||||
|
||||
const bandwidthHistory = { timestamps: [], bytesIn: [], bytesOut: [] };
|
||||
@@ -1040,6 +1092,7 @@ function cycleTheme() {
|
||||
if (oldLink) oldLink.remove();
|
||||
newLink.id = "theme-css";
|
||||
localStorage.setItem("hypermind-theme", newTheme);
|
||||
refreshThemeColors();
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = "";
|
||||
showThemeNotification(newTheme);
|
||||
|
||||
@@ -40,6 +40,7 @@ export const helpCommand = {
|
||||
},
|
||||
{ cmd: "/block <user>", desc: "Block messages from a user" },
|
||||
{ cmd: "/unblock <user>", desc: "Unblock a user" },
|
||||
{ cmd: "/who", desc: "List active users in the swarm" },
|
||||
{
|
||||
cmd: "/local <msg>",
|
||||
desc: "Send message to direct peers only (Global by default)",
|
||||
|
||||
@@ -65,16 +65,24 @@ const main = async () => {
|
||||
() => swarmManager.getSwarm().connections.size
|
||||
);
|
||||
|
||||
setInterval(() => {
|
||||
const diagnosticsTimer = setInterval(() => {
|
||||
broadcastUpdate();
|
||||
}, DIAGNOSTICS_INTERVAL);
|
||||
diagnosticsTimer.unref();
|
||||
|
||||
const app = createServer(identity, peerManager, swarmManager, sseManager, diagnostics);
|
||||
startServer(app, identity);
|
||||
const webServer = startServer(app, identity);
|
||||
|
||||
const handleShutdown = () => {
|
||||
let shuttingDown = false;
|
||||
const handleShutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearInterval(diagnosticsTimer);
|
||||
diagnostics.stopLogging();
|
||||
swarmManager.shutdown();
|
||||
await swarmManager.shutdown();
|
||||
webServer.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", handleShutdown);
|
||||
|
||||
@@ -7,7 +7,6 @@ const crypto = require("crypto");
|
||||
const {
|
||||
MAX_RELAY_HOPS,
|
||||
ENABLE_CHAT,
|
||||
CHAT_RATE_LIMIT,
|
||||
} = require("../config/constants");
|
||||
const { BloomFilterManager } = require("../state/bloom");
|
||||
const { generateScreenname } = require("../utils/name-generator");
|
||||
|
||||
+5
-7
@@ -2,13 +2,11 @@ const Hyperswarm = require("hyperswarm");
|
||||
const { signMessage } = require("../core/security");
|
||||
const {
|
||||
TOPIC,
|
||||
TOPIC_NAME,
|
||||
HEARTBEAT_INTERVAL,
|
||||
MAX_CONNECTIONS,
|
||||
CONNECTION_ROTATION_INTERVAL,
|
||||
ENABLE_CHAT,
|
||||
} = require("../config/constants");
|
||||
const { generateScreenname } = require("../utils/name-generator");
|
||||
|
||||
class SwarmManager {
|
||||
constructor(
|
||||
@@ -122,6 +120,7 @@ class SwarmManager {
|
||||
this.broadcastFn();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
this.heartbeatInterval.unref();
|
||||
}
|
||||
|
||||
startRotation() {
|
||||
@@ -148,9 +147,10 @@ class SwarmManager {
|
||||
oldest.destroy();
|
||||
}
|
||||
}, CONNECTION_ROTATION_INTERVAL);
|
||||
this.rotationInterval.unref();
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
async shutdown() {
|
||||
const sig = signMessage(
|
||||
`type:LEAVE:${this.identity.id}`,
|
||||
this.identity.privateKey
|
||||
@@ -176,10 +176,8 @@ class SwarmManager {
|
||||
if (this.rotationInterval) {
|
||||
clearInterval(this.rotationInterval);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
this.messageHandler.bloomFilter.stop();
|
||||
await this.swarm.destroy();
|
||||
}
|
||||
|
||||
getSwarm() {
|
||||
|
||||
@@ -92,6 +92,19 @@ class PeerManager {
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
getPeerList() {
|
||||
const peers = [];
|
||||
for (const [id, data] of this.seenPeers.entries()) {
|
||||
peers.push({
|
||||
id,
|
||||
seq: data.seq,
|
||||
lastSeen: data.lastSeen,
|
||||
ip: data.ip || null,
|
||||
});
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PeerManager };
|
||||
|
||||
@@ -48,6 +48,7 @@ const setupRoutes = (
|
||||
peerManager,
|
||||
swarm,
|
||||
diagnostics,
|
||||
sseManager,
|
||||
};
|
||||
|
||||
const chatDeps = {
|
||||
|
||||
+31
-1
@@ -1,4 +1,5 @@
|
||||
const { ENABLE_CHAT } = require("../../config/constants");
|
||||
const { ENABLE_CHAT, ENABLE_MAP } = require("../../config/constants");
|
||||
const { generateScreenname } = require("../../utils/name-generator");
|
||||
|
||||
const setupStatsRoutes = (router, dependencies) => {
|
||||
const { peerManager, swarm, diagnostics } = dependencies;
|
||||
@@ -12,9 +13,38 @@ const setupStatsRoutes = (router, dependencies) => {
|
||||
screenname: dependencies.identity.screenname,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT,
|
||||
mapEnabled: ENABLE_MAP,
|
||||
peers: peerManager.getPeersWithIps(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/api/health", (req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
uptime: process.uptime(),
|
||||
peers: peerManager.size,
|
||||
direct: swarm.getSwarm().connections.size,
|
||||
sseClients: dependencies.sseManager ? dependencies.sseManager.size : 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/api/peers", (req, res) => {
|
||||
const peers = peerManager
|
||||
.getPeerList()
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.map((peer) => ({
|
||||
id: peer.id,
|
||||
screenname: generateScreenname(peer.id),
|
||||
lastSeen: peer.lastSeen,
|
||||
ip: peer.ip,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
count: peers.length,
|
||||
peers,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { setupStatsRoutes };
|
||||
|
||||
+2
-1
@@ -11,10 +11,11 @@ const createServer = (identity, peerManager, swarm, sseManager, diagnostics) =>
|
||||
}
|
||||
|
||||
const startServer = (app, identity) => {
|
||||
app.listen(PORT, () => {
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Hypermind Node running on port ${PORT}`);
|
||||
console.log(`ID: ${identity.id}`);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = { createServer, startServer };
|
||||
|
||||
+9
-1
@@ -25,7 +25,15 @@ class SSEManager {
|
||||
broadcast(data) {
|
||||
const message = JSON.stringify(data);
|
||||
for (const client of this.clients) {
|
||||
client.write(`data: ${message}\n\n`);
|
||||
if (client.writableEnded || client.destroyed) {
|
||||
this.clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
client.write(`data: ${message}\n\n`);
|
||||
} catch (e) {
|
||||
this.clients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ const test = (method, path, body = null, validate, timeout = 5000) => {
|
||||
const json = JSON.parse(data);
|
||||
return json.count !== undefined && json.id && json.screenname && json.diagnostics;
|
||||
}),
|
||||
test("GET", "/api/peers", null, (data) => {
|
||||
const json = JSON.parse(data);
|
||||
return typeof json.count === "number" && Array.isArray(json.peers);
|
||||
}),
|
||||
test("GET", "/api/github/latest-release", null, (data) => {
|
||||
const json = JSON.parse(data);
|
||||
return json.tag_name && json.html_url;
|
||||
|
||||
Reference in New Issue
Block a user