merge network-detail: rich detail panel, corroboration edges, 24h timeline, search
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 =>
|
|
||||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 "23–24 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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user