stage-31: Newsline polish — featured case, time buckets, severity accents, generated visuals

The home page goes from a flat event stream to something that reads like
a news blog:

- Featured-case hero card at the top, picked as the highest-severity
  case (CRITICAL > HIGH, tie-break recency) from the last 7 days. Wide,
  with a procedurally generated SVG hero behind a gradient overlay that
  carries title + severity + TLP + feed + ingest time.
- Recent activity is now grouped under Today / Yesterday / Earlier this
  week / Older bucket headers.
- Each item gets a left-border severity accent (red CRITICAL, amber
  HIGH, muted MEDIUM/LOW) so the page is scannable at a glance.

Images: new cockpit/case_visuals.py generates SVGs from case data —
zero external image gen, zero curated assets. Every visual is
deterministic from case_id (so a case keeps its identity across
sessions) and themed to its severity:

- case_hero_svg() — 880x220 hero with severity radial glow, a faint
  scan grid, a particle constellation with auto-connecting lines, HUD
  corner brackets, and the case id whispered in the bottom-right.
- case_glyph_svg() — small mirror-symmetric identicon (5-grid),
  severity-colored, shown beside each case news item in place of an
  emoji icon. Two case_ids → two distinct glyphs; same id → same glyph.

7 news tests pass; visual sanity print confirms hero is deterministic
and uses the right severity accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-25 19:02:22 +02:00
parent 4d36db90f1
commit 76a0b0b636
6 changed files with 363 additions and 20 deletions

View File

@@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from psyc import db, log
from psyc.cockpit import adminauth, docker_view, inference, journey as journey_view
from psyc.cockpit import adminauth, case_visuals, docker_view, inference, journey as journey_view
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
@@ -42,13 +42,24 @@ def _admin_ok(request: Request) -> bool:
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
items = news_line.recent_items(limit=40)
featured = news_line.featured_case()
case_index: dict = {}
for i in items:
if i.kind == "case" and i.case_id and i.case_id not in case_index:
got = db.get_case(i.case_id)
if not isinstance(got, Err):
case_index[i.case_id] = case_visuals.case_glyph_svg(got.value)
return TEMPLATES.TemplateResponse(
request,
"home.html",
{
"kpis": news_line.kpis(),
"items": news_line.recent_items(limit=24),
"buckets": news_line.bucket_items(items),
"feeds": news_line.feed_health(),
"featured": featured,
"featured_hero": case_visuals.case_hero_svg(featured) if featured else "",
"case_glyphs": case_index,
},
)

View File

@@ -0,0 +1,166 @@
"""Procedural SVG visuals derived from case data.
Zero external image-gen, zero curated assets — every visual is generated
server-side from the case_id (deterministic per case) and severity. Cyber-HUD
aesthetic, theme-coordinated with cockpit.css.
"""
from __future__ import annotations
import hashlib
import math
from typing import Optional
from psyc.models import Case, Severity
_SEV_ACCENT = {
Severity.CRITICAL: "#f87171",
Severity.HIGH: "#fbbf24",
Severity.MEDIUM: "#1ec8ff",
Severity.LOW: "#7d8597",
}
_DEFAULT_ACCENT = "#1ec8ff"
def _seed(case_id: str) -> int:
"""Stable 32-bit int seed from a string — deterministic per case."""
return int.from_bytes(hashlib.sha256(case_id.encode()).digest()[:4], "big")
def _prng(seed: int):
"""Mulberry32 — small fast deterministic PRNG. Yields floats in [0,1)."""
state = [seed & 0xFFFFFFFF]
def nxt():
state[0] = (state[0] + 0x6D2B79F5) & 0xFFFFFFFF
t = state[0]
t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
t ^= (t + ((t ^ (t >> 7)) * (t | 61))) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
return nxt
def _accent(case: Optional[Case]) -> str:
if case and case.classification.severity:
return _SEV_ACCENT.get(case.classification.severity, _DEFAULT_ACCENT)
return _DEFAULT_ACCENT
# ---------- hero SVG (featured card) -----------------------------------
def case_hero_svg(case: Case, width: int = 880, height: int = 220) -> str:
"""Wide SVG for the featured-case hero. Particle constellation + severity glow."""
seed = _seed(case.case_id)
rng = _prng(seed)
accent = _accent(case)
sid = f"h{seed:x}"
parts = []
parts.append(
f'<defs>'
f'<linearGradient id="bg-{sid}" x1="0" y1="0" x2="1" y2="1">'
f'<stop offset="0%" stop-color="#0f1115"/><stop offset="100%" stop-color="#1c2230"/>'
f'</linearGradient>'
f'<radialGradient id="glow-{sid}" cx="78%" cy="50%" r="60%">'
f'<stop offset="0%" stop-color="{accent}" stop-opacity="0.42"/>'
f'<stop offset="55%" stop-color="{accent}" stop-opacity="0.08"/>'
f'<stop offset="100%" stop-color="{accent}" stop-opacity="0"/>'
f'</radialGradient>'
f'<radialGradient id="grain-{sid}" cx="20%" cy="35%" r="60%">'
f'<stop offset="0%" stop-color="#1ec8ff" stop-opacity="0.10"/>'
f'<stop offset="100%" stop-color="#1ec8ff" stop-opacity="0"/>'
f'</radialGradient>'
f'</defs>'
)
parts.append(f'<rect width="{width}" height="{height}" fill="url(#bg-{sid})"/>')
parts.append(f'<rect width="{width}" height="{height}" fill="url(#grain-{sid})"/>')
parts.append(f'<rect width="{width}" height="{height}" fill="url(#glow-{sid})"/>')
# Faint scan grid
for x in range(0, width, 44):
parts.append(f'<line x1="{x}" y1="0" x2="{x}" y2="{height}" stroke="{accent}" stroke-opacity="0.04"/>')
for y in range(0, height, 44):
parts.append(f'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="{accent}" stroke-opacity="0.04"/>')
# Particle field
n = 40
pts = [(rng() * width, rng() * height, 1 + rng() * 3, 0.3 + rng() * 0.55) for _ in range(n)]
# Connect close particles
for i in range(n):
ax, ay, *_ = pts[i]
for j in range(i + 1, n):
bx, by, *_ = pts[j]
d = math.hypot(bx - ax, by - ay)
if d < 90:
op = (1 - d / 90) * 0.20
parts.append(
f'<line x1="{ax:.1f}" y1="{ay:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
f'stroke="{accent}" stroke-width="0.6" stroke-opacity="{op:.2f}"/>'
)
for x, y, s, op in pts:
if rng() < 0.7:
parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{s:.1f}" fill="{accent}" fill-opacity="{op:.2f}"/>')
else:
parts.append(
f'<rect x="{x - s:.1f}" y="{y - s:.1f}" width="{s * 2:.1f}" height="{s * 2:.1f}" '
f'fill="{accent}" fill-opacity="{op:.2f}" transform="rotate(45 {x:.1f} {y:.1f})"/>'
)
# HUD corner brackets
for cx, cy, dx, dy in [
(12, 12, 1, 1), (width - 12, 12, -1, 1),
(12, height - 12, 1, -1), (width - 12, height - 12, -1, -1),
]:
parts.append(
f'<polyline points="{cx + dx*18},{cy} {cx},{cy} {cx},{cy + dy*18}" '
f'fill="none" stroke="{accent}" stroke-width="1.6" stroke-opacity="0.55"/>'
)
# Ornamental case id, very faint
parts.append(
f'<text x="{width - 14}" y="{height - 12}" text-anchor="end" '
f'fill="{accent}" fill-opacity="0.22" font-family="ui-monospace,Menlo,monospace" font-size="11">'
f'{case.case_id}</text>'
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
f'preserveAspectRatio="xMidYMid slice" class="case-hero-svg" aria-hidden="true">'
+ "".join(parts) + "</svg>"
)
# ---------- glyph SVG (news list items) ---------------------------------
def case_glyph_svg(case: Case, size: int = 36) -> str:
"""A small identicon-like SVG glyph for a case — symmetric, severity-colored."""
seed = _seed(case.case_id)
rng = _prng(seed)
accent = _accent(case)
grid = 5
cell = size / grid
parts = [f'<rect width="{size}" height="{size}" fill="#1c2230" rx="7"/>']
pad = 3
# mirror-symmetric pattern
for cy in range(grid):
for cx in range((grid + 1) // 2):
if rng() < 0.55:
op = 0.45 + rng() * 0.45
x = cx * cell + pad / 2
y = cy * cell + pad / 2
w = cell - pad
h = cell - pad
parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'fill="{accent}" fill-opacity="{op:.2f}" rx="1"/>')
# mirror
mx = (grid - 1 - cx) * cell + pad / 2
if cx != grid - 1 - cx:
parts.append(f'<rect x="{mx:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'fill="{accent}" fill-opacity="{op:.2f}" rx="1"/>')
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" '
f'width="{size}" height="{size}" class="case-glyph-svg" aria-hidden="true">'
+ "".join(parts) + "</svg>"
)

View File

@@ -867,3 +867,63 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
.model-status { display: none; }
.brand-icon { height: 32px; width: 100px; }
}
/* ── featured case hero ────────────────────────────────────── */
.featured {
position: relative; margin: 0 0 18px;
border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
background: var(--panel-2);
box-shadow: 0 6px 30px rgba(0,0,0,0.45);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.featured.sev-critical { border-color: var(--red); box-shadow: 0 6px 30px rgba(248,113,113,0.18); }
.featured.sev-high { border-color: var(--amber); box-shadow: 0 6px 30px rgba(251,191,36,0.16); }
.featured:hover { transform: translateY(-1px); box-shadow: 0 10px 36px rgba(30,200,255,0.18); }
.featured-link { display: block; color: inherit; text-decoration: none; position: relative; }
.featured-link:hover { text-decoration: none; }
.featured-hero { position: relative; aspect-ratio: 880 / 220; max-height: 240px; overflow: hidden; }
.featured-hero svg { width: 100%; height: 100%; display: block; }
.featured-overlay {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 18px 22px 18px;
background: linear-gradient(180deg, transparent, rgba(15,17,21,0.85) 55%);
}
.featured-tag {
font-family: var(--font-display); font-size: 10px; letter-spacing: 0.22em;
color: var(--accent); text-transform: uppercase; opacity: 0.85;
text-shadow: 0 0 12px var(--accent-glow);
}
.featured.sev-critical .featured-tag { color: var(--red); text-shadow: 0 0 12px rgba(248,113,113,0.45); }
.featured.sev-high .featured-tag { color: var(--amber); text-shadow: 0 0 12px rgba(251,191,36,0.45); }
.featured-title {
margin: 6px 0 8px; font-size: clamp(18px, 2.4vw, 24px); color: #eaf6ff;
text-shadow: 0 0 18px rgba(0,0,0,0.6);
}
.featured-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; }
.featured-meta .muted { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
@media (max-width: 720px) {
.featured-hero { aspect-ratio: 16 / 9; }
.featured-overlay { padding: 12px 14px; }
}
/* ── time-bucket headers ───────────────────────────────────── */
.bucket-head {
display: flex; align-items: baseline; gap: 10px;
font-family: var(--font-display); font-size: 13px;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted);
margin: 18px 0 6px; padding-bottom: 4px;
border-bottom: 1px dashed var(--border);
}
.bucket-head:first-of-type { margin-top: 12px; }
.bucket-count { font-family: ui-monospace, Menlo, monospace; color: var(--accent); font-size: 12px; letter-spacing: 0; }
/* ── per-item severity accent + glyph ──────────────────────── */
.news-item { border-left: 3px solid transparent; padding-left: 10px; transition: background 0.12s; }
.news-item:hover { background: rgba(30,200,255,0.04); }
.news-item.sev-critical { border-left-color: var(--red); }
.news-item.sev-high { border-left-color: var(--amber); }
.news-item.sev-medium { border-left-color: #fde68a; }
.news-item.sev-low { border-left-color: var(--muted); }
.news-item .news-icon { background: transparent; border: none; padding: 0; }
.news-item .news-icon .case-glyph-svg { display: block; border: 1px solid var(--border); border-radius: 7px; }

View File

@@ -37,20 +37,48 @@
</a>
</section>
{% if featured %}
<section class="featured sev-{{ featured.classification.severity.value }}">
<a class="featured-link" href="/cases/{{ featured.case_id }}">
<div class="featured-hero">{{ featured_hero|safe }}</div>
<div class="featured-overlay">
<div class="featured-tag">⌖ FEATURED THREAT</div>
<h2 class="featured-title">{{ featured.summary }}</h2>
<div class="featured-meta">
<span class="sev-badge">{{ featured.classification.severity.value if featured.classification.severity else '—' }}</span>
<span class="tlp-badge tlp-{{ featured.classification.tlp.value }}">{{ featured.classification.tlp.value }}</span>
<span class="muted">from {{ featured.source_metadata.feed or 'unknown' }}</span>
<span class="muted">ingested {{ featured.ingested_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</a>
</section>
{% endif %}
<div class="home-grid">
<section class="panel home-news">
<div class="panel-head">
<h2>Recent activity</h2>
<span class="count">{{ items|length }} items</span>
<span class="count">{{ buckets|sum(attribute='items')|length }} items</span>
</div>
<p class="page-intro">Live feed of what psyc has detected and what it has done about it.</p>
{% if not items %}
{% if not buckets %}
<p class="empty">Nothing recent yet — start with <code>psyc fetch-all</code>.</p>
{% endif %}
{% for b in buckets %}
<h3 class="bucket-head">{{ b.label }} <span class="bucket-count">{{ b.items|length }}</span></h3>
<ol class="news-list">
{% for i in items %}
{% for i in b.items %}
<li class="news-item news-kind-{{ i.kind }}{% if i.severity %} sev-{{ i.severity }}{% endif %}">
<div class="news-icon">{{ i.icon }}</div>
<div class="news-icon">
{% if i.kind == 'case' and i.case_id and case_glyphs.get(i.case_id) %}
{{ case_glyphs[i.case_id]|safe }}
{% else %}
{{ i.icon }}
{% endif %}
</div>
<div class="news-body">
<div class="news-head">
<span class="news-headline">{{ i.headline }}</span>
@@ -58,13 +86,14 @@
</div>
<div class="news-sub">{{ i.body }}</div>
<div class="news-meta">
<time>{{ i.timestamp.strftime('%Y-%m-%d %H:%M') }}</time>
<time>{{ i.timestamp.strftime('%H:%M' if b.label == 'Today' else '%Y-%m-%d %H:%M') }}</time>
{% if i.case_id %} · <a href="/cases/{{ i.case_id }}">{{ i.case_id }}</a>{% endif %}
</div>
</div>
</li>
{% endfor %}
</ol>
{% endfor %}
</section>
<aside class="panel home-side">

View File

@@ -108,6 +108,54 @@ def recent_items(limit: int = 30, high_only: bool = False) -> List[NewsItem]:
# ---------- feed health (sidebar / footer of home) -----------------------
class Bucket(BaseModel):
label: str
items: List[NewsItem]
def bucket_items(items: List[NewsItem]) -> List[Bucket]:
"""Group items into Today / Yesterday / Earlier this week / Older."""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday_start = today_start - timedelta(days=1)
week_start = today_start - timedelta(days=7)
order = ("Today", "Yesterday", "Earlier this week", "Older")
buckets: Dict[str, List[NewsItem]] = {k: [] for k in order}
for i in items:
ts = i.timestamp
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
if ts >= today_start: buckets["Today"].append(i)
elif ts >= yesterday_start: buckets["Yesterday"].append(i)
elif ts >= week_start: buckets["Earlier this week"].append(i)
else: buckets["Older"].append(i)
return [Bucket(label=k, items=buckets[k]) for k in order if buckets[k]]
_SEV_RANK = {Severity.CRITICAL: 3, Severity.HIGH: 2, Severity.MEDIUM: 1, Severity.LOW: 0}
def featured_case() -> Optional[Case]:
"""Pick a case to spotlight: highest-severity from the last 7 days,
breaking ties by recency. Returns None if nothing HIGH+ in the window."""
now = datetime.now(timezone.utc)
week = now - timedelta(days=7)
candidates: List[Case] = []
for c in db.list_cases(limit=2000):
ts = c.ingested_at
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
if ts and ts >= week and c.classification.severity in (Severity.HIGH, Severity.CRITICAL):
candidates.append(c)
if not candidates:
return None
candidates.sort(
key=lambda c: (_SEV_RANK.get(c.classification.severity, -1), c.ingested_at),
reverse=True,
)
return candidates[0]
def feed_health() -> List[FeedHealth]:
"""Per-feed counts + most-recent ingest. Useful as a 'sources live?' panel."""
cases = db.list_cases(limit=10_000)

View File

@@ -59,6 +59,35 @@ def test_feed_health_groups_by_feed(fresh_db):
assert h[0].feed == "urlhaus"
def test_bucket_items_groups_by_recency(fresh_db):
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
items = [
news.NewsItem(timestamp=now, kind="case", headline="t1", body="", icon=""),
news.NewsItem(timestamp=now - timedelta(days=1), kind="case", headline="t2", body="", icon=""),
news.NewsItem(timestamp=now - timedelta(days=3), kind="case", headline="t3", body="", icon=""),
news.NewsItem(timestamp=now - timedelta(days=14), kind="case", headline="t4", body="", icon=""),
]
labels = [b.label for b in news.bucket_items(items)]
# all four buckets should appear, in chronological order
assert labels == ["Today", "Yesterday", "Earlier this week", "Older"]
def test_featured_case_picks_highest_severity(fresh_db):
db.upsert_case(make_case(feed="urlhaus", urls=["http://1.1.1.1/x"], severity=Severity.LOW, age_days=0))
db.upsert_case(make_case(feed="urlhaus", urls=["http://2.2.2.2/x"], severity=Severity.HIGH, age_days=1))
db.upsert_case(make_case(feed="urlhaus", urls=["http://3.3.3.3/x"], severity=Severity.CRITICAL, age_days=2))
f = news.featured_case()
assert f is not None
assert f.classification.severity is Severity.CRITICAL
def test_featured_case_none_when_nothing_high(fresh_db):
db.upsert_case(make_case(feed="urlhaus", urls=["http://1.1.1.1/x"], severity=Severity.LOW, age_days=0))
db.upsert_case(make_case(feed="urlhaus", urls=["http://2.2.2.2/x"], severity=Severity.MEDIUM, age_days=1))
assert news.featured_case() is None
def test_outcome_kinds_match_render_map(fresh_db):
# Every Outcome should produce a NewsItem (no KeyError).
c = make_case(feed="urlhaus", ips=["1.2.3.4"])