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 typing import List
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
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 courier as courier_line
|
||||||
from psyc.lines import ledger as ledger_line
|
from psyc.lines import ledger as ledger_line
|
||||||
from psyc.lines import lookup as lookup_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 respond as respond_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
|
||||||
@@ -41,9 +42,15 @@ def _admin_ok(request: Request) -> bool:
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> HTMLResponse:
|
def index(request: Request) -> HTMLResponse:
|
||||||
cases = db.list_cases(limit=200)
|
return TEMPLATES.TemplateResponse(
|
||||||
total = db.case_count()
|
request,
|
||||||
return TEMPLATES.TemplateResponse(request, "cases.html", {"cases": cases, "total": total})
|
"home.html",
|
||||||
|
{
|
||||||
|
"kpis": news_line.kpis(),
|
||||||
|
"items": news_line.recent_items(limit=24),
|
||||||
|
"feeds": news_line.feed_health(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/cases", response_class=HTMLResponse)
|
@app.get("/cases", response_class=HTMLResponse)
|
||||||
@@ -112,6 +119,17 @@ def healthz() -> dict:
|
|||||||
return {"status": "ok"}
|
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")
|
@app.get("/api/inference-status")
|
||||||
def inference_status() -> dict:
|
def inference_status() -> dict:
|
||||||
adapter = inference.server_adapter()
|
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; }
|
.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 { margin: 0; padding-left: 18px; font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--text); }
|
||||||
.td-portlist li { margin: 2px 0; }
|
.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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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>
|
<title>{% block title %}psyc cockpit{% endblock %}</title>
|
||||||
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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/cockpit.css">
|
||||||
<link rel="stylesheet" href="/static/psyc-tokens.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>
|
</head>
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
<header class="topbar">
|
<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