From 16cf873044e3bb94d33939fd27936793f058d319 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Mon, 25 May 2026 17:18:40 +0200 Subject: [PATCH] stage-30: home page (Newsline digest) + PWA + mobile pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/psyc/cockpit/app.py | 26 +++++- src/psyc/cockpit/static/cockpit.css | 104 +++++++++++++++++++++ src/psyc/cockpit/static/manifest.json | 17 ++++ src/psyc/cockpit/static/sw.js | 61 +++++++++++++ src/psyc/cockpit/templates/base.html | 14 +++ src/psyc/cockpit/templates/home.html | 84 +++++++++++++++++ src/psyc/lines/news.py | 126 ++++++++++++++++++++++++++ tests/test_news.py | 73 +++++++++++++++ 8 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 src/psyc/cockpit/static/manifest.json create mode 100644 src/psyc/cockpit/static/sw.js create mode 100644 src/psyc/cockpit/templates/home.html create mode 100644 src/psyc/lines/news.py create mode 100644 tests/test_news.py diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index 9b387e8..7bded93 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -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() diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index de9eaff..88a88d4 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -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; } +} diff --git a/src/psyc/cockpit/static/manifest.json b/src/psyc/cockpit/static/manifest.json new file mode 100644 index 0000000..9501a36 --- /dev/null +++ b/src/psyc/cockpit/static/manifest.json @@ -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"] +} diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js new file mode 100644 index 0000000..a391d29 --- /dev/null +++ b/src/psyc/cockpit/static/sw.js @@ -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( + "psyc offline" + + "" + + "

psyc · offline

The cockpit is offline. Reconnect to load fresh data.

", + { headers: { "Content-Type": "text/html" } } + ))) + ); + } +}); diff --git a/src/psyc/cockpit/templates/base.html b/src/psyc/cockpit/templates/base.html index f5459b5..235791a 100644 --- a/src/psyc/cockpit/templates/base.html +++ b/src/psyc/cockpit/templates/base.html @@ -2,13 +2,27 @@ + + + + + {% block title %}psyc cockpit{% endblock %} + + +
diff --git a/src/psyc/cockpit/templates/home.html b/src/psyc/cockpit/templates/home.html new file mode 100644 index 0000000..cdaab39 --- /dev/null +++ b/src/psyc/cockpit/templates/home.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}psyc — operations cockpit{% endblock %} +{% block content %} + +
+
+

Defensive CTI in motion

+

What psyc has seen and done — at a glance.

+
+ All cases → +
+ +
+ +
{{ kpis.cases }}
+
cases tracked
+
+ +
{{ kpis.iocs }}
+
IOCs indexed
+
+
+
+{{ kpis.new_24h }}
+
new in 24 h
+
+
+
{{ kpis.high_total }}
+
high / critical
+
+ +
⚡ {{ kpis.enforcements_24h }}
+
enforced 24 h
+
+ +
{{ kpis.ledger_total }}
+
ledger entries
+
+
+ +
+
+
+

Recent activity

+ {{ items|length }} items +
+

Live feed of what psyc has detected and what it has done about it.

+ {% if not items %} +

Nothing recent yet — start with psyc fetch-all.

+ {% endif %} +
    + {% for i in items %} +
  1. +
    {{ i.icon }}
    +
    +
    + {{ i.headline }} + {% if i.severity %}{{ i.severity }}{% endif %} +
    +
    {{ i.body }}
    +
    + + {% if i.case_id %} · {{ i.case_id }}{% endif %} +
    +
    +
  2. + {% endfor %} +
+
+ + +
+{% endblock %} diff --git a/src/psyc/lines/news.py b/src/psyc/lines/news.py new file mode 100644 index 0000000..03bde43 --- /dev/null +++ b/src/psyc/lines/news.py @@ -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 diff --git a/tests/test_news.py b/tests/test_news.py new file mode 100644 index 0000000..cd9b5ec --- /dev/null +++ b/tests/test_news.py @@ -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))