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

Click any node in the graph above to inspect it.

+
+
+

signals · last 24h

+ +
+
+
+
+

Self fingerprint: {{ fingerprint }}

From 70b6af6a3527b8030b3960f1a64e1d01f507cfcd Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:57:49 +0200 Subject: [PATCH 3/5] stage-netd-c network detail: rich detail panel + hover tooltips + search/intensity + timeline JS --- src/psyc/cockpit/static/federation_network.js | 490 +++++++++++++++--- 1 file changed, 414 insertions(+), 76 deletions(-) diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js index 2c1dbda..807ec3b 100644 --- a/src/psyc/cockpit/static/federation_network.js +++ b/src/psyc/cockpit/static/federation_network.js @@ -1,13 +1,18 @@ -/* psyc — federation network force-directed graph. +/* psyc — federation network force-directed graph (enriched detail layer). * * Self at center, direct peers around it, transitive peers (distance=2) on * the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness - * ∝ weight), knows (dotted grey). + * ∝ weight), knows (dotted grey), corroborate (dotted accent, faint pulse). * - * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop so - * the two pages feel familiar; once both are stable the shared engine can - * factor out into a force_graph.js module — for now, a copy keeps the diff - * narrow. + * Compared to the previous version this file additionally renders: + * • per-peer compact stat badge below the sublabel + * • opacity-scaled fill based on log(signals_24h) for non-self nodes + * • a search/filter bar that dims non-matching nodes/edges + * • a hover tooltip with key stats + * • a much richer click-to-inspect detail panel + * • a 24h stacked timeline strip at the bottom of the page + * + * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop. */ (function () { @@ -18,10 +23,14 @@ const loadingEl = document.getElementById("fn-loading"); const errorEl = document.getElementById("fn-error"); const transitiveCountEl = document.getElementById("fn-transitive-count"); + const tooltipEl = document.getElementById("fn-tooltip"); + const searchEl = document.getElementById("fn-search"); + const searchCountEl = document.getElementById("fn-search-count"); + const timelineEl = document.getElementById("fn-timeline"); + const timelineAxisEl = document.getElementById("fn-timeline-axis"); + const timelineMetaEl = document.getElementById("fn-timeline-meta"); if (!svg) return; - // Fetch data, then build the graph. The /data endpoint includes transitive - // peers (mid-cost cached server-side at 5 min TTL). fetch("/admin/federation/network/data", { credentials: "same-origin" }) .then(r => { if (!r.ok) throw new Error("HTTP " + r.status); @@ -39,23 +48,68 @@ } }); + // ---------- shared escape ----------------------------------------------- + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + function shortFp(fp) { + if (!fp) return "—"; + if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8); + return fp; + } + function render(data) { const selfFp = data.self_fingerprint || ""; const nodesData = data.nodes || []; const edgesData = data.edges || []; + const topStats = data.stats || {}; if (transitiveCountEl) { const n = nodesData.filter(n => (n.distance || 0) >= 2).length; transitiveCountEl.textContent = String(n); } + // ---------- color palette for peers (timeline + tooltips) ------------- + // 12 evenly-spaced HSL hues, accent-leaning. Used in the timeline strip + // to colorize per-peer segments inside each bar. + const PEER_PALETTE = [ + "#1ec8ff", "#a78bfa", "#4ade80", "#fbbf24", "#f87171", "#34d399", + "#60a5fa", "#f472b6", "#fb923c", "#22d3ee", "#c084fc", "#facc15", + ]; + const peerColor = Object.create(null); + const peerOrder = nodesData + .filter(n => !n.is_self) + .map(n => n.fingerprint) + .sort(); + peerOrder.forEach((fp, i) => { + peerColor[fp] = PEER_PALETTE[i % PEER_PALETTE.length]; + }); + // ---------- build node + edge sim objects ----------------------------- + // Pre-compute the max 24h signal count so we can log-normalize fill + // opacity per node (busy peer → fully saturated, quiet → faint). + let maxSignals24h = 0; + for (const nd of nodesData) { + const s = (nd.stats && nd.stats.signals_24h) || 0; + if (s > maxSignals24h) maxSignals24h = s; + } + const nodes = []; const nodeByFp = Object.create(null); for (const nd of nodesData) { const isSelf = !!nd.is_self; const dist = Number(nd.distance || 0); const r = isSelf ? 38 : (dist >= 2 ? 9 : 16); + const stats = nd.stats || null; + let intensity = 1; + if (!isSelf && stats && maxSignals24h > 0) { + // log scale so a 100-signal peer doesn't blow out a 5-signal one. + const s = stats.signals_24h || 0; + const num = Math.log2(s + 1); + const den = Math.log2(maxSignals24h + 1) || 1; + intensity = 0.18 + 0.82 * (num / den); + } const n = { id: nd.fingerprint, fp: nd.fingerprint, @@ -64,9 +118,10 @@ status: nd.status || "unknown", is_self: isSelf, distance: dist, + stats, + intensity, r, x: 0, y: 0, vx: 0, vy: 0, fixed: false, - tooltip: buildTooltip(nd), }; nodes.push(n); nodeByFp[n.id] = n; @@ -87,15 +142,6 @@ }); } - function buildTooltip(nd) { - const lines = []; - lines.push((nd.is_self ? "self · " : "") + (nd.domain || nd.label || nd.fingerprint)); - lines.push("fp: " + nd.fingerprint); - lines.push("status: " + (nd.status || "unknown")); - lines.push("distance: " + (nd.distance || 0)); - return lines.join("\n"); - } - // ---------- viewport + seeding --------------------------------------- function viewport() { const W = svg.clientWidth || 900; @@ -108,10 +154,7 @@ (function seed() { const cx = W / 2, cy = H / 2; nodes.forEach((n, i) => { - if (n.is_self) { - n.x = cx; n.y = cy; - return; - } + if (n.is_self) { n.x = cx; n.y = cy; return; } const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22; const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2; n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20; @@ -119,8 +162,6 @@ }); })(); - // Force-sim params — same shape as topology.js, slightly softer springs - // so the two rings settle visibly. const REPULSION = 1500; const SPRING_K = 0.035; const SPRING_REST_BASE = 110; @@ -144,7 +185,8 @@ for (const e of edges) { const a = nodeByFp[e.source], b = nodeByFp[e.target]; if (!a || !b) continue; - // "knows" edges (distance-2) rest longer so transitive bands stay clear. + // corroborate edges are decorative; don't pull on the layout. + if (e.kind === "corroborate") continue; const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) + 0.1; @@ -163,30 +205,38 @@ n.y = Math.max(n.r, Math.min(H - n.r, n.y)); } } - - // Pre-settle so the first frame isn't a glob. for (let i = 0; i < 280; i++) tick(); - // ---------- render ---------------------------------------------------- + // ---------- render SVG groups ---------------------------------------- + // Order matters: corroborate edges go first so they sit behind the + // primary edges, then the rest, then nodes on top. const ns = "http://www.w3.org/2000/svg"; + const corrG = document.createElementNS(ns, "g"); const edgesG = document.createElementNS(ns, "g"); const nodesG = document.createElementNS(ns, "g"); + corrG.setAttribute("class", "fn-edges fn-edges-corr"); edgesG.setAttribute("class", "fn-edges"); nodesG.setAttribute("class", "fn-nodes"); + svg.appendChild(corrG); svg.appendChild(edgesG); svg.appendChild(nodesG); const edgeEls = edges.map(e => { const grp = document.createElementNS(ns, "g"); grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind); + grp.dataset.source = e.source; + grp.dataset.target = e.target; const ln = document.createElementNS(ns, "line"); ln.setAttribute("class", "fn-edge"); - // Signal weight controls stroke width; cap at 5px so a noisy peer - // doesn't blot out the layout. if (e.kind === "signal") { const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8); ln.setAttribute("stroke-width", w.toFixed(2)); } + if (e.kind === "corroborate") { + // Stroke width hints at how many shared hashes this pair has. + const w = Math.min(3, 0.8 + Math.log2(e.weight + 1) * 0.5); + ln.setAttribute("stroke-width", w.toFixed(2)); + } grp.appendChild(ln); if (e.label) { const lbl = document.createElementNS(ns, "text"); @@ -194,8 +244,9 @@ lbl.textContent = e.label; grp.appendChild(lbl); } - edgesG.appendChild(grp); - return { line: ln, label: grp.querySelector("text") }; + const host = e.kind === "corroborate" ? corrG : edgesG; + host.appendChild(grp); + return { line: ln, label: grp.querySelector("text"), grp }; }); function _classFor(n) { @@ -209,17 +260,20 @@ g.setAttribute("class", _classFor(n)); g.dataset.fp = n.fp; + let shape; if (n.is_self) { const sz = n.r; - const rect = document.createElementNS(ns, "rect"); - rect.setAttribute("x", -sz); rect.setAttribute("y", -sz); - rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2); - rect.setAttribute("rx", 10); rect.setAttribute("ry", 10); - g.appendChild(rect); + shape = document.createElementNS(ns, "rect"); + shape.setAttribute("x", -sz); shape.setAttribute("y", -sz); + shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2); + shape.setAttribute("rx", 10); shape.setAttribute("ry", 10); + g.appendChild(shape); } else { - const c = document.createElementNS(ns, "circle"); - c.setAttribute("r", n.r); - g.appendChild(c); + shape = document.createElementNS(ns, "circle"); + shape.setAttribute("r", n.r); + // Log-normalized fill opacity: quiet peer → faint, busy → full. + shape.setAttribute("fill-opacity", n.intensity.toFixed(2)); + g.appendChild(shape); } const text = document.createElementNS(ns, "text"); @@ -234,10 +288,25 @@ sub.setAttribute("dy", n.r + 24); sub.textContent = n.fp.slice(0, 8) + "…"; g.appendChild(sub); + + // Stat badge — compact "↓ signals · ✓ vouches-in · ⚡ quorum". + // Hidden for distance=2 nodes via CSS, since their data is sparse. + if (n.stats) { + const badge = document.createElementNS(ns, "text"); + badge.setAttribute("class", "fn-stat-badge"); + badge.setAttribute("dy", n.r + 36); + const s = n.stats; + badge.textContent = + "↓ " + (s.signals_24h || 0) + + " · ✓ " + (s.vouches_in_count || 0) + + " · ⚡ " + (s.quorum_contribution || 0); + g.appendChild(badge); + } } + // Native stays as an accessibility fallback for keyboard users. const title = document.createElementNS(ns, "title"); - title.textContent = n.tooltip; + title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp); g.appendChild(title); nodesG.appendChild(g); @@ -263,7 +332,46 @@ } paint(); - // ---------- drag + click -------------------------------------------- + // ---------- tooltip -------------------------------------------------- + function showTooltip(n, clientX, clientY) { + if (!tooltipEl) return; + const s = n.stats || {}; + const rows = []; + rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">${esc(n.status)}</span></div>`); + if (!n.is_self) { + rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signals_24h || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(s.last_seen_relative || "—")}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">vouches</span><span class="v">in ${s.vouches_in_count || 0} · out ${s.vouches_out_count || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">quorum</span><span class="v">${s.quorum_contribution || 0}</span></div>`); + } else { + rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${topStats.total_peers || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${topStats.signals_buffered_24h || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">quorum-met</span><span class="v">${topStats.quorum_met_count || 0}</span></div>`); + } + tooltipEl.innerHTML = rows.join(""); + tooltipEl.classList.add("is-visible"); + positionTooltip(clientX, clientY); + } + function positionTooltip(clientX, clientY) { + if (!tooltipEl) return; + const parent = svg.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + let x = clientX - rect.left + 14; + let y = clientY - rect.top + 14; + const tw = tooltipEl.offsetWidth || 240; + const th = tooltipEl.offsetHeight || 100; + if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14; + if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14; + tooltipEl.style.left = x + "px"; + tooltipEl.style.top = y + "px"; + } + function hideTooltip() { + if (tooltipEl) tooltipEl.classList.remove("is-visible"); + } + + // ---------- drag + click + hover ------------------------------------ let dragging = null, dragOffset = { x: 0, y: 0 }; let pressedNode = null, pressedAt = null, moved = false; function svgPoint(clientX, clientY) { @@ -271,17 +379,21 @@ return pt.matrixTransform(svg.getScreenCTM().inverse()); } nodeEls.forEach((g, i) => { + const n = nodes[i]; g.addEventListener("mousedown", ev => { ev.preventDefault(); - pressedNode = nodes[i]; + pressedNode = n; pressedAt = { x: ev.clientX, y: ev.clientY }; moved = false; - dragging = nodes[i]; + dragging = n; const p = svgPoint(ev.clientX, ev.clientY); dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y; if (currentLayout === "force") dragging.fixed = true; g.classList.add("dragging"); }); + g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY)); + g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY)); + g.addEventListener("mouseleave", hideTooltip); }); document.addEventListener("mousemove", ev => { if (pressedAt) { @@ -309,44 +421,166 @@ }); // ---------- detail panel -------------------------------------------- - function esc(s) { - return String(s == null ? "" : s).replace(/[&<>"']/g, c => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); - } - function selectNode(n) { nodeEls.forEach(el => el.classList.remove("selected")); const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`); if (me) me.classList.add("selected"); renderDetail(n); } - + function jumpToFp(fp) { + const target = nodeByFp[fp]; + if (!target) return; + selectNode(target); + } function clearSelection() { nodeEls.forEach(el => el.classList.remove("selected")); if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>'; } - function countEdges(fp) { - let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0; - for (const e of edges) { - if (e.kind === "vouch" && e.source === fp) vouchOut++; - else if (e.kind === "vouch" && e.target === fp) vouchIn++; - else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight; - else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight; - else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++; + // Aggregate self stats from all peer.stats blocks so the self node has + // a meaningful detail card too. + function selfStats() { + let signals_24h = 0, vouches_in = 0, vouches_out = 0, quorum = 0; + let cases_24h = 0, iocs_24h = 0; + const sev = { critical: 0, high: 0, medium: 0, low: 0 }; + const iocType = { url: 0, domain: 0, ip: 0, hash: 0, cve: 0 }; + for (const nd of nodes) { + if (nd.is_self || !nd.stats) continue; + signals_24h += nd.stats.signals_24h || 0; + cases_24h += nd.stats.cases_24h || 0; + iocs_24h += nd.stats.iocs_24h || 0; + vouches_in += nd.stats.vouches_in_count || 0; + vouches_out += nd.stats.vouches_out_count || 0; + quorum += nd.stats.quorum_contribution || 0; + const sb = nd.stats.severity_breakdown || {}; + for (const k of Object.keys(sev)) sev[k] += sb[k] || 0; + const ib = nd.stats.ioc_type_breakdown || {}; + for (const k of Object.keys(iocType)) iocType[k] += ib[k] || 0; } - return { vouchOut, vouchIn, signalsIn, knows }; + return { + signals_24h, cases_24h, iocs_24h, + severity_breakdown: sev, ioc_type_breakdown: iocType, + vouches_in_count: vouches_in, vouches_out_count: vouches_out, + quorum_contribution: quorum, + }; + } + + function sevRow(sev) { + const order = ["critical", "high", "medium", "low"]; + const cells = order.map(k => { + const n = (sev && sev[k]) || 0; + return `<span class="fn-sev-chip fn-sev-${k}">${k}<span class="n">${n}</span></span>`; + }).join(""); + return `<div class="fn-sev-row">${cells}</div>`; + } + function iocRow(it) { + const order = ["url", "domain", "ip", "hash", "cve"]; + const cells = order.map(k => { + const n = (it && it[k]) || 0; + return `<span class="fn-ioc-chip"><span class="k">${k}</span><span class="n">${n}</span></span>`; + }).join(""); + return `<div class="fn-sev-row">${cells}</div>`; } function renderDetail(n) { if (!detailEl) return; - const stats = countEdges(n.fp); const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER"); const kindCls = n.is_self ? "td-kind-host" : (n.distance >= 2 ? "td-kind-cont" : "td-kind-net"); const statusBadge = `<span class="state-badge fn-status-badge-${esc(n.status)}">${esc(n.status)}</span>`; - const jumpBack = (!n.is_self && n.domain) - ? `<p style="margin-top:10px;"><a href="/admin/federation" class="td-jump">→ open peer in /admin/federation</a></p>` - : ""; + + const s = n.is_self ? selfStats() : (n.stats || {}); + const signals24h = s.signals_24h || 0; + const sigQuorum = s.quorum_contribution || 0; + const quorumPct = signals24h > 0 ? Math.min(100, Math.round((sigQuorum / signals24h) * 100)) : 0; + + // Identity section. Self has no remote "last_seen"; show "—". + const fullFp = `<code class="full-fp">${esc(n.fp)}</code>` + + `<button type="button" class="fn-copy-btn" data-copy="${esc(n.fp)}">copy</button>`; + const identity = ` + <div class="fn-detail-sec"> + <h4>identity</h4> + <div class="row"><span class="k">fingerprint</span><span class="v">${fullFp}</span></div> + <div class="row"><span class="k">domain</span><span class="v">${n.domain ? esc(n.domain) : "—"}</span></div> + <div class="row"><span class="k">status</span><span class="v">${statusBadge}</span></div> + <div class="row"><span class="k">distance</span><span class="v">${n.is_self ? "self" : (n.distance >= 2 ? "transitive (2 hops)" : "direct (1 hop)")}</span></div> + <div class="row"><span class="k">last seen</span><span class="v">${esc((n.stats && n.stats.last_seen_relative) || "—")}</span></div> + </div>`; + + // Signals section. + const signals = ` + <div class="fn-detail-sec"> + <h4>signals · 24h</h4> + <div class="row"><span class="k">total</span><span class="v">${signals24h}</span></div> + <div class="row"><span class="k">cases</span><span class="v">${s.cases_24h || 0}</span></div> + <div class="row"><span class="k">iocs</span><span class="v">${s.iocs_24h || 0}</span></div> + <div class="row"><span class="k">all-time</span><span class="v">${(n.stats && n.stats.signals_total) || (n.is_self ? "—" : 0)}</span></div> + ${sevRow(s.severity_breakdown)} + ${iocRow(s.ioc_type_breakdown)} + </div>`; + + // Vouches section. + const vouchesPeerList = (() => { + if (n.is_self) return ""; + const count = (n.stats && n.stats.vouches_in_count) || 0; + if (!count) return ""; + // Find the actual voucher fingerprints by scanning rendered nodes + // — server doesn't ship the list per-peer, but the edge list does. + const vouchers = edges + .filter(e => e.kind === "vouch" && e.target === n.fp) + .map(e => e.source); + // Plus the case where peer-to-peer vouches exist as data but no edge + // (transitive nodes don't always get vouch edges); show known set. + const uniq = Array.from(new Set(vouchers)); + if (!uniq.length) return ""; + const chips = uniq.map(fp => + `<button type="button" class="fn-fp-jump" data-jump="${esc(fp)}">${esc(shortFp(fp))}</button>` + ).join(""); + return `<div style="margin-top:6px;">${chips}</div>`; + })(); + const vouches = ` + <div class="fn-detail-sec"> + <h4>vouches</h4> + <div class="row"><span class="k">in</span><span class="v">${s.vouches_in_count || 0}</span></div> + <div class="row"><span class="k">out</span><span class="v">${s.vouches_out_count || 0}</span></div> + ${vouchesPeerList} + </div>`; + + // Quorum section — small progress bar of "signals that are quorum-met". + const quorum = ` + <div class="fn-detail-sec"> + <h4>quorum</h4> + <div class="row"><span class="k">contribution</span><span class="v">${sigQuorum}</span></div> + <div class="row"><span class="k">of 24h total</span><span class="v">${quorumPct}%</span></div> + <div class="fn-quorum-bar"><div class="fn-quorum-fill" style="width:${quorumPct}%;"></div></div> + </div>`; + + // Transparency log section. + const translog = (() => { + const entries = (n.stats && n.stats.recent_translog) || []; + if (!entries.length) { + return `<div class="fn-detail-sec"><h4>transparency log</h4><div class="row"><span class="k">recent</span><span class="v">—</span></div></div>`; + } + const items = entries.map(e => { + const ts = (e.timestamp || "").slice(0, 19).replace("T", " "); + const hash = (e.hash || "").slice(0, 12); + return `<li><span class="id">#${esc(e.id)}</span><span class="type">${esc(e.entry_type)}</span><span class="ts">${esc(ts)}</span><span class="hash">${esc(hash)}…</span></li>`; + }).join(""); + return `<div class="fn-detail-sec"><h4>transparency log</h4><ul class="fn-trans-list">${items}</ul></div>`; + })(); + + // Actions. + const rawStatsUrl = "/admin/federation/network/data"; + const actions = ` + <div class="fn-detail-sec"> + <h4>actions</h4> + <div class="fn-actions"> + <a class="fn-action-btn" href="/admin/federation">peer registry</a> + <a class="fn-action-btn" href="/admin/federation/vouches">vouches</a> + <a class="fn-action-btn" href="/admin/federation/quorum">quorum</a> + <a class="fn-action-btn" href="${rawStatsUrl}" target="_blank" rel="noopener">raw JSON</a> + </div> + </div>`; + const html = ` <div class="td-head"> <span class="td-kind ${kindCls}">${esc(kindLabel)}</span> @@ -354,20 +588,43 @@ ${statusBadge} <button type="button" class="td-close" aria-label="close">×</button> </div> - <dl class="td-kv"> - <dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd> - <dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd> - <dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd> - <dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd> - <dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd> - <dt>Knows-edges</dt><dd>${stats.knows}</dd> - </dl> - ${jumpBack} + <div class="fn-detail-card"> + ${identity} + ${signals} + ${vouches} + ${quorum} + ${translog} + ${actions} + </div> `; detailEl.innerHTML = html; detailEl.classList.add("has-selection"); + const close = detailEl.querySelector(".td-close"); if (close) close.addEventListener("click", clearSelection); + + // Wire copy buttons. + detailEl.querySelectorAll(".fn-copy-btn").forEach(btn => { + btn.addEventListener("click", () => { + const v = btn.getAttribute("data-copy") || ""; + if (!v) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(v).catch(() => {}); + } + const t = btn.textContent; + btn.textContent = "copied"; + setTimeout(() => { btn.textContent = t; }, 1100); + }); + }); + + // Wire jump-to-fp chips. + detailEl.querySelectorAll(".fn-fp-jump").forEach(btn => { + btn.addEventListener("click", () => { + const fp = btn.getAttribute("data-jump") || ""; + if (fp) jumpToFp(fp); + }); + }); + detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" }); } @@ -387,8 +644,6 @@ loop(); // ---------- edge liveness + flow toggle ----------------------------- - // Signal edges always flow (we just saw N signals in 24h). Vouch edges - // are static. Knows edges fade. edges.forEach((e, i) => { const ln = edgeEls[i].line; if (e.kind === "signal") ln.classList.add("alive"); @@ -460,8 +715,6 @@ const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial }; let currentLayout = "force"; - // Force-mode bootstraps with self pinned at center — so the very first - // settle radiates outward naturally. const selfNode = nodes.find(n => n.is_self); if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; } @@ -491,6 +744,91 @@ }); } + // ---------- search / filter ----------------------------------------- + function applySearch(qRaw) { + const q = (qRaw || "").trim().toLowerCase(); + if (!q) { + nodeEls.forEach(el => el.classList.remove("dimmed", "match")); + edgeEls.forEach(els => els.grp.classList.remove("dimmed")); + if (searchCountEl) searchCountEl.textContent = ""; + return; + } + const matchFps = new Set(); + nodes.forEach((n, i) => { + const hay = (n.label || "") + " " + (n.domain || "") + " " + n.fp; + if (hay.toLowerCase().indexOf(q) !== -1) { + matchFps.add(n.fp); + nodeEls[i].classList.add("match"); + nodeEls[i].classList.remove("dimmed"); + } else { + nodeEls[i].classList.remove("match"); + nodeEls[i].classList.add("dimmed"); + } + }); + edges.forEach((e, i) => { + const visible = matchFps.has(e.source) || matchFps.has(e.target); + edgeEls[i].grp.classList.toggle("dimmed", !visible); + }); + if (searchCountEl) { + searchCountEl.textContent = matchFps.size + " match" + (matchFps.size === 1 ? "" : "es"); + } + } + if (searchEl) { + searchEl.addEventListener("input", ev => applySearch(ev.target.value)); + } + + // ---------- 24h timeline strip -------------------------------------- + function renderTimeline() { + if (!timelineEl) return; + const buckets = topStats.signal_timeline_24h || []; + if (!buckets.length) { + timelineEl.innerHTML = `<div class="fn-timeline-empty">no signals in the last 24h</div>`; + if (timelineAxisEl) timelineAxisEl.innerHTML = ""; + if (timelineMetaEl) timelineMetaEl.textContent = "0 signals"; + return; + } + // Find max bucket total for height-scaling. + let maxTotal = 0; + let allTotal = 0; + for (const b of buckets) { + if ((b.total || 0) > maxTotal) maxTotal = b.total || 0; + allTotal += b.total || 0; + } + if (timelineMetaEl) { + timelineMetaEl.textContent = + `${allTotal} signal${allTotal === 1 ? "" : "s"} · peak ${maxTotal}/hr`; + } + const bars = buckets.map((b, idx) => { + const total = b.total || 0; + const perPeer = b.per_peer || {}; + const hPct = maxTotal > 0 ? Math.round((total / maxTotal) * 100) : 0; + const segHtml = Object.keys(perPeer).map(fp => { + const seg = perPeer[fp]; + const pct = total > 0 ? (seg / total) * hPct : 0; + const color = peerColor[fp] || "var(--accent)"; + return `<div class="fn-timeline-bar-seg" data-fp="${esc(fp)}" data-n="${seg}" style="height:${pct.toFixed(2)}%;background:${color};"></div>`; + }).join(""); + const hoursAgo = 23 - idx; + const tooltipLines = [`${hoursAgo}h ago · ${total} signals`]; + for (const fp of Object.keys(perPeer)) { + tooltipLines.push(` ${shortFp(fp)}: ${perPeer[fp]}`); + } + return `<div class="fn-timeline-bar" data-hour="${hoursAgo}" title="${esc(tooltipLines.join("\n"))}">${segHtml}</div>`; + }).join(""); + timelineEl.innerHTML = bars; + + if (timelineAxisEl) { + // Axis labels every 6 hours. + const axis = buckets.map((b, idx) => { + const hoursAgo = 23 - idx; + const show = (hoursAgo % 6 === 0); + return `<span>${show ? "-" + hoursAgo + "h" : ""}</span>`; + }).join(""); + timelineAxisEl.innerHTML = axis; + } + } + renderTimeline(); + // Wheel zoom. let zoom = 1, panX = 0, panY = 0; svg.addEventListener("wheel", ev => { From 980cf74b76d257ff09468fbb4c7e8322daa88abb Mon Sep 17 00:00:00 2001 From: m17hr1l <m17hr1l@wehackforyou.com> Date: Sun, 7 Jun 2026 00:57:53 +0200 Subject: [PATCH 4/5] stage-netd-d cockpit SW: bump CACHE_VERSION to psyc-v7 for network detail CSS+JS --- src/psyc/cockpit/static/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js index 61c9ee9..84e5686 100644 --- a/src/psyc/cockpit/static/sw.js +++ b/src/psyc/cockpit/static/sw.js @@ -5,7 +5,7 @@ // This makes the cockpit installable as a PWA and survives flaky connections, // without serving stale operational data behind the operator's back. -const CACHE_VERSION = "psyc-v6"; +const CACHE_VERSION = "psyc-v7"; const STATIC_ASSETS = [ "/static/cockpit.css", "/static/psyc-tokens.css", From 0d9baef4c8559028037078558b0d56b27ec63edf Mon Sep 17 00:00:00 2001 From: m17hr1l <m17hr1l@wehackforyou.com> Date: Sun, 7 Jun 2026 01:00:39 +0200 Subject: [PATCH 5/5] stage-netd-f network detail: tests for admin enrichment (stats/corroboration/timeline) --- tests/test_network_view.py | 326 ++++++++++++++++++++++++++++++++++++- 1 file changed, 325 insertions(+), 1 deletion(-) diff --git a/tests/test_network_view.py b/tests/test_network_view.py index 152da47..49fde86 100644 --- a/tests/test_network_view.py +++ b/tests/test_network_view.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json from datetime import datetime, timedelta, timezone from typing import Any, Dict from unittest.mock import patch @@ -11,11 +12,12 @@ import pytest from sqlalchemy import create_engine from psyc import db -from psyc.lines import federation, network_view +from psyc.lines import federation, network_view, translog from psyc.lines.network_view import ( NetworkEdge, NetworkNode, NetworkView, + build_admin_view, build_local_view, build_public_view, build_transitive_view, @@ -332,3 +334,325 @@ def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir): assert "trusted.example" in calls assert "unknown.example" not in calls + + +# ---------- admin view: per-peer enrichment + corroboration + timeline --- + +def _no_transitive(): + """patch.object helper — silence network fetches in admin-view tests.""" + return patch.object(network_view, "_fetch_peer_network", return_value=None) + + +def test_admin_view_includes_stats_on_peer_nodes(fresh_db, fed_dir): + """Every non-self node must carry a `stats` dict in the admin view.""" + fp, pem = _make_peer_pubkey() + federation.register_peer("peer.example", fp, pem, status="trusted") + with _no_transitive(): + view = build_admin_view(include_transitive=False) + self_nodes = [n for n in view["nodes"] if n["is_self"]] + peer_nodes = [n for n in view["nodes"] if not n["is_self"]] + assert len(self_nodes) == 1 + assert len(peer_nodes) == 1 + # Self has no stats; peers do. + assert self_nodes[0]["stats"] is None + peer_stats = peer_nodes[0]["stats"] + assert isinstance(peer_stats, dict) + for key in ( + "signals_24h", "signals_total", "cases_24h", "iocs_24h", + "severity_breakdown", "ioc_type_breakdown", + "vouches_in_count", "vouches_out_count", + "quorum_contribution", "last_seen", "last_seen_relative", + "recent_translog", + ): + assert key in peer_stats, f"missing {key}" + # last_seen is None when no signals have landed yet. + assert peer_stats["last_seen"] is None + assert peer_stats["last_seen_relative"] == "—" + + +def test_admin_view_signals_24h_excludes_stale(fresh_db, fed_dir): + """signals_24h must count only rows inside the 24h window.""" + fp, pem = _make_peer_pubkey() + federation.register_peer("peer.example", fp, pem, status="trusted") + now_iso = datetime.now(timezone.utc).isoformat() + stale_iso = (datetime.now(timezone.utc) - timedelta(hours=30)).isoformat() + for i in range(3): + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id=f"v{i}", + signal_hash=f"h{i}", + received_at=now_iso, + raw_json="{}", + )) + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id="stale", + signal_hash="stale-hash", + received_at=stale_iso, + raw_json="{}", + )) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + peer = next(n for n in view["nodes"] if not n["is_self"]) + assert peer["stats"]["signals_24h"] == 3 + # All-time total still sees the stale row. + assert peer["stats"]["signals_total"] == 4 + # last_seen is populated and the relative is a short string. + assert peer["stats"]["last_seen"] is not None + assert peer["stats"]["last_seen_relative"] != "—" + + +def test_admin_view_severity_and_ioc_breakdown_from_raw_json(fresh_db, fed_dir): + """severity_breakdown is read from raw_json for case rows, ioc_type for ioc rows.""" + fp, pem = _make_peer_pubkey() + federation.register_peer("peer.example", fp, pem, status="trusted") + now_iso = datetime.now(timezone.utc).isoformat() + cases = [ + {"severity": "critical", "case_id": "c1"}, + {"severity": "critical", "case_id": "c2"}, + {"severity": "high", "case_id": "c3"}, + {"severity": "low", "case_id": "c4"}, + ] + for c in cases: + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="case", + signal_id=c["case_id"], + signal_hash=f"hash-{c['case_id']}", + received_at=now_iso, + raw_json=json.dumps(c), + )) + iocs = [ + {"type": "url", "value": "https://a"}, + {"type": "url", "value": "https://b"}, + {"type": "domain", "value": "x.com"}, + {"type": "ip", "value": "1.2.3.4"}, + ] + for ioc in iocs: + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id=ioc["value"], + signal_hash=f"hash-{ioc['value']}", + received_at=now_iso, + raw_json=json.dumps(ioc), + )) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + stats = next(n for n in view["nodes"] if not n["is_self"])["stats"] + assert stats["cases_24h"] == 4 + assert stats["iocs_24h"] == 4 + sev = stats["severity_breakdown"] + assert sev == {"critical": 2, "high": 1, "medium": 0, "low": 1} + ioc_t = stats["ioc_type_breakdown"] + assert ioc_t == {"url": 2, "domain": 1, "ip": 1, "hash": 0, "cve": 0} + + +def test_admin_view_vouch_in_out_counts(fresh_db, fed_dir): + """vouches_in_count counts vouches naming this peer; out counts what they've issued.""" + fp_a, pem_a = _make_peer_pubkey() + fp_b, pem_b = _make_peer_pubkey() + federation.register_peer("a.example", fp_a, pem_a, status="trusted") + federation.register_peer("b.example", fp_b, pem_b, status="trusted") + now = datetime.now(timezone.utc).isoformat() + # A vouches for B; we vouch for B too — B sees vouches_in=2. + db.upsert_vouch(dict( + voucher_fingerprint=fp_a, + target_fingerprint=fp_b, + issued_at=now, expires_at=None, signature="x", + )) + federation.issue_vouch(fp_b, ttl_days=30) + # B vouches for A — A sees vouches_in=1, B sees vouches_out=1. + db.upsert_vouch(dict( + voucher_fingerprint=fp_b, + target_fingerprint=fp_a, + issued_at=now, expires_at=None, signature="y", + )) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]} + assert by_fp[fp_a]["stats"]["vouches_in_count"] == 1 + assert by_fp[fp_a]["stats"]["vouches_out_count"] == 1 # vouches for B + assert by_fp[fp_b]["stats"]["vouches_in_count"] == 2 # A + us + assert by_fp[fp_b]["stats"]["vouches_out_count"] == 1 # vouches for A + + +def test_admin_view_corroborated_signals(fresh_db, fed_dir): + """Pairs of peers reporting the same signal_hash → corroborated entry + edge.""" + fp_a, pem_a = _make_peer_pubkey() + fp_b, pem_b = _make_peer_pubkey() + federation.register_peer("a.example", fp_a, pem_a, status="trusted") + federation.register_peer("b.example", fp_b, pem_b, status="trusted") + now_iso = datetime.now(timezone.utc).isoformat() + for peer_fp in (fp_a, fp_b): + db.record_signal(dict( + peer_fingerprint=peer_fp, + signal_type="ioc", + signal_id="evil.com", + signal_hash="shared-hash-1", + received_at=now_iso, + raw_json="{}", + )) + # A also reports a hash B doesn't — should NOT corroborate. + db.record_signal(dict( + peer_fingerprint=fp_a, + signal_type="ioc", + signal_id="solo.com", + signal_hash="solo-hash", + received_at=now_iso, + raw_json="{}", + )) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + corr = view["stats"]["corroborated_signals"] + hashes = {c["signal_hash"] for c in corr} + assert "shared-hash-1" in hashes + assert "solo-hash" not in hashes + shared = next(c for c in corr if c["signal_hash"] == "shared-hash-1") + assert set(shared["peer_fingerprints"]) == {fp_a, fp_b} + assert shared["peer_count"] == 2 + + # One corroborate edge between the pair (orientation-independent). + corr_edges = [e for e in view["edges"] if e["kind"] == "corroborate"] + assert len(corr_edges) == 1 + pair = {corr_edges[0]["source_fingerprint"], corr_edges[0]["target_fingerprint"]} + assert pair == {fp_a, fp_b} + assert corr_edges[0]["weight"] == 1.0 + + +def test_admin_view_signal_timeline_24h_returns_24_buckets(fresh_db, fed_dir): + """signal_timeline_24h is a 24-bucket list with correct totals.""" + fp, pem = _make_peer_pubkey() + federation.register_peer("peer.example", fp, pem, status="trusted") + now = datetime.now(timezone.utc) + # Two signals one hour ago, three signals five hours ago. + one_h = (now - timedelta(hours=1, minutes=5)).isoformat() + five_h = (now - timedelta(hours=5, minutes=5)).isoformat() + for i in range(2): + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id=f"a{i}", + signal_hash=f"h-a-{i}", + received_at=one_h, + raw_json="{}", + )) + for i in range(3): + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id=f"b{i}", + signal_hash=f"h-b-{i}", + received_at=five_h, + raw_json="{}", + )) + # Stale signal — must NOT show up. + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id="stale", + signal_hash="stale-hash", + received_at=(now - timedelta(hours=48)).isoformat(), + raw_json="{}", + )) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + buckets = view["stats"]["signal_timeline_24h"] + assert isinstance(buckets, list) + assert len(buckets) == 24 + totals = [b["total"] for b in buckets] + assert sum(totals) == 5 # stale excluded + # Bucket hour_offsets are 0..23 in oldest-first order. + assert [b["hour_offset"] for b in buckets] == list(range(24)) + + +def test_admin_view_quorum_contribution(fresh_db, fed_dir): + """quorum_contribution counts this peer's distinct hashes that are quorum-met.""" + fp_a, pem_a = _make_peer_pubkey() + fp_b, pem_b = _make_peer_pubkey() + federation.register_peer("a.example", fp_a, pem_a, status="trusted") + federation.register_peer("b.example", fp_b, pem_b, status="trusted") + now_iso = datetime.now(timezone.utc).isoformat() + # Shared hash → both peers report it → quorum-met (default k=2). + for peer_fp in (fp_a, fp_b): + db.record_signal(dict( + peer_fingerprint=peer_fp, + signal_type="ioc", + signal_id="shared", + signal_hash="quorum-hash", + received_at=now_iso, + raw_json="{}", + )) + # Solo hash from A → not quorum-met. + db.record_signal(dict( + peer_fingerprint=fp_a, + signal_type="ioc", + signal_id="solo", + signal_hash="solo-hash", + received_at=now_iso, + raw_json="{}", + )) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]} + assert by_fp[fp_a]["stats"]["quorum_contribution"] == 1 + assert by_fp[fp_b]["stats"]["quorum_contribution"] == 1 + + +def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir): + """recent_translog lists entries where entry_data.peer_fingerprint matches.""" + fp_a, pem_a = _make_peer_pubkey() + fp_b, pem_b = _make_peer_pubkey() + federation.register_peer("a.example", fp_a, pem_a, status="trusted") + federation.register_peer("b.example", fp_b, pem_b, status="trusted") + # Append translog rows that name each peer. + translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "ioc", "signal_id": "x"}) + translog.append("signal", {"peer_fingerprint": fp_b, "signal_type": "case", "signal_id": "y"}) + translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "case", "signal_id": "z"}) + with _no_transitive(): + view = build_admin_view(include_transitive=False) + by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]} + a_log = by_fp[fp_a]["stats"]["recent_translog"] + b_log = by_fp[fp_b]["stats"]["recent_translog"] + assert len(a_log) == 2 + assert len(b_log) == 1 + # Each row carries the documented shape. + for row in a_log + b_log: + assert set(row.keys()) == {"id", "entry_type", "timestamp", "hash"} + + +def test_public_view_still_has_no_stats(fresh_db, fed_dir): + """Public payload must not surface admin-only enrichments — sensitive. + + Even after `build_admin_view` has been invoked (which mutates node.stats + on the cached transitive view), the public view path must stay clean. + """ + fp, pem = _make_peer_pubkey() + federation.register_peer("trusted.example", fp, pem, status="trusted") + # Seed signals + corroborated hash so admin view has rich state. + now_iso = datetime.now(timezone.utc).isoformat() + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id="leak", + signal_hash="leak-hash", + received_at=now_iso, + raw_json=json.dumps({"type": "url", "value": "https://leak"}), + )) + # Build admin view first so any caching kicks in. + with _no_transitive(): + build_admin_view(include_transitive=False) + # Now build the public view and assert no admin-only fields leak. + payload = build_public_view() + flat = json.dumps(payload, default=str) + assert "signals_24h" not in flat + assert "severity_breakdown" not in flat + assert "corroborated_signals" not in flat + assert "signal_timeline_24h" not in flat + assert "recent_translog" not in flat + assert "leak-hash" not in flat + # Peer entries in the public view never carry a `stats` field. + for p in payload.get("peers", []): + assert "stats" not in p