stage-5: Case Journey — animated 7-beat pipeline replay

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 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-18 19:53:25 +02:00
parent afba077f6f
commit 9bd5a30495
5 changed files with 215 additions and 1 deletions

View File

@@ -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)

144
src/psyc/cockpit/journey.py Normal file
View File

@@ -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"],
)

View File

@@ -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); }

View File

@@ -3,7 +3,10 @@
{% block content %}
<section class="panel">
<a class="back" href="/cases">← back to cases</a>
<h1>{{ case.case_id }}</h1>
<div class="panel-head">
<h1>{{ case.case_id }}</h1>
<a class="replay-btn" href="/cases/{{ case.case_id }}/journey">▶ case journey</a>
</div>
<p class="summary-lead">{{ case.summary }}</p>
<div class="grid">

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ case.case_id }} — Journey — psyc{% endblock %}
{% block content %}
<section class="panel">
<a class="back" href="/cases/{{ case.case_id }}">← {{ case.case_id }}</a>
<div class="panel-head">
<h1>Case Journey</h1>
<button class="replay-btn" onclick="replayJourney()">▶ replay</button>
</div>
<p class="summary-lead">{{ case.summary }}</p>
<ol class="journey playing" id="journey">
{% for b in beats %}
<li class="beat {{ 'beat-done' if b.occurred else 'beat-skip' }}" style="--i: {{ loop.index0 }}">
<span class="beat-node"></span>
<div class="beat-card">
<span class="beat-line">{{ b.index }} · {{ b.line }}</span>
<h2>{{ b.title }}{% if not b.occurred %} <span class="beat-pending">pending</span>{% endif %}</h2>
<p class="beat-caption">{{ b.caption }}</p>
{% if b.facts %}
<ul class="beat-facts">{% for f in b.facts %}<li>{{ f }}</li>{% endfor %}</ul>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</section>
<script>
function replayJourney() {
const j = document.getElementById('journey');
j.classList.remove('playing');
void j.offsetWidth;
j.classList.add('playing');
}
</script>
{% endblock %}