kaya-go/cmd/yggdrasil/dashboard.html
2026-03-26 16:47:47 +01:00

1072 lines
33 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Kaya Dashboard</title>
<style>
:root {
--bg-color: #f4f4f4;
--card-bg: #ffffff;
--text-main: #1a1a1a;
--text-secondary: #666666;
--accent-orange: #ff6600;
--network-green: #00c853;
--border-color: #e0e0e0;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
}
* { box-sizing: border-box; }
body { font-family: var(--font-sans); background: var(--bg-color); color: var(--text-main); margin: 0; padding: 28px; line-height: 1.4; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-bottom: 16px; margin-bottom: 22px; border-bottom: 6px solid var(--accent-orange); }
header h2 { margin: 0; font-size: 30px; font-weight: 800; letter-spacing: -1px; text-transform: uppercase; }
#stamp { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); background: #fff; padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 2px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 18px; }
.card { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 4px; padding: 18px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); min-width: 0; }
.card h3 { margin: 0 0 12px 0; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: var(--accent-orange); border-bottom: 1px solid var(--border-color); padding-bottom: 8px; }
.table-wrap { overflow-x: auto; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 12px; table-layout: fixed; }
th { font-weight: 600; color: var(--text-secondary); padding: 10px 6px; border-bottom: 2px solid var(--border-color); font-size: 11px; text-transform: uppercase; white-space: nowrap; }
td { padding: 8px 6px; border-bottom: 1px solid #eee; font-family: var(--font-mono); color: var(--text-main); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; }
tr:last-child td { border-bottom: none; }
tr.peer-row { cursor: help; transition: background 0.1s; }
tr.peer-row:hover { background-color: #f9f9f9; }
button { background: #fff; color: #d32f2f; border: 1px solid #d32f2f; border-radius: 3px; padding: 6px 10px; cursor: pointer; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; }
button:hover { background: #d32f2f; color: #fff; }
pre { white-space: pre-wrap; margin: 0; font-family: var(--font-mono); font-size: 11px; background: #fafafa; padding: 12px; border: 1px solid var(--border-color); color: #555; max-height: 340px; overflow: auto; }
.net-green { color: var(--network-green); font-weight: bold; }
.full-width { grid-column: 1 / -1; }
.ip-cell { word-break: break-word; white-space: normal; }
.uri-cell { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.port-icon-wrapper { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; }
svg.port-icon { width: 24px; height: 24px; }
.port-body { fill: none; stroke: #ddd; stroke-width: 2; }
.port-led { fill: #ddd; transition: all 0.2s; }
.port-icon.connected .port-body { stroke: var(--accent-orange); }
.port-icon.connected .port-led { fill: var(--accent-orange); }
.port-icon.active .port-body { stroke: var(--network-green); }
.port-icon.active .port-led { fill: var(--network-green); animation: blink-traffic 1s steps(2) infinite; }
@keyframes blink-traffic { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }
/* Cell Phone Signal Icon Styles */
.signal-icon-wrapper { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin-left: 4px; }
svg.signal-icon { width: 16px; height: 16px; }
.signal-bar { fill: #ddd; transition: fill 0.2s; }
.signal-bar.active { fill: var(--network-green); }
.graph-container { width: 100%; height: 220px; background: #fafafa; border: 1px solid var(--border-color); border-radius: 4px; overflow: hidden; }
canvas#trafficGraph { width: 100%; height: 100%; display: block; }
.graph-stats { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-family: var(--font-mono); font-size: 14px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
.stat-val { font-weight: bold; font-size: 16px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.tooltip {
position: fixed;
background: #222;
color: #fff;
padding: 10px;
border-radius: 4px;
font-size: 11px;
font-family: var(--font-mono);
z-index: 9999;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
border: 1px solid #444;
max-width: 350px;
line-height: 1.5;
display: none;
}
.tooltip strong { color: var(--accent-orange); }
.tooltip-row { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 2px; }
/* Tree Visualization Styles (Mindmap / Binary Tree Style) */
.tree-visual {
padding: 40px 20px;
overflow: auto;
text-align: center;
min-height: 300px;
background: #fafafa;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.tree-visual ul {
padding-top: 20px;
position: relative;
transition: all 0.5s;
display: flex;
justify-content: center;
}
.tree-visual li {
float: left;
text-align: center;
list-style-type: none;
position: relative;
padding: 20px 10px 0 10px;
transition: all 0.5s;
}
/* Connectors */
.tree-visual li::before, .tree-visual li::after {
content: '';
position: absolute;
top: 0;
right: 50%;
border-top: 1px solid #ccc;
width: 50%;
height: 20px;
}
.tree-visual li::after {
right: auto;
left: 50%;
border-left: 1px solid #ccc;
}
.tree-visual li:only-child::after, .tree-visual li:only-child::before {
display: none;
}
.tree-visual li:only-child {
padding-top: 0;
}
.tree-visual li:first-child::before, .tree-visual li:last-child::after {
border: 0 none;
}
.tree-visual li:last-child::before {
border-right: 1px solid #ccc;
border-radius: 0 5px 0 0;
}
.tree-visual li:first-child::after {
border-radius: 5px 0 0 0;
}
.tree-visual ul ul::before {
content: '';
position: absolute;
top: 0;
left: 50%;
border-left: 1px solid #ccc;
width: 0;
height: 20px;
}
/* Node Styling */
.tree-node {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 14px;
text-decoration: none;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
transition: all 0.3s;
position: relative;
z-index: 2;
min-width: 100px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
cursor: pointer; /* Nodes are clickable */
}
.tree-node-up {
border-color: var(--network-green);
border-top-width: 3px;
}
.tree-node-down {
border-color: #ccc;
color: #888;
opacity: 0.8;
}
.tree-node-active {
border-color: var(--accent-orange);
border-top-width: 3px;
}
.tree-node-title {
font-weight: 700;
font-size: 13px;
color: var(--text-main);
margin-bottom: 4px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-subtitle {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-secondary);
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
margin-top: 4px;
}
.tree-node-ip {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-secondary);
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
}
.tree-node-details {
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-secondary);
margin-top: 2px;
text-align: left;
width: 100%;
}
.tree-node-details div {
padding: 1px 0;
}
.tree-node:hover {
transform: scale(1.05);
z-index: 10;
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.tree-node-up:hover {
background: #f0fff4;
}
.tree-node-down:hover {
background: #f9f9f9;
}
.tree-node-active:hover {
background: #fff4e6;
}
/* Path node specific styling */
.tree-node-path {
border-color: #9c27b0;
border-top-width: 3px;
}
.tree-node-path:hover {
background: #f3e5f5;
}
/* Expandable tree nodes */
.tree-visual .expandable {
cursor: pointer;
}
.tree-visual .expandable::after {
content: '+';
position: absolute;
top: -8px;
right: -8px;
background: var(--accent-orange);
color: #fff;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.tree-visual .expanded::after {
content: '-';
}
.tree-visual li.collapsed > ul {
display: none;
}
/* Interactive Card Styles for Full Screen Feature */
.card.interactive-card {
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
position: relative;
}
.card.interactive-card:hover {
border-color: var(--accent-orange);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
/* Add a hint icon or visual cue */
.card.interactive-card h3::after {
content: '⤢';
float: right;
opacity: 0.3;
font-size: 14px;
line-height: 14px;
margin-top: 2px;
}
/* Full Screen Modal Overlay */
#fullScreenModal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
z-index: 10000;
display: flex;
flex-direction: column;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
#fullScreenModal.active {
opacity: 1;
pointer-events: auto;
}
.modal-header {
flex: 0 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 30px;
background: #fff;
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.modal-title {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
text-transform: uppercase;
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
transition: all 0.2s;
line-height: 1;
}
.modal-close-btn:hover {
background: #f0f0f0;
color: #d32f2f;
}
.modal-body {
flex: 1 1 auto;
overflow: auto;
padding: 20px;
position: relative;
width: 100%;
height: 100%;
}
/* Ensure the tree visualizer takes full available space in modal */
#modalBodyContent .tree-visual {
height: 100%;
width: 100%;
min-height: unset; /* Let it grow */
border: none;
background: transparent;
}
@media (max-width: 900px) {
body { padding: 12px; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<h2>Kaya</h2>
<div id="stamp">Loading…</div>
</header>
<div class="grid">
<div class="card">
<h3>Self Info</h3>
<pre id="self">\{\}</pre>
</div>
<!-- Made interactive: clicking opens full screen -->
<div class="card interactive-card" onclick="handleCardClick(event, 'flowsEl', 'Flows (Sessions)')">
<h3>Flows (Sessions)</h3>
<div id="flowsEl" class="table-wrap tree-visual"></div>
</div>
<div class="card full-width">
<h3>Peers Switch</h3>
<div class="table-wrap"><table id="peers"></table></div>
</div>
<!-- Made interactive: clicking opens full screen -->
<div class="card interactive-card" onclick="handleCardClick(event, 'pathsEl', 'Paths')">
<h3>Paths</h3>
<div id="pathsEl" class="table-wrap tree-visual"></div>
</div>
<!-- Made interactive: clicking opens full screen -->
<div class="card interactive-card" onclick="handleCardClick(event, 'treeEl', 'Network Tree')">
<h3>Tree</h3>
<div id="treeEl" class="table-wrap tree-visual"></div>
</div>
<div class="card full-width">
<h3>Total Bandwidth (Real-time)</h3>
<div class="graph-stats">
<div class="stat-item">
<div class="legend-dot" style="background: var(--network-green);"></div>
<div>⬇ Download: <span id="downSpeedEl" class="stat-val net-green">0.00 Mbit/s</span></div>
</div>
<div class="stat-item">
<div class="legend-dot" style="background: var(--accent-orange);"></div>
<div>⬆ Upload: <span id="upSpeedEl" class="stat-val" style="color: var(--accent-orange)">0.00 Mbit/s</span></div>
</div>
</div>
<div class="graph-container"><canvas id="trafficGraph"></canvas></div>
</div>
</div>
<!-- Tooltip Element -->
<div id="tooltipEl" class="tooltip"></div>
<!-- Full Screen Modal -->
<div id="fullScreenModal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">View</div>
<button class="modal-close-btn" onclick="closeFullView()"></button>
</div>
<div class="modal-body" id="modalBody">
<div id="modalBodyContent"></div>
</div>
</div>
<script>
const peerStore = new Map();
const graphData = { maxPoints: 60, download: [], upload: [] };
let reloadInFlight = false;
let peerIdCounter = 0;
// Tooltip Logic
const tooltipEl = document.getElementById('tooltipEl');
function showTooltip(e, id) {
const p = peerStore.get(id);
if (!p) return hideTooltip();
const content = `
<div style="border-bottom:1px solid #444; padding-bottom:4px; margin-bottom:4px; font-weight:bold; color:#fff;">
Details
</div>
<div class="tooltip-row"><span>URI:</span> <span style="color:#aaa; word-break:break-all;">${esc(safeText(p.uri))}</span></div>
<div class="tooltip-row"><span>Remote:</span> <span style="color:#aaa;">${esc(safeText(p.remote))}</span></div>
<div class="tooltip-row"><span>IP:</span> <span style="color:#aaa;">${esc(safeText(p.ip))}</span></div>
<div class="tooltip-row"><span>Cost:</span> <span style="color:var(--accent-orange);">${safeNum(p.cost)}</span></div>
<div class="tooltip-row"><span>Priority:</span> <span>${safeNum(p.priority)}</span></div>
<div class="tooltip-row"><span>Inbound:</span> <span>${p.inbound ? 'Yes' : 'No'}</span></div>
<div class="tooltip-row"><span>Banned:</span> <span style="color:${p.banned ? '#ff4444' : '#4caf50'}">${p.banned ? 'Yes' : 'No'}</span></div>
`;
tooltipEl.innerHTML = content;
tooltipEl.style.display = 'block';
moveTooltip(e);
document.addEventListener('mousemove', moveTooltip);
e.target.closest('tr').addEventListener('mouseleave', hideTooltip, { once: true });
}
function hideTooltip() {
tooltipEl.style.display = 'none';
document.removeEventListener('mousemove', moveTooltip);
}
function moveTooltip(e) {
const x = e.clientX + 15;
const y = e.clientY + 15;
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
const tWidth = tooltipEl.offsetWidth;
const tHeight = tooltipEl.offsetHeight;
let finalX = x;
let finalY = y;
if (x + tWidth > winWidth) finalX = x - tWidth - 10;
if (y + tHeight > winHeight) finalY = y - tHeight - 10;
tooltipEl.style.left = finalX + 'px';
tooltipEl.style.top = finalY + 'px';
}
function tHead(cols) {
return '<tr>' + cols.map(c => '<th>' + c + '</th>').join('') + '</tr>';
}
function esc(v) {
return String(v ?? '').replace(/[&<>"]/g, m => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;'
}[m]));
}
function safeArray(v) {
return Array.isArray(v) ? v : [];
}
function safeNum(v) {
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
if (typeof v === 'bigint') return Number(v);
if (typeof v === 'string') {
const cleaned = v.trim().replace(/,/g, '');
if (!cleaned) return 0;
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
function safeText(v, fallback) {
if (v === null || v === undefined) return fallback || '';
try {
return String(v);
} catch {
return fallback || '';
}
}
function bytesPerSecToMbit(bytesPerSec) {
const n = safeNum(bytesPerSec);
const mbits = (n * 8) / 1000000;
return Number.isFinite(mbits) ? mbits : 0;
}
function fmtMbit(bytesPerSec) {
return bytesPerSecToMbit(bytesPerSec).toFixed(2) + ' Mbit/s';
}
function makePeerId(peer, index) {
const uri = safeText(peer && peer.uri, '');
peerIdCounter += 1;
return 'peer_' + index + '_' + peerIdCounter + '_' + uri.length;
}
async function call(path, payload) {
try {
const r = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!r.ok) {
alert(await r.text());
return;
}
} catch (err) {
console.error('POST failed', err);
alert('Request failed');
return;
}
await reload();
}
function peerAction(id, action) {
const p = peerStore.get(id);
if (!p) return;
if (action === 'disconnect') {
return call('/api/peer/traffic', { uri: p.uri, enabled: false });
}
}
function pushGraphSample(downloadMbit, uploadMbit) {
const down = safeNum(downloadMbit);
const up = safeNum(uploadMbit);
graphData.download.push(down);
graphData.upload.push(up);
while (graphData.download.length > graphData.maxPoints) graphData.download.shift();
while (graphData.upload.length > graphData.maxPoints) graphData.upload.shift();
}
function drawGraph() {
const canvas = document.getElementById('trafficGraph');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const parent = canvas.parentNode;
if (!parent) return;
const rect = parent.getBoundingClientRect();
const width = Math.max(10, Math.floor(rect.width));
const height = Math.max(10, Math.floor(rect.height));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const y = (h / 4) * i;
ctx.moveTo(0, y);
ctx.lineTo(w, y);
}
ctx.stroke();
const pointCount = Math.max(graphData.download.length, graphData.upload.length);
if (pointCount === 0) return;
const allValues = graphData.download.concat(graphData.upload).map(safeNum);
let maxVal = 0;
for (const v of allValues) {
if (v > maxVal) maxVal = v;
}
if (!Number.isFinite(maxVal) || maxVal <= 0) maxVal = 1;
maxVal *= 1.1;
function drawLine(data, color) {
const values = data.map(safeNum);
if (values.length === 0) return;
const denom = Math.max(1, graphData.maxPoints - 1);
const step = w / denom;
ctx.beginPath();
for (let i = 0; i < values.length; i++) {
const x = i * step;
const y = h - ((values[i] / maxVal) * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();
if (values.length >= 2) {
const lastX = (values.length - 1) * step;
ctx.lineTo(lastX, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = color + '22';
ctx.fill();
}
}
const rootStyle = getComputedStyle(document.documentElement);
const uploadColor = rootStyle.getPropertyValue('--accent-orange').trim() || '#ff6600';
const downloadColor = rootStyle.getPropertyValue('--network-green').trim() || '#00c853';
drawLine(graphData.upload, uploadColor);
drawLine(graphData.download, downloadColor);
}
function buildPeersTable(peers) {
const rows = [tHead(['Port','URI','Remote','Up','IP','Uptime','RTT','RX','TX','⬇ RX Rate','⬆ TX Rate','Action'])];
let totalRx = 0;
let totalTx = 0;
peerStore.clear();
// Find min and max cost for signal strength calculation
let minCost = Infinity;
let maxCost = -Infinity;
for (let i = 0; i < peers.length; i++) {
const cost = safeNum(peers[i].cost);
if (cost < minCost) minCost = cost;
if (cost > maxCost) maxCost = cost;
}
// Handle case where all costs are the same or undefined
if (minCost === Infinity) minCost = 0;
if (maxCost === -Infinity) maxCost = 0;
if (minCost === maxCost) { maxCost = minCost + 1; minCost = minCost - 1; }
for (let i = 0; i < peers.length; i++) {
const p = peers[i] || {};
try {
const rxRate = safeNum(p.rx_rate);
const txRate = safeNum(p.tx_rate);
totalRx += rxRate;
totalTx += txRate;
const id = makePeerId(p, i);
peerStore.set(id, p);
const isActive = (rxRate > 0 || txRate > 0);
const isConnected = !!p.up;
const portStateClass = isActive ? 'active' : (isConnected ? 'connected' : '');
// Calculate signal strength (0-4, where 4 is best/lowest cost)
const cost = safeNum(p.cost);
let signalStrength = 0;
if (maxCost !== minCost) {
const normalized = (maxCost - cost) / (maxCost - minCost); // 0 (worst/highest cost) to 1 (best/lowest cost)
signalStrength = Math.round(normalized * 4); // 0-4
} else {
signalStrength = 4; // All same cost, show best signal
}
if (signalStrength < 0) signalStrength = 0;
if (signalStrength > 4) signalStrength = 4;
// Generate cell phone signal icon SVG based on signal strength (0-4)
const signalIconHtml =
'<div class="signal-icon-wrapper">' +
'<svg class="signal-icon" viewBox="0 0 16 16" aria-hidden="true">' +
// Bar 1 (smallest) - shown when signalStrength >= 1
'<rect class="signal-bar' + (signalStrength >= 1 ? ' active' : '') + '" x="1" y="11" width="2.5" height="4" rx="0.5" />' +
// Bar 2 - shown when signalStrength >= 2
'<rect class="signal-bar' + (signalStrength >= 2 ? ' active' : '') + '" x="5.5" y="8" width="2.5" height="7" rx="0.5" />' +
// Bar 3 - shown when signalStrength >= 3
'<rect class="signal-bar' + (signalStrength >= 3 ? ' active' : '') + '" x="10" y="5" width="2.5" height="10" rx="0.5" />' +
// Bar 4 (tallest) - shown when signalStrength >= 4
'<rect class="signal-bar' + (signalStrength >= 4 ? ' active' : '') + '" x="14.5" y="2" width="2.5" height="13" rx="0.5" />' +
'</svg>' +
'</div>';
const portIconHtml =
'<div class="port-icon-wrapper">' +
'<svg class="port-icon ' + portStateClass + '" viewBox="0 0 24 24" aria-hidden="true">' +
'<rect class="port-body" x="4" y="6" width="16" height="12" rx="2" />' +
'<path class="port-body" d="M8 10h8v4H8z" />' +
'<circle class="port-led" cx="17" cy="9" r="1.5" />' +
'</svg>' +
'</div>';
rows.push(
'<tr class="peer-row" ' +
'data-peer-id="' + esc(id) + '" ' +
'onmouseenter="showTooltip(event, \'' + esc(id) + '\')">' +
'<td>' + portIconHtml + signalIconHtml + '</td>' +
'<td class="uri-cell" title="' + esc(safeText(p.uri)) + '">' + esc(safeText(p.uri)) + '</td>' +
'<td>' + esc(safeText(p.remote)) + '</td>' +
'<td style="font-weight:bold; color:' + (p.up ? 'var(--accent-orange)' : '#aaa') + '">' + (p.up ? 'UP' : 'DOWN') + '</td>' +
'<td class="ip-cell">' + esc(safeText(p.ip)) + '</td>' +
'<td>' + esc(safeText(p.uptime)) + '</td>' +
'<td>' + esc(safeText(p.rtt)) + '</td>' +
'<td>' + esc(safeText(p.rx)) + '</td>' +
'<td>' + esc(safeText(p.tx)) + '</td>' +
'<td class="net-green">⬇ ' + bytesPerSecToMbit(rxRate).toFixed(2) + ' Mbit/s</td>' +
'<td style="color:var(--accent-orange)">⬆ ' + bytesPerSecToMbit(txRate).toFixed(2) + ' Mbit/s</td>' +
'<td><button onclick="peerAction(\'' + esc(id) + '\',\'disconnect\')">Disconnect</button></td>' +
'</tr>'
);
} catch (err) {
console.error('Failed to render peer row', p, err);
}
}
return {
html: rows.join(''),
totalRx: totalRx,
totalTx: totalTx
};
}
function buildSimpleTable(rows, cols, mapper) {
const out = [tHead(cols)];
for (let i = 0; i < rows.length; i++) {
try {
out.push(mapper(rows[i] || {}, i));
} catch (err) {
console.error('Failed to build row', rows[i], err);
}
}
return out.join('');
}
// New function to build the flows tree visualization
function buildFlowsTree(sessions) {
if (!sessions || sessions.length === 0) {
return `<div style="padding: 20px; color: #666; text-align: center;">No active flows</div>`;
}
let html = `<ul>`;
// Root node for Flows
html += `<li>
<div class="tree-node tree-node-active">
<div class="tree-node-title">Flows</div>
<div class="tree-node-subtitle">${sessions.length} sessions</div>
</div>
<ul>`;
// Add each flow as a child node
sessions.forEach((f, idx) => {
const ip = safeText(f.ip, 'N/A');
const shortIp = ip.length > 15 ? ip.substring(0, 12) + '...' : ip;
const rx = safeText(f.rx, '0');
const tx = safeText(f.tx, '0');
const uptime = safeText(f.uptime, '0s');
html += `<li>
<div class="tree-node expandable expanded" onclick="toggleNode(this)">
<div class="tree-node-title">Flow ${idx + 1}</div>
<div class="tree-node-ip">${esc(shortIp)}</div>
<div class="tree-node-details">
<div>RX: ${esc(rx)}</div>
<div>TX: ${esc(tx)}</div>
<div>Uptime: ${esc(uptime)}</div>
</div>
</div>
<ul>
<li>
<div class="tree-node" style="min-width: 80px;">
<div class="tree-node-title">IP</div>
<div class="tree-node-subtitle">${esc(ip)}</div>
</div>
</li>
<li>
<div class="tree-node" style="min-width: 80px;">
<div class="tree-node-title">RX</div>
<div class="tree-node-subtitle">${esc(rx)}</div>
</div>
</li>
<li>
<div class="tree-node" style="min-width: 80px;">
<div class="tree-node-title">TX</div>
<div class="tree-node-subtitle">${esc(tx)}</div>
</div>
</li>
<li>
<div class="tree-node" style="min-width: 80px;">
<div class="tree-node-title">Uptime</div>
<div class="tree-node-subtitle">${esc(uptime)}</div>
</div>
</li>
</ul>
</li>`;
});
html += `</ul></li></ul>`;
return html;
}
// New function to build the paths tree visualization
function buildPathsTree(paths) {
if (!paths || paths.length === 0) {
return `<div style="padding: 20px; color: #666; text-align: center;">No paths available</div>`;
}
let html = `<ul>`;
// Root node for Paths
html += `<li>
<div class="tree-node tree-node-path">
<div class="tree-node-title">Paths</div>
<div class="tree-node-subtitle">${paths.length} routes</div>
</div>
<ul>`;
// Add each path as a child node
paths.forEach((p, idx) => {
const ip = safeText(p.ip, 'N/A');
const pathArray = safeArray(p.path);
const shortIp = ip.length > 15 ? ip.substring(0, 12) + '...' : ip;
html += `<li>
<div class="tree-node expandable expanded" onclick="toggleNode(this)">
<div class="tree-node-title">Path ${idx + 1}</div>
<div class="tree-node-ip">${esc(shortIp)}</div>
<div class="tree-node-subtitle">${pathArray.length} hops</div>
</div>
<ul>`;
// Add each hop in the path as a nested node
if (pathArray.length > 0) {
pathArray.forEach((hopIp, hopIdx) => {
const shortHopIp = hopIp.length > 12 ? hopIp.substring(0, 9) + '...' : hopIp;
html += `<li>
<div class="tree-node" style="min-width: 100px;">
<div class="tree-node-title">Hop ${hopIdx + 1}</div>
<div class="tree-node-ip">${esc(shortHopIp)}</div>
</div>
</li>`;
});
} else {
html += `<li>
<div class="tree-node" style="min-width: 100px;">
<div class="tree-node-title">Direct</div>
<div class="tree-node-ip">${esc(shortIp)}</div>
</div>
</li>`;
}
html += `</ul></li>`;
});
html += `</ul></li></ul>`;
return html;
}
// Toggle function for expandable tree nodes
function toggleNode(node) {
node.classList.toggle('expanded');
node.closest('li').classList.toggle('collapsed');
}
// New function to build the mindmap tree visualization
function buildTreeVisual(tree, peers) {
const peersMap = new Map();
peers.forEach(p => { if(p.ip) peersMap.set(p.ip, p); });
// Helper to extract name from URI or fallback to IP
const getLabel = (ip) => {
const p = peersMap.get(ip);
let name = ip;
if (p && p.uri) {
// Extract name from URI (format: kaya://name@ip...)
const match = p.uri.match(/\/\/(.+?)@/);
if (match && match[1]) name = match[1];
}
return name;
};
// Helper to get node status class
const getNodeClass = (ip) => {
const p = peersMap.get(ip);
if (p && p.up) return 'tree-node-up';
return 'tree-node-down';
};
// Build adjacency list for children lookup
const childrenMap = new Map();
const allIps = new Set();
tree.forEach(t => {
allIps.add(t.ip);
if (!t.parent) return;
if (!childrenMap.has(t.parent)) childrenMap.set(t.parent, []);
childrenMap.get(t.parent).push(t);
});
// Identify root nodes: nodes whose parent is not in the tree list or is empty
const roots = tree.filter(t => !t.parent || !allIps.has(t.parent));
if (roots.length === 0 && tree.length > 0) roots.push(tree[0]); // Fallback
const buildNodeHtml = (node) => {
const label = getLabel(node.ip);
const shortIp = node.ip.substring(node.ip.lastIndexOf('.') + 1);
const statusClass = getNodeClass(node.ip);
const children = childrenMap.get(node.ip) || [];
let html = `<li>
<div class="tree-node ${statusClass} ${children.length > 0 ? 'expandable expanded' : ''}" ${children.length > 0 ? 'onclick="toggleNode(this)"' : ''} title="IP: ${esc(node.ip)}">
<div class="tree-node-title">${esc(label)}</div>
<div class="tree-node-ip">${esc(shortIp)}</div>
</div>`;
if (children.length > 0) {
html += `<ul>`;
children.forEach(child => {
html += buildNodeHtml(child);
});
html += `</ul>`;
}
html += `</li>`;
return html;
};
let finalHtml = `<ul>`;
roots.forEach(r => finalHtml += buildNodeHtml(r));
finalHtml += `</ul>`;
return finalHtml;
}
// Modal interaction logic
function handleCardClick(e, sourceId, title) {
// If the user clicked directly on a tree node (to expand/collapse), don't open the modal.
// We check closest because the click might be on a span/div inside the node.
if (e.target.closest('.tree-node')) {
return;
}
openFullView(sourceId, title);
}
function openFullView(sourceId, title) {
const sourceEl = document.getElementById(sourceId);
const modal = document.getElementById('fullScreenModal');
const modalTitle = document.getElementById('modalTitle');
const modalContent = document.getElementById('modalBodyContent');
if (sourceEl && modal && modalTitle && modalContent) {
modalTitle.textContent = title;
// Copy the current state of the tree to the modal
modalContent.innerHTML = sourceEl.innerHTML;
modal.classList.add('active');
}
}
function closeFullView() {
const modal = document.getElementById('fullScreenModal');
if (modal) {
modal.classList.remove('active');
// Optional: If we wanted to sync back state, we could do:
// const sourceId = ... (mapped from title or stored)
// document.getElementById(sourceId).innerHTML = document.getElementById('modalBodyContent').innerHTML;
}
}
async function reload() {
if (reloadInFlight) return;
reloadInFlight = true;
try {
const res = await fetch('/api/status', { cache: 'no-store' });
if (!res.ok) {
throw new Error('Failed to load dashboard status: HTTP ' + res.status);
}
const s = await res.json();
const nowValue = s && s.now ? new Date(s.now) : new Date();
const nowText = Number.isNaN(nowValue.getTime()) ? new Date().toLocaleString() : nowValue.toLocaleString();
document.getElementById('stamp').textContent = 'Updated ' + nowText;
document.getElementById('self').textContent = JSON.stringify((s && s.self) || {}, null, 2);
const peers = safeArray(s && s.peers);
const sessions = safeArray(s && s.sessions);
const paths = safeArray(s && s.paths);
const tree = safeArray(s && s.tree);
const peersResult = buildPeersTable(peers);
document.getElementById('peers').innerHTML = peersResult.html;
// Render the new Flows Tree Visualization
document.getElementById('flowsEl').innerHTML = buildFlowsTree(sessions);
// Render the new Paths Tree Visualization
document.getElementById('pathsEl').innerHTML = buildPathsTree(paths);
// Render the Tree Visualization
document.getElementById('treeEl').innerHTML = buildTreeVisual(tree, peers);
const downMbit = bytesPerSecToMbit(peersResult.totalRx);
const upMbit = bytesPerSecToMbit(peersResult.totalTx);
document.getElementById('downSpeedEl').textContent = downMbit.toFixed(2) + ' Mbit/s';
document.getElementById('upSpeedEl').textContent = upMbit.toFixed(2) + ' Mbit/s';
pushGraphSample(downMbit, upMbit);
drawGraph();
} catch (err) {
console.error('Reload failed', err);
document.getElementById('stamp').textContent = 'Update failed';
} finally {
reloadInFlight = false;
}
}
setInterval(() => {
reload().catch(err => console.error(err));
}, 2000);
reload().catch(err => console.error(err));
window.addEventListener('resize', drawGraph);
</script>
</body>
</html>