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-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-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); } .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 * Self at center, direct peers around it, transitive peers (distance=2) on
* the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness * 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 * Compared to the previous version this file additionally renders:
* the two pages feel familiar; once both are stable the shared engine can * • per-peer compact stat badge below the sublabel
* factor out into a force_graph.js module — for now, a copy keeps the diff * • opacity-scaled fill based on log(signals_24h) for non-self nodes
* narrow. * • 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 () { (function () {
@@ -18,10 +23,14 @@
const loadingEl = document.getElementById("fn-loading"); const loadingEl = document.getElementById("fn-loading");
const errorEl = document.getElementById("fn-error"); const errorEl = document.getElementById("fn-error");
const transitiveCountEl = document.getElementById("fn-transitive-count"); 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; 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" }) fetch("/admin/federation/network/data", { credentials: "same-origin" })
.then(r => { .then(r => {
if (!r.ok) throw new Error("HTTP " + r.status); 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) { function render(data) {
const selfFp = data.self_fingerprint || ""; const selfFp = data.self_fingerprint || "";
const nodesData = data.nodes || []; const nodesData = data.nodes || [];
const edgesData = data.edges || []; const edgesData = data.edges || [];
const topStats = data.stats || {};
if (transitiveCountEl) { if (transitiveCountEl) {
const n = nodesData.filter(n => (n.distance || 0) >= 2).length; const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
transitiveCountEl.textContent = String(n); 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 ----------------------------- // ---------- 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 nodes = [];
const nodeByFp = Object.create(null); const nodeByFp = Object.create(null);
for (const nd of nodesData) { for (const nd of nodesData) {
const isSelf = !!nd.is_self; const isSelf = !!nd.is_self;
const dist = Number(nd.distance || 0); const dist = Number(nd.distance || 0);
const r = isSelf ? 38 : (dist >= 2 ? 9 : 16); 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 = { const n = {
id: nd.fingerprint, id: nd.fingerprint,
fp: nd.fingerprint, fp: nd.fingerprint,
@@ -64,9 +118,10 @@
status: nd.status || "unknown", status: nd.status || "unknown",
is_self: isSelf, is_self: isSelf,
distance: dist, distance: dist,
stats,
intensity,
r, r,
x: 0, y: 0, vx: 0, vy: 0, fixed: false, x: 0, y: 0, vx: 0, vy: 0, fixed: false,
tooltip: buildTooltip(nd),
}; };
nodes.push(n); nodes.push(n);
nodeByFp[n.id] = 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 --------------------------------------- // ---------- viewport + seeding ---------------------------------------
function viewport() { function viewport() {
const W = svg.clientWidth || 900; const W = svg.clientWidth || 900;
@@ -108,10 +154,7 @@
(function seed() { (function seed() {
const cx = W / 2, cy = H / 2; const cx = W / 2, cy = H / 2;
nodes.forEach((n, i) => { nodes.forEach((n, i) => {
if (n.is_self) { if (n.is_self) { n.x = cx; n.y = cy; return; }
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 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; const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20; 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 REPULSION = 1500;
const SPRING_K = 0.035; const SPRING_K = 0.035;
const SPRING_REST_BASE = 110; const SPRING_REST_BASE = 110;
@@ -144,7 +185,8 @@
for (const e of edges) { for (const e of edges) {
const a = nodeByFp[e.source], b = nodeByFp[e.target]; const a = nodeByFp[e.source], b = nodeByFp[e.target];
if (!a || !b) continue; 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 rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) + 0.1; 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)); 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(); 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 ns = "http://www.w3.org/2000/svg";
const corrG = document.createElementNS(ns, "g");
const edgesG = document.createElementNS(ns, "g"); const edgesG = document.createElementNS(ns, "g");
const nodesG = document.createElementNS(ns, "g"); const nodesG = document.createElementNS(ns, "g");
corrG.setAttribute("class", "fn-edges fn-edges-corr");
edgesG.setAttribute("class", "fn-edges"); edgesG.setAttribute("class", "fn-edges");
nodesG.setAttribute("class", "fn-nodes"); nodesG.setAttribute("class", "fn-nodes");
svg.appendChild(corrG);
svg.appendChild(edgesG); svg.appendChild(edgesG);
svg.appendChild(nodesG); svg.appendChild(nodesG);
const edgeEls = edges.map(e => { const edgeEls = edges.map(e => {
const grp = document.createElementNS(ns, "g"); const grp = document.createElementNS(ns, "g");
grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind); 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"); const ln = document.createElementNS(ns, "line");
ln.setAttribute("class", "fn-edge"); 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") { if (e.kind === "signal") {
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8); const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
ln.setAttribute("stroke-width", w.toFixed(2)); 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); grp.appendChild(ln);
if (e.label) { if (e.label) {
const lbl = document.createElementNS(ns, "text"); const lbl = document.createElementNS(ns, "text");
@@ -194,8 +244,9 @@
lbl.textContent = e.label; lbl.textContent = e.label;
grp.appendChild(lbl); grp.appendChild(lbl);
} }
edgesG.appendChild(grp); const host = e.kind === "corroborate" ? corrG : edgesG;
return { line: ln, label: grp.querySelector("text") }; host.appendChild(grp);
return { line: ln, label: grp.querySelector("text"), grp };
}); });
function _classFor(n) { function _classFor(n) {
@@ -209,17 +260,20 @@
g.setAttribute("class", _classFor(n)); g.setAttribute("class", _classFor(n));
g.dataset.fp = n.fp; g.dataset.fp = n.fp;
let shape;
if (n.is_self) { if (n.is_self) {
const sz = n.r; const sz = n.r;
const rect = document.createElementNS(ns, "rect"); shape = document.createElementNS(ns, "rect");
rect.setAttribute("x", -sz); rect.setAttribute("y", -sz); shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2); shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
rect.setAttribute("rx", 10); rect.setAttribute("ry", 10); shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
g.appendChild(rect); g.appendChild(shape);
} else { } else {
const c = document.createElementNS(ns, "circle"); shape = document.createElementNS(ns, "circle");
c.setAttribute("r", n.r); shape.setAttribute("r", n.r);
g.appendChild(c); // 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"); const text = document.createElementNS(ns, "text");
@@ -234,10 +288,25 @@
sub.setAttribute("dy", n.r + 24); sub.setAttribute("dy", n.r + 24);
sub.textContent = n.fp.slice(0, 8) + "…"; sub.textContent = n.fp.slice(0, 8) + "…";
g.appendChild(sub); 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"); 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); g.appendChild(title);
nodesG.appendChild(g); nodesG.appendChild(g);
@@ -263,7 +332,46 @@
} }
paint(); 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 dragging = null, dragOffset = { x: 0, y: 0 };
let pressedNode = null, pressedAt = null, moved = false; let pressedNode = null, pressedAt = null, moved = false;
function svgPoint(clientX, clientY) { function svgPoint(clientX, clientY) {
@@ -271,17 +379,21 @@
return pt.matrixTransform(svg.getScreenCTM().inverse()); return pt.matrixTransform(svg.getScreenCTM().inverse());
} }
nodeEls.forEach((g, i) => { nodeEls.forEach((g, i) => {
const n = nodes[i];
g.addEventListener("mousedown", ev => { g.addEventListener("mousedown", ev => {
ev.preventDefault(); ev.preventDefault();
pressedNode = nodes[i]; pressedNode = n;
pressedAt = { x: ev.clientX, y: ev.clientY }; pressedAt = { x: ev.clientX, y: ev.clientY };
moved = false; moved = false;
dragging = nodes[i]; dragging = n;
const p = svgPoint(ev.clientX, ev.clientY); const p = svgPoint(ev.clientX, ev.clientY);
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y; dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
if (currentLayout === "force") dragging.fixed = true; if (currentLayout === "force") dragging.fixed = true;
g.classList.add("dragging"); 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 => { document.addEventListener("mousemove", ev => {
if (pressedAt) { if (pressedAt) {
@@ -309,44 +421,166 @@
}); });
// ---------- detail panel -------------------------------------------- // ---------- detail panel --------------------------------------------
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function selectNode(n) { function selectNode(n) {
nodeEls.forEach(el => el.classList.remove("selected")); nodeEls.forEach(el => el.classList.remove("selected"));
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`); const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
if (me) me.classList.add("selected"); if (me) me.classList.add("selected");
renderDetail(n); renderDetail(n);
} }
function jumpToFp(fp) {
const target = nodeByFp[fp];
if (!target) return;
selectNode(target);
}
function clearSelection() { function clearSelection() {
nodeEls.forEach(el => el.classList.remove("selected")); 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>'; if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
} }
function countEdges(fp) { // Aggregate self stats from all peer.stats blocks so the self node has
let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0; // a meaningful detail card too.
for (const e of edges) { function selfStats() {
if (e.kind === "vouch" && e.source === fp) vouchOut++; let signals_24h = 0, vouches_in = 0, vouches_out = 0, quorum = 0;
else if (e.kind === "vouch" && e.target === fp) vouchIn++; let cases_24h = 0, iocs_24h = 0;
else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight; const sev = { critical: 0, high: 0, medium: 0, low: 0 };
else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight; const iocType = { url: 0, domain: 0, ip: 0, hash: 0, cve: 0 };
else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++; 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) { function renderDetail(n) {
if (!detailEl) return; if (!detailEl) return;
const stats = countEdges(n.fp);
const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER"); 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 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 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 = ` const html = `
<div class="td-head"> <div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span> <span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
@@ -354,20 +588,43 @@
${statusBadge} ${statusBadge}
<button type="button" class="td-close" aria-label="close">×</button> <button type="button" class="td-close" aria-label="close">×</button>
</div> </div>
<dl class="td-kv"> <div class="fn-detail-card">
<dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd> ${identity}
<dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd> ${signals}
<dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd> ${vouches}
<dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd> ${quorum}
<dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd> ${translog}
<dt>Knows-edges</dt><dd>${stats.knows}</dd> ${actions}
</dl> </div>
${jumpBack}
`; `;
detailEl.innerHTML = html; detailEl.innerHTML = html;
detailEl.classList.add("has-selection"); detailEl.classList.add("has-selection");
const close = detailEl.querySelector(".td-close"); const close = detailEl.querySelector(".td-close");
if (close) close.addEventListener("click", clearSelection); 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" }); detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
} }
@@ -387,8 +644,6 @@
loop(); loop();
// ---------- edge liveness + flow toggle ----------------------------- // ---------- 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) => { edges.forEach((e, i) => {
const ln = edgeEls[i].line; const ln = edgeEls[i].line;
if (e.kind === "signal") ln.classList.add("alive"); if (e.kind === "signal") ln.classList.add("alive");
@@ -460,8 +715,6 @@
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial }; const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
let currentLayout = "force"; 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); const selfNode = nodes.find(n => n.is_self);
if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; } 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. // Wheel zoom.
let zoom = 1, panX = 0, panY = 0; let zoom = 1, panX = 0, panY = 0;
svg.addEventListener("wheel", ev => { svg.addEventListener("wheel", ev => {

View File

@@ -5,7 +5,7 @@
// This makes the cockpit installable as a PWA and survives flaky connections, // This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back. // without serving stale operational data behind the operator's back.
const CACHE_VERSION = "psyc-v6"; const CACHE_VERSION = "psyc-v7";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/static/cockpit.css", "/static/cockpit.css",
"/static/psyc-tokens.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 class="fn-stat"><div class="fn-stat-label">quorum-met</div><div class="fn-stat-value">{{ stats.quorum_met_count }}</div></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-stage">
<div class="topo-toolbar"> <div class="topo-toolbar">
<div class="topo-layouts" role="tablist"> <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-unknown"></span>unknown</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-blocked"></span>blocked</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> <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> </div>
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg> <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-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 id="fn-error" class="gate-error" style="display:none;"></div>
</div> </div>
@@ -44,6 +51,15 @@
<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p> <p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>
</div> </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> <p class="page-intro" style="margin-top:18px;">Self fingerprint: <code style="color:var(--accent);">{{ fingerprint }}</code></p>
</section> </section>

View File

@@ -29,7 +29,7 @@ import httpx
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from psyc import db, log from psyc import db, log
from psyc.lines import federation from psyc.lines import federation, translog
_log = log.get(__name__) _log = log.get(__name__)
@@ -48,6 +48,11 @@ class NetworkNode(BaseModel):
`distance` is the topological hop count from self: 0 for self, 1 for `distance` is the topological hop count from self: 0 for self, 1 for
directly-registered peers, 2 for peers-of-peers discovered via the directly-registered peers, 2 for peers-of-peers discovered via the
transitive fetch. `status` is the trust label the UI colors by. 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 fingerprint: str
domain: Optional[str] = None domain: Optional[str] = None
@@ -55,17 +60,19 @@ class NetworkNode(BaseModel):
status: str # "self" | "trusted" | "vouched" | "unknown" | "blocked" status: str # "self" | "trusted" | "vouched" | "unknown" | "blocked"
is_self: bool = False is_self: bool = False
distance: int = 1 distance: int = 1
stats: Optional[Dict[str, Any]] = None
class NetworkEdge(BaseModel): class NetworkEdge(BaseModel):
"""One edge on the federation map. """One edge on the federation map.
`kind` drives stroke style in the UI: vouch = solid, signal = dashed `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 source_fingerprint: str
target_fingerprint: str target_fingerprint: str
kind: str # "vouch" | "signal" | "knows" kind: str # "vouch" | "signal" | "knows" | "corroborate"
weight: float = 1.0 weight: float = 1.0
label: str = "" label: str = ""
bidirectional: bool = False bidirectional: bool = False
@@ -405,6 +412,246 @@ def build_public_view() -> Dict[str, Any]:
return payload 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) -------------------------- # ---------- admin-only payload (data endpoint) --------------------------
def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]: 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 Unlike `build_public_view`, this DOES include unknown + blocked peers
and recent signal hashes — it's only ever served behind admin auth. 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() 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 { return {
"self_fingerprint": view.nodes[0].fingerprint, "self_fingerprint": our_fp,
"nodes": [n.model_dump() for n in view.nodes], "nodes": [n.model_dump() for n in view.nodes],
"edges": [e.model_dump() for e in view.edges], "edges": [e.model_dump() for e in view.edges],
"stats": view.stats, "stats": view.stats,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict from typing import Any, Dict
from unittest.mock import patch from unittest.mock import patch
@@ -11,11 +12,12 @@ import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from psyc import db 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 ( from psyc.lines.network_view import (
NetworkEdge, NetworkEdge,
NetworkNode, NetworkNode,
NetworkView, NetworkView,
build_admin_view,
build_local_view, build_local_view,
build_public_view, build_public_view,
build_transitive_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 "trusted.example" in calls
assert "unknown.example" not 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