2204 lines
135 KiB
Python
Executable File
2204 lines
135 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'
|
||
LEAD_WARRANT_STATE_PATH=DATA/'lead_warrant_state.json'
|
||
DIGEST_FEEDBACK_PATH=DIGEST_ROOT/'data'/'feedback.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'
|
||
HERMES_CRON_JOBS=HERMES_HOME/'cron'/'jobs.json'
|
||
DASHBOARD_JSON=DATA/'dashboard.json'
|
||
WIND_WATCHDOG_STATE_PATH=HERMES_HOME/'state'/'wind_alert_watchdog.json'
|
||
CRON_GROUP_TARGET='telegram:-1003632706797'
|
||
|
||
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': 'Doris Kitchen Imports', 'url': 'http://10.5.1.16:8092/imports', 'health_url': 'http://10.5.1.16:8092/imports', 'purpose': 'Flag bad KitchenOwl recipe imports for Doris to repair later.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8092'},
|
||
{'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': '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': 'http://10.5.1.16:8080', '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 story_key(item:dict[str,Any] | None)->str:
|
||
if not item:
|
||
return ''
|
||
url=str(item.get('url') or '').strip()
|
||
if url:
|
||
return url
|
||
ident=str(item.get('id') or '').strip()
|
||
if ident:
|
||
return ident
|
||
title=str(item.get('title') or '').strip().lower()
|
||
source=str(item.get('source') or '').strip().lower()
|
||
return f"{source}::{title}".strip(':')
|
||
|
||
def load_lead_warrant_state()->dict[str,Any]:
|
||
state=load_json(LEAD_WARRANT_STATE_PATH,{})
|
||
if not isinstance(state,dict):
|
||
state={}
|
||
acknowledged=state.get('acknowledged',[])
|
||
ignored_sources=state.get('ignored_sources',[])
|
||
ignored_topics=state.get('ignored_topics',[])
|
||
if not isinstance(acknowledged,list):
|
||
acknowledged=[]
|
||
if not isinstance(ignored_sources,list):
|
||
ignored_sources=[]
|
||
if not isinstance(ignored_topics,list):
|
||
ignored_topics=[]
|
||
return {
|
||
'acknowledged':[str(x).strip() for x in acknowledged if str(x).strip()][:250],
|
||
'ignored_sources':[str(x).strip().lower() for x in ignored_sources if str(x).strip()][:100],
|
||
'ignored_topics':[str(x).strip().lower() for x in ignored_topics if str(x).strip()][:100],
|
||
}
|
||
|
||
def load_dashboard_feedback()->list[dict[str,Any]]:
|
||
items=load_json(DIGEST_FEEDBACK_PATH,[])
|
||
return items if isinstance(items,list) else []
|
||
|
||
def lead_feedback_blocks(item:dict[str,Any], feedback:list[dict[str,Any]])->bool:
|
||
source=str(item.get('source') or '').strip().lower()
|
||
category=str(item.get('category') or '').strip().lower()
|
||
domain=(urlparse(str(item.get('url') or '')).netloc or '').lower().removeprefix('www.')
|
||
for entry in feedback:
|
||
if str(entry.get('vote') or '').strip().lower() != 'down':
|
||
continue
|
||
term=str(entry.get('term') or '').strip().lower()
|
||
if term == f'source:{source}' and source:
|
||
return True
|
||
if term == f'category:{category}' and category:
|
||
return True
|
||
if term == f'domain:{domain}' and domain:
|
||
return True
|
||
return False
|
||
|
||
def good_lead_candidate(item:dict[str,Any], prefs:dict[str,Any], feedback:list[dict[str,Any]], ignored_sources:set[str], ignored_topics:set[str])->bool:
|
||
if not item:
|
||
return False
|
||
source=str(item.get('source') or '').strip().lower()
|
||
category=str(item.get('category') or '').strip().lower()
|
||
domain=(urlparse(str(item.get('url') or '')).netloc or '').lower().removeprefix('www.')
|
||
if source in ignored_sources or category in ignored_topics:
|
||
return False
|
||
if lead_feedback_blocks(item, feedback):
|
||
return False
|
||
if domain in {'reddit.com','news.ycombinator.com'}:
|
||
return False
|
||
score=score_item(item,prefs)
|
||
quiet={str(x).strip().lower() for x in prefs.get('quiet_categories',[])}
|
||
if category in quiet and score < 3:
|
||
return False
|
||
if category not in {'local','homelab','smart_home','radio','astronomy','us','world','tech','security'}:
|
||
return False
|
||
return score >= 5
|
||
|
||
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=[]
|
||
base_url=str(sec.get('instance') or '').rstrip('/')
|
||
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'),
|
||
'url':(f"{base_url}/chores/{c.get('id')}" if base_url and c.get('id') else ''),
|
||
'_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 hermes_cron_summary()->dict[str,Any]:
|
||
data=load_json(HERMES_CRON_JOBS, {'jobs': []}) if HERMES_CRON_JOBS.exists() else {'jobs': []}
|
||
jobs=data.get('jobs') or []
|
||
enabled=[job for job in jobs if job.get('enabled')]
|
||
if not jobs:
|
||
return {
|
||
'headline':'No cron jobs configured',
|
||
'body':'Hermes has no scheduled jobs on this box yet.',
|
||
'state':'attention',
|
||
}
|
||
now=datetime.now(timezone.utc)
|
||
failing=[job for job in enabled if str(job.get('last_status') or '').lower() not in {'', 'ok', 'scheduled'}]
|
||
stale=[]
|
||
for job in enabled:
|
||
next_run=parse_dt(str(job.get('next_run_at') or ''))
|
||
if next_run and next_run < (now - timedelta(minutes=20)):
|
||
stale.append(job)
|
||
group_jobs=[job for job in enabled if str(job.get('deliver') or '') == CRON_GROUP_TARGET]
|
||
dm_jobs=[job for job in enabled if str(job.get('deliver') or '') in {'telegram:John','origin','telegram:8568847571'}]
|
||
if failing:
|
||
headline=f"{len(failing)} cron failure{'s' if len(failing)!=1 else ''}"
|
||
sample=', '.join(str(job.get('name') or 'unnamed') for job in failing[:2])
|
||
body=f"Needs attention: {sample}. {len(group_jobs)} routine to group · {len(dm_jobs)} critical to DM"
|
||
state='warn'
|
||
elif stale:
|
||
headline=f"{len(stale)} cron job{'s' if len(stale)!=1 else ''} look stale"
|
||
sample=', '.join(str(job.get('name') or 'unnamed') for job in stale[:2])
|
||
body=f"Past next run: {sample}. {len(group_jobs)} routine to group · {len(dm_jobs)} critical to DM"
|
||
state='attention'
|
||
else:
|
||
headline=f"{len(enabled)} job{'s' if len(enabled)!=1 else ''} healthy"
|
||
body=f"{len(group_jobs)} routine to group · {len(dm_jobs)} critical to DM · {len(enabled)-len(group_jobs)-len(dm_jobs)} local/other"
|
||
state='ok'
|
||
return {
|
||
'headline':headline,
|
||
'body':body,
|
||
'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 id='{esc(key)}' 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_task_anchor(task:dict[str,Any])->str:
|
||
task_id=str(task.get('id') or '').strip()
|
||
return f"donetick-task-{task_id}" if task_id else ''
|
||
|
||
|
||
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||
if not items:
|
||
return f"<section id='donetick-panel' 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()
|
||
task_url=str(x.get('url') or '')
|
||
anchor_id=donetick_task_anchor(x)
|
||
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 ''
|
||
click_attrs=f" data-task-url='{esc(task_url)}' tabindex='0' role='link'" if task_url else ''
|
||
anchor_attr=f" id='{esc(anchor_id)}'" if anchor_id else ''
|
||
rows.append(f"<article class='task-card {state}' data-task-id='{esc(x.get('id') or '')}'{anchor_attr}{click_attrs}><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 id='donetick-panel' 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));
|
||
});
|
||
});
|
||
const openHashTarget=()=>{
|
||
const hash=(window.location.hash||'').trim();
|
||
if(!hash || hash==='#') return;
|
||
const target=document.querySelector(hash);
|
||
if(!target) return;
|
||
let node=target;
|
||
while(node){
|
||
if(node.tagName==='DETAILS') node.open=true;
|
||
node=node.parentElement;
|
||
}
|
||
window.setTimeout(()=>target.scrollIntoView({behavior:'smooth', block:'start'}), 30);
|
||
};
|
||
window.addEventListener('hashchange', openHashTarget);
|
||
window.setTimeout(openHashTarget, 30);
|
||
const openTaskCard=(card,newTab=false)=>{
|
||
if(!card) return;
|
||
const href=(card.dataset.taskUrl||'').trim();
|
||
if(!href) return;
|
||
if(newTab) window.open(href,'_blank','noopener,noreferrer');
|
||
else window.open(href,'_blank','noopener,noreferrer');
|
||
};
|
||
document.addEventListener('click',(ev)=>{
|
||
const ignored=ev.target.closest('button,a,input,select,textarea,label');
|
||
if(ignored) return;
|
||
const taskCard=ev.target.closest('.task-card[data-task-url]');
|
||
if(!taskCard) return;
|
||
openTaskCard(taskCard, ev.metaKey || ev.ctrlKey || ev.button===1);
|
||
});
|
||
document.addEventListener('keydown',(ev)=>{
|
||
const taskCard=ev.target.closest('.task-card[data-task-url]');
|
||
if(!taskCard) return;
|
||
if(ev.key!=='Enter' && ev.key!==' ') return;
|
||
ev.preventDefault();
|
||
openTaskCard(taskCard, ev.metaKey || ev.ctrlKey);
|
||
});
|
||
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;});
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const btn=ev.target.closest('[data-lead-warrant-ack]');
|
||
if(!btn) return;
|
||
const key=(btn.dataset.leadKey||btn.closest('[data-lead-warrant-card]')?.dataset.leadKey||'').trim();
|
||
if(!key) return;
|
||
btn.disabled=true;
|
||
const previous=btn.textContent;
|
||
btn.textContent='Acknowledging…';
|
||
postJson('/api/lead-warrant-ack',{key}).then(()=>{
|
||
setToast('Lead warrant acknowledged. Pulling the next lead…');
|
||
window.setTimeout(()=>window.location.reload(),350);
|
||
}).catch(err=>{
|
||
console.warn('lead warrant ack failed',err);
|
||
btn.disabled=false;
|
||
btn.textContent=previous || 'Acknowledge + next lead';
|
||
setToast('Lead warrant refresh failed. Try again in a moment.');
|
||
});
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const btn=ev.target.closest('[data-lead-warrant-ignore]');
|
||
if(!btn) return;
|
||
const card=btn.closest('[data-lead-warrant-card]');
|
||
const mode=(btn.dataset.ignoreMode||'').trim();
|
||
const key=(btn.dataset.leadKey||card?.dataset.leadKey||'').trim();
|
||
const topic=(btn.dataset.leadTopic||card?.dataset.leadTopic||'').trim();
|
||
const source=(btn.dataset.leadSource||card?.dataset.leadSource||'').trim();
|
||
if(!key || !mode) return;
|
||
btn.disabled=true;
|
||
const previous=btn.textContent;
|
||
btn.textContent=mode==='topic' ? 'Ignoring topic…' : 'Ignoring source…';
|
||
postJson('/api/lead-warrant-ignore',{key,mode,topic,source}).then(()=>{
|
||
setToast(mode==='topic' ? 'Topic ignored for lead rotation. Pulling a different lead…' : 'Source ignored for lead rotation. Pulling a different lead…');
|
||
window.setTimeout(()=>window.location.reload(),350);
|
||
}).catch(err=>{
|
||
console.warn('lead warrant ignore failed',err);
|
||
btn.disabled=false;
|
||
btn.textContent=previous || (mode==='topic' ? 'Ignore topic' : 'Ignore source');
|
||
setToast('Lead warrant ignore failed. Try again in a moment.');
|
||
});
|
||
});
|
||
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='',href:str='',target: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 ''
|
||
card_html=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>"
|
||
if not href:
|
||
return card_html
|
||
target_attr=f" target='{esc(target)}' rel='noreferrer'" if target else ''
|
||
return f"<a class='focus-card-link' href='{esc(href)}'{target_attr}>{card_html}</a>"
|
||
|
||
|
||
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], excluded_story_keys:set[str] | None=None)->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']
|
||
excluded_story_keys=excluded_story_keys or set()
|
||
local_story=next((item for item in local_items if story_key(item) not in excluded_story_keys), None)
|
||
cron_summary=hermes_cron_summary()
|
||
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]
|
||
first_urgent_anchor=donetick_task_anchor(first_urgent)
|
||
chores_href=f"#{first_urgent_anchor}" if first_urgent_anchor else '#donetick-panel'
|
||
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.'
|
||
chores_href='#donetick-panel'
|
||
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'
|
||
local_headline=str(local_story.get('title') or '') if local_story else ('Local lane already covered' if local_items else 'Local lane quiet')
|
||
local_body=(
|
||
f"{local_story.get('source','Local feed')} • {label(local_story.get('category','local'))}"
|
||
if local_story else
|
||
('The strongest local item is already promoted above, so this slot stays out of the way.' if local_items else 'Nothing local survived filtering hard enough to earn the homepage.')
|
||
)
|
||
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'
|
||
cron_state=str(cron_summary.get('state') or 'attention')
|
||
cards=[
|
||
focus_card(
|
||
'Local watch',
|
||
'Useful nearby',
|
||
local_headline,
|
||
local_body,
|
||
f'local {local_state}'.strip(),
|
||
href=str(local_story.get('url') or '') if local_story else '' ,
|
||
target='_blank' if local_story and local_story.get('url') else ''
|
||
),
|
||
focus_card(
|
||
'Chores with heat',
|
||
'Donetick',
|
||
chores_headline,
|
||
chores_body,
|
||
chores_state,
|
||
href=chores_href
|
||
),
|
||
focus_card(
|
||
'Hermes now',
|
||
'Usage / budget',
|
||
hermes_headline,
|
||
hermes_body,
|
||
hermes_state,
|
||
href='#hermes-usage'
|
||
),
|
||
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,
|
||
href='#paperless-review'
|
||
),
|
||
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,
|
||
href='#homelab-pulse'
|
||
),
|
||
focus_card(
|
||
'Automation lane',
|
||
'Hermes cron',
|
||
str(cron_summary.get('headline') or 'Cron summary unavailable'),
|
||
str(cron_summary.get('body') or 'Could not read cron job state.'),
|
||
cron_state,
|
||
href='services.html'
|
||
),
|
||
]
|
||
section_state=combine_states([local_state,chores_state,hermes_state,paperless_state,homelab_state,cron_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(preferred_items:list[dict[str,Any]], fallback_items:list[dict[str,Any]], prefs:dict[str,Any])->dict[str,Any] | None:
|
||
state=load_lead_warrant_state()
|
||
feedback=load_dashboard_feedback()
|
||
acknowledged=set(state.get('acknowledged',[]))
|
||
ignored_sources=set(state.get('ignored_sources',[]))
|
||
ignored_topics=set(state.get('ignored_topics',[]))
|
||
candidates=[]
|
||
fallback=[]
|
||
seen=set()
|
||
for pool_name,pool in (('preferred',preferred_items),('fallback',fallback_items)):
|
||
for item in pool:
|
||
key=story_key(item)
|
||
dedupe=key or f"{str(item.get('source') or '').strip().lower()}::{str(item.get('title') or '').strip().lower()}"
|
||
if dedupe in seen:
|
||
continue
|
||
seen.add(dedupe)
|
||
fallback.append(item)
|
||
if good_lead_candidate(item,prefs,feedback,ignored_sources,ignored_topics):
|
||
candidates.append(item)
|
||
for item in candidates:
|
||
if story_key(item) not in acknowledged:
|
||
return item
|
||
if candidates:
|
||
return candidates[0]
|
||
for item in fallback:
|
||
if story_key(item) not in acknowledged:
|
||
return item
|
||
if fallback:
|
||
return fallback[0]
|
||
return None
|
||
|
||
|
||
def choose_secondary_feature_story(preferred_items:list[dict[str,Any]], fallback_items:list[dict[str,Any]], primary_story:dict[str,Any] | None, prefs:dict[str,Any])->dict[str,Any] | None:
|
||
primary_key=story_key(primary_story) if primary_story else ''
|
||
state=load_lead_warrant_state()
|
||
feedback=load_dashboard_feedback()
|
||
acknowledged=set(state.get('acknowledged',[]))
|
||
ignored_sources=set(state.get('ignored_sources',[]))
|
||
ignored_topics=set(state.get('ignored_topics',[]))
|
||
candidates=[]
|
||
fallback=[]
|
||
seen=set()
|
||
for pool_name,pool in (('preferred',preferred_items),('fallback',fallback_items)):
|
||
for item in pool:
|
||
key=story_key(item)
|
||
dedupe=key or f"{str(item.get('source') or '').strip().lower()}::{str(item.get('title') or '').strip().lower()}"
|
||
if dedupe in seen or (primary_key and key == primary_key):
|
||
continue
|
||
seen.add(dedupe)
|
||
fallback.append(item)
|
||
if good_lead_candidate(item,prefs,feedback,ignored_sources,ignored_topics):
|
||
candidates.append(item)
|
||
for item in candidates:
|
||
if story_key(item) not in acknowledged:
|
||
return item
|
||
if candidates:
|
||
return candidates[0]
|
||
for item in fallback:
|
||
if story_key(item) not in acknowledged:
|
||
return item
|
||
if fallback:
|
||
return fallback[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='Next local signal' if category=='local' else ('Next homelab signal' if category in {'homelab','smart_home','radio'} else 'Next 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'>Secondary signal so the warrant and the next-click card are not the same story.</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</section>"""
|
||
|
||
|
||
def build_hero_caseboard(feature_item:dict[str,Any] | None, todos:list[dict[str,Any]], paperless:dict[str,Any])->str:
|
||
lead_title='Lead signal pending'
|
||
lead_summary='No strong lead item surfaced yet, so the board is standing by for the next worthwhile signal.'
|
||
lead_meta=['Signals desk']
|
||
lead_url=''
|
||
lead_source=''
|
||
lead_topic=''
|
||
if feature_item:
|
||
lead_title=str(feature_item.get('title') or lead_title)
|
||
lead_summary=((feature_item.get('summary') or '').strip() or str(why(feature_item) or lead_summary))[:220]
|
||
lead_meta=[label(str(feature_item.get('category') or 'other')), str(feature_item.get('source') or 'Source')]
|
||
lead_url=str(feature_item.get('url') or '')
|
||
lead_source=str(feature_item.get('source') or '')
|
||
lead_topic=str(feature_item.get('category') or '')
|
||
lead_key=story_key(feature_item)
|
||
|
||
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
|
||
next_task=urgent_todos[0] if urgent_todos else (todos[0] if todos else {})
|
||
task_title=str(next_task.get('title') or 'No urgent chores')
|
||
task_meta=' · '.join(x for x in [str(next_task.get('due_title') or ''), str(next_task.get('assignee') or '')] if x) or 'Donetick is not yelling at you right now.'
|
||
task_body=(str(next_task.get('subtitle') or '') or str(next_task.get('body') or '') or 'No immediate field action queued.')[:140]
|
||
paperless_headline=(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')
|
||
paperless_body=str(paperless.get('headline') or 'Paperless queue is clear.')[:140]
|
||
lead_meta_html=''.join(f"<span>{esc(part)}</span>" for part in lead_meta if part)
|
||
lead_cta=(f"<a href='{esc(lead_url)}' target='_blank' rel='noreferrer' class='hero-dossier-link'>Open lead item</a>" if lead_url else "<span class='hero-dossier-link disabled'>Awaiting live lead</span>")
|
||
lead_ack=(f"<button type='button' class='paperless-btn hero-dossier-btn' data-lead-warrant-ack data-lead-key='{esc(lead_key)}'>Acknowledge + next lead</button>" if lead_key else "<button type='button' class='paperless-btn hero-dossier-btn' disabled>No live lead yet</button>")
|
||
lead_ignore_topic=(f"<button type='button' class='paperless-btn hero-dossier-btn' data-lead-warrant-ignore data-ignore-mode='topic' data-lead-key='{esc(lead_key)}' data-lead-topic='{esc(lead_topic)}'>Ignore topic</button>" if lead_key and lead_topic else '')
|
||
lead_ignore_source=(f"<button type='button' class='paperless-btn hero-dossier-btn' data-lead-warrant-ignore data-ignore-mode='source' data-lead-key='{esc(lead_key)}' data-lead-source='{esc(lead_source)}'>Ignore source</button>" if lead_key and lead_source else '')
|
||
return f"""
|
||
<section class='hero-board dossier-grid'>
|
||
<div class='hero-poster-stack'>
|
||
{hot_fuzz_art('operator brief')}
|
||
<div class='brand-badges hero-badges'><span class='pill badge-hotfuzz for-the-greater-good'>For the Greater Good</span><span class='pill'>Evidence locker: operator brief</span></div>
|
||
<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-dossier-stack'>
|
||
<article class='hero-dossier-card lead-warrant evidence-board' data-lead-warrant-card data-lead-key='{esc(lead_key)}' data-lead-topic='{esc(lead_topic)}' data-lead-source='{esc(lead_source)}'>
|
||
<div class='case-legend'><span class='eyebrow'>Lead warrant</span><span class='hero-chip'>Operator brief</span></div>
|
||
<h2>{esc(lead_title)}</h2>
|
||
<p>{esc(lead_summary)}</p>
|
||
<div class='hero-dossier-meta'>{lead_meta_html}</div>
|
||
<div class='hero-dossier-actions'>{lead_cta}{lead_ack}{lead_ignore_topic}{lead_ignore_source}</div>
|
||
</article>
|
||
<div class='hero-slip-grid'>
|
||
<article class='hero-dossier-card evidence-board'>
|
||
<span class='hero-slip-label'>Next field action</span>
|
||
<strong>{esc(task_title)}</strong>
|
||
<p>{esc(task_meta)}</p>
|
||
<small>{esc(task_body)}</small>
|
||
</article>
|
||
<article class='hero-dossier-card evidence-board'>
|
||
<span class='hero-slip-label'>Paperless queue</span>
|
||
<strong>{esc(paperless_headline)}</strong>
|
||
<p>{esc(paperless_body)}</p>
|
||
<small>Keep the desk clear before it turns into a paperwork siege.</small>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</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))
|
||
detail=str(e).strip()
|
||
suffix=f' · {detail}' if detail else ''
|
||
return {'state':'warn','badge':'Down','detail':f'tcp {int(tcp_port)} {type(e).__name__} · {elapsed} ms{suffix}'}
|
||
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))
|
||
detail=str(e).strip()
|
||
suffix=f' · {detail}' if detail else ''
|
||
return {'state':'warn','badge':'Down','detail':f'{type(e).__name__} · {elapsed} ms{suffix}'}
|
||
|
||
|
||
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)->str:
|
||
items=[
|
||
('Home','index.html','home'),
|
||
('Services','services.html','services'),
|
||
('Donetick','https://donetick.paccoco.com','donetick'),
|
||
('Paperless','https://paperless.paccoco.com','paperless'),
|
||
('n8n','https://n8n.paccoco.com','n8n'),
|
||
('Grafana','https://grafana.paccoco.com','grafana'),
|
||
('Gitea','https://gitea.paccoco.com','gitea'),
|
||
('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'),
|
||
]
|
||
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'>N.W.A. Case File</span><strong>Doris Constabulary</strong></div><div class='nav-links'>" + ''.join(links) + "</div></nav>"
|
||
|
||
|
||
def hot_fuzz_art(label:str)->str:
|
||
safe_label=esc(label.upper())
|
||
return f"""<div class='hot-fuzz-art' aria-hidden='true'><div class='film-grain'></div><div class='cinematic-glow'></div><div class='casefile-stamp operator-evidence-tag'>Operator Evidence Board</div><svg viewBox='0 0 320 220' class='poster-illustration' role='presentation'><defs><linearGradient id='dashboard-sunset' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='#f2c14e'></stop><stop offset='50%' stop-color='#d53600'></stop><stop offset='100%' stop-color='#910f3f'></stop></linearGradient></defs><rect x='8' y='8' width='304' height='204' rx='24' class='poster-frame'></rect><circle cx='228' cy='72' r='58' class='poster-halo'></circle><path d='M18 170 L112 100 L134 124 L40 198 Z' class='poster-tape tape-left'></path><path d='M202 82 L302 32 L314 60 L214 112 Z' class='poster-tape tape-right'></path><path d='M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z' class='constable-silhouette lead'></path><path d='M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z' class='constable-silhouette partner'></path><circle cx='84' cy='60' r='28' class='swan-stamp'></circle><path d='M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z' class='swan-mark'></path><path d='M126 82 h68 v14 h-68z' class='dossier-strip'></path><path d='M126 104 h82 v10 h-82z' class='dossier-strip'></path><path d='M126 122 h54 v10 h-54z' class='dossier-strip'></path><text x='160' y='204' class='poster-callout'>{safe_label}</text></svg></div>"""
|
||
|
||
|
||
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><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('services')}<header class='hero casefile-header evidence-board'><div><p class='eyebrow'>Sandford services desk</p><div class='case-legend'><h1>Services Directory</h1><span class='hero-chip'>Operator board</span></div><p class='sub'>Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}</p>{hot_fuzz_art('services')}<div class='brand-badges'><span class='pill badge-hotfuzz for-the-greater-good'>For the Greater Good</span><span class='pill'>Evidence locker: services</span></div></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 dossier-grid'><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)
|
||
feature_item=choose_feature_story(top_briefing, selected, prefs)
|
||
secondary_feature_item=choose_secondary_feature_story(top_briefing, selected, feature_item, prefs)
|
||
excluded_focus_story_keys={key for key in [story_key(feature_item), story_key(secondary_feature_item)] if key}
|
||
focus_html,focus_state=build_focus_cards(groups['local'],todos,pulse,paperless,hermes,excluded_story_keys=excluded_focus_story_keys)
|
||
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)
|
||
hero_caseboard_html=build_hero_caseboard(feature_item,todos,paperless)
|
||
feature_html=build_feature_story(secondary_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)}'><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('home')}
|
||
<header class='hero casefile-header evidence-board'><div class='hero-main'><p class='eyebrow'>Sandford operator desk</p><div class='case-legend'><h1>Doris Dashboard</h1><span class='hero-chip'>Operator board</span></div><div class='hero-title-row'><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>{hero_caseboard_html}</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 evidence-board'><div class='section-heading-inline case-legend'><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 dossier-grid'><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())
|