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:
@@ -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
144
src/psyc/cockpit/journey.py
Normal 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"],
|
||||
)
|
||||
@@ -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); }
|
||||
|
||||
@@ -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">
|
||||
|
||||
36
src/psyc/cockpit/templates/journey.html
Normal file
36
src/psyc/cockpit/templates/journey.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user