stage-30: home page (Newsline digest) + PWA + mobile pass
NEW / start page replaces the redirect-to-/cases: - KPI strip (cases, IOCs, +24h, high/critical, ⚡ enforced 24h, ledger total) — clickable, responsive grid (2 cols mobile, 3 mid, 6 desktop). - Recent activity feed: ledger events (enforced/submitted/rejected/failed) + newest case ingests, interleaved newest-first, with severity badges, icons, case links. Sources via lines/news.py. - Feed health sidebar: per-feed count + last ingest time. PWA: - /static/manifest.json declares a standalone install with theme colors. - /static/sw.js — cache-first for static, network-first for HTML/API, with a graceful offline page. Registered from / scope via a dedicated /sw.js route that sets Service-Worker-Allowed: /. - viewport + apple-touch-icon + theme-color meta tags in base.html. Mobile pass on the chrome: - Topbar wraps; nav horizontally scrolls instead of crowding; brand-sub hides. - Tables (cases, ledger) scroll horizontally on narrow screens instead of exploding the layout. - Hero / KPI / news-list layouts collapse cleanly at < 720px. 4 news tests; verified locally — home page renders, /sw.js serves with Service-Worker-Allowed: /, manifest is valid JSON. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
@@ -16,6 +16,7 @@ from psyc.cockpit import adminauth, docker_view, inference, journey as journey_v
|
||||
from psyc.lines import courier as courier_line
|
||||
from psyc.lines import ledger as ledger_line
|
||||
from psyc.lines import lookup as lookup_line
|
||||
from psyc.lines import news as news_line
|
||||
from psyc.lines import respond as respond_line
|
||||
from psyc.lines import route as route_line
|
||||
from psyc.lines import seal as seal_line
|
||||
@@ -41,9 +42,15 @@ def _admin_ok(request: Request) -> bool:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
cases = db.list_cases(limit=200)
|
||||
total = db.case_count()
|
||||
return TEMPLATES.TemplateResponse(request, "cases.html", {"cases": cases, "total": total})
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"home.html",
|
||||
{
|
||||
"kpis": news_line.kpis(),
|
||||
"items": news_line.recent_items(limit=24),
|
||||
"feeds": news_line.feed_health(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/cases", response_class=HTMLResponse)
|
||||
@@ -112,6 +119,17 @@ def healthz() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# PWA service worker — must live at the root so its scope is the whole site.
|
||||
# Static file is on disk under /static/sw.js; this route just serves it from /.
|
||||
@app.get("/sw.js", include_in_schema=False)
|
||||
def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
HERE / "static" / "sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/inference-status")
|
||||
def inference_status() -> dict:
|
||||
adapter = inference.server_adapter()
|
||||
|
||||
@@ -719,3 +719,107 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
|
||||
.port-pill { display: inline-block; padding: 1px 7px; margin-right: 4px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.4); border-radius: 10px; color: var(--amber); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
.td-portlist { margin: 0; padding-left: 18px; font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--text); }
|
||||
.td-portlist li { margin: 2px 0; }
|
||||
|
||||
/* ── home / Newsline ────────────────────────────────────────── */
|
||||
.hero {
|
||||
display: flex; gap: 16px; align-items: flex-end; justify-content: space-between;
|
||||
flex-wrap: wrap; margin-bottom: 18px;
|
||||
padding: 28px 28px 22px;
|
||||
background: radial-gradient(700px 300px at 12% 0%, rgba(30,200,255,0.10), transparent 60%),
|
||||
radial-gradient(700px 300px at 95% 100%, rgba(167,139,250,0.08), transparent 60%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 12px; position: relative;
|
||||
}
|
||||
.hero::before {
|
||||
content: ""; position: absolute; top: 0; left: 20%; right: 20%; height: 2px; border-radius: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
box-shadow: 0 0 14px var(--accent); opacity: 0.85;
|
||||
}
|
||||
.hero-title {
|
||||
margin: 0; font-size: clamp(20px, 4vw, 30px); letter-spacing: 0.04em;
|
||||
text-shadow: 0 0 22px var(--accent-glow);
|
||||
}
|
||||
.hero-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-cta {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 16px; border: 1px solid var(--accent); border-radius: 8px;
|
||||
color: var(--accent); font-family: var(--font-display); letter-spacing: 0.08em; font-size: 12px;
|
||||
background: rgba(30,200,255,0.06); white-space: nowrap;
|
||||
}
|
||||
.hero-cta:hover { background: rgba(30,200,255,0.15); text-decoration: none; box-shadow: 0 0 14px var(--accent-glow); }
|
||||
|
||||
.kpis {
|
||||
display: grid; gap: 10px;
|
||||
grid-template-columns: repeat(2, 1fr); /* mobile: 2 columns */
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
@media (min-width: 720px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (min-width: 1080px) { .kpis { grid-template-columns: repeat(6, 1fr); } }
|
||||
.kpi {
|
||||
display: block; padding: 14px 12px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--panel-2), rgba(18,22,30,0.55));
|
||||
color: var(--text); text-decoration: none; transition: all 0.15s;
|
||||
}
|
||||
.kpi:hover { border-color: var(--accent); box-shadow: 0 0 14px var(--accent-glow); text-decoration: none; }
|
||||
.kpi-num { font-family: var(--font-display); font-size: clamp(22px, 4vw, 28px); font-weight: 700; line-height: 1; }
|
||||
.kpi-label { font-size: 11px; color: var(--muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.kpi-warn { border-color: rgba(251,191,36,0.4); }
|
||||
.kpi-warn .kpi-num { color: var(--amber); }
|
||||
.kpi-accent { border-color: rgba(30,200,255,0.4); }
|
||||
.kpi-accent .kpi-num { color: var(--accent); }
|
||||
|
||||
.home-grid {
|
||||
display: grid; gap: 16px;
|
||||
grid-template-columns: 1fr; /* mobile: stacked */
|
||||
}
|
||||
@media (min-width: 980px) {
|
||||
.home-grid { grid-template-columns: minmax(0,1fr) 280px; } /* desktop: main + sidebar */
|
||||
}
|
||||
|
||||
.news-list { list-style: none; margin: 0; padding: 0; }
|
||||
.news-item {
|
||||
display: grid; grid-template-columns: 32px 1fr; gap: 12px;
|
||||
padding: 12px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.news-item:last-child { border-bottom: none; }
|
||||
.news-icon {
|
||||
width: 32px; height: 32px; display: grid; place-items: center;
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 14px; color: var(--muted); flex-shrink: 0;
|
||||
}
|
||||
.news-kind-enforced .news-icon { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.08); }
|
||||
.news-kind-rejected .news-icon { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.06); }
|
||||
.news-kind-submitted .news-icon { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.06); }
|
||||
.news-kind-failed .news-icon { color: var(--red); border-color: var(--red); }
|
||||
.news-item.sev-critical .news-icon { color: var(--red); border-color: var(--red); }
|
||||
.news-item.sev-high .news-icon { color: var(--amber); border-color: var(--amber); }
|
||||
.news-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.news-headline { font-family: var(--font-display); font-size: 14px; color: var(--text); }
|
||||
.news-sub { color: var(--muted); font-size: 12px; margin-top: 3px; line-height: 1.4; word-break: break-word; }
|
||||
.news-meta { color: var(--muted); font-size: 11px; margin-top: 4px; font-family: ui-monospace, Menlo, monospace; }
|
||||
.news-meta a { color: var(--accent); }
|
||||
|
||||
.feed-health { list-style: none; margin: 0; padding: 0; }
|
||||
.feed-row {
|
||||
display: grid; grid-template-columns: 1fr auto auto; gap: 10px;
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
}
|
||||
.feed-row:last-child { border-bottom: none; }
|
||||
.feed-name { color: var(--text); }
|
||||
.feed-count { color: var(--accent); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
|
||||
.feed-time { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
|
||||
/* ── mobile pass for the rest of the cockpit ────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.topbar { flex-wrap: wrap; gap: 8px; padding: 8px 14px; }
|
||||
.brand-icon { height: 36px; width: 120px; }
|
||||
.brand-sub { display: none; }
|
||||
.nav { width: 100%; overflow-x: auto; white-space: nowrap; padding-bottom: 4px; }
|
||||
.nav a { margin-left: 0; margin-right: 14px; font-size: 13px; }
|
||||
.family-icon { height: 32px; }
|
||||
.model-status, .admin-chip { font-size: 9.5px; padding-top: 2px; padding-bottom: 2px; }
|
||||
.content { padding: 14px; }
|
||||
.panel { padding: 14px; }
|
||||
/* tables: let horizontal scroll handle wide columns */
|
||||
table.cases, table.ledger { display: block; overflow-x: auto; white-space: nowrap; }
|
||||
}
|
||||
|
||||
17
src/psyc/cockpit/static/manifest.json
Normal file
17
src/psyc/cockpit/static/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "psyc — defensive CTI cockpit",
|
||||
"short_name": "psyc",
|
||||
"description": "Defensive cyber-threat-intelligence routing, sealing, and human-gated response.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1115",
|
||||
"theme_color": "#1ec8ff",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{ "src": "/static/psyc-logo.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/psyc-logo.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/psyc-logo.png", "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable" }
|
||||
],
|
||||
"categories": ["security", "productivity"]
|
||||
}
|
||||
61
src/psyc/cockpit/static/sw.js
Normal file
61
src/psyc/cockpit/static/sw.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// psyc — minimal service worker.
|
||||
// Strategy:
|
||||
// • static assets (CSS/JS/PNG) → cache-first, fall back to network
|
||||
// • HTML pages and API responses → network-first (always fresh data)
|
||||
// This makes the cockpit installable as a PWA and survives flaky connections,
|
||||
// without serving stale operational data behind the operator's back.
|
||||
|
||||
const CACHE_VERSION = "psyc-v1";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/cockpit.css",
|
||||
"/static/psyc-tokens.css",
|
||||
"/static/psyc-logo.png",
|
||||
"/static/nn-sc-icon.png",
|
||||
"/static/manifest.json",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_VERSION).then((c) => c.addAll(STATIC_ASSETS)).catch(() => {})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
function isStatic(req) {
|
||||
return /\.(css|js|png|svg|ico|woff2?)$/.test(new URL(req.url).pathname) ||
|
||||
new URL(req.url).pathname.startsWith("/static/");
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
if (isStatic(req)) {
|
||||
// cache-first
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((resp) => {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE_VERSION).then((c) => c.put(req, copy)).catch(() => {});
|
||||
return resp;
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// network-first for HTML + API
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req).then((hit) => hit || new Response(
|
||||
"<!doctype html><meta charset=utf-8><title>psyc offline</title>" +
|
||||
"<style>body{background:#0f1115;color:#d8dee9;font-family:sans-serif;padding:40px;text-align:center}h1{color:#1ec8ff}</style>" +
|
||||
"<h1>psyc · offline</h1><p>The cockpit is offline. Reconnect to load fresh data.</p>",
|
||||
{ headers: { "Content-Type": "text/html" } }
|
||||
)))
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,13 +2,27 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f1115">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="psyc">
|
||||
<title>{% block title %}psyc cockpit{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
|
||||
<link rel="apple-touch-icon" href="/static/psyc-logo.png">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/cockpit.css">
|
||||
<link rel="stylesheet" href="/static/psyc-tokens.css">
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<header class="topbar">
|
||||
|
||||
84
src/psyc/cockpit/templates/home.html
Normal file
84
src/psyc/cockpit/templates/home.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}psyc — operations cockpit{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title">Defensive CTI in motion</h1>
|
||||
<p class="hero-sub">What psyc has seen and done — at a glance.</p>
|
||||
</div>
|
||||
<a class="hero-cta" href="/cases">All cases →</a>
|
||||
</section>
|
||||
|
||||
<section class="kpis">
|
||||
<a class="kpi" href="/cases">
|
||||
<div class="kpi-num">{{ kpis.cases }}</div>
|
||||
<div class="kpi-label">cases tracked</div>
|
||||
</a>
|
||||
<a class="kpi" href="/lookup">
|
||||
<div class="kpi-num">{{ kpis.iocs }}</div>
|
||||
<div class="kpi-label">IOCs indexed</div>
|
||||
</a>
|
||||
<div class="kpi">
|
||||
<div class="kpi-num">+{{ kpis.new_24h }}</div>
|
||||
<div class="kpi-label">new in 24 h</div>
|
||||
</div>
|
||||
<div class="kpi kpi-warn">
|
||||
<div class="kpi-num">{{ kpis.high_total }}</div>
|
||||
<div class="kpi-label">high / critical</div>
|
||||
</div>
|
||||
<a class="kpi kpi-accent" href="/response">
|
||||
<div class="kpi-num">⚡ {{ kpis.enforcements_24h }}</div>
|
||||
<div class="kpi-label">enforced 24 h</div>
|
||||
</a>
|
||||
<a class="kpi" href="/ledger">
|
||||
<div class="kpi-num">{{ kpis.ledger_total }}</div>
|
||||
<div class="kpi-label">ledger entries</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<div class="home-grid">
|
||||
<section class="panel home-news">
|
||||
<div class="panel-head">
|
||||
<h2>Recent activity</h2>
|
||||
<span class="count">{{ 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 %}
|
||||
<p class="empty">Nothing recent yet — start with <code>psyc fetch-all</code>.</p>
|
||||
{% endif %}
|
||||
<ol class="news-list">
|
||||
{% for i in 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-body">
|
||||
<div class="news-head">
|
||||
<span class="news-headline">{{ i.headline }}</span>
|
||||
{% if i.severity %}<span class="sev-badge">{{ i.severity }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="news-sub">{{ i.body }}</div>
|
||||
<div class="news-meta">
|
||||
<time>{{ i.timestamp.strftime('%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>
|
||||
</section>
|
||||
|
||||
<aside class="panel home-side">
|
||||
<div class="panel-head"><h2>Feed health</h2></div>
|
||||
<p class="page-intro">Where psyc's data is coming from.</p>
|
||||
<ul class="feed-health">
|
||||
{% for f in feeds %}
|
||||
<li class="feed-row">
|
||||
<span class="feed-name">{{ f.feed }}</span>
|
||||
<span class="feed-count">{{ f.count }}</span>
|
||||
<span class="feed-time">{{ f.latest.strftime('%m-%d %H:%M') if f.latest else '—' }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
126
src/psyc/lines/news.py
Normal file
126
src/psyc/lines/news.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Newsline — turn ledger + case activity into a human-readable digest.
|
||||
|
||||
Surfaces what psyc has *done* and what it has *seen* as a stream of news items
|
||||
for the start page. Pure read aggregation over the existing case and ledger
|
||||
stores — no new state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import ledger as ledger_line
|
||||
from psyc.models import Case, LedgerEntry, Outcome, Severity
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
|
||||
class NewsItem(BaseModel):
|
||||
timestamp: datetime
|
||||
kind: str # case | enforced | submitted | rejected | actioned | failed
|
||||
headline: str
|
||||
body: str
|
||||
severity: Optional[str] = None
|
||||
case_id: Optional[str] = None
|
||||
icon: str = "•" # tiny glyph for the card
|
||||
|
||||
|
||||
class FeedHealth(BaseModel):
|
||||
feed: str
|
||||
count: int
|
||||
latest: Optional[datetime] = None
|
||||
|
||||
|
||||
# ---------- KPI strip ----------------------------------------------------
|
||||
|
||||
def kpis() -> Dict[str, int]:
|
||||
"""Counters shown at the top of the home page."""
|
||||
cases = db.list_cases(limit=10_000)
|
||||
today = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
new_24h = sum(1 for c in cases if c.ingested_at and c.ingested_at >= today)
|
||||
high = sum(1 for c in cases
|
||||
if c.classification.severity in (Severity.HIGH, Severity.CRITICAL))
|
||||
ledger = ledger_line.list_recent(limit=10_000)
|
||||
enforcements_24h = sum(1 for e in ledger if e.timestamp >= today and e.outcome is Outcome.ACTIONED)
|
||||
return {
|
||||
"cases": len(cases),
|
||||
"iocs": db.ioc_count(),
|
||||
"new_24h": new_24h,
|
||||
"high_total": high,
|
||||
"enforcements_24h": enforcements_24h,
|
||||
"ledger_total": ledger_line.count(),
|
||||
}
|
||||
|
||||
|
||||
# ---------- news items ---------------------------------------------------
|
||||
|
||||
_OUTCOME_RENDER = {
|
||||
Outcome.ACTIONED: ("⚡", "enforced", "psyc enforced the response for {case} → {dest}"),
|
||||
Outcome.SUBMITTED: ("→", "submitted", "Submitted to {dest} for {case}"),
|
||||
Outcome.ACKNOWLEDGED:("✓", "submitted", "{dest} acknowledged the submission for {case}"),
|
||||
Outcome.REJECTED: ("⊘", "rejected", "Blocked / declined: {dest} for {case}"),
|
||||
Outcome.FAILED: ("✗", "failed", "Delivery to {dest} failed for {case}"),
|
||||
Outcome.PENDING_APPROVAL: ("⏳", "pending", "Awaiting approval: {dest} for {case}"),
|
||||
}
|
||||
|
||||
|
||||
def _ledger_to_news(e: LedgerEntry) -> NewsItem:
|
||||
icon, kind, fmt = _OUTCOME_RENDER.get(e.outcome, ("•", "ledger", "Ledger event for {case}"))
|
||||
headline = fmt.format(case=e.case_id, dest=e.destination)
|
||||
body = e.detail or f"{e.outcome.value} · TLP:{e.tlp.value}"
|
||||
return NewsItem(
|
||||
timestamp=e.timestamp, kind=kind, headline=headline, body=body,
|
||||
case_id=e.case_id, icon=icon,
|
||||
)
|
||||
|
||||
|
||||
def _case_to_news(c: Case) -> NewsItem:
|
||||
sev = c.classification.severity.value if c.classification.severity else None
|
||||
incident = c.classification.incident_type.value if c.classification.incident_type else "case"
|
||||
feed = c.source_metadata.get("feed", "feed")
|
||||
headline = f"New {sev or 'unrated'} {incident} from {feed}"
|
||||
return NewsItem(
|
||||
timestamp=c.ingested_at, kind="case",
|
||||
headline=headline, body=c.summary[:200],
|
||||
severity=sev, case_id=c.case_id,
|
||||
icon={"critical": "🚨", "high": "⚠", "medium": "•", "low": "·"}.get(sev or "", "•"),
|
||||
)
|
||||
|
||||
|
||||
def recent_items(limit: int = 30, high_only: bool = False) -> List[NewsItem]:
|
||||
"""Interleave the latest ledger events with the latest case ingests, newest first."""
|
||||
items: List[NewsItem] = []
|
||||
for e in ledger_line.list_recent(limit=limit * 2):
|
||||
items.append(_ledger_to_news(e))
|
||||
cases = db.list_cases(limit=limit * 2)
|
||||
for c in cases:
|
||||
if high_only and c.classification.severity not in (Severity.HIGH, Severity.CRITICAL):
|
||||
continue
|
||||
items.append(_case_to_news(c))
|
||||
items.sort(key=lambda i: i.timestamp, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
# ---------- feed health (sidebar / footer of home) -----------------------
|
||||
|
||||
def feed_health() -> List[FeedHealth]:
|
||||
"""Per-feed counts + most-recent ingest. Useful as a 'sources live?' panel."""
|
||||
cases = db.list_cases(limit=10_000)
|
||||
buckets: Dict[str, FeedHealth] = {}
|
||||
for c in cases:
|
||||
feed = c.source_metadata.get("feed") or "unknown"
|
||||
h = buckets.get(feed)
|
||||
if h is None:
|
||||
buckets[feed] = FeedHealth(feed=feed, count=1, latest=c.ingested_at)
|
||||
else:
|
||||
h.count += 1
|
||||
if c.ingested_at and (h.latest is None or c.ingested_at > h.latest):
|
||||
h.latest = c.ingested_at
|
||||
out = list(buckets.values())
|
||||
out.sort(key=lambda h: h.count, reverse=True)
|
||||
return out
|
||||
73
tests/test_news.py
Normal file
73
tests/test_news.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Newsline — start-page digest aggregation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import ledger as ledger_line
|
||||
from psyc.lines import news
|
||||
from psyc.models import Outcome, Severity, TLP
|
||||
from conftest import make_case
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
def test_kpis_count_basic(fresh_db):
|
||||
# vary age_days so make_case() doesn't collide its case_id
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://1.1.1.1/x"], severity=Severity.LOW, age_days=1))
|
||||
db.upsert_case(make_case(feed="feodo", ips=["2.2.2.2"], severity=Severity.HIGH, age_days=0))
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://3.3.3.3/x"], severity=Severity.CRITICAL, age_days=2))
|
||||
k = news.kpis()
|
||||
assert k["cases"] == 3
|
||||
assert k["high_total"] == 2 # high + critical
|
||||
|
||||
|
||||
def test_recent_items_interleaves_ledger_and_cases(fresh_db):
|
||||
c = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(c)
|
||||
ledger_line.write(
|
||||
case_id=c.case_id, destination="CERT-Bund", payload_hash="",
|
||||
submitter_identity="x", tlp=TLP.AMBER, outcome=Outcome.ACTIONED,
|
||||
)
|
||||
items = news.recent_items(limit=10)
|
||||
kinds = {i.kind for i in items}
|
||||
assert "case" in kinds
|
||||
assert "enforced" in kinds
|
||||
# newest-first ordering
|
||||
assert items == sorted(items, key=lambda i: i.timestamp, reverse=True)
|
||||
|
||||
|
||||
def test_feed_health_groups_by_feed(fresh_db):
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://a/"], age_days=1))
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://b/"], age_days=2))
|
||||
db.upsert_case(make_case(feed="otx", ips=["1.1.1.1"], age_days=1))
|
||||
h = news.feed_health()
|
||||
by_feed = {f.feed: f for f in h}
|
||||
assert by_feed["urlhaus"].count == 2
|
||||
assert by_feed["otx"].count == 1
|
||||
# highest count first
|
||||
assert h[0].feed == "urlhaus"
|
||||
|
||||
|
||||
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"])
|
||||
db.upsert_case(c)
|
||||
for outcome in Outcome:
|
||||
ledger_line.write(
|
||||
case_id=c.case_id, destination="X", payload_hash="",
|
||||
submitter_identity="x", tlp=TLP.AMBER, outcome=outcome,
|
||||
)
|
||||
items = news.recent_items(limit=200)
|
||||
# at least one item per outcome we wrote
|
||||
assert len([i for i in items if i.kind != "case"]) >= len(list(Outcome))
|
||||
Reference in New Issue
Block a user