Compare commits

...

6 Commits

6 changed files with 1423 additions and 83 deletions

View File

@@ -1244,3 +1244,313 @@ body.wide #federation-network-graph { height: 720px; }
.fn-status-badge-vouched { color: #c4b5fd; border-color: #a78bfa; background: rgba(167,139,250,0.10); }
.fn-status-badge-unknown { color: var(--muted); border-color: var(--muted); }
.fn-status-badge-blocked { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
/* ---------- federation network — enriched detail layer ---------------- */
/* Per-node stat badge: small monospace pill sitting just below the
sublabel ("8 sig · 2 vch · 1 quo"). SVG <text> styled, not a real
HTML pill — we keep it inline with the node group for layout. */
.fn-stat-badge {
fill: var(--accent);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
text-anchor: middle;
pointer-events: none;
opacity: 0.85;
letter-spacing: 0.02em;
}
.fn-distance-2 .fn-stat-badge { display: none; }
/* Corroboration edges — dotted faint accent, lower z visually. */
.fn-kind-corroborate .fn-edge {
stroke: var(--accent);
stroke-width: 1.1;
stroke-dasharray: 1 5;
stroke-linecap: round;
opacity: 0.28;
animation: fn-corr-pulse 3.2s ease-in-out infinite;
}
.fn-kind-corroborate .fn-edge-label {
fill: rgba(170, 220, 255, 0.55);
font-size: 8.5px;
display: none; /* surfaced via tooltip; chart stays calm */
}
.fn-kind-corroborate .fn-edge-grp { pointer-events: none; }
@keyframes fn-corr-pulse {
0%, 100% { stroke-opacity: 0.22; }
50% { stroke-opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
.fn-kind-corroborate .fn-edge { animation: none; }
}
#federation-network-graph.flow-off .fn-kind-corroborate .fn-edge { animation: none; }
/* Hover tooltip — absolutely positioned, accent-bordered HUD pill. */
.fn-tooltip {
position: absolute;
z-index: 50;
background: rgba(15, 17, 21, 0.96);
border: 1px solid var(--accent);
border-radius: 6px;
box-shadow: 0 0 18px var(--accent-glow), 0 6px 22px rgba(0,0,0,0.55);
padding: 8px 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--text);
line-height: 1.45;
pointer-events: none;
max-width: 320px;
white-space: nowrap;
display: none;
}
.fn-tooltip.is-visible { display: block; }
.fn-tooltip-title {
color: var(--accent);
font-family: var(--font-display);
font-size: 12px;
margin-bottom: 4px;
letter-spacing: 0.05em;
}
.fn-tooltip-row { display: flex; gap: 10px; }
.fn-tooltip-row .k { color: var(--muted); min-width: 70px; }
.fn-tooltip-row .v { color: var(--text); }
/* Search/filter bar above the graph. */
.fn-search-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
}
.fn-search-bar label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.fn-search-input {
flex: 1;
max-width: 460px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.fn-search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.fn-search-count { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
/* Search dim/highlight states. */
.fn-node.dimmed { opacity: 0.15; }
.fn-node.match circle, .fn-node.match rect { stroke: var(--amber); stroke-width: 2.4; filter: drop-shadow(0 0 8px rgba(251,191,36,0.55)); }
.fn-edge-grp.dimmed { opacity: 0.08; }
/* Rich detail card — sits in the existing .topo-detail container. */
.fn-detail-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin-top: 10px;
}
.fn-detail-sec {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
min-width: 0; /* allow children to wrap */
}
.fn-detail-sec h4 {
margin: 0 0 8px;
font-size: 11px;
font-family: var(--font-display);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.10em;
font-weight: 600;
}
.fn-detail-sec .row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
.fn-detail-sec .row .k { color: var(--muted); }
.fn-detail-sec .row .v { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
.fn-detail-sec code {
font-size: 11px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 5px;
word-break: break-all;
display: inline-block;
}
.fn-detail-sec .full-fp { font-size: 11px; line-height: 1.55; }
.fn-copy-btn {
display: inline-block;
background: transparent;
color: var(--accent);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 10px;
padding: 1px 6px;
margin-left: 6px;
font-family: ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
letter-spacing: 0.04em;
}
.fn-copy-btn:hover { border-color: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
/* Severity chips inside the Signals section. */
.fn-sev-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.fn-sev-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
border: 1px solid var(--border);
background: var(--panel);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fn-sev-chip .n { font-weight: 700; }
.fn-sev-critical { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
.fn-sev-high { color: var(--amber); border-color: var(--amber); background: rgba(251,191,36,0.10); }
.fn-sev-medium { color: #fde68a; border-color: rgba(253,224,71,0.55); background: rgba(253,224,71,0.06); }
.fn-sev-low { color: var(--muted); border-color: var(--border); }
/* IOC-type chips reuse the chip shell with muted accents. */
.fn-ioc-chip {
display: inline-flex; gap: 4px; padding: 2px 8px;
border-radius: 10px; font-size: 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
border: 1px solid var(--border); background: var(--panel);
color: var(--text);
}
.fn-ioc-chip .k { color: var(--accent); }
.fn-ioc-chip .n { color: var(--text); font-weight: 700; }
/* Quorum progress bar. */
.fn-quorum-bar {
position: relative;
height: 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
margin-top: 6px;
}
.fn-quorum-fill {
position: absolute; inset: 0 auto 0 0;
background: linear-gradient(90deg, var(--accent), var(--green));
box-shadow: 0 0 8px var(--accent-glow);
}
/* Translog list inside the detail card. */
.fn-trans-list {
list-style: none; margin: 0; padding: 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
max-height: 180px;
overflow-y: auto;
}
.fn-trans-list li {
display: flex; gap: 8px; padding: 3px 0;
border-bottom: 1px dashed var(--border);
}
.fn-trans-list .id { color: var(--muted); min-width: 38px; }
.fn-trans-list .type { color: var(--accent); min-width: 50px; }
.fn-trans-list .ts { color: var(--muted); }
.fn-trans-list .hash { color: var(--text); }
/* Clickable fingerprint chip — jumps to that peer in the graph. */
.fn-fp-jump {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--accent);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
cursor: pointer;
margin: 2px 4px 2px 0;
display: inline-block;
}
.fn-fp-jump:hover { border-color: var(--accent); text-shadow: 0 0 8px var(--accent-glow); }
/* Action buttons inside the detail card. */
.fn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.fn-action-btn {
display: inline-block;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--accent);
background: var(--panel);
font-size: 12px;
font-family: ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
text-decoration: none;
}
.fn-action-btn:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); text-decoration: none; }
/* 24h timeline strip. */
.fn-timeline-wrap {
margin-top: 18px;
padding: 12px 14px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
}
.fn-timeline-head {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 8px;
}
.fn-timeline-head h3 {
margin: 0; font-size: 12px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.10em; font-weight: 600;
font-family: var(--font-display);
}
.fn-timeline-head .meta { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
.fn-timeline {
display: flex;
align-items: flex-end;
gap: 2px;
height: 90px;
border-bottom: 1px solid var(--border);
padding-bottom: 2px;
}
.fn-timeline-bar {
flex: 1;
display: flex;
flex-direction: column-reverse; /* segments stack from bottom up */
align-items: stretch;
min-width: 6px;
height: 100%;
position: relative;
background: rgba(125,133,151,0.04);
border-bottom: 1px solid transparent;
cursor: default;
}
.fn-timeline-bar:hover { background: rgba(30,200,255,0.08); }
.fn-timeline-bar-seg {
width: 100%;
min-height: 1px;
transition: filter 0.15s;
}
.fn-timeline-bar:hover .fn-timeline-bar-seg { filter: brightness(1.25); }
.fn-timeline-axis {
display: flex; gap: 2px; margin-top: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 9px; color: var(--muted);
}
.fn-timeline-axis span { flex: 1; text-align: center; min-width: 6px; }
.fn-timeline-empty {
color: var(--muted); font-size: 12px; font-style: italic;
text-align: center; padding: 22px 0;
}

View File

@@ -1,13 +1,18 @@
/* psyc — federation network force-directed graph.
/* psyc — federation network force-directed graph (enriched detail layer).
*
* Self at center, direct peers around it, transitive peers (distance=2) on
* the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness
* ∝ weight), knows (dotted grey).
* ∝ weight), knows (dotted grey), corroborate (dotted accent, faint pulse).
*
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop so
* the two pages feel familiar; once both are stable the shared engine can
* factor out into a force_graph.js module — for now, a copy keeps the diff
* narrow.
* Compared to the previous version this file additionally renders:
* • per-peer compact stat badge below the sublabel
* • opacity-scaled fill based on log(signals_24h) for non-self nodes
* • a search/filter bar that dims non-matching nodes/edges
* • a hover tooltip with key stats
* • a much richer click-to-inspect detail panel
* • a 24h stacked timeline strip at the bottom of the page
*
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop.
*/
(function () {
@@ -18,10 +23,14 @@
const loadingEl = document.getElementById("fn-loading");
const errorEl = document.getElementById("fn-error");
const transitiveCountEl = document.getElementById("fn-transitive-count");
const tooltipEl = document.getElementById("fn-tooltip");
const searchEl = document.getElementById("fn-search");
const searchCountEl = document.getElementById("fn-search-count");
const timelineEl = document.getElementById("fn-timeline");
const timelineAxisEl = document.getElementById("fn-timeline-axis");
const timelineMetaEl = document.getElementById("fn-timeline-meta");
if (!svg) return;
// Fetch data, then build the graph. The /data endpoint includes transitive
// peers (mid-cost cached server-side at 5 min TTL).
fetch("/admin/federation/network/data", { credentials: "same-origin" })
.then(r => {
if (!r.ok) throw new Error("HTTP " + r.status);
@@ -39,23 +48,68 @@
}
});
// ---------- shared escape -----------------------------------------------
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function shortFp(fp) {
if (!fp) return "—";
if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8);
return fp;
}
function render(data) {
const selfFp = data.self_fingerprint || "";
const nodesData = data.nodes || [];
const edgesData = data.edges || [];
const topStats = data.stats || {};
if (transitiveCountEl) {
const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
transitiveCountEl.textContent = String(n);
}
// ---------- color palette for peers (timeline + tooltips) -------------
// 12 evenly-spaced HSL hues, accent-leaning. Used in the timeline strip
// to colorize per-peer segments inside each bar.
const PEER_PALETTE = [
"#1ec8ff", "#a78bfa", "#4ade80", "#fbbf24", "#f87171", "#34d399",
"#60a5fa", "#f472b6", "#fb923c", "#22d3ee", "#c084fc", "#facc15",
];
const peerColor = Object.create(null);
const peerOrder = nodesData
.filter(n => !n.is_self)
.map(n => n.fingerprint)
.sort();
peerOrder.forEach((fp, i) => {
peerColor[fp] = PEER_PALETTE[i % PEER_PALETTE.length];
});
// ---------- build node + edge sim objects -----------------------------
// Pre-compute the max 24h signal count so we can log-normalize fill
// opacity per node (busy peer → fully saturated, quiet → faint).
let maxSignals24h = 0;
for (const nd of nodesData) {
const s = (nd.stats && nd.stats.signals_24h) || 0;
if (s > maxSignals24h) maxSignals24h = s;
}
const nodes = [];
const nodeByFp = Object.create(null);
for (const nd of nodesData) {
const isSelf = !!nd.is_self;
const dist = Number(nd.distance || 0);
const r = isSelf ? 38 : (dist >= 2 ? 9 : 16);
const stats = nd.stats || null;
let intensity = 1;
if (!isSelf && stats && maxSignals24h > 0) {
// log scale so a 100-signal peer doesn't blow out a 5-signal one.
const s = stats.signals_24h || 0;
const num = Math.log2(s + 1);
const den = Math.log2(maxSignals24h + 1) || 1;
intensity = 0.18 + 0.82 * (num / den);
}
const n = {
id: nd.fingerprint,
fp: nd.fingerprint,
@@ -64,9 +118,10 @@
status: nd.status || "unknown",
is_self: isSelf,
distance: dist,
stats,
intensity,
r,
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
tooltip: buildTooltip(nd),
};
nodes.push(n);
nodeByFp[n.id] = n;
@@ -87,15 +142,6 @@
});
}
function buildTooltip(nd) {
const lines = [];
lines.push((nd.is_self ? "self · " : "") + (nd.domain || nd.label || nd.fingerprint));
lines.push("fp: " + nd.fingerprint);
lines.push("status: " + (nd.status || "unknown"));
lines.push("distance: " + (nd.distance || 0));
return lines.join("\n");
}
// ---------- viewport + seeding ---------------------------------------
function viewport() {
const W = svg.clientWidth || 900;
@@ -108,10 +154,7 @@
(function seed() {
const cx = W / 2, cy = H / 2;
nodes.forEach((n, i) => {
if (n.is_self) {
n.x = cx; n.y = cy;
return;
}
if (n.is_self) { n.x = cx; n.y = cy; return; }
const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22;
const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20;
@@ -119,8 +162,6 @@
});
})();
// Force-sim params — same shape as topology.js, slightly softer springs
// so the two rings settle visibly.
const REPULSION = 1500;
const SPRING_K = 0.035;
const SPRING_REST_BASE = 110;
@@ -144,7 +185,8 @@
for (const e of edges) {
const a = nodeByFp[e.source], b = nodeByFp[e.target];
if (!a || !b) continue;
// "knows" edges (distance-2) rest longer so transitive bands stay clear.
// corroborate edges are decorative; don't pull on the layout.
if (e.kind === "corroborate") continue;
const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
@@ -163,30 +205,38 @@
n.y = Math.max(n.r, Math.min(H - n.r, n.y));
}
}
// Pre-settle so the first frame isn't a glob.
for (let i = 0; i < 280; i++) tick();
// ---------- render ----------------------------------------------------
// ---------- render SVG groups ----------------------------------------
// Order matters: corroborate edges go first so they sit behind the
// primary edges, then the rest, then nodes on top.
const ns = "http://www.w3.org/2000/svg";
const corrG = document.createElementNS(ns, "g");
const edgesG = document.createElementNS(ns, "g");
const nodesG = document.createElementNS(ns, "g");
corrG.setAttribute("class", "fn-edges fn-edges-corr");
edgesG.setAttribute("class", "fn-edges");
nodesG.setAttribute("class", "fn-nodes");
svg.appendChild(corrG);
svg.appendChild(edgesG);
svg.appendChild(nodesG);
const edgeEls = edges.map(e => {
const grp = document.createElementNS(ns, "g");
grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind);
grp.dataset.source = e.source;
grp.dataset.target = e.target;
const ln = document.createElementNS(ns, "line");
ln.setAttribute("class", "fn-edge");
// Signal weight controls stroke width; cap at 5px so a noisy peer
// doesn't blot out the layout.
if (e.kind === "signal") {
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
ln.setAttribute("stroke-width", w.toFixed(2));
}
if (e.kind === "corroborate") {
// Stroke width hints at how many shared hashes this pair has.
const w = Math.min(3, 0.8 + Math.log2(e.weight + 1) * 0.5);
ln.setAttribute("stroke-width", w.toFixed(2));
}
grp.appendChild(ln);
if (e.label) {
const lbl = document.createElementNS(ns, "text");
@@ -194,8 +244,9 @@
lbl.textContent = e.label;
grp.appendChild(lbl);
}
edgesG.appendChild(grp);
return { line: ln, label: grp.querySelector("text") };
const host = e.kind === "corroborate" ? corrG : edgesG;
host.appendChild(grp);
return { line: ln, label: grp.querySelector("text"), grp };
});
function _classFor(n) {
@@ -209,17 +260,20 @@
g.setAttribute("class", _classFor(n));
g.dataset.fp = n.fp;
let shape;
if (n.is_self) {
const sz = n.r;
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("x", -sz); rect.setAttribute("y", -sz);
rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2);
rect.setAttribute("rx", 10); rect.setAttribute("ry", 10);
g.appendChild(rect);
shape = document.createElementNS(ns, "rect");
shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
g.appendChild(shape);
} else {
const c = document.createElementNS(ns, "circle");
c.setAttribute("r", n.r);
g.appendChild(c);
shape = document.createElementNS(ns, "circle");
shape.setAttribute("r", n.r);
// Log-normalized fill opacity: quiet peer → faint, busy → full.
shape.setAttribute("fill-opacity", n.intensity.toFixed(2));
g.appendChild(shape);
}
const text = document.createElementNS(ns, "text");
@@ -234,10 +288,25 @@
sub.setAttribute("dy", n.r + 24);
sub.textContent = n.fp.slice(0, 8) + "…";
g.appendChild(sub);
// Stat badge — compact "↓ signals · ✓ vouches-in · ⚡ quorum".
// Hidden for distance=2 nodes via CSS, since their data is sparse.
if (n.stats) {
const badge = document.createElementNS(ns, "text");
badge.setAttribute("class", "fn-stat-badge");
badge.setAttribute("dy", n.r + 36);
const s = n.stats;
badge.textContent =
"↓ " + (s.signals_24h || 0) +
" · ✓ " + (s.vouches_in_count || 0) +
" · ⚡ " + (s.quorum_contribution || 0);
g.appendChild(badge);
}
}
// Native <title> stays as an accessibility fallback for keyboard users.
const title = document.createElementNS(ns, "title");
title.textContent = n.tooltip;
title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp);
g.appendChild(title);
nodesG.appendChild(g);
@@ -263,7 +332,46 @@
}
paint();
// ---------- drag + click --------------------------------------------
// ---------- tooltip --------------------------------------------------
function showTooltip(n, clientX, clientY) {
if (!tooltipEl) return;
const s = n.stats || {};
const rows = [];
rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">${esc(n.status)}</span></div>`);
if (!n.is_self) {
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signals_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(s.last_seen_relative || "—")}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">vouches</span><span class="v">in ${s.vouches_in_count || 0} · out ${s.vouches_out_count || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum</span><span class="v">${s.quorum_contribution || 0}</span></div>`);
} else {
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${topStats.total_peers || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${topStats.signals_buffered_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum-met</span><span class="v">${topStats.quorum_met_count || 0}</span></div>`);
}
tooltipEl.innerHTML = rows.join("");
tooltipEl.classList.add("is-visible");
positionTooltip(clientX, clientY);
}
function positionTooltip(clientX, clientY) {
if (!tooltipEl) return;
const parent = svg.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
let x = clientX - rect.left + 14;
let y = clientY - rect.top + 14;
const tw = tooltipEl.offsetWidth || 240;
const th = tooltipEl.offsetHeight || 100;
if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14;
if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14;
tooltipEl.style.left = x + "px";
tooltipEl.style.top = y + "px";
}
function hideTooltip() {
if (tooltipEl) tooltipEl.classList.remove("is-visible");
}
// ---------- drag + click + hover ------------------------------------
let dragging = null, dragOffset = { x: 0, y: 0 };
let pressedNode = null, pressedAt = null, moved = false;
function svgPoint(clientX, clientY) {
@@ -271,17 +379,21 @@
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
nodeEls.forEach((g, i) => {
const n = nodes[i];
g.addEventListener("mousedown", ev => {
ev.preventDefault();
pressedNode = nodes[i];
pressedNode = n;
pressedAt = { x: ev.clientX, y: ev.clientY };
moved = false;
dragging = nodes[i];
dragging = n;
const p = svgPoint(ev.clientX, ev.clientY);
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
if (currentLayout === "force") dragging.fixed = true;
g.classList.add("dragging");
});
g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY));
g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY));
g.addEventListener("mouseleave", hideTooltip);
});
document.addEventListener("mousemove", ev => {
if (pressedAt) {
@@ -309,44 +421,166 @@
});
// ---------- detail panel --------------------------------------------
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function selectNode(n) {
nodeEls.forEach(el => el.classList.remove("selected"));
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
if (me) me.classList.add("selected");
renderDetail(n);
}
function jumpToFp(fp) {
const target = nodeByFp[fp];
if (!target) return;
selectNode(target);
}
function clearSelection() {
nodeEls.forEach(el => el.classList.remove("selected"));
if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
}
function countEdges(fp) {
let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0;
for (const e of edges) {
if (e.kind === "vouch" && e.source === fp) vouchOut++;
else if (e.kind === "vouch" && e.target === fp) vouchIn++;
else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight;
else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight;
else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++;
// Aggregate self stats from all peer.stats blocks so the self node has
// a meaningful detail card too.
function selfStats() {
let signals_24h = 0, vouches_in = 0, vouches_out = 0, quorum = 0;
let cases_24h = 0, iocs_24h = 0;
const sev = { critical: 0, high: 0, medium: 0, low: 0 };
const iocType = { url: 0, domain: 0, ip: 0, hash: 0, cve: 0 };
for (const nd of nodes) {
if (nd.is_self || !nd.stats) continue;
signals_24h += nd.stats.signals_24h || 0;
cases_24h += nd.stats.cases_24h || 0;
iocs_24h += nd.stats.iocs_24h || 0;
vouches_in += nd.stats.vouches_in_count || 0;
vouches_out += nd.stats.vouches_out_count || 0;
quorum += nd.stats.quorum_contribution || 0;
const sb = nd.stats.severity_breakdown || {};
for (const k of Object.keys(sev)) sev[k] += sb[k] || 0;
const ib = nd.stats.ioc_type_breakdown || {};
for (const k of Object.keys(iocType)) iocType[k] += ib[k] || 0;
}
return { vouchOut, vouchIn, signalsIn, knows };
return {
signals_24h, cases_24h, iocs_24h,
severity_breakdown: sev, ioc_type_breakdown: iocType,
vouches_in_count: vouches_in, vouches_out_count: vouches_out,
quorum_contribution: quorum,
};
}
function sevRow(sev) {
const order = ["critical", "high", "medium", "low"];
const cells = order.map(k => {
const n = (sev && sev[k]) || 0;
return `<span class="fn-sev-chip fn-sev-${k}">${k}<span class="n">${n}</span></span>`;
}).join("");
return `<div class="fn-sev-row">${cells}</div>`;
}
function iocRow(it) {
const order = ["url", "domain", "ip", "hash", "cve"];
const cells = order.map(k => {
const n = (it && it[k]) || 0;
return `<span class="fn-ioc-chip"><span class="k">${k}</span><span class="n">${n}</span></span>`;
}).join("");
return `<div class="fn-sev-row">${cells}</div>`;
}
function renderDetail(n) {
if (!detailEl) return;
const stats = countEdges(n.fp);
const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER");
const kindCls = n.is_self ? "td-kind-host" : (n.distance >= 2 ? "td-kind-cont" : "td-kind-net");
const statusBadge = `<span class="state-badge fn-status-badge-${esc(n.status)}">${esc(n.status)}</span>`;
const jumpBack = (!n.is_self && n.domain)
? `<p style="margin-top:10px;"><a href="/admin/federation" class="td-jump">→ open peer in /admin/federation</a></p>`
: "";
const s = n.is_self ? selfStats() : (n.stats || {});
const signals24h = s.signals_24h || 0;
const sigQuorum = s.quorum_contribution || 0;
const quorumPct = signals24h > 0 ? Math.min(100, Math.round((sigQuorum / signals24h) * 100)) : 0;
// Identity section. Self has no remote "last_seen"; show "—".
const fullFp = `<code class="full-fp">${esc(n.fp)}</code>` +
`<button type="button" class="fn-copy-btn" data-copy="${esc(n.fp)}">copy</button>`;
const identity = `
<div class="fn-detail-sec">
<h4>identity</h4>
<div class="row"><span class="k">fingerprint</span><span class="v">${fullFp}</span></div>
<div class="row"><span class="k">domain</span><span class="v">${n.domain ? esc(n.domain) : "—"}</span></div>
<div class="row"><span class="k">status</span><span class="v">${statusBadge}</span></div>
<div class="row"><span class="k">distance</span><span class="v">${n.is_self ? "self" : (n.distance >= 2 ? "transitive (2 hops)" : "direct (1 hop)")}</span></div>
<div class="row"><span class="k">last seen</span><span class="v">${esc((n.stats && n.stats.last_seen_relative) || "—")}</span></div>
</div>`;
// Signals section.
const signals = `
<div class="fn-detail-sec">
<h4>signals · 24h</h4>
<div class="row"><span class="k">total</span><span class="v">${signals24h}</span></div>
<div class="row"><span class="k">cases</span><span class="v">${s.cases_24h || 0}</span></div>
<div class="row"><span class="k">iocs</span><span class="v">${s.iocs_24h || 0}</span></div>
<div class="row"><span class="k">all-time</span><span class="v">${(n.stats && n.stats.signals_total) || (n.is_self ? "—" : 0)}</span></div>
${sevRow(s.severity_breakdown)}
${iocRow(s.ioc_type_breakdown)}
</div>`;
// Vouches section.
const vouchesPeerList = (() => {
if (n.is_self) return "";
const count = (n.stats && n.stats.vouches_in_count) || 0;
if (!count) return "";
// Find the actual voucher fingerprints by scanning rendered nodes
// — server doesn't ship the list per-peer, but the edge list does.
const vouchers = edges
.filter(e => e.kind === "vouch" && e.target === n.fp)
.map(e => e.source);
// Plus the case where peer-to-peer vouches exist as data but no edge
// (transitive nodes don't always get vouch edges); show known set.
const uniq = Array.from(new Set(vouchers));
if (!uniq.length) return "";
const chips = uniq.map(fp =>
`<button type="button" class="fn-fp-jump" data-jump="${esc(fp)}">${esc(shortFp(fp))}</button>`
).join("");
return `<div style="margin-top:6px;">${chips}</div>`;
})();
const vouches = `
<div class="fn-detail-sec">
<h4>vouches</h4>
<div class="row"><span class="k">in</span><span class="v">${s.vouches_in_count || 0}</span></div>
<div class="row"><span class="k">out</span><span class="v">${s.vouches_out_count || 0}</span></div>
${vouchesPeerList}
</div>`;
// Quorum section — small progress bar of "signals that are quorum-met".
const quorum = `
<div class="fn-detail-sec">
<h4>quorum</h4>
<div class="row"><span class="k">contribution</span><span class="v">${sigQuorum}</span></div>
<div class="row"><span class="k">of 24h total</span><span class="v">${quorumPct}%</span></div>
<div class="fn-quorum-bar"><div class="fn-quorum-fill" style="width:${quorumPct}%;"></div></div>
</div>`;
// Transparency log section.
const translog = (() => {
const entries = (n.stats && n.stats.recent_translog) || [];
if (!entries.length) {
return `<div class="fn-detail-sec"><h4>transparency log</h4><div class="row"><span class="k">recent</span><span class="v">—</span></div></div>`;
}
const items = entries.map(e => {
const ts = (e.timestamp || "").slice(0, 19).replace("T", " ");
const hash = (e.hash || "").slice(0, 12);
return `<li><span class="id">#${esc(e.id)}</span><span class="type">${esc(e.entry_type)}</span><span class="ts">${esc(ts)}</span><span class="hash">${esc(hash)}…</span></li>`;
}).join("");
return `<div class="fn-detail-sec"><h4>transparency log</h4><ul class="fn-trans-list">${items}</ul></div>`;
})();
// Actions.
const rawStatsUrl = "/admin/federation/network/data";
const actions = `
<div class="fn-detail-sec">
<h4>actions</h4>
<div class="fn-actions">
<a class="fn-action-btn" href="/admin/federation">peer registry</a>
<a class="fn-action-btn" href="/admin/federation/vouches">vouches</a>
<a class="fn-action-btn" href="/admin/federation/quorum">quorum</a>
<a class="fn-action-btn" href="${rawStatsUrl}" target="_blank" rel="noopener">raw JSON</a>
</div>
</div>`;
const html = `
<div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
@@ -354,20 +588,43 @@
${statusBadge}
<button type="button" class="td-close" aria-label="close">×</button>
</div>
<dl class="td-kv">
<dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd>
<dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd>
<dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd>
<dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd>
<dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd>
<dt>Knows-edges</dt><dd>${stats.knows}</dd>
</dl>
${jumpBack}
<div class="fn-detail-card">
${identity}
${signals}
${vouches}
${quorum}
${translog}
${actions}
</div>
`;
detailEl.innerHTML = html;
detailEl.classList.add("has-selection");
const close = detailEl.querySelector(".td-close");
if (close) close.addEventListener("click", clearSelection);
// Wire copy buttons.
detailEl.querySelectorAll(".fn-copy-btn").forEach(btn => {
btn.addEventListener("click", () => {
const v = btn.getAttribute("data-copy") || "";
if (!v) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).catch(() => {});
}
const t = btn.textContent;
btn.textContent = "copied";
setTimeout(() => { btn.textContent = t; }, 1100);
});
});
// Wire jump-to-fp chips.
detailEl.querySelectorAll(".fn-fp-jump").forEach(btn => {
btn.addEventListener("click", () => {
const fp = btn.getAttribute("data-jump") || "";
if (fp) jumpToFp(fp);
});
});
detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
@@ -387,8 +644,6 @@
loop();
// ---------- edge liveness + flow toggle -----------------------------
// Signal edges always flow (we just saw N signals in 24h). Vouch edges
// are static. Knows edges fade.
edges.forEach((e, i) => {
const ln = edgeEls[i].line;
if (e.kind === "signal") ln.classList.add("alive");
@@ -460,8 +715,6 @@
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
let currentLayout = "force";
// Force-mode bootstraps with self pinned at center — so the very first
// settle radiates outward naturally.
const selfNode = nodes.find(n => n.is_self);
if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; }
@@ -491,6 +744,91 @@
});
}
// ---------- search / filter -----------------------------------------
function applySearch(qRaw) {
const q = (qRaw || "").trim().toLowerCase();
if (!q) {
nodeEls.forEach(el => el.classList.remove("dimmed", "match"));
edgeEls.forEach(els => els.grp.classList.remove("dimmed"));
if (searchCountEl) searchCountEl.textContent = "";
return;
}
const matchFps = new Set();
nodes.forEach((n, i) => {
const hay = (n.label || "") + " " + (n.domain || "") + " " + n.fp;
if (hay.toLowerCase().indexOf(q) !== -1) {
matchFps.add(n.fp);
nodeEls[i].classList.add("match");
nodeEls[i].classList.remove("dimmed");
} else {
nodeEls[i].classList.remove("match");
nodeEls[i].classList.add("dimmed");
}
});
edges.forEach((e, i) => {
const visible = matchFps.has(e.source) || matchFps.has(e.target);
edgeEls[i].grp.classList.toggle("dimmed", !visible);
});
if (searchCountEl) {
searchCountEl.textContent = matchFps.size + " match" + (matchFps.size === 1 ? "" : "es");
}
}
if (searchEl) {
searchEl.addEventListener("input", ev => applySearch(ev.target.value));
}
// ---------- 24h timeline strip --------------------------------------
function renderTimeline() {
if (!timelineEl) return;
const buckets = topStats.signal_timeline_24h || [];
if (!buckets.length) {
timelineEl.innerHTML = `<div class="fn-timeline-empty">no signals in the last 24h</div>`;
if (timelineAxisEl) timelineAxisEl.innerHTML = "";
if (timelineMetaEl) timelineMetaEl.textContent = "0 signals";
return;
}
// Find max bucket total for height-scaling.
let maxTotal = 0;
let allTotal = 0;
for (const b of buckets) {
if ((b.total || 0) > maxTotal) maxTotal = b.total || 0;
allTotal += b.total || 0;
}
if (timelineMetaEl) {
timelineMetaEl.textContent =
`${allTotal} signal${allTotal === 1 ? "" : "s"} · peak ${maxTotal}/hr`;
}
const bars = buckets.map((b, idx) => {
const total = b.total || 0;
const perPeer = b.per_peer || {};
const hPct = maxTotal > 0 ? Math.round((total / maxTotal) * 100) : 0;
const segHtml = Object.keys(perPeer).map(fp => {
const seg = perPeer[fp];
const pct = total > 0 ? (seg / total) * hPct : 0;
const color = peerColor[fp] || "var(--accent)";
return `<div class="fn-timeline-bar-seg" data-fp="${esc(fp)}" data-n="${seg}" style="height:${pct.toFixed(2)}%;background:${color};"></div>`;
}).join("");
const hoursAgo = 23 - idx;
const tooltipLines = [`${hoursAgo}h ago · ${total} signals`];
for (const fp of Object.keys(perPeer)) {
tooltipLines.push(` ${shortFp(fp)}: ${perPeer[fp]}`);
}
return `<div class="fn-timeline-bar" data-hour="${hoursAgo}" title="${esc(tooltipLines.join("\n"))}">${segHtml}</div>`;
}).join("");
timelineEl.innerHTML = bars;
if (timelineAxisEl) {
// Axis labels every 6 hours.
const axis = buckets.map((b, idx) => {
const hoursAgo = 23 - idx;
const show = (hoursAgo % 6 === 0);
return `<span>${show ? "-" + hoursAgo + "h" : ""}</span>`;
}).join("");
timelineAxisEl.innerHTML = axis;
}
}
renderTimeline();
// Wheel zoom.
let zoom = 1, panX = 0, panY = 0;
svg.addEventListener("wheel", ev => {

View File

@@ -5,7 +5,7 @@
// This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back.
const CACHE_VERSION = "psyc-v6";
const CACHE_VERSION = "psyc-v7";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",

View File

@@ -19,6 +19,12 @@
<div class="fn-stat"><div class="fn-stat-label">quorum-met</div><div class="fn-stat-value">{{ stats.quorum_met_count }}</div></div>
</div>
<div class="fn-search-bar">
<label for="fn-search">filter</label>
<input type="search" id="fn-search" class="fn-search-input" placeholder="domain or fingerprint substring…" autocomplete="off" spellcheck="false">
<span id="fn-search-count" class="fn-search-count"></span>
</div>
<div class="topo-stage">
<div class="topo-toolbar">
<div class="topo-layouts" role="tablist">
@@ -33,9 +39,10 @@
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>unknown</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-blocked"></span>blocked</span>
<button type="button" id="fn-reset" class="btn">re-settle</button>
<span class="topo-hint">drag · scroll to zoom</span>
<span class="topo-hint">drag · scroll to zoom · hover for tooltip</span>
</div>
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
<div id="fn-tooltip" class="fn-tooltip"></div>
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
<div id="fn-error" class="gate-error" style="display:none;"></div>
</div>
@@ -44,6 +51,15 @@
<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>
</div>
<div class="fn-timeline-wrap">
<div class="fn-timeline-head">
<h3>signals · last 24h</h3>
<span class="meta" id="fn-timeline-meta"></span>
</div>
<div id="fn-timeline" class="fn-timeline" aria-label="signals received per hour for the last 24 hours"></div>
<div id="fn-timeline-axis" class="fn-timeline-axis"></div>
</div>
<p class="page-intro" style="margin-top:18px;">Self fingerprint: <code style="color:var(--accent);">{{ fingerprint }}</code></p>
</section>

View File

@@ -29,7 +29,7 @@ import httpx
from pydantic import BaseModel, Field
from psyc import db, log
from psyc.lines import federation
from psyc.lines import federation, translog
_log = log.get(__name__)
@@ -48,6 +48,11 @@ class NetworkNode(BaseModel):
`distance` is the topological hop count from self: 0 for self, 1 for
directly-registered peers, 2 for peers-of-peers discovered via the
transitive fetch. `status` is the trust label the UI colors by.
`stats` carries the admin-only per-peer enrichments (24h signal counts,
severity breakdown, vouch tallies, quorum contribution, etc.) and is
populated by `build_admin_view`. It stays empty in the public/local
views so the public JSON never leaks operational state.
"""
fingerprint: str
domain: Optional[str] = None
@@ -55,17 +60,19 @@ class NetworkNode(BaseModel):
status: str # "self" | "trusted" | "vouched" | "unknown" | "blocked"
is_self: bool = False
distance: int = 1
stats: Optional[Dict[str, Any]] = None
class NetworkEdge(BaseModel):
"""One edge on the federation map.
`kind` drives stroke style in the UI: vouch = solid, signal = dashed
flow with thickness ∝ weight, knows = dotted grey transitive hint.
flow with thickness ∝ weight, knows = dotted grey transitive hint,
corroborate = dotted faint accent (two peers share a signal_hash).
"""
source_fingerprint: str
target_fingerprint: str
kind: str # "vouch" | "signal" | "knows"
kind: str # "vouch" | "signal" | "knows" | "corroborate"
weight: float = 1.0
label: str = ""
bidirectional: bool = False
@@ -405,6 +412,246 @@ def build_public_view() -> Dict[str, Any]:
return payload
# ---------- admin-only enrichment helpers -------------------------------
#
# These build the rich per-peer stats the cockpit detail panel renders. They
# read directly from the federation_signals / vouches / translog tables and
# are only ever called from `build_admin_view` — the public view must stay
# slim to avoid leaking operational state to peers.
SEVERITY_LEVELS = ("critical", "high", "medium", "low")
IOC_TYPES = ("url", "domain", "ip", "hash", "cve")
SEVERITY_SCAN_LIMIT = 1000
TRANSLOG_PER_PEER_LIMIT = 10
CORROBORATED_LIMIT = 50
def _relative_time(iso_ts: str, now: datetime) -> str:
"""Compact "3m ago" / "1h ago" / "" for the tooltip + node badge."""
if not iso_ts:
return ""
try:
ts = datetime.fromisoformat(iso_ts)
except ValueError:
return ""
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
delta = now - ts
secs = int(delta.total_seconds())
if secs < 0:
return "just now"
if secs < 60:
return f"{secs}s ago"
if secs < 3600:
return f"{secs // 60}m ago"
if secs < 86400:
return f"{secs // 3600}h ago"
return f"{secs // 86400}d ago"
def _decode_raw_json(raw: Any) -> Optional[Dict[str, Any]]:
"""federation_signals.raw_json is stored as a JSON string; parse defensively."""
if not raw:
return None
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return None
try:
v = json.loads(raw)
except Exception:
return None
return v if isinstance(v, dict) else None
def _peer_stats(
peer_fp: str,
now: datetime,
signals_24h_rows: List[Dict[str, Any]],
all_signals_for_peer_count: int,
vouches_in: int,
vouches_out: int,
quorum_contribution: int,
last_seen_iso: str,
recent_translog: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Aggregate one peer's 24h slice + tallies into the cockpit-facing dict."""
cases_24h = 0
iocs_24h = 0
severity_breakdown: Dict[str, int] = {k: 0 for k in SEVERITY_LEVELS}
ioc_type_breakdown: Dict[str, int] = {k: 0 for k in IOC_TYPES}
# We pulled rows newest-first; cap severity/ioc decoding to keep this fast.
decoded = 0
for row in signals_24h_rows:
st = row.get("signal_type") or ""
if st == "case":
cases_24h += 1
if decoded < SEVERITY_SCAN_LIMIT:
payload = _decode_raw_json(row.get("raw_json"))
if payload:
sev = str(payload.get("severity") or "").lower()
if sev in severity_breakdown:
severity_breakdown[sev] += 1
decoded += 1
elif st == "ioc":
iocs_24h += 1
if decoded < SEVERITY_SCAN_LIMIT:
payload = _decode_raw_json(row.get("raw_json"))
if payload:
t = str(payload.get("type") or "").lower()
if t in ioc_type_breakdown:
ioc_type_breakdown[t] += 1
decoded += 1
return {
"signals_24h": len(signals_24h_rows),
"signals_total": all_signals_for_peer_count,
"cases_24h": cases_24h,
"iocs_24h": iocs_24h,
"severity_breakdown": severity_breakdown,
"ioc_type_breakdown": ioc_type_breakdown,
"vouches_in_count": vouches_in,
"vouches_out_count": vouches_out,
"quorum_contribution": quorum_contribution,
"last_seen": last_seen_iso or None,
"last_seen_relative": _relative_time(last_seen_iso, now),
"recent_translog": recent_translog,
}
def _index_signals_24h(now: datetime) -> Tuple[Dict[str, List[Dict[str, Any]]], List[Dict[str, Any]]]:
"""Bucket the 24h signal buffer by peer_fingerprint and return all rows.
Two return values so the caller can both walk per-peer rows and compute
cross-cutting structures (corroboration pairs, timeline buckets) in one
pass over the buffer.
"""
cutoff = (now - timedelta(hours=SIGNAL_WINDOW_HOURS)).isoformat()
by_peer: Dict[str, List[Dict[str, Any]]] = {}
fresh: List[Dict[str, Any]] = []
for row in db.recent_signals(limit=10_000):
received = str(row.get("received_at") or "")
if received < cutoff:
break
fp = row.get("peer_fingerprint") or ""
if not fp:
continue
by_peer.setdefault(fp, []).append(row)
fresh.append(row)
return by_peer, fresh
def _all_signals_by_peer_count() -> Dict[str, int]:
"""All-time count of federation_signals rows per peer_fingerprint."""
counts: Dict[str, int] = {}
# 50k cap — well above any realistic working set, and bounded so a
# runaway signal flood can't OOM the admin page render.
for row in db.recent_signals(limit=50_000):
fp = row.get("peer_fingerprint") or ""
if not fp:
continue
counts[fp] = counts.get(fp, 0) + 1
return counts
def _recent_translog_for_peer(peer_fp: str, all_entries: List[Any]) -> List[Dict[str, Any]]:
"""Up to TRANSLOG_PER_PEER_LIMIT translog rows that name this peer.
Walks the pre-fetched batch (newest first) so we make one DB roundtrip
for the whole admin view rather than one per peer.
"""
out: List[Dict[str, Any]] = []
for entry in all_entries:
data = entry.entry_data or {}
if not isinstance(data, dict):
continue
if data.get("peer_fingerprint") != peer_fp:
continue
out.append({
"id": entry.id,
"entry_type": entry.entry_type,
"timestamp": entry.timestamp,
"hash": entry.entry_hash,
})
if len(out) >= TRANSLOG_PER_PEER_LIMIT:
break
return out
def _corroborated_signals(
fresh_signals: List[Dict[str, Any]],
peer_fps: set,
) -> List[Dict[str, Any]]:
"""signal_hashes seen from ≥2 distinct known peers in last 24h.
`peer_fps` is the set of peers we render in the graph — corroboration
edges that touch peers outside it have nowhere to anchor visually, so
we drop them.
"""
by_hash: Dict[str, Dict[str, Any]] = {}
for row in fresh_signals:
h = row.get("signal_hash") or ""
if not h:
continue
fp = row.get("peer_fingerprint") or ""
if fp not in peer_fps:
continue
entry = by_hash.setdefault(h, {
"signal_hash": h,
"signal_type": row.get("signal_type") or "",
"signal_id": row.get("signal_id") or "",
"peers": set(),
})
entry["peers"].add(fp)
out: List[Dict[str, Any]] = []
for h, entry in by_hash.items():
if len(entry["peers"]) < 2:
continue
peers_sorted = sorted(entry["peers"])
out.append({
"signal_hash": h,
"signal_type": entry["signal_type"],
"signal_id": entry["signal_id"],
"peer_count": len(peers_sorted),
"peer_fingerprints": peers_sorted,
"quorum_met": federation.is_quorum_met(h),
})
# Higher peer-counts first so the UI shows the strongest corroborations on top.
out.sort(key=lambda r: r["peer_count"], reverse=True)
return out[:CORROBORATED_LIMIT]
def _signal_timeline_24h(
fresh_signals: List[Dict[str, Any]],
now: datetime,
) -> List[Dict[str, Any]]:
"""24 hourly buckets, oldest first. Each bucket: total + per-peer counts.
`hour_offset` runs 0..23 where 0 is "2324 hours ago" and 23 is the
current hour — left-to-right oldest-to-newest matches how operators
read a timeline.
"""
buckets: List[Dict[str, Any]] = [
{"hour_offset": i, "total": 0, "per_peer": {}} for i in range(24)
]
for row in fresh_signals:
try:
ts = datetime.fromisoformat(str(row.get("received_at") or ""))
except ValueError:
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
hours_ago = int((now - ts).total_seconds() // 3600)
if hours_ago < 0 or hours_ago >= 24:
continue
idx = 23 - hours_ago
b = buckets[idx]
b["total"] += 1
fp = row.get("peer_fingerprint") or ""
if fp:
b["per_peer"][fp] = b["per_peer"].get(fp, 0) + 1
return buckets
# ---------- admin-only payload (data endpoint) --------------------------
def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
@@ -412,10 +659,115 @@ def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
Unlike `build_public_view`, this DOES include unknown + blocked peers
and recent signal hashes — it's only ever served behind admin auth.
Each non-self node gets a `stats` block:
* 24h signal counts (total / cases / iocs)
* severity + ioc-type breakdowns from raw_json
* vouches in/out tallies
* how many of this peer's signal_hashes are quorum-met
* last_seen ISO + relative ("3m ago")
* up to 10 recent translog rows that name them
Top-level `stats` gains:
* `corroborated_signals` — pairs of peers that share a signal_hash
in the last 24h. Drives the corroboration edges below.
* `signal_timeline_24h` — 24 hourly buckets for the bottom-of-page
timeline strip.
And the edge list gains a `kind="corroborate"` for every pair of peers
that share ≥1 signal_hash in the 24h window. Edge weight = number of
shared hashes for that pair.
"""
view = build_transitive_view() if include_transitive else build_local_view()
our_fp = view.nodes[0].fingerprint
now = datetime.now(timezone.utc)
# Pre-fetch the tables we'll query per-peer so the admin render is one
# batch of DB hits, not one-per-node.
signals_by_peer, fresh_signals = _index_signals_24h(now)
all_signal_counts = _all_signals_by_peer_count()
recent_translog_entries = translog.recent(limit=500)
# Vouch tallies per peer (in/out).
vouches_in: Dict[str, int] = {}
vouches_out: Dict[str, int] = {}
for row in db.list_vouches():
target = row.get("target_fingerprint") or ""
voucher = row.get("voucher_fingerprint") or ""
if target:
vouches_in[target] = vouches_in.get(target, 0) + 1
if voucher:
vouches_out[voucher] = vouches_out.get(voucher, 0) + 1
# Per-peer quorum contribution — distinct signal_hashes from this peer
# that are quorum-met. Cached per-hash within this build to dedupe work
# across peers reporting the same hash.
quorum_cache: Dict[str, bool] = {}
def _quorum_for_hash(h: str) -> bool:
if h in quorum_cache:
return quorum_cache[h]
v = federation.is_quorum_met(h)
quorum_cache[h] = v
return v
peer_fps: set = set()
for node in view.nodes:
if node.is_self:
continue
peer_fps.add(node.fingerprint)
peer_rows = signals_by_peer.get(node.fingerprint, [])
last_seen_iso = ""
if peer_rows:
# recent_signals returns newest-first → first row is latest.
last_seen_iso = str(peer_rows[0].get("received_at") or "")
peer_quorum_contrib = 0
seen_hashes: set = set()
for r in peer_rows:
h = r.get("signal_hash") or ""
if not h or h in seen_hashes:
continue
seen_hashes.add(h)
if _quorum_for_hash(h):
peer_quorum_contrib += 1
node.stats = _peer_stats(
peer_fp=node.fingerprint,
now=now,
signals_24h_rows=peer_rows,
all_signals_for_peer_count=all_signal_counts.get(node.fingerprint, 0),
vouches_in=vouches_in.get(node.fingerprint, 0),
vouches_out=vouches_out.get(node.fingerprint, 0),
quorum_contribution=peer_quorum_contrib,
last_seen_iso=last_seen_iso,
recent_translog=_recent_translog_for_peer(node.fingerprint, recent_translog_entries),
)
# Corroboration: pairs of rendered peers that share a signal_hash.
corroborated = _corroborated_signals(fresh_signals, peer_fps)
# Per-pair shared-hash count → corroborate edges.
pair_counts: Dict[Tuple[str, str], int] = {}
for entry in corroborated:
fps = entry["peer_fingerprints"]
for i in range(len(fps)):
for j in range(i + 1, len(fps)):
a, b = fps[i], fps[j]
key = (a, b) if a < b else (b, a)
pair_counts[key] = pair_counts.get(key, 0) + 1
for (a, b), count in pair_counts.items():
view.edges.append(NetworkEdge(
source_fingerprint=a,
target_fingerprint=b,
kind="corroborate",
weight=float(count),
label=f"{count} shared signals",
))
# Top-level stats — keep existing, layer on the new admin extras.
view.stats["corroborated_signals"] = corroborated
view.stats["signal_timeline_24h"] = _signal_timeline_24h(fresh_signals, now)
return {
"self_fingerprint": view.nodes[0].fingerprint,
"self_fingerprint": our_fp,
"nodes": [n.model_dump() for n in view.nodes],
"edges": [e.model_dump() for e in view.edges],
"stats": view.stats,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from unittest.mock import patch
@@ -11,11 +12,12 @@ import pytest
from sqlalchemy import create_engine
from psyc import db
from psyc.lines import federation, network_view
from psyc.lines import federation, network_view, translog
from psyc.lines.network_view import (
NetworkEdge,
NetworkNode,
NetworkView,
build_admin_view,
build_local_view,
build_public_view,
build_transitive_view,
@@ -332,3 +334,325 @@ def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir):
assert "trusted.example" in calls
assert "unknown.example" not in calls
# ---------- admin view: per-peer enrichment + corroboration + timeline ---
def _no_transitive():
"""patch.object helper — silence network fetches in admin-view tests."""
return patch.object(network_view, "_fetch_peer_network", return_value=None)
def test_admin_view_includes_stats_on_peer_nodes(fresh_db, fed_dir):
"""Every non-self node must carry a `stats` dict in the admin view."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
with _no_transitive():
view = build_admin_view(include_transitive=False)
self_nodes = [n for n in view["nodes"] if n["is_self"]]
peer_nodes = [n for n in view["nodes"] if not n["is_self"]]
assert len(self_nodes) == 1
assert len(peer_nodes) == 1
# Self has no stats; peers do.
assert self_nodes[0]["stats"] is None
peer_stats = peer_nodes[0]["stats"]
assert isinstance(peer_stats, dict)
for key in (
"signals_24h", "signals_total", "cases_24h", "iocs_24h",
"severity_breakdown", "ioc_type_breakdown",
"vouches_in_count", "vouches_out_count",
"quorum_contribution", "last_seen", "last_seen_relative",
"recent_translog",
):
assert key in peer_stats, f"missing {key}"
# last_seen is None when no signals have landed yet.
assert peer_stats["last_seen"] is None
assert peer_stats["last_seen_relative"] == ""
def test_admin_view_signals_24h_excludes_stale(fresh_db, fed_dir):
"""signals_24h must count only rows inside the 24h window."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
stale_iso = (datetime.now(timezone.utc) - timedelta(hours=30)).isoformat()
for i in range(3):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"v{i}",
signal_hash=f"h{i}",
received_at=now_iso,
raw_json="{}",
))
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="stale",
signal_hash="stale-hash",
received_at=stale_iso,
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
peer = next(n for n in view["nodes"] if not n["is_self"])
assert peer["stats"]["signals_24h"] == 3
# All-time total still sees the stale row.
assert peer["stats"]["signals_total"] == 4
# last_seen is populated and the relative is a short string.
assert peer["stats"]["last_seen"] is not None
assert peer["stats"]["last_seen_relative"] != ""
def test_admin_view_severity_and_ioc_breakdown_from_raw_json(fresh_db, fed_dir):
"""severity_breakdown is read from raw_json for case rows, ioc_type for ioc rows."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
cases = [
{"severity": "critical", "case_id": "c1"},
{"severity": "critical", "case_id": "c2"},
{"severity": "high", "case_id": "c3"},
{"severity": "low", "case_id": "c4"},
]
for c in cases:
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="case",
signal_id=c["case_id"],
signal_hash=f"hash-{c['case_id']}",
received_at=now_iso,
raw_json=json.dumps(c),
))
iocs = [
{"type": "url", "value": "https://a"},
{"type": "url", "value": "https://b"},
{"type": "domain", "value": "x.com"},
{"type": "ip", "value": "1.2.3.4"},
]
for ioc in iocs:
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=ioc["value"],
signal_hash=f"hash-{ioc['value']}",
received_at=now_iso,
raw_json=json.dumps(ioc),
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
stats = next(n for n in view["nodes"] if not n["is_self"])["stats"]
assert stats["cases_24h"] == 4
assert stats["iocs_24h"] == 4
sev = stats["severity_breakdown"]
assert sev == {"critical": 2, "high": 1, "medium": 0, "low": 1}
ioc_t = stats["ioc_type_breakdown"]
assert ioc_t == {"url": 2, "domain": 1, "ip": 1, "hash": 0, "cve": 0}
def test_admin_view_vouch_in_out_counts(fresh_db, fed_dir):
"""vouches_in_count counts vouches naming this peer; out counts what they've issued."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now = datetime.now(timezone.utc).isoformat()
# A vouches for B; we vouch for B too — B sees vouches_in=2.
db.upsert_vouch(dict(
voucher_fingerprint=fp_a,
target_fingerprint=fp_b,
issued_at=now, expires_at=None, signature="x",
))
federation.issue_vouch(fp_b, ttl_days=30)
# B vouches for A — A sees vouches_in=1, B sees vouches_out=1.
db.upsert_vouch(dict(
voucher_fingerprint=fp_b,
target_fingerprint=fp_a,
issued_at=now, expires_at=None, signature="y",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
assert by_fp[fp_a]["stats"]["vouches_in_count"] == 1
assert by_fp[fp_a]["stats"]["vouches_out_count"] == 1 # vouches for B
assert by_fp[fp_b]["stats"]["vouches_in_count"] == 2 # A + us
assert by_fp[fp_b]["stats"]["vouches_out_count"] == 1 # vouches for A
def test_admin_view_corroborated_signals(fresh_db, fed_dir):
"""Pairs of peers reporting the same signal_hash → corroborated entry + edge."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
for peer_fp in (fp_a, fp_b):
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id="evil.com",
signal_hash="shared-hash-1",
received_at=now_iso,
raw_json="{}",
))
# A also reports a hash B doesn't — should NOT corroborate.
db.record_signal(dict(
peer_fingerprint=fp_a,
signal_type="ioc",
signal_id="solo.com",
signal_hash="solo-hash",
received_at=now_iso,
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
corr = view["stats"]["corroborated_signals"]
hashes = {c["signal_hash"] for c in corr}
assert "shared-hash-1" in hashes
assert "solo-hash" not in hashes
shared = next(c for c in corr if c["signal_hash"] == "shared-hash-1")
assert set(shared["peer_fingerprints"]) == {fp_a, fp_b}
assert shared["peer_count"] == 2
# One corroborate edge between the pair (orientation-independent).
corr_edges = [e for e in view["edges"] if e["kind"] == "corroborate"]
assert len(corr_edges) == 1
pair = {corr_edges[0]["source_fingerprint"], corr_edges[0]["target_fingerprint"]}
assert pair == {fp_a, fp_b}
assert corr_edges[0]["weight"] == 1.0
def test_admin_view_signal_timeline_24h_returns_24_buckets(fresh_db, fed_dir):
"""signal_timeline_24h is a 24-bucket list with correct totals."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now = datetime.now(timezone.utc)
# Two signals one hour ago, three signals five hours ago.
one_h = (now - timedelta(hours=1, minutes=5)).isoformat()
five_h = (now - timedelta(hours=5, minutes=5)).isoformat()
for i in range(2):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"a{i}",
signal_hash=f"h-a-{i}",
received_at=one_h,
raw_json="{}",
))
for i in range(3):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"b{i}",
signal_hash=f"h-b-{i}",
received_at=five_h,
raw_json="{}",
))
# Stale signal — must NOT show up.
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="stale",
signal_hash="stale-hash",
received_at=(now - timedelta(hours=48)).isoformat(),
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
buckets = view["stats"]["signal_timeline_24h"]
assert isinstance(buckets, list)
assert len(buckets) == 24
totals = [b["total"] for b in buckets]
assert sum(totals) == 5 # stale excluded
# Bucket hour_offsets are 0..23 in oldest-first order.
assert [b["hour_offset"] for b in buckets] == list(range(24))
def test_admin_view_quorum_contribution(fresh_db, fed_dir):
"""quorum_contribution counts this peer's distinct hashes that are quorum-met."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
# Shared hash → both peers report it → quorum-met (default k=2).
for peer_fp in (fp_a, fp_b):
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id="shared",
signal_hash="quorum-hash",
received_at=now_iso,
raw_json="{}",
))
# Solo hash from A → not quorum-met.
db.record_signal(dict(
peer_fingerprint=fp_a,
signal_type="ioc",
signal_id="solo",
signal_hash="solo-hash",
received_at=now_iso,
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
assert by_fp[fp_a]["stats"]["quorum_contribution"] == 1
assert by_fp[fp_b]["stats"]["quorum_contribution"] == 1
def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir):
"""recent_translog lists entries where entry_data.peer_fingerprint matches."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
# Append translog rows that name each peer.
translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "ioc", "signal_id": "x"})
translog.append("signal", {"peer_fingerprint": fp_b, "signal_type": "case", "signal_id": "y"})
translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "case", "signal_id": "z"})
with _no_transitive():
view = build_admin_view(include_transitive=False)
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
a_log = by_fp[fp_a]["stats"]["recent_translog"]
b_log = by_fp[fp_b]["stats"]["recent_translog"]
assert len(a_log) == 2
assert len(b_log) == 1
# Each row carries the documented shape.
for row in a_log + b_log:
assert set(row.keys()) == {"id", "entry_type", "timestamp", "hash"}
def test_public_view_still_has_no_stats(fresh_db, fed_dir):
"""Public payload must not surface admin-only enrichments — sensitive.
Even after `build_admin_view` has been invoked (which mutates node.stats
on the cached transitive view), the public view path must stay clean.
"""
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
# Seed signals + corroborated hash so admin view has rich state.
now_iso = datetime.now(timezone.utc).isoformat()
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="leak",
signal_hash="leak-hash",
received_at=now_iso,
raw_json=json.dumps({"type": "url", "value": "https://leak"}),
))
# Build admin view first so any caching kicks in.
with _no_transitive():
build_admin_view(include_transitive=False)
# Now build the public view and assert no admin-only fields leak.
payload = build_public_view()
flat = json.dumps(payload, default=str)
assert "signals_24h" not in flat
assert "severity_breakdown" not in flat
assert "corroborated_signals" not in flat
assert "signal_timeline_24h" not in flat
assert "recent_translog" not in flat
assert "leak-hash" not in flat
# Peer entries in the public view never carry a `stats` field.
for p in payload.get("peers", []):
assert "stats" not in p