1103 lines
34 KiB
HTML
1103 lines
34 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); }
|
|
.signal-icon.offline .signal-bar.active { fill: #9ca3af; }
|
|
|
|
.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; }
|
|
}
|
|
.project-board {
|
|
margin-top: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: #fff;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.project-board a {
|
|
color: var(--text-main);
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
.project-board a:hover { text-decoration: underline; }
|
|
</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>
|
|
|
|
<div class="project-board">
|
|
<span aria-hidden="true"><strong>Git</strong></span>
|
|
<span>
|
|
Kaya source:
|
|
<a href="https://git.protogen.engineering/racks/kaya-go" target="_blank" rel="noopener noreferrer">https://git.protogen.engineering/racks/kaya-go</a>
|
|
</span>
|
|
</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 => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"'
|
|
}[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;
|
|
const isDownWithZeroCost = !p.up && cost === 0;
|
|
if (isDownWithZeroCost) {
|
|
signalStrength = 4;
|
|
} else 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;
|
|
const signalStateClass = isDownWithZeroCost ? ' offline' : '';
|
|
|
|
// Generate cell phone signal icon SVG based on signal strength (0-4)
|
|
const signalIconHtml =
|
|
'<div class="signal-icon-wrapper">' +
|
|
'<svg class="signal-icon' + signalStateClass + '" 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>
|