Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions docs/network-status.html
Original file line number Diff line number Diff line change
Expand Up @@ -232,17 +232,29 @@ <h2 class="text-lg font-semibold mb-3">README Badge / Shield</h2>

function renderHistory() {
const box = document.getElementById('historyList');
box.innerHTML = '';
box.replaceChildren();
for (const base of NODE_ENDPOINTS) {
const row = document.createElement('div');
row.className = 'rounded border border-slate-800 bg-slate-950 p-3';
row.innerHTML = `
<div class="flex items-center justify-between mb-2">
<div class="font-mono text-xs break-all">${base}</div>
<div class="text-xs text-slate-300">90d uptime: <span class="text-emerald-300">${uptimePct(base)}</span></div>
</div>
${sparkline(base)}
`;
// Build with safe DOM construction — never innerHTML for untrusted fields.
const top = document.createElement('div');
top.className = 'flex items-center justify-between mb-2';
const urlSpan = document.createElement('div');
urlSpan.className = 'font-mono text-xs break-all';
urlSpan.textContent = base;
const upSpan = document.createElement('div');
upSpan.className = 'text-xs text-slate-300';
upSpan.textContent = '90d uptime: ';
const upVal = document.createElement('span');
upVal.className = 'text-emerald-300';
upVal.textContent = uptimePct(base);
upSpan.appendChild(upVal);
top.append(urlSpan, upSpan);
// The sparkline returns an <svg> string with no untrusted interpolation,
// so a controlled innerHTML assignment is safe here.
const spark = document.createElement('div');
spark.innerHTML = sparkline(base);
row.append(top, spark);
box.appendChild(row);
}
}
Expand Down
129 changes: 95 additions & 34 deletions status/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,18 @@ <h2 style="margin-top:18px">Fetch Notes</h2>
}
}

// Whitelist of allowed status values; anything else collapses to "unknown"
// so we never interpolate untrusted strings into class attributes.
const STATUS_WHITELIST = new Set(["online", "offline", "degraded", "blocked", "unknown"]);

function safeClass(value, fallback = "") {
return STATUS_WHITELIST.has(value) ? value : fallback;
}

function renderNodes(results) {
$("node-list").innerHTML = results.map((result) => {
const list = $("node-list");
list.replaceChildren();
for (const result of results) {
const status = getOnlineStatus(result);
const health = result.health || {};
const epoch = result.epoch || {};
Expand All @@ -566,26 +576,55 @@ <h2 style="margin-top:18px">Fetch Notes</h2>
? `${Math.floor(Number(uptime) / 3600)}h ${Math.floor((Number(uptime) % 3600) / 60)}m`
: "unknown";

return `
<article class="node-card">
<div class="node-head">
<div>
<div class="node-title">${result.node.name}</div>
<div class="node-sub">${result.node.location}<br>${result.node.origin}</div>
</div>
<span class="badge ${status === "online" ? "ok" : status === "offline" ? "bad" : status === "blocked" ? "blocked" : ""}">${status}</span>
</div>
<div class="kv">
<div><span>Response</span><strong>${result.responseMs ? `${result.responseMs} ms` : "--"}</strong></div>
<div><span>Version</span><strong>${escapeHtml(version)}</strong></div>
<div><span>Miners</span><strong>${minerCount || "--"}</strong></div>
<div><span>Epoch</span><strong>${epoch.epoch ?? health.epoch ?? "--"}</strong></div>
<div><span>Slot</span><strong>${epoch.slot ?? "--"}</strong></div>
<div><span>Uptime</span><strong>${uptimeLabel}</strong></div>
</div>
</article>
`;
}).join("");
// Build with safe DOM construction — never innerHTML for untrusted fields.
const card = document.createElement("article");
card.className = "node-card";

const head = document.createElement("div");
head.className = "node-head";

const headInfo = document.createElement("div");
const title = document.createElement("div");
title.className = "node-title";
title.textContent = result.node.name ?? "";
const sub = document.createElement("div");
sub.className = "node-sub";
const loc = document.createElement("span");
loc.textContent = result.node.location ?? "";
sub.append(loc, document.createElement("br"));
const originSpan = document.createElement("span");
originSpan.textContent = result.node.origin ?? "";
sub.appendChild(originSpan);
headInfo.append(title, sub);

const badge = document.createElement("span");
badge.className = `badge ${safeClass(status === "online" ? "ok" : status === "offline" ? "bad" : status === "blocked" ? "blocked" : "", status === "online" ? "ok" : status === "offline" ? "bad" : "")}`;
badge.textContent = status;
head.append(headInfo, badge);

const kv = document.createElement("div");
kv.className = "kv";
const mkRow = (label, value) => {
const row = document.createElement("div");
const lbl = document.createElement("span");
lbl.textContent = label;
const strong = document.createElement("strong");
strong.textContent = value;
row.append(lbl, strong);
return row;
};
kv.append(
mkRow("Response", result.responseMs ? `${result.responseMs} ms` : "--"),
mkRow("Version", version),
mkRow("Miners", String(minerCount || "--")),
mkRow("Epoch", String(epoch.epoch ?? health.epoch ?? "--")),
mkRow("Slot", String(epoch.slot ?? "--")),
mkRow("Uptime", uptimeLabel)
);

card.append(head, kv);
list.appendChild(card);
}
}

function renderSummary(results, primaryMiners, primaryEpoch) {
Expand Down Expand Up @@ -632,30 +671,52 @@ <h2 style="margin-top:18px">Fetch Notes</h2>

const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]);
const total = miners.length || 0;
const target = $("arch-breakdown");
target.replaceChildren();
if (!rows.length) {
$("arch-breakdown").innerHTML = `<div class="muted">Miner architecture data is unavailable.</div>`;
const muted = document.createElement("div");
muted.className = "muted";
muted.textContent = "Miner architecture data is unavailable.";
target.appendChild(muted);
return;
}

$("arch-breakdown").innerHTML = rows.map(([arch, count]) => {
for (const [arch, count] of rows) {
const pct = total ? Math.round((count / total) * 100) : 0;
return `
<div class="arch-row">
<strong>${escapeHtml(arch)}</strong>
<div class="track"><div class="fill" style="width:${pct}%"></div></div>
<span class="muted">${count}</span>
</div>
`;
}).join("");
const row = document.createElement("div");
row.className = "arch-row";
const archStrong = document.createElement("strong");
archStrong.textContent = arch;
const track = document.createElement("div");
track.className = "track";
const fill = document.createElement("div");
fill.className = "fill";
fill.style.width = `${pct}%`;
track.appendChild(fill);
const mutedCount = document.createElement("span");
mutedCount.className = "muted";
mutedCount.textContent = String(count);
row.append(archStrong, track, mutedCount);
target.appendChild(row);
}
}

function renderLog(results) {
$("fetch-log").innerHTML = results.map((result) => {
const target = $("fetch-log");
target.replaceChildren();
const ALLOWED = new Set(["online", "offline", "blocked", "amber"]);
for (const result of results) {
const status = getOnlineStatus(result);
const cls = status === "online" ? "green" : status === "offline" ? "red" : status === "blocked" ? "" : "amber";
const msg = result.error || `health ok in ${result.responseMs} ms`;
return `<div class="log-item"><strong class="${cls}">${result.node.name}</strong>: ${escapeHtml(msg)}</div>`;
}).join("");
const item = document.createElement("div");
item.className = "log-item";
const strong = document.createElement("strong");
strong.className = ALLOWED.has(cls) ? cls : "";
strong.textContent = result.node.name ?? "";
item.append(strong, document.createTextNode(`: ${msg}`));
target.appendChild(item);
}
}

function escapeHtml(value) {
Expand Down
111 changes: 83 additions & 28 deletions status/templates/status.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ <h2 class="section-title">📋 Recent Incidents</h2>
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
// Whitelist of CSS class fragments safe to interpolate from server-side data.
// Any class not in this set is dropped so an attacker cannot break out of the
// attribute via a crafted node name, status string, or event label.
const ALLOWED_STATUS_CLASSES = new Set(['up', 'down', 'recovered', 'unknown']);
function safeClass(value, fallback = '') {
return ALLOWED_STATUS_CLASSES.has(String(value)) ? String(value) : fallback;
}
async function refresh() {
try {
const [statusRes, incRes, uptimeRes] = await Promise.all([
Expand All @@ -106,54 +113,102 @@ <h2 class="section-title">📋 Recent Incidents</h2>
const nid = node.name.toLowerCase().replace(/\s+/g, '-').replace('node-','node-');
const nodeId = Object.keys(uptime).find(k => uptime[k].name === node.name) || '';
const u = uptime[nodeId] || {};
const statusClass = safeClass(node.up ? 'up' : 'down');
const card = document.createElement('div');
card.className = 'node-card';
card.innerHTML = `
<div class="node-header">
<span class="node-name">${escapeHtml(node.name)}</span>
<span class="status-badge ${node.up?'up':'down'}">${node.up?'Operational':'Down'}</span>
</div>
<div class="node-stats">
<div><span class="stat-label">Location</span><br><span class="stat-value">${escapeHtml(node.location || '—')}</span></div>
<div><span class="stat-label">Response</span><br><span class="stat-value">${escapeHtml(node.response_ms || 0)}ms</span></div>
<div><span class="stat-label">Miners</span><br><span class="stat-value">${escapeHtml(node.active_miners || '—')}</span></div>
<div><span class="stat-label">Epoch</span><br><span class="stat-value">${escapeHtml(node.current_epoch || '—')}</span></div>
<div><span class="stat-label">Version</span><br><span class="stat-value">${escapeHtml(node.version || '—')}</span></div>
<div><span class="stat-label">Uptime</span><br><span class="stat-value">${escapeHtml(formatUptime(node.uptime))}</span></div>
</div>
<div class="uptime-pct">${u.uptime_pct!==undefined?escapeHtml(u.uptime_pct+'% uptime (24h)'):'—'}</div>
<div class="uptime-bar" id="bar-${nodeId}"></div>
`;
// Build the static skeleton with safe DOM methods, then escape dynamic text.
const header = document.createElement('div');
header.className = 'node-header';
const nameSpan = document.createElement('span');
nameSpan.className = 'node-name';
nameSpan.textContent = node.name ?? '';
const badge = document.createElement('span');
badge.className = `status-badge ${statusClass}`;
badge.textContent = node.up ? 'Operational' : 'Down';
header.append(nameSpan, badge);

const stats = document.createElement('div');
stats.className = 'node-stats';
const mkStat = (label, value) => {
const wrap = document.createElement('div');
const lbl = document.createElement('span');
lbl.className = 'stat-label';
lbl.textContent = label;
const val = document.createElement('span');
val.className = 'stat-value';
val.textContent = value;
wrap.append(lbl, document.createElement('br'), val);
return wrap;
};
stats.append(
mkStat('Location', node.location || '—'),
mkStat('Response', `${node.response_ms || 0}ms`),
mkStat('Miners', String(node.active_miners || '—')),
mkStat('Epoch', String(node.current_epoch || '—')),
mkStat('Version', String(node.version || '—')),
mkStat('Uptime', formatUptime(node.uptime))
);

const uptimePct = document.createElement('div');
uptimePct.className = 'uptime-pct';
uptimePct.textContent = u.uptime_pct !== undefined ? `${u.uptime_pct}% uptime (24h)` : '—';

const bar = document.createElement('div');
bar.className = 'uptime-bar';
bar.id = `bar-${nodeId}`;

card.append(header, stats, uptimePct, bar);
container.appendChild(card);
// Load history for uptime bar
if (nodeId) {
fetch('/api/history/'+nodeId).then(r=>r.json()).then(hist=>{
const bar = document.getElementById('bar-'+nodeId);
if(!bar) return;
// Show last 48 ticks (every 30 min summary)
const barEl = document.getElementById('bar-'+nodeId);
if(!barEl) return;
// Show last 48 ticks (every 30 min summary) — class names whitelisted.
const buckets = [];
const step = Math.max(1, Math.floor(hist.length/48));
for(let i=0;i<hist.length;i+=step){
const slice = hist.slice(i,i+step);
const anyDown = slice.some(h=>!h.up);
buckets.push(anyDown?'down':'up');
}
bar.innerHTML = buckets.map(s=>`<div class="tick ${s}"></div>`).join('');
barEl.replaceChildren(...buckets.map(s => {
const tick = document.createElement('div');
tick.className = `tick ${safeClass(s)}`;
return tick;
}));
});
}
}
// Incidents
const incEl = document.getElementById('incidents');
incEl.replaceChildren();
if(incidents.length===0){
incEl.innerHTML='<div class="empty">No incidents recorded</div>';
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No incidents recorded';
incEl.appendChild(empty);
} else {
incEl.innerHTML=incidents.slice(0,20).map(i=>`
<div class="incident">
<span><span class="event-${i.event === 'down' ? 'down' : i.event === 'recovered' ? 'recovered' : 'unknown'}">${i.event==='down'?'🔴':'🟢'} ${escapeHtml(i.node)} — ${escapeHtml(i.event)}</span>
${i.detail?' · '+escapeHtml(i.detail):''}</span>
<span class="time">${escapeHtml(formatTime(i.time))}</span>
</div>
`).join('');
for (const i of incidents.slice(0,20)) {
const eventClass = safeClass(
i.event === 'down' ? 'down' : i.event === 'recovered' ? 'recovered' : 'unknown'
);
const row = document.createElement('div');
row.className = 'incident';
const left = document.createElement('span');
const evt = document.createElement('span');
evt.className = `event-${eventClass}`;
evt.textContent = `${i.event==='down'?'🔴':'🟢'} ${i.node ?? ''} — ${i.event ?? ''}`;
left.append(evt);
if (i.detail) {
left.append(document.createTextNode(` · ${i.detail}`));
}
const timeSpan = document.createElement('span');
timeSpan.className = 'time';
timeSpan.textContent = formatTime(i.time);
row.append(left, timeSpan);
incEl.appendChild(row);
}
}
} catch(e) { console.error('Refresh failed:', e); }
}
Expand Down
Loading
Loading