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( + "
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 @@ + + + + +What psyc has seen and done — at a glance.
+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.