1841 lines
115 KiB
Python
Executable File
1841 lines
115 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import hashlib, html, json, math, os, re, shutil, socket, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
|
||
from collections import Counter
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
from zoneinfo import ZoneInfo
|
||
from typing import Any
|
||
|
||
ROOT=Path(__file__).resolve().parents[1]
|
||
DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||
PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
|
||
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
|
||
LAT=36.53; LON=-87.36
|
||
LOCAL_TZ=ZoneInfo('America/Chicago')
|
||
HERMES_HOME=Path.home()/'.hermes'
|
||
HERMES_STATE_DB=HERMES_HOME/'state.db'
|
||
HERMES_CONFIG=HERMES_HOME/'config.yaml'
|
||
DASHBOARD_JSON=DATA/'dashboard.json'
|
||
WIND_WATCHDOG_STATE_PATH=HERMES_HOME/'state'/'wind_alert_watchdog.json'
|
||
|
||
LABELS={
|
||
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
|
||
'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'),
|
||
'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')}
|
||
WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'}
|
||
LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'}
|
||
PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'}
|
||
|
||
SERVICE_DIRECTORY = [
|
||
{
|
||
'group': 'Portal',
|
||
'items': [
|
||
{'name': 'Doris Dashboard', 'url': 'index.html', 'health_url': 'http://127.0.0.1:8787/', 'purpose': 'Morning briefing, alerts, and operator homepage.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
|
||
{'name': 'Services Directory', 'url': 'services.html', 'health_url': 'http://127.0.0.1:8787/services.html', 'purpose': 'Front door into the rest of the stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
|
||
{'name': 'Homepage', 'url': 'http://10.5.1.6:3300', 'health_url': 'http://10.5.1.6:3300', 'purpose': 'Legacy PD portal and bookmarks page for the broader stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3300'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Shared Datastores',
|
||
'items': [
|
||
{'name': 'PostgreSQL', 'url': '', 'purpose': 'Primary shared Postgres backing Paperless, n8n, and other stateful stacks.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '5432/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 5432, 'badge': 'Healthy'},
|
||
{'name': 'MariaDB', 'url': '', 'purpose': 'Shared MariaDB service for stacks that still need MySQL-compatible storage.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '3306/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 3306, 'badge': 'Healthy'},
|
||
{'name': 'Redis', 'url': '', 'purpose': 'Shared cache and queue backend used across the homelab.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '6379/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 6379, 'badge': 'Healthy'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Productivity / Workflow',
|
||
'items': [
|
||
{'name': 'DoneTick', 'url': 'https://donetick.paccoco.com', 'health_url': 'https://donetick.paccoco.com', 'purpose': 'Chores, recurring work, and household task coordination.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '2021'},
|
||
{'name': 'Paperless-NGX', 'url': 'https://paperless.paccoco.com', 'health_url': 'https://paperless.paccoco.com', 'purpose': 'Document archive, OCR intake, and household paperwork triage.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8083'},
|
||
{'name': 'n8n', 'url': 'https://n8n.paccoco.com', 'health_url': 'https://n8n.paccoco.com', 'purpose': 'Automation bus for webhooks, alerts, and cross-service glue.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '5678'},
|
||
{'name': 'Immich', 'url': 'http://10.5.1.6:2283', 'health_url': 'http://10.5.1.6:2283', 'purpose': 'Photo backup, search, and household media timeline.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '2283'},
|
||
{'name': 'Karakeep', 'url': 'http://10.5.1.6:3100', 'health_url': 'http://10.5.1.6:3100/signin', 'purpose': 'Self-hosted bookmark capture and later-read archive.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3100'},
|
||
{'name': 'KitchenOwl', 'url': 'http://10.5.1.6:8086', 'health_url': 'http://10.5.1.6:8086', 'purpose': 'Shared shopping lists and meal planning workflows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8086'},
|
||
{'name': 'Qui', 'url': 'http://10.5.1.6:7476', 'health_url': 'http://10.5.1.6:7476', 'purpose': 'Household utility app and quick operator tooling surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '7476'},
|
||
{'name': 'Dakboard Bridge', 'url': '', 'health_url': 'http://10.5.1.6:5087', 'purpose': 'Authenticated bridge feeding Dakboard or display-side workflows.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '5087'},
|
||
{'name': 'Doris Schoolhouse', 'url': 'https://schoolhouse.paccoco.com', 'health_url': 'https://schoolhouse.paccoco.com', 'purpose': 'Class workflows, uploads, recordings, and school intake.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8091'},
|
||
{'name': 'Doris Kitchen', 'url': 'http://10.5.1.16:8092', 'health_url': 'http://10.5.1.16:8092', 'purpose': 'Meal planning, grocery workflows, and kitchen-side operator tasks.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8092'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'AI / Search',
|
||
'items': [
|
||
{'name': 'LiteLLM', 'url': 'http://10.5.1.6:4000', 'health_url': 'http://10.5.1.6:4000/v1/models', 'purpose': 'Shared model gateway for automations, Schoolhouse, and local tools.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '4000'},
|
||
{'name': 'OpenWebUI', 'url': 'https://openwebui.paccoco.com', 'health_url': 'http://10.5.1.6:8282', 'purpose': 'Chat UI for the lab models, docs, and retrieval workflows.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8282'},
|
||
{'name': 'Qdrant', 'url': 'http://10.5.1.6:6333/dashboard', 'health_url': 'http://10.5.1.6:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '6333/6334'},
|
||
{'name': 'Whisper (CPU)', 'url': 'http://10.5.1.16:8786/docs', 'health_url': 'http://10.5.1.16:8786/health', 'purpose': 'Fast local CPU transcription endpoint on NOMAD.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'},
|
||
{'name': 'Whisper (CUDA)', 'url': 'http://10.5.1.112:8787/docs', 'health_url': 'http://10.5.1.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'},
|
||
{'name': 'OpenClaw', 'url': 'http://10.5.1.6:18789', 'health_url': 'http://10.5.1.6:18789', 'purpose': 'Legacy local helper/web surface documented in the AI stack.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '18789'},
|
||
{'name': 'SearXNG', 'url': 'http://10.5.1.6:8888', 'health_url': 'http://10.5.1.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'},
|
||
{'name': 'Ollama — light tier', 'url': 'http://10.5.1.6:11434/api/tags', 'health_url': 'http://10.5.1.6:11434/api/tags', 'purpose': 'PD light-tier Ollama runtime backing cheaper/default model paths.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '11434'},
|
||
{'name': 'Ollama — heavy tier', 'url': 'http://10.5.1.112:11434/api/tags', 'health_url': 'http://10.5.1.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'},
|
||
{'name': 'Reranker (TEI)', 'url': 'http://10.5.1.5:9787', 'health_url': 'http://10.5.1.5:9787', 'purpose': 'Serenity-hosted reranker used by retrieval flows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'SERENITY', 'port': '9787'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Homelab / Control Plane',
|
||
'items': [
|
||
{'name': 'Grafana', 'url': 'https://grafana.paccoco.com', 'health_url': 'https://grafana.paccoco.com', 'purpose': 'Metrics, dashboards, and alert drill-down.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3000'},
|
||
{'name': 'Prometheus', 'url': 'http://10.5.1.6:9090', 'health_url': 'http://10.5.1.6:9090/-/healthy', 'purpose': 'Metrics scrape and alert evaluation backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9090'},
|
||
{'name': 'Gitea', 'url': 'https://gitea.paccoco.com', 'health_url': 'https://gitea.paccoco.com', 'purpose': 'Repos, issues, and source control.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3002 / 2222'},
|
||
{'name': 'Dockhand', 'url': 'http://10.5.1.6:3230', 'health_url': 'http://10.5.1.6:3230', 'purpose': 'Container housekeeping and image/update operations.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3230'},
|
||
{'name': 'Uptime Kuma', 'url': 'http://10.5.1.6:3001', 'health_url': 'http://10.5.1.6:3001', 'purpose': 'Availability checks and service-watch status board.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3001'},
|
||
{'name': 'Gotify', 'url': 'https://gotify.paccoco.com', 'health_url': 'https://gotify.paccoco.com', 'purpose': 'Push notification hub for alerts and workflow fan-out.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8443'},
|
||
{'name': 'RackPeek', 'url': 'http://10.5.1.6:8283', 'health_url': 'http://10.5.1.6:8283', 'purpose': 'Rack/device inventory and operator reference panel.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8283'},
|
||
{'name': 'Shlink', 'url': 'http://10.5.1.6:8087', 'health_url': 'http://10.5.1.6:8087/rest/health', 'purpose': 'Short-link API and redirect backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8087'},
|
||
{'name': 'Shlink Web Client', 'url': 'http://10.5.1.6:8088', 'health_url': 'http://10.5.1.6:8088', 'purpose': 'Short-link management UI for the Shlink backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8088'},
|
||
{'name': 'Loki', 'url': '', 'purpose': 'Log aggregation backend for Grafana and the monitoring stack.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Grafana/compose when needed'},
|
||
{'name': 'Promtail', 'url': '', 'purpose': 'Log shipping agent feeding Loki from the homelab nodes.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Grafana/compose when needed'},
|
||
{'name': 'cAdvisor', 'url': '', 'purpose': 'Container metrics exporter feeding Prometheus.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Prometheus targets when needed'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Media / Library',
|
||
'items': [
|
||
{'name': 'Plex', 'url': 'http://10.5.1.6:32400/web/index.html', 'health_url': 'http://10.5.1.6:32400/identity', 'purpose': 'Primary media server, playback hub, and GPU-backed transcoding surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '32400'},
|
||
{'name': 'Tautulli', 'url': 'http://10.5.1.6:8181', 'health_url': 'http://10.5.1.6:8181', 'purpose': 'Plex activity analytics, user history, and watch-session drill-down.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8181'},
|
||
{'name': 'Audiobookshelf', 'url': 'http://10.5.1.6:13358', 'health_url': 'http://10.5.1.6:13358', 'purpose': 'Audiobook and podcast library with self-hosted playback and progress sync.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '13358'},
|
||
{'name': 'Calibre-Web', 'url': 'http://10.5.1.6:8183', 'health_url': 'http://10.5.1.6:8183', 'purpose': 'Ebook library browser and metadata management front-end.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8183'},
|
||
{'name': 'Seerr (Overseerr)', 'url': 'http://10.5.1.6:5055', 'health_url': 'http://10.5.1.6:5055', 'purpose': 'Request intake for media additions and library demand tracking.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '5055'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Smart Home / Access',
|
||
'items': [
|
||
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but Doris does not yet have a stable operator-safe route.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; local port currently refuses from NOMAD, so keep this manual until routed cleanly'},
|
||
{'name': 'Kima Hub', 'url': 'http://10.5.1.6:3333', 'health_url': 'http://10.5.1.6:3333', 'purpose': 'Custom IoT gateway and smart-home integration surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3333'},
|
||
{'name': 'Pi-hole VIP', 'url': 'http://10.5.1.53/ui/', 'health_url': 'http://10.5.1.53/admin/', 'purpose': 'Floating DNS/admin surface for the HA Pi-hole pair your clients should target.', 'exposure': 'LAN', 'state': 'ok', 'host': 'VIP', 'port': '53 / 80'},
|
||
{'name': 'Pi-hole PD', 'url': 'http://10.5.1.6/ui/', 'health_url': 'http://10.5.1.6/admin/', 'purpose': 'Primary PD Pi-hole node behind the floating DNS VIP.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '53 / 80'},
|
||
{'name': 'Pi-hole NOMAD', 'url': 'https://10.5.1.16/login', 'purpose': 'NOMAD backup Pi-hole replica for the HA DNS pair.', 'exposure': 'LAN', 'state': 'attention', 'host': 'NOMAD', 'port': '53 / 80/443', 'probe': False, 'badge': 'Manual', 'detail': 'documented active; local admin currently presents a self-signed TLS flow from NOMAD so Doris leaves this manual'},
|
||
{'name': 'Authelia', 'url': 'http://10.5.1.6:9091', 'health_url': 'http://10.5.1.6:9091', 'purpose': 'Identity and auth gate backing protected public routes.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9091'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Mapping / Radio / Mesh',
|
||
'items': [
|
||
{'name': 'MeshMonitor', 'url': 'https://meshmonitor.paccoco.com', 'health_url': 'http://10.5.1.6:8081', 'purpose': 'Meshtastic monitoring, node views, and map/radio telemetry.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8081'},
|
||
{'name': 'TileServer GL', 'url': 'http://10.5.1.6:8082', 'health_url': 'http://10.5.1.6:8082', 'purpose': 'Map tile serving for local geo and radio/mapping workflows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8082'},
|
||
{'name': 'MeshCore to MQTT relay', 'url': '', 'purpose': 'Containerized relay moving MeshCore data into MQTT for the radio stack.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'NOMAD', 'port': 'outbound MQTT only', 'probe': False, 'badge': 'Manual', 'detail': 'documented running as mctomqtt; verify via container/runtime logs when needed'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Game Servers',
|
||
'items': [
|
||
{'name': 'Pelican Panel', 'url': 'https://panel.paccoco.com', 'health_url': 'https://panel.paccoco.com', 'purpose': 'Game-server management panel for the NOMAD Wings stack.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '443'},
|
||
{'name': 'Wings Node API', 'url': 'https://node1.paccoco.com', 'health_url': 'https://node1.paccoco.com', 'purpose': 'Public-facing Wings node endpoint; 401 means the daemon is reachable and auth-protected.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8443 / 443'},
|
||
{'name': 'Minecraft Server 25565', 'url': '', 'purpose': 'First Wings-managed Java server on NOMAD.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '25565/tcp+udp', 'tcp_host': '10.5.1.16', 'tcp_port': 25565, 'badge': 'Healthy'},
|
||
{'name': 'Minecraft Server 25566', 'url': '', 'purpose': 'Second Wings-managed Java/NeoForge server on NOMAD.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '25566/tcp+udp', 'tcp_host': '10.5.1.16', 'tcp_port': 25566, 'badge': 'Healthy'},
|
||
],
|
||
},
|
||
{
|
||
'group': 'Nomad Local Apps',
|
||
'items': [
|
||
{'name': 'Dozzle', 'url': 'http://10.5.1.16:9999', 'health_url': 'http://10.5.1.16:9999', 'purpose': 'Live Docker logs on NOMAD without opening a shell.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '9999'},
|
||
{'name': 'Flatnotes', 'url': 'http://10.5.1.16:8200', 'health_url': 'http://10.5.1.16:8200', 'purpose': 'Quick notes and lightweight knowledge capture.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8200'},
|
||
{'name': 'Kiwix', 'url': 'http://10.5.1.16:8090', 'health_url': 'http://10.5.1.16:8090', 'purpose': 'Offline library / knowledge mirror for disconnected use.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8090'},
|
||
],
|
||
},
|
||
]
|
||
|
||
|
||
SECTION_STATE_RANK={'ok':0,'attention':1,'warn':2}
|
||
|
||
def esc(v:Any)->str: return html.escape(str(v or ''),quote=True)
|
||
def load_json(p:Path,d:Any)->Any:
|
||
try: return json.loads(p.read_text()) if p.exists() else d
|
||
except Exception: return d
|
||
|
||
def strip_markup(text:str)->str:
|
||
text=re.sub(r'<[^>]+>',' ',text or '')
|
||
return re.sub(r'\s+',' ',html.unescape(text)).strip()
|
||
|
||
def parse_time(v:str)->datetime:
|
||
try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc)
|
||
except Exception: return datetime.now(timezone.utc)
|
||
|
||
def parse_dt(v:str|None)->datetime|None:
|
||
if not v: return None
|
||
try: return datetime.fromisoformat(v.replace('Z','+00:00'))
|
||
except Exception: return None
|
||
|
||
def c_to_f(c:float|int|None)->str:
|
||
if c is None: return '—'
|
||
return f"{round(c*9/5+32)}°F"
|
||
|
||
def kmh_to_mph(k:float|int|None)->str:
|
||
if k is None: return '—'
|
||
return f"{round(k*0.621371)} mph"
|
||
|
||
def compact_number(value:int|float|None)->str:
|
||
if value in (None,''): return '—'
|
||
try: n=float(value)
|
||
except Exception: return str(value)
|
||
abs_n=abs(n)
|
||
if abs_n >= 1_000_000_000: return f"{n/1_000_000_000:.1f}B"
|
||
if abs_n >= 1_000_000: return f"{n/1_000_000:.1f}M"
|
||
if abs_n >= 1_000: return f"{n/1_000:.1f}K"
|
||
if n.is_integer(): return f"{int(n):,}"
|
||
return f"{n:,.1f}"
|
||
|
||
def pct_text(value:float|int|None)->str:
|
||
if value in (None,''): return '—'
|
||
try: return f"{round(float(value))}%"
|
||
except Exception: return '—'
|
||
|
||
def percent_value(part:int|float|None,total:int|float|None)->float|None:
|
||
try:
|
||
numerator=float(part or 0)
|
||
denominator=float(total or 0)
|
||
except Exception:
|
||
return None
|
||
if denominator <= 0:
|
||
return None
|
||
return (numerator/denominator)*100.0
|
||
|
||
def usage_window_state(remaining_percent:float|int|None)->str:
|
||
if remaining_percent in (None,''): return 'attention'
|
||
try: remaining=float(remaining_percent)
|
||
except Exception: return 'attention'
|
||
if remaining <= 15: return 'warn'
|
||
if remaining <= 35: return 'attention'
|
||
return 'ok'
|
||
|
||
def find_usage_window(windows:list[dict[str,Any]], label_hint:str)->dict[str,Any] | None:
|
||
hint=(label_hint or '').strip().lower()
|
||
for window in windows:
|
||
label=str(window.get('label') or '').strip().lower()
|
||
if hint and hint in label:
|
||
return window
|
||
return windows[0] if windows else None
|
||
|
||
def total_tokens_value(row:dict[str,Any] | sqlite3.Row)->int:
|
||
keys=('input_tokens','output_tokens','cache_read_tokens','cache_write_tokens','reasoning_tokens')
|
||
total=0
|
||
for key in keys:
|
||
try: total += int((row[key] if isinstance(row, sqlite3.Row) else row.get(key,0)) or 0)
|
||
except Exception: pass
|
||
return total
|
||
|
||
def read_hermes_model_config()->dict[str,str]:
|
||
default='unknown'; provider='unknown'
|
||
try:
|
||
data=yaml.safe_load(HERMES_CONFIG.read_text(encoding='utf-8')) or {}
|
||
except Exception:
|
||
return {'default_model':default,'provider':provider}
|
||
model_cfg=data.get('model') if isinstance(data,dict) else {}
|
||
if isinstance(model_cfg,dict):
|
||
default=str(model_cfg.get('default') or default)
|
||
provider=str(model_cfg.get('provider') or provider)
|
||
return {'default_model':default or 'unknown','provider':provider or 'unknown'}
|
||
|
||
def moon_phase(dt:datetime)->dict[str,str]:
|
||
# Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days.
|
||
known=datetime(2000,1,6,18,14,tzinfo=timezone.utc)
|
||
days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400
|
||
age=days%29.53058867
|
||
illum=(1-math.cos(2*math.pi*age/29.53058867))/2
|
||
names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')]
|
||
name,emoji='New Moon','🌑'
|
||
for limit,n,e in names:
|
||
if age<limit: name,emoji=n,e; break
|
||
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
|
||
|
||
def fetch_weather()->dict[str,Any]:
|
||
url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}'
|
||
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature'
|
||
'&hourly=temperature_2m,precipitation_probability'
|
||
'&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max'
|
||
'&timezone=America/Chicago&forecast_days=4')
|
||
try:
|
||
with urllib.request.urlopen(url,timeout=12) as r: raw=json.load(r)
|
||
cur=raw.get('current',{}); daily=raw.get('daily',{}); hourly=raw.get('hourly',{})
|
||
probs=hourly.get('precipitation_probability') or []
|
||
daily_codes=daily.get('weather_code') or []
|
||
return {
|
||
'ok':True,
|
||
'current':cur,
|
||
'daily':daily,
|
||
'max_precip':max(probs[:24] or [0]),
|
||
'condition':WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))),
|
||
'today_condition':WEATHER_CODES.get(daily_codes[0], WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code')))) if daily_codes else WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))),
|
||
'source':'Open-Meteo'
|
||
}
|
||
except Exception as e:
|
||
return {'ok':False,'error':str(e),'source':'Open-Meteo'}
|
||
|
||
def decrypt_donetick()->dict[str,Any]|None:
|
||
secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc')
|
||
keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key')
|
||
if not secret.exists() or not keyfile.exists(): return None
|
||
try:
|
||
res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True)
|
||
return json.loads(res.stdout).get('donetick')
|
||
except Exception:
|
||
return None
|
||
|
||
def donetick_headers()->dict[str,str]|None:
|
||
sec=decrypt_donetick()
|
||
if not sec: return None
|
||
try:
|
||
login=urllib.request.Request(sec['instance']+sec['jwt_endpoint'],data=json.dumps({'username':sec['service_account']['username'],'password':sec['service_account']['password']}).encode(),headers={'Content-Type':'application/json','Accept':'application/json'},method='POST')
|
||
with urllib.request.urlopen(login,timeout=12) as r: token=json.loads(r.read())['access_token']
|
||
return {'Authorization':'Bearer '+token,'Accept':'application/json','Content-Type':'application/json'}
|
||
except Exception:
|
||
return None
|
||
|
||
def combine_states(states:list[str])->str:
|
||
best='ok'
|
||
for state in states:
|
||
if SECTION_STATE_RANK.get(state,-1) > SECTION_STATE_RANK.get(best,-1):
|
||
best=state
|
||
return best
|
||
|
||
def fetch_donetick_todos()->list[dict[str,Any]]:
|
||
sec=decrypt_donetick(); headers=donetick_headers()
|
||
if not sec or not headers: return []
|
||
try:
|
||
req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers)
|
||
with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[])
|
||
users={}
|
||
try:
|
||
ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers)
|
||
with urllib.request.urlopen(ureq,timeout=12) as r:
|
||
users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])}
|
||
except Exception: pass
|
||
out=[]
|
||
for c in chores:
|
||
if c.get('status') not in (0,None): continue
|
||
due=c.get('nextDueDate') or ''
|
||
assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}")
|
||
due_title,state,due_local=due_title_and_state(due)
|
||
out.append({
|
||
'title':c.get('name','Untitled task'),
|
||
'time':due[:16].replace('T',' '),
|
||
'body':strip_markup(c.get('description','')),
|
||
'assignee':assignee,
|
||
'priority':priority_num(c.get('priority')) or 0,
|
||
'priority_text':priority_text(c.get('priority')) or '',
|
||
'due':due,
|
||
'due_title':due_title,
|
||
'state':state,
|
||
'subtitle':subtitle_text(c),
|
||
'chips':visible_label_chips(c),
|
||
'source':'Donetick',
|
||
'id':c.get('id'),
|
||
'_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1),
|
||
'_sort_missing':0 if due_local else 1,
|
||
'_sort_due':due_local.timestamp() if due_local else 99999999999,
|
||
'_sort_name':str(c.get('name','')).lower(),
|
||
})
|
||
out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name','')))
|
||
for item in out:
|
||
for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'):
|
||
item.pop(k,None)
|
||
return out[:10]
|
||
except Exception:
|
||
return []
|
||
|
||
def priority_text(priority:Any)->str|None:
|
||
try:
|
||
p=int(priority)
|
||
return f'P{p}' if p in (1,2,3,4) else None
|
||
except Exception:
|
||
return None
|
||
|
||
def priority_num(priority:Any)->int|None:
|
||
try:
|
||
p=int(priority)
|
||
return p if p in (1,2,3,4) else None
|
||
except Exception:
|
||
return None
|
||
|
||
def recurrence_text(task:dict[str,Any])->str|None:
|
||
ftype=str(task.get('frequencyType','') or '').strip().lower()
|
||
freq=task.get('frequency')
|
||
try: freq_num=int(freq) if freq is not None else None
|
||
except Exception: freq_num=None
|
||
if not ftype: return None
|
||
if ftype=='daily': return 'Daily' if freq_num in (None,1) else f'Every {freq_num} days'
|
||
if ftype=='weekly': return 'Weekly' if freq_num in (None,1) else f'Every {freq_num} weeks'
|
||
if ftype=='monthly': return 'Monthly' if freq_num in (None,1) else f'Every {freq_num} months'
|
||
if ftype=='yearly': return 'Yearly' if freq_num in (None,1) else f'Every {freq_num} years'
|
||
if ftype=='once': return 'One-time'
|
||
return ftype.capitalize()
|
||
|
||
def due_title_and_state(due_raw:str|None)->tuple[str,str,datetime|None]:
|
||
dt=parse_dt(due_raw)
|
||
if not dt: return ('No due date','nodue',None)
|
||
local=dt.astimezone(LOCAL_TZ)
|
||
now=datetime.now(LOCAL_TZ)
|
||
days=(local.date()-now.date()).days
|
||
time_text=local.strftime('%-I:%M %p')
|
||
if days < 0:
|
||
if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local)
|
||
return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local)
|
||
if days == 0: return (f'Due today • {time_text}','today',local)
|
||
if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local)
|
||
if days <= 6: return (f"Due {local.strftime('%a')} • {time_text}",'upcoming',local)
|
||
return (f"Due {local.strftime('%b %-d')} • {time_text}",'upcoming',local)
|
||
|
||
def dedupe_parts(parts:list[str])->list[str]:
|
||
seen=set(); out=[]
|
||
for p in parts:
|
||
if not p: continue
|
||
key=p.strip().lower()
|
||
if key in seen: continue
|
||
seen.add(key); out.append(p)
|
||
return out
|
||
|
||
def subtitle_text(task:dict[str,Any])->str:
|
||
parts=[]
|
||
labels=task.get('labelsV2') or []
|
||
label_names=[str(lbl.get('name','')).strip() for lbl in labels if lbl.get('name')]
|
||
prominent=[x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS]
|
||
low=[x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS]
|
||
parts.extend(prominent[:3])
|
||
ptxt=priority_text(task.get('priority'))
|
||
if ptxt: parts.append(ptxt)
|
||
rtxt=recurrence_text(task)
|
||
if rtxt:
|
||
label_keys={x.strip().lower() for x in label_names}
|
||
if rtxt.strip().lower() not in label_keys: parts.append(rtxt)
|
||
if not prominent and low: parts.extend(low[:1])
|
||
return ' • '.join(dedupe_parts(parts))
|
||
|
||
def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]:
|
||
chips=[]
|
||
for lbl in task.get('labelsV2') or []:
|
||
name=str(lbl.get('name','')).strip()
|
||
if not name or name.lower() in LOW_VALUE_LABELS: continue
|
||
color=str(lbl.get('color','')).strip() or '#607d8b'
|
||
chips.append({'name':name,'color':color})
|
||
return chips[:4]
|
||
|
||
def run(cmd:list[str])->str:
|
||
try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip()
|
||
except Exception: return ''
|
||
|
||
|
||
def dashboard_service_status()->dict[str,str]:
|
||
active=run(['systemctl','is-active','doris-dashboard.service'])
|
||
substate=run(['systemctl','show','-p','SubState','--value','doris-dashboard.service'])
|
||
pid=run(['systemctl','show','-p','ExecMainPID','--value','doris-dashboard.service'])
|
||
if active:
|
||
detail=active
|
||
if substate and substate != active:
|
||
detail=f'{active} ({substate})'
|
||
if active == 'active' and pid and pid != '0':
|
||
detail=f'{detail} · pid {pid}'
|
||
return {'value':detail,'state':'ok' if active == 'active' else 'warn'}
|
||
listener=run(['bash','-lc',"ss -ltn '( sport = :8787 )' | tail -n +2"])
|
||
if listener:
|
||
return {'value':'web listener up on :8787','state':'ok'}
|
||
return {'value':'no systemd state and nothing listening on :8787','state':'warn'}
|
||
|
||
|
||
def dashboard_cron_status()->dict[str,str]:
|
||
info=load_json(DASHBOARD_JSON,{}) if DASHBOARD_JSON.exists() else {}
|
||
generated_raw=info.get('generated_at_local') or info.get('generated_at') or ''
|
||
generated=parse_dt(generated_raw)
|
||
if not generated and DASHBOARD_JSON.exists():
|
||
try:
|
||
generated=datetime.fromtimestamp(DASHBOARD_JSON.stat().st_mtime, tz=timezone.utc).astimezone(LOCAL_TZ)
|
||
except Exception:
|
||
generated=None
|
||
if not generated:
|
||
return {'value':'no dashboard generation timestamp yet','state':'warn'}
|
||
local_generated=generated.astimezone(LOCAL_TZ)
|
||
now=datetime.now(LOCAL_TZ)
|
||
age_minutes=max(0,int((now-local_generated).total_seconds()//60))
|
||
next_run=local_generated.replace(second=0,microsecond=0)
|
||
slots=(5,35)
|
||
probe=now.replace(second=0,microsecond=0)
|
||
while True:
|
||
if probe.minute in slots and probe > now:
|
||
next_run=probe
|
||
break
|
||
probe += timedelta(minutes=1)
|
||
age_text='just now' if age_minutes < 1 else (f'{age_minutes}m ago' if age_minutes < 60 else f'{age_minutes//60}h {age_minutes%60}m ago')
|
||
value=f'updated {age_text} · next {next_run.strftime("%-I:%M %p")}'
|
||
state='ok' if age_minutes <= 45 else 'warn'
|
||
return {'value':value,'state':state}
|
||
|
||
|
||
def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
|
||
usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace')
|
||
disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)'
|
||
dash=dashboard_service_status()
|
||
cron=dashboard_cron_status()
|
||
newest=max((parse_time(i.get('collected_at') or i.get('published_at') or '') for i in candidates), default=datetime.now(timezone.utc))
|
||
age=(datetime.now(timezone.utc)-newest).total_seconds()/3600
|
||
return [
|
||
{'label':'Dashboard service','value':dash['value'],'state':dash['state']},
|
||
{'label':'Dashboard cron','value':cron['value'],'state':cron['state']},
|
||
{'label':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'},
|
||
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
|
||
]
|
||
|
||
|
||
def system_snapshot()->list[dict[str,str]]:
|
||
uptime_text='unknown'
|
||
try:
|
||
uptime_seconds=float(Path('/proc/uptime').read_text().split()[0])
|
||
hours=int(uptime_seconds//3600)
|
||
minutes=int((uptime_seconds%3600)//60)
|
||
uptime_text=(f'{hours}h {minutes}m' if hours else f'{minutes}m')
|
||
except Exception:
|
||
pass
|
||
load_text='unknown'
|
||
load_state='ok'
|
||
try:
|
||
load1,load5,load15=os.getloadavg()
|
||
load_text=f'{load1:.2f} / {load5:.2f} / {load15:.2f}'
|
||
cpu_count=os.cpu_count() or 1
|
||
if load1 > cpu_count*1.25:
|
||
load_state='warn'
|
||
elif load1 > cpu_count*0.85:
|
||
load_state='attention'
|
||
except Exception:
|
||
load_state='warn'
|
||
mem_text='unknown'
|
||
mem_state='ok'
|
||
try:
|
||
parts=Path('/proc/meminfo').read_text().splitlines()
|
||
raw={line.split(':',1)[0]: int(line.split(':',1)[1].strip().split()[0]) for line in parts if ':' in line}
|
||
total=raw.get('MemTotal',0)
|
||
avail=raw.get('MemAvailable',0)
|
||
used=max(total-avail,0)
|
||
pct=(used/total*100) if total else 0
|
||
mem_text=f'{pct:.0f}% used ({used/1024/1024:.1f} / {total/1024/1024:.1f} GiB)'
|
||
if pct >= 85:
|
||
mem_state='warn'
|
||
elif pct >= 70:
|
||
mem_state='attention'
|
||
except Exception:
|
||
mem_state='warn'
|
||
root_usage=shutil.disk_usage('/')
|
||
root_pct=root_usage.used/root_usage.total*100 if root_usage.total else 0
|
||
root_text=f'{root_pct:.0f}% used ({root_usage.free/1024**3:.1f} GiB free)'
|
||
return [
|
||
{'label':'Host','value':run(['hostname']) or 'nomad','state':'ok'},
|
||
{'label':'Uptime','value':uptime_text,'state':'ok'},
|
||
{'label':'Load avg','value':load_text,'state':load_state},
|
||
{'label':'Memory','value':mem_text,'state':mem_state},
|
||
{'label':'Root disk','value':root_text,'state':'warn' if root_pct >= 85 else ('attention' if root_pct >= 70 else 'ok')},
|
||
]
|
||
|
||
TOPIC_STOPWORDS={
|
||
'the','a','an','and','or','to','of','for','in','on','with','after','at','from','is','are','be','as','by','how','why','what','when','your','you','new','this','that','its','their','his','her','our','but','into','over','under','about','amid','through','during','just','says','say','will','can','could','should','would','has','have','had','was','were','not','than','more','less','look','looks','looking','likely'
|
||
}
|
||
TOPIC_TOKEN_ALIASES={
|
||
'spacex':'spacex','starship':'starship','psyche':'psyche','nasa':'nasa','mars':'mars','moon':'moon','artemis':'artemis','maven':'maven',
|
||
'filing':'ipo','paperwork':'ipo','public':'ipo','ipo':'ipo','s1':'ipo','s-1':'ipo',
|
||
'marshals':'marshals','marshal':'marshals','fugitive':'wanted','wanted':'wanted','alias':'alias','identity':'alias','captured':'arrested','custody':'arrested',
|
||
'homelab':'homelab','docker':'docker','truenas':'truenas','proxmox':'proxmox','opnsense':'opnsense','wireguard':'wireguard','zigbee':'zigbee','matter':'matter','radio':'radio'
|
||
}
|
||
|
||
|
||
def normalize_topic_tokens(item:dict[str,Any])->list[str]:
|
||
raw=' '.join(str(item.get(k,'') or '') for k in ('title','summary','url')).lower()
|
||
raw=raw.replace('u.s.','us').replace('u.s','us').replace('u k','uk')
|
||
words=re.findall(r"[a-z0-9]+", raw)
|
||
out=[]
|
||
for word in words:
|
||
if len(word) <= 2 or word in TOPIC_STOPWORDS:
|
||
continue
|
||
out.append(TOPIC_TOKEN_ALIASES.get(word, word))
|
||
return out
|
||
|
||
|
||
def explicit_topic_key(item:dict[str,Any])->str|None:
|
||
raw=' '.join(str(item.get(k,'') or '') for k in ('title','summary','url')).lower()
|
||
raw=raw.replace('u.s.','us').replace('u.s','us')
|
||
if 'spacex' in raw and any(term in raw for term in (' ipo', ' s-1', 's1', 'going public', 'going-public', 'paperwork', 'financial filing', 'finances', 'opens its books', 'sec.gov/archives/edgar')):
|
||
return 'spacex-ipo'
|
||
if 'psyche' in raw and 'mars' in raw:
|
||
return 'psyche-mars-flyby'
|
||
if 'psyche' in raw:
|
||
return 'psyche-mission'
|
||
if 'starship' in raw and 'spacex' in raw:
|
||
return 'spacex-starship'
|
||
if 'marshals' in raw and 'nashville' in raw and any(term in raw for term in ('wanted', 'fugitive', 'alias', 'false identity')):
|
||
return 'nashville-marshals-fugitive'
|
||
return None
|
||
|
||
|
||
def topic_key(item:dict[str,Any],docfreq:Counter[str]|None=None)->str:
|
||
explicit=explicit_topic_key(item)
|
||
if explicit:
|
||
return explicit
|
||
tokens=normalize_topic_tokens(item)
|
||
if not tokens:
|
||
return slugify(str(item.get('title') or item.get('url') or 'story'))
|
||
unique=[]
|
||
seen=set()
|
||
for tok in tokens:
|
||
if tok in seen:
|
||
continue
|
||
seen.add(tok)
|
||
unique.append(tok)
|
||
if docfreq is None:
|
||
return '-'.join(unique[:4])
|
||
unique.sort(key=lambda tok:(docfreq.get(tok,999999), -len(tok), tok))
|
||
return '-'.join(unique[:4])
|
||
|
||
|
||
def score_item(item:dict[str,Any],prefs:dict[str,Any])->float:
|
||
score=float(item.get('priority',1))
|
||
title=str(item.get('title',''))
|
||
summary=str(item.get('summary',''))
|
||
source=str(item.get('source',''))
|
||
category=str(item.get('category') or 'other')
|
||
text=f"{title} {summary}".lower()
|
||
if category in prefs.get('boost_categories',[]): score+=2
|
||
if category in prefs.get('quiet_categories',[]): score-=3
|
||
if source in prefs.get('boost_sources',[]): score+=1.5
|
||
for kw in prefs.get('boost_keywords',[]):
|
||
if kw.lower() in text: score+=1.5
|
||
for kw in prefs.get('mute_keywords',[]):
|
||
if kw.lower() in text: score-=3
|
||
practical_terms=(
|
||
'guide','explainer','explained','tutorial','comic','formula','study','scientists','mission',
|
||
'linux','self-hosted','open source','homelab','home assistant','radio','antenna','weather',
|
||
'budget','housing','school','schools','college','habit','learning','science'
|
||
)
|
||
if any(term in text for term in practical_terms):
|
||
score+=1.2
|
||
low_value_terms=(
|
||
'celebrity','singer','sexual assault','allegation','allegations','raped','rape','fugitive',
|
||
'arrested','on the run','heroin','gossip','lawsuit'
|
||
)
|
||
if any(term in text for term in low_value_terms):
|
||
score-=4
|
||
if category=='local':
|
||
civic_terms=('budget','tax','funding','school','schools','housing','storm','weather','traffic','road','county','city')
|
||
if any(term in text for term in civic_terms):
|
||
score+=1.4
|
||
if any(term in text for term in ('fugitive','arrested','shooting','murder','heroin')):
|
||
score-=4.5
|
||
if category in {'us','world'} and any(term in text for term in ('celebrity','singer','sexual assault','allegation','allegations','gossip')):
|
||
score-=5
|
||
if source=='CISA Alerts':
|
||
terms=[t.lower() for t in prefs.get('homelab_relevance_keywords',[])]
|
||
if terms and not any(t in text for t in terms): score-=8
|
||
return score
|
||
def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)->list[dict[str,Any]]:
|
||
now=datetime.now(timezone.utc); recent=[]
|
||
for i in items:
|
||
if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i)
|
||
docfreq=Counter()
|
||
for i in recent:
|
||
docfreq.update(set(normalize_topic_tokens(i)))
|
||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||
|
||
out=[]; seen=set(); topic_seen=set(); sources=Counter(); categories=Counter()
|
||
|
||
def consider(limit_per_category:int|None, topic_budget:int=1)->None:
|
||
for i in recent:
|
||
if len(out)>=max_items: break
|
||
url=i.get('url',''); src=i.get('source',''); cat=(i.get('category') or 'other').strip() or 'other'; tkey=topic_key(i,docfreq)
|
||
if url in seen or sources[src]>=2: continue
|
||
if tkey in topic_seen and topic_budget <= 0: continue
|
||
if limit_per_category is not None and categories[cat] >= limit_per_category: continue
|
||
out.append(i); seen.add(url); sources[src]+=1; categories[cat]+=1
|
||
if tkey: topic_seen.add(tkey)
|
||
|
||
# First fill favors breadth across categories and unique topics, then allows a
|
||
# little more category depth while still suppressing same-topic clones.
|
||
consider(1, 0)
|
||
consider(2, 0)
|
||
consider(3, 0)
|
||
return out
|
||
|
||
|
||
def select_exploration_items(items:list[dict[str,Any]],prefs:dict[str,Any],exclude_urls:set[str]|None=None,max_items:int=4)->list[dict[str,Any]]:
|
||
now=datetime.now(timezone.utc)
|
||
exclude_urls=exclude_urls or set()
|
||
boosted={str(x).strip() for x in prefs.get('boost_categories',[])}
|
||
quiet={str(x).strip() for x in prefs.get('quiet_categories',[])}
|
||
preferred=['tech','radio','security','gaming','cooking']
|
||
recent=[i for i in items if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600]
|
||
docfreq=Counter()
|
||
for i in recent:
|
||
docfreq.update(set(normalize_topic_tokens(i)))
|
||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||
|
||
seeded_topics={topic_key(i,docfreq) for i in recent if i.get('url','') in exclude_urls}
|
||
out=[]; seen=set(exclude_urls); topic_seen=set(seeded_topics); categories=Counter(); sources=Counter()
|
||
|
||
def consider(allowed_boosted:bool, only_categories:set[str]|None=None, per_category_limit:int=1)->None:
|
||
for i in recent:
|
||
if len(out)>=max_items: break
|
||
url=i.get('url',''); src=i.get('source',''); cat=(i.get('category') or 'other').strip() or 'other'; tkey=topic_key(i,docfreq)
|
||
if not url or url in seen or sources[src]>=1 or categories[cat]>=per_category_limit or tkey in topic_seen:
|
||
continue
|
||
if cat in quiet:
|
||
continue
|
||
if only_categories is not None and cat not in only_categories:
|
||
continue
|
||
if not allowed_boosted and cat in boosted:
|
||
continue
|
||
out.append(i); seen.add(url); topic_seen.add(tkey); categories[cat]+=1; sources[src]+=1
|
||
|
||
# Keep this lane genuinely exploratory: adjacent-interest categories first,
|
||
# then broader off-profile picks, all with one story per topic.
|
||
consider(False, set(preferred), 1)
|
||
consider(False, set(preferred), 2)
|
||
consider(False, None, 1)
|
||
consider(False, None, 2)
|
||
return out
|
||
|
||
def label(cat:str)->str:
|
||
e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}'
|
||
|
||
def slugify(text:str)->str:
|
||
return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section'
|
||
|
||
def why(i:dict[str,Any])->str:
|
||
cat=i.get('category'); src=i.get('source','')
|
||
if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.'
|
||
if cat=='homelab': return 'Practical self-hosting / infrastructure signal.'
|
||
if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.'
|
||
if cat=='local': return 'Local or regional relevance.'
|
||
if cat=='radio': return 'Ham-radio interest signal.'
|
||
if cat=='security': return 'Kept only because it appears practically relevant.'
|
||
return f'Interesting signal from {src}.'
|
||
|
||
def card(i:dict[str,Any], compact:bool=False)->str:
|
||
summary=(i.get('summary') or '').strip()[:120 if compact else 260]
|
||
when=(i.get('published_at','')[:16].replace('T',' '))
|
||
cls='compact-card' if compact else 'card'
|
||
why_html='' if compact else f"<div class='why'>Why: {esc(why(i))}</div>"
|
||
topic=(i.get('category') or 'other').strip() or 'other'
|
||
source=(i.get('source') or '').strip()
|
||
domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.')
|
||
feedback_html='' if not compact else (
|
||
f"<div class='feedback'>"
|
||
f"<button type='button' class='feedback-btn' data-feedback='up' aria-pressed='false' title='Prefer more {esc(topic)} stories'>👍</button>"
|
||
f"<button type='button' class='feedback-btn' data-feedback='down' aria-pressed='false' title='Less like this — hide this story'>👎</button>"
|
||
f"</div>"
|
||
)
|
||
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
|
||
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
|
||
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
|
||
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
|
||
|
||
def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str:
|
||
body=''.join(card(i,compact=compact) for i in items) if items else f"<p class='empty'>{esc(empty)}</p>"
|
||
style='news-list' if compact else 'grid'
|
||
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",slugify(title),open_by_default=True)
|
||
|
||
def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='',state:str='',smart:bool=False)->str:
|
||
open_attr=' open' if open_by_default else ''
|
||
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
|
||
state_class=f' state-{state}' if state else ''
|
||
smart_attr=f" data-smart-state='{esc(state)}'" if smart and state else ''
|
||
return f"<details class='collapsible {esc(extra_class)}{state_class}' data-section-key='{esc(key)}'{smart_attr}{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||
|
||
def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]:
|
||
out=list(items or [])
|
||
title='Paperless notes/tags audit'
|
||
if not any((x.get('title') or '').strip().lower()==title.lower() for x in out):
|
||
out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"})
|
||
return out
|
||
|
||
def load_paperless_review()->list[dict[str,Any]]:
|
||
p=DATA/'paperless_review.json'
|
||
remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json')
|
||
ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys')
|
||
if remote:
|
||
tmp=DATA/'paperless_review.remote.tmp'
|
||
try:
|
||
cmd=['scp','-q','-o','ConnectTimeout=8']
|
||
if ssh_key and Path(ssh_key).exists():
|
||
cmd.extend(['-i',ssh_key])
|
||
cmd.extend([remote,str(tmp)])
|
||
res=subprocess.run(cmd,capture_output=True,text=True,timeout=12)
|
||
if res.returncode==0 and tmp.exists(): tmp.replace(p)
|
||
elif tmp.exists(): tmp.unlink()
|
||
except Exception:
|
||
try: tmp.unlink()
|
||
except Exception: pass
|
||
try: return json.loads(p.read_text())
|
||
except Exception: return []
|
||
|
||
def load_paperless_state()->dict[str,Any]:
|
||
return load_json(PAPERLESS_STATE_PATH,{}) or {}
|
||
|
||
def paperless_focus_summary(items:list[dict[str,Any]]|None=None,state:dict[str,Any]|None=None)->dict[str,Any]:
|
||
items=items if items is not None else load_paperless_review()
|
||
state=state if state is not None else load_paperless_state()
|
||
active=[]; flagged=[]
|
||
for item in items:
|
||
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
|
||
if status in {'dismissed','good_to_go'}:
|
||
continue
|
||
if status in {'acknowledged','needs_attention'}:
|
||
flagged.append(item)
|
||
else:
|
||
active.append(item)
|
||
headline=(flagged[0] if flagged else (active[0] if active else {})).get('title') or 'No documents waiting'
|
||
return {'active_count':len(active),'flagged_count':len(flagged),'headline':headline}
|
||
|
||
def paperless_review_section()->str:
|
||
items=load_paperless_review()
|
||
state=load_paperless_state()
|
||
if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel')
|
||
urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'}
|
||
active=[]; needs_attention=[]
|
||
for item in items:
|
||
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
|
||
if status in {'dismissed','good_to_go'}:
|
||
continue
|
||
if status=='acknowledged':
|
||
status='needs_attention'
|
||
(needs_attention if status=='needs_attention' else active).append(item)
|
||
def render_card(item:dict[str,Any], needs_attention_card:bool=False)->str:
|
||
triage=item.get('triage',{}) or {}
|
||
current=item.get('current',{}) or {}
|
||
urgency=triage.get('urgency','none')
|
||
summary=strip_markup(triage.get('summary',''))
|
||
reason=strip_markup(triage.get('reason',''))
|
||
meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x]
|
||
meta=' • '.join(str(x) for x in meta_parts)
|
||
status_class=' needs-attention' if needs_attention_card else ''
|
||
primary_label='Clear flag' if needs_attention_card else 'Needs attention'
|
||
primary_action='needs_attention'
|
||
return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
|
||
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='{primary_action}'>{primary_label}</button><button type='button' class='paperless-btn good' data-paperless-action='good_to_go'>Good to go</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div>
|
||
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
|
||
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
|
||
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
|
||
{f"<p class='paperless-reason'>{esc(reason)}</p>" if reason else ''}
|
||
</article>"""
|
||
visible_active=active[:3]
|
||
overflow_active=active[3:]
|
||
active_cards=''.join(render_card(item,False) for item in visible_active)
|
||
flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8])
|
||
count=len(active)
|
||
flag_count=len(needs_attention)
|
||
empty_active="<p class='empty'>No active documents waiting.</p>"
|
||
body=f"<div class='paperless-grid'>{active_cards or empty_active}</div>"
|
||
if overflow_active:
|
||
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
|
||
if flag_count:
|
||
body += f"<details class='paperless-acked' open><summary>Needs attention <span class='badge'>{esc(str(flag_count))}</span></summary><div class='paperless-grid acked-grid'>{flagged_cards}</div><p class='empty'>Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.</p></details>"
|
||
if count+flag_count>3:
|
||
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>"
|
||
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{flag_count} flagged for follow-up · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=(count+flag_count)>0,extra_class='panel')
|
||
|
||
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
|
||
if not items:
|
||
body=f"<li class='empty'>{esc(empty)}</li>"
|
||
else:
|
||
rows=[]
|
||
for x in items[:8]:
|
||
kicker=x.get('time') or x.get('title') or x.get('label')
|
||
headline=x.get('title') if x.get('time') else ''
|
||
assignee=x.get('assignee') or ''
|
||
detail=x.get('body') or x.get('summary') or x.get('value') or ''
|
||
assignee_html=f"<span class='muted'>{esc(assignee)}</span>" if assignee else ''
|
||
headline_html=f"<div class='panel-item-title'>{esc(headline)}</div>" if headline else ''
|
||
detail_html=f"<div class='panel-item-body'>{esc(detail)}</div>" if detail else ''
|
||
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{assignee_html}</div>{headline_html}{detail_html}</li>")
|
||
body=''.join(rows)
|
||
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||
|
||
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||
if not items:
|
||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||
rows=[]
|
||
for x in items[:6]:
|
||
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
|
||
subtitle_text=(x.get('subtitle') or '').strip()
|
||
chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')}
|
||
subtitle_parts=[]
|
||
for part in [p.strip() for p in subtitle_text.split('•') if p.strip()]:
|
||
if part.lower() in chip_names:
|
||
continue
|
||
subtitle_parts.append(part)
|
||
subtitle=' • '.join(subtitle_parts)
|
||
state=esc(x.get('state') or 'upcoming')
|
||
due_style=''
|
||
if state=='overdue': due_style=' style="color:#ff6b6b;"'
|
||
elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"'
|
||
assignee_html=f"<span class='task-assignee'>{esc(x.get('assignee') or '')}</span>" if x.get('assignee') and x.get('assignee')!='Unassigned' else ''
|
||
chips_html=f"<div class='task-chips'>{chips}</div>" if chips else ''
|
||
subtitle_html=f"<div class='task-subtitle'>{esc(subtitle)}</div>" if subtitle else ''
|
||
done_button=f"<button type='button' class='task-done-btn' data-donetick-complete data-task-id='{esc(x.get('id') or '')}'>Mark done</button>" if x.get('id') else ''
|
||
rows.append(f"<article class='task-card {state}' data-task-id='{esc(x.get('id') or '')}'><div class='task-top'><div class='task-state-dot' aria-hidden='true'></div><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{assignee_html}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{chips_html}{subtitle_html}{done_button}</article>")
|
||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||
|
||
def feedback_bootstrap()->str:
|
||
return """<script>
|
||
(()=>{
|
||
const KEY='doris-dashboard-topic-prefs-v2';
|
||
const HIDDEN_CARD_KEY='doris-dashboard-hidden-cards-v1';
|
||
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
|
||
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"hidden":[]}')}catch(e){return {hidden:[]}}};
|
||
const safeHiddenCards=()=>{try{return JSON.parse(localStorage.getItem(HIDDEN_CARD_KEY)||'[]')}catch(e){return []}};
|
||
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
|
||
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({hidden:[...new Set(prefs.hidden||[])]}));
|
||
const saveHiddenCards=(ids)=>localStorage.setItem(HIDDEN_CARD_KEY, JSON.stringify([...new Set(ids||[])]));
|
||
const prefs=safeRead();
|
||
let hiddenCards=safeHiddenCards();
|
||
const collapsed=safeCollapse();
|
||
const cards=[...document.querySelectorAll('[data-topic]')];
|
||
const sections=[...document.querySelectorAll('details[data-section-key]')];
|
||
const toast=document.createElement('div');
|
||
toast.className='dashboard-toast';
|
||
toast.hidden=true;
|
||
document.body.appendChild(toast);
|
||
let lastAction=null;
|
||
let toastTimer=null;
|
||
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
|
||
const getJson=(url)=>fetch(url,{headers:{'Accept':'application/json'}}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
|
||
const cardId=(card)=>card.querySelector('h3 a')?.getAttribute('href')||card.querySelector('h3 a')?.textContent||card.dataset.topic||Math.random().toString(36).slice(2);
|
||
cards.forEach(card=>{card.dataset.cardId=cardId(card);});
|
||
const setToast=(html)=>{
|
||
toast.innerHTML=html;
|
||
toast.hidden=false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer=setTimeout(()=>{toast.hidden=true;},4500);
|
||
};
|
||
const sync=()=>{
|
||
cards.forEach(card=>{
|
||
const topic=card.dataset.topic||'other';
|
||
const hidden=(prefs.hidden||[]).includes(topic) || hiddenCards.includes(card.dataset.cardId||'');
|
||
card.style.display=hidden?'none':'';
|
||
card.classList.remove('topic-liked');
|
||
card.querySelectorAll('[data-feedback]').forEach(btn=>{
|
||
btn.setAttribute('aria-pressed', 'false');
|
||
btn.classList.toggle('active-up', false);
|
||
btn.classList.toggle('active-down', false);
|
||
});
|
||
});
|
||
const badge=document.querySelector('[data-hidden-topics-count]');
|
||
if (badge) badge.textContent=String(hiddenCards.length);
|
||
};
|
||
sections.forEach(sec=>{
|
||
const key=sec.dataset.sectionKey;
|
||
if(sec.dataset.smartState){
|
||
sec.open=sec.dataset.smartState==='warn';
|
||
} else if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key];
|
||
sec.addEventListener('toggle',()=>{
|
||
collapsed[key]=!sec.open;
|
||
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
|
||
});
|
||
});
|
||
document.addEventListener('click', (ev)=>{
|
||
const btn=ev.target.closest('[data-feedback]');
|
||
if(!btn) return;
|
||
const card=btn.closest('[data-topic]');
|
||
if(!card) return;
|
||
const topic=card.dataset.topic||'other';
|
||
const id=card.dataset.cardId||'';
|
||
if(btn.dataset.feedback==='down'){
|
||
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||
hiddenCards.push(id);
|
||
saveHiddenCards(hiddenCards);
|
||
setToast(`Right — less <strong>${topic}</strong> like this, and this story’s off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||
} else {
|
||
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||
hiddenCards.push(id);
|
||
saveHiddenCards(hiddenCards);
|
||
setToast(`Right — I’ll look for more <strong>${topic}</strong> like this, and I’ve cleared this story off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||
}
|
||
save(prefs); sync();
|
||
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const undo=ev.target.closest('[data-undo-topic]');
|
||
if(!undo || !lastAction) return;
|
||
prefs.hidden=[...(lastAction.prefs.hidden||[])];
|
||
hiddenCards=[...(lastAction.hiddenCards||[])];
|
||
save(prefs); saveHiddenCards(hiddenCards); sync();
|
||
setToast('Undid the last topic/story action.');
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const btn=ev.target.closest('[data-donetick-complete]');
|
||
if(btn){
|
||
const taskId=btn.dataset.taskId || btn.closest('[data-task-id]')?.dataset.taskId || '';
|
||
if(!taskId) return;
|
||
btn.disabled=true;
|
||
btn.textContent='Completing…';
|
||
postJson('/api/donetick-complete',{id:taskId}).then(()=>{
|
||
setToast('Marked done in Donetick. Refreshing dashboard state…');
|
||
window.setTimeout(()=>window.location.reload(),350);
|
||
}).catch(err=>{
|
||
console.warn('donetick completion failed',err);
|
||
btn.disabled=false;
|
||
btn.textContent='Mark done';
|
||
setToast('Donetick completion failed. Try again in a moment.');
|
||
});
|
||
return;
|
||
}
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const btn=ev.target.closest('[data-paperless-action]');
|
||
if(!btn) return;
|
||
const card=btn.closest('[data-paperless-id]');
|
||
if(!card) return;
|
||
const id=card.dataset.paperlessId;
|
||
const action=btn.dataset.paperlessAction;
|
||
btn.disabled=true;
|
||
postJson('/api/paperless-action',{id,action}).then(()=>{
|
||
window.location.reload();
|
||
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
|
||
});
|
||
const pollDonetickFocus=(()=>{
|
||
let inFlight=false;
|
||
return ()=>{
|
||
const current=document.body?.dataset?.donetickFocusSignature||'';
|
||
if(!current || document.hidden || inFlight) return;
|
||
inFlight=true;
|
||
getJson(`/api/donetick-focus-state?current=${encodeURIComponent(current)}`).then(data=>{
|
||
if(data && data.signature){
|
||
document.body.dataset.donetickFocusSignature=data.signature;
|
||
}
|
||
if(data && data.changed){
|
||
setToast('Donetick changed outside the dashboard. Reloading focus cards…');
|
||
window.setTimeout(()=>window.location.reload(),300);
|
||
}
|
||
}).catch(err=>{
|
||
console.warn('donetick focus poll failed',err);
|
||
}).finally(()=>{
|
||
inFlight=false;
|
||
});
|
||
};
|
||
})();
|
||
window.setInterval(pollDonetickFocus, 60000);
|
||
window.setTimeout(pollDonetickFocus, 60000);
|
||
const reset=document.querySelector('[data-reset-topic-prefs]');
|
||
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden stories and local topic hides.'); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
|
||
sync();
|
||
})();
|
||
</script>"""
|
||
|
||
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
|
||
severity=cls if cls in SECTION_STATE_RANK else ''
|
||
severity_attr=f" data-severity='{esc(severity)}'" if severity else ''
|
||
return f"<div class='metric {esc(cls)}'{severity_attr}><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
|
||
|
||
def load_wind_watchdog_notice()->dict[str,Any]:
|
||
state=load_json(WIND_WATCHDOG_STATE_PATH,{}) or {}
|
||
notice=state.get('dashboard_notice') or {}
|
||
return notice if notice.get('active') else {}
|
||
|
||
def forecast_panel(weather:dict[str,Any])->str:
|
||
if not weather.get('ok'):
|
||
return ""
|
||
daily=weather.get('daily',{}) or {}
|
||
days=daily.get('time') or []
|
||
highs=daily.get('temperature_2m_max') or []
|
||
lows=daily.get('temperature_2m_min') or []
|
||
precip=daily.get('precipitation_probability_max') or []
|
||
codes=daily.get('weather_code') or []
|
||
cards=[]
|
||
day_slice=days[1:4] if len(days) > 1 else days[:3]
|
||
start_idx=1 if len(days) > 1 else 0
|
||
for offset, day in enumerate(day_slice):
|
||
idx=start_idx + offset
|
||
try:
|
||
dt=datetime.fromisoformat(day)
|
||
label=dt.strftime('%a')
|
||
full_label=dt.strftime('%A')
|
||
except Exception:
|
||
label=day
|
||
full_label=day
|
||
high=highs[idx] if idx < len(highs) else None
|
||
low=lows[idx] if idx < len(lows) else None
|
||
rain=precip[idx] if idx < len(precip) else '—'
|
||
condition=WEATHER_CODES.get(codes[idx],'Forecast') if idx < len(codes) else 'Forecast'
|
||
cards.append(f"""<article class='forecast-card'>
|
||
<span class='forecast-day'>{esc(label)}</span>
|
||
<strong>{esc(full_label)}</strong>
|
||
<div class='forecast-temps'>{esc(c_to_f(high))} <span>/ {esc(c_to_f(low))}</span></div>
|
||
<em>{esc(condition)}</em>
|
||
<div class='forecast-meta'>Rain chance {esc(str(rain))}%</div>
|
||
</article>""")
|
||
if not cards:
|
||
return ""
|
||
return collapsible_section('3-day forecast','Next three days: conditions, highs/lows, and rain chance.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=True,extra_class='panel forecast-panel')
|
||
|
||
def weather_overview_panel(weather:dict[str,Any], wind_notice:dict[str,Any]|None=None)->str:
|
||
if not weather.get('ok'):
|
||
return collapsible_section('Weather','Current weather unavailable.',f"<section class='metrics metrics-compact'>{metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')}</section>",'weather-overview',open_by_default=True)
|
||
cur=weather.get('current',{}) or {}
|
||
daily=weather.get('daily',{}) or {}
|
||
highs=daily.get('temperature_2m_max') or []
|
||
lows=daily.get('temperature_2m_min') or []
|
||
precip=daily.get('precipitation_probability_max') or []
|
||
today_high=highs[0] if highs else None
|
||
today_low=lows[0] if lows else None
|
||
today_rain=precip[0] if precip else weather.get('max_precip',0)
|
||
notice=wind_notice or {}
|
||
notice_html=''
|
||
notice_state=''
|
||
if notice:
|
||
notice_state=str(notice.get('state') or 'attention')
|
||
notice_title=notice.get('title') or 'Weather heads-up'
|
||
notice_headline=notice.get('headline') or ''
|
||
notice_body=notice.get('body') or ''
|
||
notice_timing=notice.get('timing') or ''
|
||
lead_hours=notice.get('lead_hours')
|
||
lead_html=f"<span>Lead {esc(str(lead_hours))}h</span>" if lead_hours not in (None,'') else ''
|
||
timing_html=f"<span>{esc(notice_timing)}</span>" if notice_timing else ''
|
||
notice_html=f"""
|
||
<article class='weather-alert-card {esc(notice_state)}'>
|
||
<div class='weather-alert-top'>
|
||
<span class='weather-alert-kicker'>{esc(notice_title)}</span>
|
||
<span class='weather-alert-state'>{esc(notice_state.upper())}</span>
|
||
</div>
|
||
{f"<strong>{esc(notice_headline)}</strong>" if notice_headline else ''}
|
||
{f"<p>{esc(notice_body)}</p>" if notice_body else ''}
|
||
<div class='weather-alert-meta'>{lead_html}{timing_html}</div>
|
||
</article>"""
|
||
overview=f"""
|
||
<section class='weather-overview'>
|
||
<article class='weather-now-card'>
|
||
<div class='weather-now-top'>
|
||
<span class='weather-kicker'>Clarksville</span>
|
||
<span class='weather-source'>{esc(weather.get('source',''))}</span>
|
||
</div>
|
||
<div class='weather-now-main'>
|
||
<strong>{esc(c_to_f(cur.get('temperature_2m')))}</strong>
|
||
<div>
|
||
<div class='weather-condition'>{esc(weather.get('condition','Current conditions'))}</div>
|
||
<div class='weather-range'>Today {esc(c_to_f(today_high))} / {esc(c_to_f(today_low))}</div>
|
||
</div>
|
||
</div>
|
||
<div class='weather-detail-row'>
|
||
<span>Feels {esc(c_to_f(cur.get('apparent_temperature')))}</span>
|
||
<span>Humidity {esc(str(cur.get('relative_humidity_2m','—')))}%</span>
|
||
<span>Wind {esc(kmh_to_mph(cur.get('wind_speed_10m')))}</span>
|
||
<span>Rain {esc(str(today_rain))}%</span>
|
||
</div>
|
||
</article>
|
||
{notice_html}
|
||
</section>"""
|
||
sub='Today first, then the next three days.' if not notice else 'Weather first. Longer-range wind heads-up stays here until it is close enough to deserve a ping.'
|
||
return collapsible_section('Weather',sub,overview,'weather-overview',open_by_default=True,extra_class='panel weather-panel',state=notice_state,smart=bool(notice_state))
|
||
|
||
def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||
config=read_hermes_model_config()
|
||
stats={
|
||
'ok':False,
|
||
'period_days':days,
|
||
'default_model':config.get('default_model','unknown'),
|
||
'provider':config.get('provider','unknown'),
|
||
'sessions':0,
|
||
'messages':0,
|
||
'tool_calls':0,
|
||
'user_messages':0,
|
||
'assistant_messages':0,
|
||
'total_tokens':0,
|
||
'input_tokens':0,
|
||
'output_tokens':0,
|
||
'cache_read_tokens':0,
|
||
'cache_write_tokens':0,
|
||
'reasoning_tokens':0,
|
||
'last_24h_tokens':0,
|
||
'last_24h_sessions':0,
|
||
'last_24h_tool_calls':0,
|
||
'previous_24h_tokens':0,
|
||
'last_7d_tokens':0,
|
||
'avg_tokens_per_session':0,
|
||
'avg_tool_calls_per_session':0,
|
||
'cache_share_percent':None,
|
||
'models':[],
|
||
'platforms':[],
|
||
'top_tools':[],
|
||
'recent_sessions':[],
|
||
'latest_session':None,
|
||
'account_windows':[],
|
||
'daily_tokens':[],
|
||
'generated_at':datetime.now(LOCAL_TZ).isoformat(),
|
||
}
|
||
if not HERMES_STATE_DB.exists():
|
||
stats['error']='Hermes state DB not found'
|
||
return stats
|
||
now_ts=time.time()
|
||
cutoff=now_ts-(days*86400)
|
||
day_cutoff=now_ts-86400
|
||
prev_day_cutoff=now_ts-(2*86400)
|
||
week_cutoff=now_ts-(7*86400)
|
||
try:
|
||
conn=sqlite3.connect(f"file:{HERMES_STATE_DB}?mode=ro", uri=True)
|
||
conn.row_factory=sqlite3.Row
|
||
cur=conn.cursor()
|
||
overview=cur.execute("""
|
||
SELECT
|
||
count(*) AS sessions,
|
||
sum(coalesce(input_tokens,0)) AS input_tokens,
|
||
sum(coalesce(output_tokens,0)) AS output_tokens,
|
||
sum(coalesce(cache_read_tokens,0)) AS cache_read_tokens,
|
||
sum(coalesce(cache_write_tokens,0)) AS cache_write_tokens,
|
||
sum(coalesce(reasoning_tokens,0)) AS reasoning_tokens,
|
||
sum(coalesce(api_call_count,0)) AS api_calls
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
""",(cutoff,)).fetchone()
|
||
stats.update({
|
||
'sessions':int((overview['sessions'] or 0) if overview else 0),
|
||
'input_tokens':int((overview['input_tokens'] or 0) if overview else 0),
|
||
'output_tokens':int((overview['output_tokens'] or 0) if overview else 0),
|
||
'cache_read_tokens':int((overview['cache_read_tokens'] or 0) if overview else 0),
|
||
'cache_write_tokens':int((overview['cache_write_tokens'] or 0) if overview else 0),
|
||
'reasoning_tokens':int((overview['reasoning_tokens'] or 0) if overview else 0),
|
||
})
|
||
stats['total_tokens']=stats['input_tokens']+stats['output_tokens']+stats['cache_read_tokens']+stats['cache_write_tokens']+stats['reasoning_tokens']
|
||
stats['cache_share_percent']=percent_value(stats['cache_read_tokens'], stats['total_tokens'])
|
||
stats['avg_tokens_per_session']=round(stats['total_tokens']/stats['sessions']) if stats['sessions'] else 0
|
||
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role != 'session_meta' AND timestamp >= ?",(cutoff,)).fetchone()
|
||
stats['messages']=int((row['c'] or 0) if row else 0)
|
||
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='tool' AND timestamp >= ?",(cutoff,)).fetchone()
|
||
stats['tool_calls']=int((row['c'] or 0) if row else 0)
|
||
stats['avg_tool_calls_per_session']=round(stats['tool_calls']/stats['sessions'],1) if stats['sessions'] else 0
|
||
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='user' AND timestamp >= ?",(cutoff,)).fetchone()
|
||
stats['user_messages']=int((row['c'] or 0) if row else 0)
|
||
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='assistant' AND timestamp >= ?",(cutoff,)).fetchone()
|
||
stats['assistant_messages']=int((row['c'] or 0) if row else 0)
|
||
row=cur.execute("""
|
||
SELECT count(*) AS sessions,
|
||
sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
""",(day_cutoff,)).fetchone()
|
||
stats['last_24h_sessions']=int((row['sessions'] or 0) if row else 0)
|
||
stats['last_24h_tokens']=int((row['total_tokens'] or 0) if row else 0)
|
||
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='tool' AND timestamp >= ?",(day_cutoff,)).fetchone()
|
||
stats['last_24h_tool_calls']=int((row['c'] or 0) if row else 0)
|
||
row=cur.execute("""
|
||
SELECT sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||
FROM sessions
|
||
WHERE started_at >= ? AND started_at < ?
|
||
""",(prev_day_cutoff, day_cutoff)).fetchone()
|
||
stats['previous_24h_tokens']=int((row['total_tokens'] or 0) if row else 0)
|
||
row=cur.execute("""
|
||
SELECT sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
""",(week_cutoff,)).fetchone()
|
||
stats['last_7d_tokens']=int((row['total_tokens'] or 0) if row else 0)
|
||
stats['models']=[dict(r) for r in cur.execute("""
|
||
SELECT model, count(*) AS sessions,
|
||
sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
GROUP BY model
|
||
ORDER BY total_tokens DESC, sessions DESC
|
||
LIMIT 5
|
||
""",(cutoff,))]
|
||
stats['platforms']=[dict(r) for r in cur.execute("""
|
||
SELECT source, count(*) AS sessions,
|
||
sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
GROUP BY source
|
||
ORDER BY total_tokens DESC, sessions DESC
|
||
LIMIT 5
|
||
""",(cutoff,))]
|
||
stats['top_tools']=[dict(r) for r in cur.execute("""
|
||
SELECT tool_name, count(*) AS calls
|
||
FROM messages
|
||
WHERE role='tool' AND tool_name IS NOT NULL AND tool_name != '' AND timestamp >= ?
|
||
GROUP BY tool_name
|
||
ORDER BY calls DESC, tool_name ASC
|
||
LIMIT 8
|
||
""",(cutoff,))]
|
||
stats['recent_sessions']=[dict(r) for r in cur.execute("""
|
||
SELECT source, model, started_at,
|
||
(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens,
|
||
coalesce(api_call_count,0) AS api_calls
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
ORDER BY started_at DESC
|
||
LIMIT 5
|
||
""",(cutoff,))]
|
||
day_buckets={}
|
||
for day_index in range(6,-1,-1):
|
||
day=(datetime.now(LOCAL_TZ)-timedelta(days=day_index)).date()
|
||
day_buckets[day.isoformat()]={'label':day.strftime('%a'),'tokens':0,'sessions':0}
|
||
day_rows=cur.execute("""
|
||
SELECT started_at,
|
||
(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||
FROM sessions
|
||
WHERE started_at >= ?
|
||
ORDER BY started_at ASC
|
||
""",(week_cutoff,)).fetchall()
|
||
for row in day_rows:
|
||
try:
|
||
day_key=datetime.fromtimestamp(float(row['started_at']), LOCAL_TZ).date().isoformat()
|
||
except Exception:
|
||
continue
|
||
bucket=day_buckets.get(day_key)
|
||
if not bucket:
|
||
continue
|
||
bucket['tokens'] += int(row['total_tokens'] or 0)
|
||
bucket['sessions'] += 1
|
||
stats['daily_tokens']=list(day_buckets.values())
|
||
for item in stats['recent_sessions']:
|
||
started=item.get('started_at')
|
||
try: item['started_local']=datetime.fromtimestamp(float(started), LOCAL_TZ).strftime('%a %-I:%M %p')
|
||
except Exception: item['started_local']='recent'
|
||
stats['latest_session']=stats['recent_sessions'][0] if stats['recent_sessions'] else None
|
||
try:
|
||
hermes_agent_root=HERMES_HOME/'hermes-agent'
|
||
hermes_agent_python=hermes_agent_root/'venv'/'bin'/'python'
|
||
if hermes_agent_python.exists():
|
||
script=(
|
||
"import importlib, json\n"
|
||
"from zoneinfo import ZoneInfo\n"
|
||
"fetch_account_usage=getattr(importlib.import_module('agent.account_usage'),'fetch_account_usage')\n"
|
||
"snapshot=fetch_account_usage('openai-codex')\n"
|
||
"windows=[]\n"
|
||
"if snapshot:\n"
|
||
" local_tz=ZoneInfo('America/Chicago')\n"
|
||
" for window in getattr(snapshot,'windows',()):\n"
|
||
" reset_at=getattr(window,'reset_at',None)\n"
|
||
" used=float(getattr(window,'used_percent',0.0) or 0.0)\n"
|
||
" windows.append({'label': str(getattr(window,'label','') or ''), 'used_percent': used, 'remaining_percent': max(0.0,100.0-used), 'reset_local': reset_at.astimezone(local_tz).strftime('%a %-I:%M %p') if reset_at else 'unknown'})\n"
|
||
"print(json.dumps(windows))\n"
|
||
)
|
||
res=subprocess.run([str(hermes_agent_python),'-c',script],capture_output=True,text=True,timeout=20,check=True,cwd=str(hermes_agent_root))
|
||
stats['account_windows']=json.loads(res.stdout or '[]')
|
||
except Exception:
|
||
stats['account_windows']=[]
|
||
stats['ok']=True
|
||
conn.close()
|
||
return stats
|
||
except Exception:
|
||
stats['error']='Hermes telemetry is temporarily unavailable.'
|
||
return stats
|
||
|
||
def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='')->str:
|
||
severity=''
|
||
for token in str(tone).split():
|
||
if token in SECTION_STATE_RANK:
|
||
severity=token
|
||
break
|
||
severity_attr=f" data-severity='{esc(severity)}'" if severity else ''
|
||
return f"<article class='focus-card {esc(tone)}'{severity_attr}><span class='focus-kicker'>{esc(kicker)}</span><h3>{esc(title)}</h3><strong>{esc(headline)}</strong><p>{esc(body)}</p></article>"
|
||
|
||
|
||
def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->tuple[str,str]:
|
||
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
|
||
warn_items=[item for item in pulse if item.get('state')=='warn']
|
||
local_story=local_items[0] if local_items else None
|
||
session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None
|
||
session_remaining=session_window.get('remaining_percent') if session_window else None
|
||
session_reset=str(session_window.get('reset_local') or 'unknown') if session_window else 'unknown'
|
||
hermes_model=(hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'your main model'
|
||
if urgent_todos:
|
||
chores_headline=f"{len(urgent_todos)} urgent / due today"
|
||
first_urgent=urgent_todos[0]
|
||
chores_body=(first_urgent.get('title') or 'You have work waiting') + (f" • {first_urgent.get('due_title')}" if first_urgent.get('due_title') else '')
|
||
else:
|
||
chores_headline='No urgent chores'
|
||
chores_body='Nothing due soon enough to nag you here.'
|
||
if hermes.get('ok'):
|
||
if session_remaining is not None:
|
||
hermes_headline=f"{hermes_model} · {pct_text(session_remaining)} session left"
|
||
hermes_body=f"{compact_number(hermes.get('last_24h_tokens',0))} tokens in 24h · {compact_number(hermes.get('last_24h_tool_calls',0))} tool calls · resets {session_reset}"
|
||
else:
|
||
hermes_headline=f"{hermes_model} · {compact_number(hermes.get('last_24h_tokens',0))} tokens today"
|
||
hermes_body=f"{compact_number(hermes.get('last_24h_sessions',0))} sessions in 24h · {compact_number(hermes.get('tool_calls',0))} tool calls in {hermes.get('period_days',30)} days"
|
||
else:
|
||
hermes_headline='Hermes telemetry unavailable'
|
||
hermes_body='Could not read local Hermes usage stats right now.'
|
||
local_state='attention' if local_story else 'ok'
|
||
chores_state='warn' if urgent_todos else 'ok'
|
||
paperless_state='warn' if paperless.get('flagged_count',0) else ('ok' if not paperless.get('active_count',0) else 'attention')
|
||
homelab_state='warn' if warn_items else 'ok'
|
||
hermes_state=usage_window_state(session_remaining) if hermes.get('ok') else 'attention'
|
||
cards=[
|
||
focus_card(
|
||
'Local watch',
|
||
'Useful nearby',
|
||
(local_story.get('title') if local_story else 'Local lane quiet'),
|
||
(f"{local_story.get('source','Local feed')} • {label(local_story.get('category','local'))}" if local_story else 'Nothing local survived filtering hard enough to earn the homepage.'),
|
||
f'local {local_state}'.strip()
|
||
),
|
||
focus_card(
|
||
'Chores with heat',
|
||
'Donetick',
|
||
chores_headline,
|
||
chores_body,
|
||
chores_state
|
||
),
|
||
focus_card(
|
||
'Hermes now',
|
||
'Usage / budget',
|
||
hermes_headline,
|
||
hermes_body,
|
||
hermes_state
|
||
),
|
||
focus_card(
|
||
'Paperless follow-up',
|
||
'Queue check',
|
||
(f"{paperless.get('active_count',0)} active · {paperless.get('flagged_count',0)} flagged" if paperless.get('active_count',0) or paperless.get('flagged_count',0) else 'No documents waiting'),
|
||
str(paperless.get('headline') or 'No documents waiting'),
|
||
paperless_state
|
||
),
|
||
focus_card(
|
||
'Homelab pulse',
|
||
'NOMAD checks',
|
||
('Stable' if not warn_items else f"{len(warn_items)} warning{'s' if len(warn_items)!=1 else ''}"),
|
||
('; '.join(f"{item.get('label')}: {item.get('value')}" for item in warn_items[:2]) if warn_items else 'Dashboard service, cron, digest freshness, and disk all look fine.'),
|
||
homelab_state
|
||
),
|
||
]
|
||
section_state=combine_states([local_state,chores_state,hermes_state,paperless_state,homelab_state])
|
||
return ("<section class='focus-grid'>" + ''.join(cards) + "</section>", section_state)
|
||
|
||
|
||
def hermes_delta_text(current:int|float|None, previous:int|float|None)->str:
|
||
try:
|
||
current_value=float(current or 0)
|
||
previous_value=float(previous or 0)
|
||
except Exception:
|
||
return 'No comparison window yet'
|
||
if previous_value <= 0 and current_value <= 0:
|
||
return 'No Hermes usage in either 24h window'
|
||
if previous_value <= 0:
|
||
return 'Up from a cold previous 24h window'
|
||
delta=((current_value-previous_value)/previous_value)*100.0
|
||
direction='up' if delta >= 0 else 'down'
|
||
return f"{abs(delta):.0f}% {direction} vs previous 24h"
|
||
|
||
|
||
def choose_feature_story(local_items:list[dict[str,Any]], homelab_items:list[dict[str,Any]], space_items:list[dict[str,Any]], signal_items:list[dict[str,Any]])->dict[str,Any] | None:
|
||
for pool in (local_items, homelab_items, space_items, signal_items):
|
||
if pool:
|
||
return pool[0]
|
||
return None
|
||
|
||
|
||
def build_feature_story(feature_item:dict[str,Any] | None)->str:
|
||
if not feature_item:
|
||
return ''
|
||
title=str(feature_item.get('title') or 'Lead item')
|
||
summary=(feature_item.get('summary') or '').strip()[:260]
|
||
source=str(feature_item.get('source') or 'Source')
|
||
when=(feature_item.get('published_at','')[:16].replace('T',' '))
|
||
category=str(feature_item.get('category') or 'other')
|
||
url=str(feature_item.get('url') or '')
|
||
feature_label='Lead local item' if category=='local' else ('Lead homelab item' if category in {'homelab','smart_home','radio'} else 'Lead signal')
|
||
why_text=why(feature_item)
|
||
return f"""
|
||
<section class='feature-story'>
|
||
<article class='feature-card {esc(category)}'>
|
||
<div class='feature-meta'><span>{esc(feature_label)}</span><span>{esc(source)}</span><span>{esc(when)} UTC</span></div>
|
||
<div class='feature-body'>
|
||
<div class='feature-copy'>
|
||
<span class='feature-kicker'>{esc(label(category))}</span>
|
||
<h2><a href='{esc(url)}' target='_blank' rel='noreferrer'>{esc(title)}</a></h2>
|
||
<p>{esc(summary)}</p>
|
||
<div class='feature-why'>Why it made lead: {esc(why_text)}</div>
|
||
</div>
|
||
<div class='feature-actions'>
|
||
<a href='{esc(url)}' target='_blank' rel='noreferrer' class='feature-open'>Open story</a>
|
||
<span class='feature-note'>Promoted so you get one strong item instead of four equal tiles.</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</section>"""
|
||
|
||
|
||
def build_operator_snapshot(todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
|
||
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
|
||
warn_items=[item for item in pulse if item.get('state')=='warn']
|
||
session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None
|
||
session_remaining=session_window.get('remaining_percent') if session_window else None
|
||
metrics=[
|
||
metric_card('Urgent queue', compact_number(len(urgent_todos)), 'Donetick chores due today / overdue', 'warn' if urgent_todos else 'ok'),
|
||
metric_card('Hermes burn', compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—', hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0)) if hermes.get('ok') else 'Telemetry unavailable', usage_window_state(session_remaining) if hermes.get('ok') else 'attention'),
|
||
metric_card('Session headroom', pct_text(session_remaining) if hermes.get('ok') else '—', f"Resets {session_window.get('reset_local') or 'unknown'}" if session_window else 'No live budget window available', usage_window_state(session_remaining) if hermes.get('ok') else 'attention'),
|
||
metric_card('Operator inbox', f"{paperless.get('active_count',0)} docs / {len(warn_items)} lab warns", 'Paperless + homelab friction to clear next', 'warn' if paperless.get('flagged_count',0) or warn_items else ('attention' if paperless.get('active_count',0) else 'ok')),
|
||
]
|
||
return "<section class='snapshot-strip'>" + ''.join(metrics) + "</section>"
|
||
|
||
|
||
def render_hermes_trend_card(hermes:dict[str,Any])->str:
|
||
buckets=hermes.get('daily_tokens') or []
|
||
if not buckets:
|
||
return "<article class='summary-card'><h3>Usage trend</h3><p>No 7-day history available yet.</p></article>"
|
||
max_tokens=max((int(item.get('tokens') or 0) for item in buckets), default=0) or 1
|
||
peak=max(buckets, key=lambda item: int(item.get('tokens') or 0))
|
||
bars=[]
|
||
for item in buckets:
|
||
tokens=int(item.get('tokens') or 0)
|
||
height=max(14, round((tokens/max_tokens)*84)) if tokens else 10
|
||
bars.append(f"<div class='trend-bar-wrap'><span class='trend-value'>{esc(compact_number(tokens))}</span><div class='trend-bar' style='height:{height}px'></div><span class='trend-label'>{esc(item.get('label') or '')}</span></div>")
|
||
summary=f"Peak {peak.get('label')} at {compact_number(peak.get('tokens',0))} tokens; now {hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0))}."
|
||
return f"<article class='summary-card'><h3>Usage trend</h3><div class='trend-chart'>{''.join(bars)}</div><p>{esc(summary)}</p></article>"
|
||
|
||
|
||
def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
|
||
urgent_todos=len([item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'])
|
||
warn_count=len([item for item in pulse if item.get('state')=='warn'])
|
||
local_status='local lane has signal' if local_items else 'local lane is quiet'
|
||
paperless_text=f"{paperless.get('active_count',0)} Paperless item{'s' if paperless.get('active_count',0)!=1 else ''}"
|
||
hermes_model=(hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'your main model'
|
||
session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None
|
||
if hermes.get('ok'):
|
||
if session_window and session_window.get('remaining_percent') is not None:
|
||
hermes_text=f"Hermes pushed {compact_number(hermes.get('last_24h_tokens',0))} tokens in the last day on {hermes_model} with {pct_text(session_window.get('remaining_percent'))} session quota left"
|
||
else:
|
||
hermes_text=f"Hermes pushed {compact_number(hermes.get('last_24h_tokens',0))} tokens in the last day on {hermes_model}"
|
||
else:
|
||
hermes_text='Hermes telemetry is unavailable right now'
|
||
return f"{urgent_todos} urgent chore{'s' if urgent_todos!=1 else ''}, {paperless_text}, {warn_count} homelab warning{'s' if warn_count!=1 else ''}, {hermes_text}, and the {local_status}."
|
||
|
||
|
||
def donetick_focus_signature(todos:list[dict[str,Any]])->str:
|
||
urgent=[{
|
||
'id': str(item.get('id') or ''),
|
||
'title': str(item.get('title') or ''),
|
||
'due_title': str(item.get('due_title') or ''),
|
||
'due': str(item.get('due') or ''),
|
||
'state': str(item.get('state') or ''),
|
||
} for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
|
||
payload=json.dumps(urgent,separators=(',',':'),sort_keys=True)
|
||
return hashlib.sha1(payload.encode('utf-8')).hexdigest()[:16]
|
||
|
||
|
||
def list_rows(rows:list[str])->str:
|
||
return ''.join(f"<li>{row}</li>" for row in rows) if rows else "<li class='empty'>Nothing to show.</li>"
|
||
|
||
|
||
def build_system_section(snapshot:list[dict[str,str]])->str:
|
||
metrics=''.join(metric_card(item.get('label','Metric'), item.get('value','—'), 'live host state', item.get('state','')) for item in snapshot)
|
||
subtitle='Live NOMAD host state: uptime, load, memory pressure, and root disk usage.'
|
||
state=combine_states([str(item.get('state') or 'ok') for item in snapshot])
|
||
return collapsible_section('System snapshot',subtitle,f"<section class='metrics metrics-compact'>{metrics}</section>",'system-snapshot',open_by_default=(state=='warn'),state=state,smart=True)
|
||
|
||
|
||
def build_hermes_section(hermes:dict[str,Any])->str:
|
||
if not hermes.get('ok'):
|
||
body="<section class='summary-grid'><article class='summary-card'><h3>Hermes telemetry unavailable</h3><p>Local Hermes usage stats could not be read right now.</p></article></section>"
|
||
return collapsible_section('Hermes usage',f"Could not read the last {hermes.get('period_days',30)} days of local Hermes state.",body,'hermes-usage',open_by_default=True)
|
||
session_window=find_usage_window(hermes.get('account_windows',[]), 'session')
|
||
weekly_window=find_usage_window(hermes.get('account_windows',[]), 'week')
|
||
account_cards=[]
|
||
for window in [session_window, weekly_window]:
|
||
if not window:
|
||
continue
|
||
label=str(window.get('label') or 'Usage window')
|
||
remaining_value=window.get('remaining_percent')
|
||
remaining=pct_text(remaining_value)
|
||
used=pct_text(window.get('used_percent'))
|
||
reset_local=str(window.get('reset_local') or 'unknown')
|
||
state=usage_window_state(remaining_value)
|
||
account_cards.append(metric_card(f"Codex {label}",remaining,f"{used} used · resets {reset_local}",state))
|
||
latest=hermes.get('latest_session') or {}
|
||
latest_summary='No recent session rows'
|
||
if latest:
|
||
latest_summary=f"{str(latest.get('source') or 'session').title()} · {str(latest.get('started_local') or 'recent')} · {compact_number(latest.get('total_tokens',0))} tokens"
|
||
metrics=''.join(account_cards + [
|
||
metric_card('Default model',str(hermes.get('default_model','unknown')),str(hermes.get('provider','unknown')),'space'),
|
||
metric_card('Last 24h',compact_number(hermes.get('last_24h_tokens',0)),f"{compact_number(hermes.get('last_24h_sessions',0))} sessions · {compact_number(hermes.get('last_24h_tool_calls',0))} tool calls",'space'),
|
||
metric_card('7-day tokens',compact_number(hermes.get('last_7d_tokens',0)),'rolling week','space'),
|
||
metric_card('Cache reuse',pct_text(hermes.get('cache_share_percent')),f"{compact_number(hermes.get('cache_read_tokens',0))} cached of {compact_number(hermes.get('total_tokens',0))}",'space'),
|
||
metric_card(f"{hermes.get('period_days',30)}-day tokens",compact_number(hermes.get('total_tokens',0)),'all session rows','space'),
|
||
metric_card('Avg session',compact_number(hermes.get('avg_tokens_per_session',0)),f"{compact_number(hermes.get('avg_tool_calls_per_session',0))} tool calls / session",'space'),
|
||
metric_card('Sessions / messages',f"{compact_number(hermes.get('sessions',0))} / {compact_number(hermes.get('messages',0))}",'state.db window','space'),
|
||
metric_card('Latest session',str(latest.get('model') or hermes.get('default_model','unknown')),latest_summary,'space'),
|
||
])
|
||
model_rows=[]
|
||
for item in hermes.get('models',[])[:5]:
|
||
model_rows.append(f"{esc(item.get('model') or 'unknown')} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens")
|
||
platform_rows=[]
|
||
for item in hermes.get('platforms',[])[:5]:
|
||
platform_rows.append(f"{esc((item.get('source') or 'unknown').title())} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens")
|
||
tool_rows=[]
|
||
for item in hermes.get('top_tools',[])[:8]:
|
||
tool_rows.append(f"{esc(item.get('tool_name') or 'tool')} · {compact_number(item.get('calls',0))} calls")
|
||
recent_rows=[]
|
||
for item in hermes.get('recent_sessions',[])[:5]:
|
||
source=(item.get('source') or 'session').title()
|
||
model=item.get('model') or 'unknown'
|
||
recent_rows.append(f"{esc(source)} · {esc(item.get('started_local') or 'recent')} · {esc(model)} · {compact_number(item.get('total_tokens',0))} tokens")
|
||
operator_rows=[
|
||
f"Last 24h burn: {compact_number(hermes.get('last_24h_tokens',0))} tokens across {compact_number(hermes.get('last_24h_sessions',0))} sessions",
|
||
f"24h change: {hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0))}",
|
||
f"Rolling 7-day burn: {compact_number(hermes.get('last_7d_tokens',0))} tokens",
|
||
f"Cache reuse: {pct_text(hermes.get('cache_share_percent'))}",
|
||
f"Average session size: {compact_number(hermes.get('avg_tokens_per_session',0))} tokens",
|
||
]
|
||
detail_html=f"""
|
||
<section class='summary-grid'>
|
||
<article class='summary-card'>
|
||
<h3>Operator read</h3>
|
||
<ul>{list_rows(operator_rows)}</ul>
|
||
</article>
|
||
{render_hermes_trend_card(hermes)}
|
||
<article class='summary-card'>
|
||
<h3>Model mix</h3>
|
||
<ul>{list_rows(model_rows)}</ul>
|
||
</article>
|
||
<article class='summary-card'>
|
||
<h3>Platform split</h3>
|
||
<ul>{list_rows(platform_rows)}</ul>
|
||
</article>
|
||
<article class='summary-card'>
|
||
<h3>Top tools</h3>
|
||
<ul>{list_rows(tool_rows)}</ul>
|
||
</article>
|
||
<article class='summary-card'>
|
||
<h3>Recent sessions</h3>
|
||
<ul>{list_rows(recent_rows)}</ul>
|
||
</article>
|
||
</section>
|
||
"""
|
||
body=f"<section class='metrics hermes-metrics'>{metrics}</section>{detail_html}"
|
||
subtitle=f"Live read from ~/.hermes/state.db over the last {hermes.get('period_days',30)} days, plus current Codex budget windows when available."
|
||
return collapsible_section('Hermes usage',subtitle,body,'hermes-usage',open_by_default=True)
|
||
|
||
|
||
def check_service_health(item:dict[str,Any])->dict[str,str]:
|
||
if item.get('probe') is False:
|
||
state=str(item.get('state') or 'attention')
|
||
badge=str(item.get('badge') or ('Healthy' if state=='ok' else 'Manual'))
|
||
detail=str(item.get('detail') or 'manual operator check only')
|
||
return {'state':state,'badge':badge,'detail':detail}
|
||
tcp_host=str(item.get('tcp_host') or '').strip()
|
||
tcp_port=item.get('tcp_port')
|
||
if tcp_host and tcp_port:
|
||
started=time.time()
|
||
sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
sock.settimeout(3)
|
||
try:
|
||
sock.connect((tcp_host, int(tcp_port)))
|
||
elapsed=max(1,int((time.time()-started)*1000))
|
||
return {'state':'ok','badge':str(item.get('badge') or 'Healthy'),'detail':f'tcp {int(tcp_port)} open · {elapsed} ms'}
|
||
except Exception as e:
|
||
elapsed=max(1,int((time.time()-started)*1000))
|
||
return {'state':'warn','badge':'Down','detail':f'tcp {int(tcp_port)} {type(e).__name__} · {elapsed} ms'}
|
||
finally:
|
||
sock.close()
|
||
target=str(item.get('health_url') or item.get('url') or '').strip()
|
||
if not target:
|
||
return {'state':'warn','badge':'Down','detail':'no health URL configured'}
|
||
started=time.time()
|
||
req=urllib.request.Request(target,headers={'User-Agent':'DorisDashboard/1.0'})
|
||
final_url=target
|
||
try:
|
||
with urllib.request.urlopen(req,timeout=8) as r:
|
||
code=getattr(r,'status',200) or 200
|
||
final_url=r.geturl() or target
|
||
elapsed=max(1,int((time.time()-started)*1000))
|
||
detail_parts=[str(code), f'{elapsed} ms']
|
||
lower=final_url.lower()
|
||
if '/auth/resource/' in lower or '/accounts/login/' in lower or '/login' in lower:
|
||
detail_parts.append('auth gate')
|
||
elif final_url.rstrip('/') != target.rstrip('/'):
|
||
detail_parts.append('redirected')
|
||
return {'state':'ok','badge':'Healthy','detail':' · '.join(detail_parts)}
|
||
except urllib.error.HTTPError as e:
|
||
elapsed=max(1,int((time.time()-started)*1000))
|
||
code=e.code
|
||
if code in {401,403}:
|
||
return {'state':'ok','badge':'Healthy','detail':f'{code} · {elapsed} ms · auth required'}
|
||
if code in {404,429}:
|
||
return {'state':'attention','badge':'Degraded','detail':f'{code} · {elapsed} ms'}
|
||
if code >= 500:
|
||
return {'state':'warn','badge':'Down','detail':f'{code} · {elapsed} ms'}
|
||
return {'state':'attention','badge':'Degraded','detail':f'{code} · {elapsed} ms'}
|
||
except Exception as e:
|
||
elapsed=max(1,int((time.time()-started)*1000))
|
||
return {'state':'warn','badge':'Down','detail':f'{type(e).__name__} · {elapsed} ms'}
|
||
|
||
|
||
def service_group_state(items:list[dict[str,Any]])->str:
|
||
return combine_states([str(item.get('health',{}).get('state') or item.get('state') or 'attention') for item in items])
|
||
|
||
|
||
def top_nav(active:str='home')->str:
|
||
items=[
|
||
('Home','index.html','home'),
|
||
('Services','services.html','services'),
|
||
('Donetick','https://donetick.paccoco.com','external'),
|
||
('Paperless','https://paperless.paccoco.com','external'),
|
||
('n8n','https://n8n.paccoco.com','external'),
|
||
('Grafana','https://grafana.paccoco.com','external'),
|
||
('Gitea','https://gitea.paccoco.com','external'),
|
||
('Schoolhouse','https://schoolhouse.paccoco.com','external'),
|
||
]
|
||
links=[]
|
||
for label,url,key in items:
|
||
classes=['nav-link']
|
||
attrs=[]
|
||
if key==active:
|
||
classes.append('active')
|
||
attrs.append("aria-current='page'")
|
||
if url.startswith('http'):
|
||
attrs.append("target='_blank'")
|
||
attrs.append("rel='noreferrer'")
|
||
links.append(f"<a class='{' '.join(classes)}' href='{esc(url)}' {' '.join(attrs)}>{esc(label)}</a>")
|
||
return "<nav class='top-nav'><div class='nav-brand'><span class='nav-kicker'>Doris</span><strong>Operator Portal</strong></div><div class='nav-links'>" + ''.join(links) + "</div></nav>"
|
||
|
||
|
||
def build_services_inventory()->tuple[list[dict[str,Any]],list[dict[str,Any]]]:
|
||
groups=[]
|
||
flat=[]
|
||
for group in SERVICE_DIRECTORY:
|
||
items=[]
|
||
for entry in group.get('items',[]):
|
||
item=dict(entry)
|
||
item['health']=check_service_health(item)
|
||
item['health_state']=str(item.get('health',{}).get('state') or item.get('state') or 'ok')
|
||
items.append(item)
|
||
flat.append(item)
|
||
groups.append({'group':str(group.get('group') or 'Services'),'items':items,'state':service_group_state(items)})
|
||
return groups, flat
|
||
|
||
|
||
def build_services_directory(groups_data:list[dict[str,Any]])->str:
|
||
groups=[]
|
||
for group in groups_data:
|
||
cards=[]
|
||
for item in group.get('items',[]):
|
||
health=item.get('health',{})
|
||
state=str(item.get('health_state') or health.get('state') or item.get('state') or 'ok')
|
||
exposure=str(item.get('exposure') or 'local').upper()
|
||
url=str(item.get('url') or '').strip()
|
||
attrs="target='_blank' rel='noreferrer'" if url.startswith('http') else ''
|
||
facts=[]
|
||
if item.get('host'):
|
||
facts.append(f"<span class='service-fact'><strong>Host</strong>{esc(item.get('host'))}</span>")
|
||
if item.get('port'):
|
||
facts.append(f"<span class='service-fact'><strong>Port</strong>{esc(item.get('port'))}</span>")
|
||
facts.append(f"<span class='service-fact'><strong>Access</strong>{esc(exposure)}</span>")
|
||
note=str(item.get('note') or '').strip()
|
||
meta_text=esc(health.get('detail') or 'No live check')
|
||
if note:
|
||
meta_text += f" · {esc(note)}"
|
||
action_html=(f"<a href='{esc(url)}' class='service-open' {attrs}>Open</a>" if url else "<span class='service-open disabled'>No direct route</span>")
|
||
title_html=(f"<a href='{esc(url)}' {attrs}>{esc(item.get('name') or 'Service')}</a>" if url else esc(item.get('name') or 'Service'))
|
||
cards.append(
|
||
f"<article class='service-card {esc(state)}'>"
|
||
f"<div class='service-top'><div><span class='service-group-badge'>{esc(group.get('group','Service'))}</span>"
|
||
f"<h3>{title_html}</h3></div>"
|
||
f"<div class='service-badge-stack'><span class='service-health {esc(state)}'>{esc(health.get('badge') or state.title())}</span><span class='service-badge'>{esc(exposure)}</span></div></div>"
|
||
f"<p>{esc(item.get('purpose') or '')}</p>"
|
||
f"<div class='service-facts'>{''.join(facts)}</div>"
|
||
f"<div class='service-meta'>{meta_text}</div>"
|
||
f"<div class='service-actions'>{action_html}</div>"
|
||
f"</article>"
|
||
)
|
||
groups.append(collapsible_section(str(group.get('group') or 'Services'),'Direct links into the stack.',f"<div class='services-grid'>{''.join(cards)}</div>",slugify(str(group.get('group') or 'services')),open_by_default=(group.get('state')=='warn'),state=str(group.get('state') or 'ok'),smart=True))
|
||
return ''.join(groups)
|
||
|
||
|
||
def render_services_inventory(flat_items:list[dict[str,Any]])->str:
|
||
state_counts=Counter(str(item.get('health_state') or 'attention') for item in flat_items)
|
||
exposure_counts=Counter(str(item.get('exposure') or 'LOCAL').upper() for item in flat_items)
|
||
host_counts=Counter(str(item.get('host') or 'Unknown') for item in flat_items)
|
||
total=len(flat_items)
|
||
host_bits=''.join(f"<li><strong>{esc(host)}</strong><span>{count} services</span></li>" for host,count in host_counts.most_common())
|
||
exposure_bits=''.join(f"<li><strong>{esc(name)}</strong><span>{count}</span></li>" for name,count in exposure_counts.most_common())
|
||
return (
|
||
"<section class='summary-grid'>"
|
||
f"<article class='summary-card'><h3>Coverage</h3><ul><li><strong>{total}</strong><span>listed services</span></li><li><strong>{state_counts.get('ok',0)}</strong><span>healthy now</span></li><li><strong>{state_counts.get('attention',0)}</strong><span>manual/degraded</span></li><li><strong>{state_counts.get('warn',0)}</strong><span>down</span></li></ul></article>"
|
||
f"<article class='summary-card'><h3>Access split</h3><ul>{exposure_bits}</ul></article>"
|
||
f"<article class='summary-card'><h3>Host map</h3><ul>{host_bits}</ul></article>"
|
||
"<article class='summary-card'><h3>Operator rule</h3><p>Doris is the launchpad. Public apps can stay behind auth gates; LAN tools should still show a clean probe. Manual cards mean the docs say the service exists but Doris does not yet have a stable operator-safe route for it.</p></article>"
|
||
"</section>"
|
||
)
|
||
|
||
|
||
def render_services_page(hero_summary:str,candidate_count:int,source_count:int,hero_tokens:str,now:datetime)->str:
|
||
groups_data, flat_items=build_services_inventory()
|
||
services_html=build_services_directory(groups_data)
|
||
inventory_html=render_services_inventory(flat_items)
|
||
utility_html=collapsible_section('Portal notes','Keep Doris as the front door, not a monolith.',"<section class='summary-grid'><article class='summary-card'><h3>How to use this</h3><p>Use Home for today, alerts, and the briefing. Use Services when you want the rest of the stack quickly without hunting bookmarks.</p></article><article class='summary-card'><h3>Design rule</h3><p>Shared navigation and status live here; full workflows still stay in their own apps.</p></article></section>",'portal-notes',open_by_default=True)+collapsible_section('Inventory','Coverage, access model, and host distribution.',inventory_html,'services-inventory',open_by_default=True)
|
||
return f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Doris Services</title><link rel='stylesheet' href='style.css'></head><body><main>{top_nav('services')}<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Services Directory</h1><p class='sub'>Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}</p></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time</span></div><div><strong>{candidate_count}</strong><span>News candidates</span></div><div><strong>{source_count}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header><div class='dashboard-shell services-shell'><section class='primary-column'>{services_html}</section><aside class='secondary-column'>{utility_html}</aside></div><footer><p>Operator portal links are curated from the homelab docs and current live dashboard context.</p><p>Local-only on NOMAD unless John says otherwise.</p></footer></main></body></html>"
|
||
|
||
def render()->str:
|
||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
|
||
selected=select_items(candidates,prefs); top_briefing=selected[:4]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
||
exploration=select_exploration_items(candidates,prefs,{i.get('url','') for i in selected if i.get('url')},4)
|
||
for i in selected:
|
||
if i.get('url','') in top_urls: continue
|
||
cat=i.get('category')
|
||
if cat=='astronomy': groups['space'].append(i)
|
||
elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i)
|
||
elif cat=='local': groups['local'].append(i)
|
||
else: groups['signals'].append(i)
|
||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); hermes=fetch_hermes_usage(30); system=system_snapshot()
|
||
wind_notice=load_wind_watchdog_notice()
|
||
paperless=paperless_focus_summary()
|
||
now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates)
|
||
weather_html=weather_overview_panel(weather,wind_notice)
|
||
moon_metrics=''.join([
|
||
metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'),
|
||
metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')])
|
||
pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse)
|
||
forecast_html=forecast_panel(weather)
|
||
hero_summary=build_hero_summary(groups['local'],todos,pulse,paperless,hermes)
|
||
focus_html,focus_state=build_focus_cards(groups['local'],todos,pulse,paperless,hermes)
|
||
focus_signature=donetick_focus_signature(todos)
|
||
hermes_html=build_hermes_section(hermes)
|
||
system_html=build_system_section(system)
|
||
hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—'
|
||
snapshot_html=build_operator_snapshot(todos,pulse,paperless,hermes)
|
||
feature_item=choose_feature_story(groups['local'], groups['homelab'], groups['space'], top_briefing or groups['signals'])
|
||
feature_html=build_feature_story(feature_item)
|
||
session_window=find_usage_window(hermes.get('account_windows',[]),'session') if hermes.get('ok') else None
|
||
primary_html=''.join([
|
||
feature_html,
|
||
section('Top briefing','Everything else worth a glance after the lead item.',top_briefing,'Nothing worth bothering you with.',compact=True),
|
||
weather_html,
|
||
forecast_html,
|
||
section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True),
|
||
section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:4],'No sky signal right now.',compact=True),
|
||
section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True),
|
||
section('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks right now.',compact=True),
|
||
section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True),
|
||
])
|
||
secondary_html=''.join([
|
||
collapsible_section('Focus now','What actually deserves attention before you go wandering through feeds.',focus_html,'focus-now',open_by_default=(focus_state=='warn'),state=focus_state,smart=True),
|
||
system_html,
|
||
hermes_html,
|
||
collapsible_section('Agenda','Calendar, Donetick, and pinned notes.',f"<section class='today'>{list_panel('Calendar',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}</section>",'today-overview',open_by_default=False),
|
||
collapsible_section('Sky','Moon and outside-the-window context that still matters.',f"<section class='metrics metrics-compact'>{moon_metrics}</section>",'at-a-glance',open_by_default=False),
|
||
paperless_review_section(),
|
||
collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics metrics-compact'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False),
|
||
])
|
||
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body data-donetick-focus-signature='{esc(focus_signature)}'><main>{top_nav('home')}
|
||
<header class='hero'><div class='hero-main'><p class='eyebrow'>PC Doris Thatcher</p><div class='hero-title-row'><h1>Doris Dashboard</h1><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '—')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div></div><div class='hero-rail'><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></div></header>
|
||
<section class='snapshot-block'><div class='section-heading-inline'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section>
|
||
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
|
||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||
|
||
def main()->int:
|
||
PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True)
|
||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos()); hermes=fetch_hermes_usage(30); wind_notice=load_wind_watchdog_notice()
|
||
generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))}
|
||
(DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n')
|
||
(DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n')
|
||
(DATA/'wind_alert.json').write_text(json.dumps(wind_notice or {'active':False},indent=2)+'\n')
|
||
(DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n')
|
||
(DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n')
|
||
(DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n')
|
||
(DATA/'hermes_usage.json').write_text(json.dumps(hermes,indent=2)+'\n')
|
||
if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n')
|
||
(PUBLIC/'index.html').write_text(render(),encoding='utf-8')
|
||
services_summary=build_hero_summary([item for item in articles if item.get('category')=='local'], todos, pulse, paperless_focus_summary(), hermes)
|
||
(PUBLIC/'services.html').write_text(render_services_page(services_summary, len(candidates), len(set(i.get('source','') for i in candidates)), compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—', datetime.now(LOCAL_TZ)),encoding='utf-8')
|
||
return 0
|
||
if __name__=='__main__': raise SystemExit(main())
|