From 9bd5a30495325612c281b0c57a9b70645f1ec2c0 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Mon, 18 May 2026 19:53:25 +0200 Subject: [PATCH] =?UTF-8?q?stage-5:=20Case=20Journey=20=E2=80=94=20animate?= =?UTF-8?q?d=207-beat=20pipeline=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /cases/{id}/journey view tells a case's story as it moved through psyc: Detected → Classified → Located → Sealed → Routed → Submitted → Recorded. Each beat is reconstructed from real persisted state (classification, sealed package, planned routes, ledger rows) — a replay of recorded events, not a script; beats that did not happen render as "pending". CSS-staggered reveal with pulsing timeline nodes, on-brand cyan/navy, replay button. Co-Authored-By: Claude Opus 4.7 --- src/psyc/cockpit/app.py | 10 ++ src/psyc/cockpit/journey.py | 144 ++++++++++++++++++++ src/psyc/cockpit/static/cockpit.css | 21 +++ src/psyc/cockpit/templates/case_detail.html | 5 +- src/psyc/cockpit/templates/journey.html | 36 +++++ 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/psyc/cockpit/journey.py create mode 100644 src/psyc/cockpit/templates/journey.html diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index 9bf6423..6f75417 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from psyc import db, log +from psyc.cockpit import journey as journey_view from psyc.lines import ledger as ledger_line from psyc.lines import route as route_line from psyc.lines import seal as seal_line @@ -69,6 +70,15 @@ def case_detail(request: Request, case_id: str) -> HTMLResponse: ) +@app.get("/cases/{case_id}/journey", response_class=HTMLResponse) +def case_journey(request: Request, case_id: str) -> HTMLResponse: + result = db.get_case(case_id) + if isinstance(result, Err): + raise HTTPException(status_code=404, detail=result.reason) + beats = journey_view.build_journey(result.value) + return TEMPLATES.TemplateResponse(request, "journey.html", {"case": result.value, "beats": beats}) + + @app.get("/ledger", response_class=HTMLResponse) def ledger_view(request: Request) -> HTMLResponse: entries = ledger_line.list_recent(limit=200) diff --git a/src/psyc/cockpit/journey.py b/src/psyc/cockpit/journey.py new file mode 100644 index 0000000..4f786e8 --- /dev/null +++ b/src/psyc/cockpit/journey.py @@ -0,0 +1,144 @@ +"""Case Journey — reconstructs a case's pipeline story from persisted state. + +Seven beats, one per implemented worker line. Each beat's `occurred` flag and +caption are derived from real data — this is a replay of recorded events, not +a script. Beats that did not happen for a case are returned with occurred=False. +""" + +from __future__ import annotations + +from typing import List + +from pydantic import BaseModel, Field + +from psyc.lines import ledger as ledger_line +from psyc.lines import route as route_line +from psyc.lines import seal as seal_line +from psyc.models import Case, LedgerEntry, Outcome +from psyc.result import Err + + +_ACTED_OUTCOMES = {Outcome.SUBMITTED, Outcome.ACKNOWLEDGED, Outcome.ACTIONED} + + +class Beat(BaseModel): + index: int + line: str + title: str + occurred: bool + caption: str + facts: List[str] = Field(default_factory=list) + + +def build_journey(case: Case) -> List[Beat]: + entries = ledger_line.list_by_case(case.case_id, limit=200) + beats = [ + _detected(case), + _classified(case), + _located(case), + _sealed(case), + _routed(case), + _submitted(entries), + _recorded(entries), + ] + for i, beat in enumerate(beats, start=1): + beat.index = i + return beats + + +def _detected(case: Case) -> Beat: + feed = case.source_metadata.get("feed", case.source_type or "unknown source") + obs = case.observables + counts = [] + for label, items in (("URL", obs.urls), ("IP", obs.ips), ("domain", obs.domains), + ("CVE", obs.cves), ("hash", obs.hashes)): + if items: + counts.append(f"{len(items)} {label}{'' if len(items) == 1 else 's'}") + facts = [case.summary] + if counts: + facts.append("observables: " + ", ".join(counts)) + return Beat( + index=0, line="Scoutline", title="Detected", occurred=True, + caption=f"Scoutline ingested this signal from {feed}.", facts=facts, + ) + + +def _classified(case: Case) -> Beat: + c = case.classification + if c.incident_type is None: + return Beat(index=0, line="Classifyline", title="Classified", occurred=False, + caption="Not yet classified.") + severity = c.severity.value if c.severity else "—" + internal = c.internal_class.value if c.internal_class else "—" + return Beat( + index=0, line="Classifyline", title="Classified", occurred=True, + caption=f"Classifyline rated it {severity} severity, TLP:{c.tlp.value}, internal class {internal}.", + facts=[f"incident type: {c.incident_type.value}"], + ) + + +def _located(case: Case) -> Beat: + if case.victim.country: + facts = [f"host IP(s): {', '.join(case.observables.ips)}"] if case.observables.ips else [] + return Beat(index=0, line="Mapline", title="Located", occurred=True, + caption=f"Mapline resolved the host to {case.victim.country}.", facts=facts) + caption = "No host to geolocate for this case." if not case.observables.ips else "Not yet mapped." + return Beat(index=0, line="Mapline", title="Located", occurred=False, caption=caption) + + +def _sealed(case: Case) -> Beat: + pkg_id = case.evidence.sealed_package_id + if not pkg_id: + return Beat(index=0, line="Sealine", title="Sealed", occurred=False, + caption="Evidence not sealed.") + result = seal_line.load_package(pkg_id) + if isinstance(result, Err): + return Beat(index=0, line="Sealine", title="Sealed", occurred=True, + caption=f"Evidence sealed — package {pkg_id}.") + pkg = result.value + return Beat( + index=0, line="Sealine", title="Sealed", occurred=True, + caption=f"Sealine encrypted the evidence for {len(pkg.recipients)} authorized recipient(s).", + facts=[f"package {pkg.package_id}", f"recipients: {', '.join(pkg.recipients)}", + f"plaintext SHA-256: {pkg.plaintext_hash[:24]}…"], + ) + + +def _routed(case: Case) -> Beat: + if case.classification.incident_type is None: + return Beat(index=0, line="Routeline", title="Routed", occurred=False, + caption="Routing pending classification.") + routes, blocked = route_line.plan(case) + facts = [f"✓ {r.destination_name}" for r in routes] + facts += [f"⊘ {b.destination_name} — {b.reason}" for b in blocked] + return Beat( + index=0, line="Routeline", title="Routed", occurred=True, + caption=f"Routeline cleared {len(routes)} destination(s) and blocked {len(blocked)} by policy.", + facts=facts, + ) + + +def _submitted(entries: List[LedgerEntry]) -> Beat: + acted = [e for e in entries if e.outcome in _ACTED_OUTCOMES] + if not acted: + return Beat(index=0, line="Courier", title="Submitted", occurred=False, + caption="Not yet submitted to any destination.") + facts = [] + for e in acted: + receipt = f" · {e.response_id}" if e.response_id else "" + facts.append(f"{e.destination} → {e.outcome.value}{receipt}") + return Beat( + index=0, line="Courier", title="Submitted", occurred=True, + caption=f"Courier delivered the payload to {len(acted)} destination(s).", facts=facts, + ) + + +def _recorded(entries: List[LedgerEntry]) -> Beat: + if not entries: + return Beat(index=0, line="Ledgerline", title="Recorded", occurred=False, + caption="No ledger entries yet.") + return Beat( + index=0, line="Ledgerline", title="Recorded", occurred=True, + caption=f"Ledgerline wrote {len(entries)} immutable audit record(s).", + facts=[f"latest entry: {entries[0].timestamp.strftime('%Y-%m-%d %H:%M:%S')} UTC"], + ) diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 7b24933..709d45b 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -102,3 +102,24 @@ tr.sev-low .sev-badge { color: var(--muted); } .loss-bar-track { background: var(--panel-2); border: 1px solid var(--border); border-radius: 3px; height: 16px; overflow: hidden; } .loss-bar { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--green)); } .loss-val { text-align: right; color: var(--text); } + +/* Case Journey timeline (/cases/{id}/journey) */ +.journey { list-style: none; margin: 20px 0 0; padding: 0; position: relative; } +.journey::before { content: ""; position: absolute; left: 11px; top: 8px; bottom: 22px; width: 2px; background: var(--border); } +.beat { position: relative; padding: 0 0 18px 40px; } +.journey.playing .beat { opacity: 0; transform: translateY(12px); animation: beat-in 0.5s ease forwards; animation-delay: calc(var(--i) * 0.55s); } +@keyframes beat-in { to { opacity: 1; transform: none; } } +.beat-node { position: absolute; left: 4px; top: 4px; width: 16px; height: 16px; border-radius: 50%; background: var(--panel-2); border: 2px solid var(--border); } +.beat-done .beat-node { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 8px var(--accent-glow); } +.journey.playing .beat-done .beat-node { animation: node-pulse 0.6s ease; animation-delay: calc(var(--i) * 0.55s); } +@keyframes node-pulse { 0% { transform: scale(0.4); } 60% { transform: scale(1.35); } 100% { transform: scale(1); } } +.beat-card { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; } +.beat-skip .beat-card { opacity: 0.5; } +.beat-line { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; } +.beat-card h2 { margin: 2px 0 6px; font-size: 15px; } +.beat-pending { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 10px; padding: 1px 8px; text-transform: uppercase; letter-spacing: 0.5px; } +.beat-caption { margin: 0; font-size: 13px; } +.beat-facts { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); } +.beat-facts li { margin: 2px 0; } +.replay-btn { background: var(--panel-2); color: var(--accent); border: 1px solid var(--border); border-radius: 4px; padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer; } +.replay-btn:hover { border-color: var(--accent); } diff --git a/src/psyc/cockpit/templates/case_detail.html b/src/psyc/cockpit/templates/case_detail.html index c19cd2b..829b15b 100644 --- a/src/psyc/cockpit/templates/case_detail.html +++ b/src/psyc/cockpit/templates/case_detail.html @@ -3,7 +3,10 @@ {% block content %}
← back to cases -

{{ case.case_id }}

+
+

{{ case.case_id }}

+ ▶ case journey +

{{ case.summary }}

diff --git a/src/psyc/cockpit/templates/journey.html b/src/psyc/cockpit/templates/journey.html new file mode 100644 index 0000000..ea28a3d --- /dev/null +++ b/src/psyc/cockpit/templates/journey.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}{{ case.case_id }} — Journey — psyc{% endblock %} +{% block content %} +
+ ← {{ case.case_id }} +
+

Case Journey

+ +
+

{{ case.summary }}

+ +
    + {% for b in beats %} +
  1. + +
    + {{ b.index }} · {{ b.line }} +

    {{ b.title }}{% if not b.occurred %} pending{% endif %}

    +

    {{ b.caption }}

    + {% if b.facts %} +
      {% for f in b.facts %}
    • {{ f }}
    • {% endfor %}
    + {% endif %} +
    +
  2. + {% endfor %} +
+
+ +{% endblock %}