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 fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from psyc import db, log
|
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 ledger as ledger_line
|
||||||
from psyc.lines import route as route_line
|
from psyc.lines import route as route_line
|
||||||
from psyc.lines import seal as seal_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)
|
@app.get("/ledger", response_class=HTMLResponse)
|
||||||
def ledger_view(request: Request) -> HTMLResponse:
|
def ledger_view(request: Request) -> HTMLResponse:
|
||||||
entries = ledger_line.list_recent(limit=200)
|
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-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-bar { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--green)); }
|
||||||
.loss-val { text-align: right; color: var(--text); }
|
.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 %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<a class="back" href="/cases">← back to cases</a>
|
<a class="back" href="/cases">← back to cases</a>
|
||||||
|
<div class="panel-head">
|
||||||
<h1>{{ case.case_id }}</h1>
|
<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>
|
<p class="summary-lead">{{ case.summary }}</p>
|
||||||
|
|
||||||
<div class="grid">
|
<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