feat: expand Doris homelab services directory

This commit is contained in:
Fizzlepoof
2026-05-21 04:40:16 +00:00
parent c149b1acf8
commit 6d033eaaca
3 changed files with 408 additions and 17 deletions

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib, html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
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
@@ -35,37 +35,100 @@ SERVICE_DIRECTORY = [
'items': [
{'name': 'Doris Dashboard', 'url': 'index.html', 'health_url': 'http://127.0.0.1:8787/', 'purpose': 'Morning briefing, alerts, and operator homepage.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
{'name': 'Services Directory', 'url': 'services.html', 'health_url': 'http://127.0.0.1:8787/services.html', 'purpose': 'Front door into the rest of the stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
{'name': 'Homepage', 'url': 'http://10.5.1.6:3300', 'health_url': 'http://10.5.1.6:3300', 'purpose': 'Legacy PD portal and bookmarks page for the broader stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3300'},
],
},
{
'group': 'Shared Datastores',
'items': [
{'name': 'PostgreSQL', 'url': '', 'purpose': 'Primary shared Postgres backing Paperless, n8n, and other stateful stacks.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '5432/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 5432, 'badge': 'Healthy'},
{'name': 'MariaDB', 'url': '', 'purpose': 'Shared MariaDB service for stacks that still need MySQL-compatible storage.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '3306/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 3306, 'badge': 'Healthy'},
{'name': 'Redis', 'url': '', 'purpose': 'Shared cache and queue backend used across the homelab.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '6379/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 6379, 'badge': 'Healthy'},
],
},
{
'group': 'Productivity / Workflow',
'items': [
{'name': 'Donetick', 'url': 'https://donetick.paccoco.com', 'purpose': 'Chores, recurring work, and household task coordination.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '2021'},
{'name': 'Paperless', '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', 'purpose': 'Automation bus for webhooks, alerts, and cross-service glue.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '5678'},
{'name': 'Doris Schoolhouse', 'url': 'https://schoolhouse.paccoco.com', 'purpose': 'Class workflows, uploads, recordings, and school intake.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8091'},
{'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 API', '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': '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.16:6333/dashboard', 'health_url': 'http://10.5.1.16:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '6333/6334'},
{'name': 'Whisper API', 'url': 'http://10.5.1.16:8786/docs', 'health_url': 'http://10.5.1.16:8786/health', 'purpose': 'Fast local transcription endpoint for Schoolhouse and ad-hoc uploads.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'},
{'name': 'Ollama (NOMAD)', 'url': 'http://10.5.1.16:11434/api/tags', 'health_url': 'http://10.5.1.16:11434/api/tags', 'purpose': 'Project NOMAD local model runtime and admin surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '11434'},
{'name': 'Qdrant', 'url': 'http://10.5.1.6:6333/dashboard', 'health_url': 'http://10.5.1.6:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '6333/6334'},
{'name': 'Whisper (CPU)', 'url': 'http://10.5.1.16:8786/docs', 'health_url': 'http://10.5.1.16:8786/health', 'purpose': 'Fast local CPU transcription endpoint on NOMAD.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'},
{'name': 'Whisper (CUDA)', 'url': 'http://10.5.1.112:8787/docs', 'health_url': 'http://10.5.1.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'},
{'name': 'OpenClaw', 'url': 'http://10.5.1.6:18789', 'health_url': 'http://10.5.1.6:18789', 'purpose': 'Legacy local helper/web surface documented in the AI stack.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '18789'},
{'name': 'SearXNG', 'url': 'http://10.5.1.6:8888', 'health_url': 'http://10.5.1.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'},
{'name': 'Ollama — light tier', 'url': 'http://10.5.1.6:11434/api/tags', 'health_url': 'http://10.5.1.6:11434/api/tags', 'purpose': 'PD light-tier Ollama runtime backing cheaper/default model paths.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '11434'},
{'name': 'Ollama — heavy tier', 'url': 'http://10.5.1.112:11434/api/tags', 'health_url': 'http://10.5.1.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'},
{'name': 'Reranker (TEI)', 'url': 'http://10.5.1.5:9787', 'health_url': 'http://10.5.1.5:9787', 'purpose': 'Serenity-hosted reranker used by retrieval flows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'SERENITY', 'port': '9787'},
],
},
{
'group': 'Homelab / Control Plane',
'items': [
{'name': 'Grafana', 'url': 'https://grafana.paccoco.com', 'purpose': 'Metrics, dashboards, and alert drill-down.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3000'},
{'name': 'Gitea', 'url': 'https://gitea.paccoco.com', 'purpose': 'Repos, issues, and source control.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3002 / 2222'},
{'name': 'Grafana', 'url': 'https://grafana.paccoco.com', 'health_url': 'https://grafana.paccoco.com', 'purpose': 'Metrics, dashboards, and alert drill-down.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3000'},
{'name': 'Prometheus', 'url': 'http://10.5.1.6:9090', 'health_url': 'http://10.5.1.6:9090/-/healthy', 'purpose': 'Metrics scrape and alert evaluation backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9090'},
{'name': 'Gitea', 'url': 'https://gitea.paccoco.com', 'health_url': 'https://gitea.paccoco.com', 'purpose': 'Repos, issues, and source control.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3002 / 2222'},
{'name': 'Dockhand', 'url': 'http://10.5.1.6:3230', 'health_url': 'http://10.5.1.6:3230', 'purpose': 'Container housekeeping and image/update operations.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3230'},
{'name': 'Uptime Kuma', 'url': 'http://10.5.1.6:3001', 'health_url': 'http://10.5.1.6:3001', 'purpose': 'Availability checks and service-watch status board.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3001'},
{'name': 'Gotify', 'url': 'https://gotify.paccoco.com', 'health_url': 'https://gotify.paccoco.com', 'purpose': 'Push notification hub for alerts and workflow fan-out.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8443'},
{'name': 'RackPeek', 'url': 'http://10.5.1.6:8283', 'health_url': 'http://10.5.1.6:8283', 'purpose': 'Rack/device inventory and operator reference panel.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8283'},
{'name': 'Shlink', 'url': 'http://10.5.1.6:8087', 'health_url': 'http://10.5.1.6:8087/rest/health', 'purpose': 'Short-link API and redirect backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8087'},
{'name': 'Shlink Web Client', 'url': 'http://10.5.1.6:8088', 'health_url': 'http://10.5.1.6:8088', 'purpose': 'Short-link management UI for the Shlink backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8088'},
{'name': 'Loki', 'url': '', 'purpose': 'Log aggregation backend for Grafana and the monitoring stack.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Grafana/compose when needed'},
{'name': 'Promtail', 'url': '', 'purpose': 'Log shipping agent feeding Loki from the homelab nodes.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Grafana/compose when needed'},
{'name': 'cAdvisor', 'url': '', 'purpose': 'Container metrics exporter feeding Prometheus.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Prometheus targets when needed'},
],
},
{
'group': 'Media / Library',
'items': [
{'name': 'Plex', 'url': 'http://10.5.1.6:32400/web/index.html', 'health_url': 'http://10.5.1.6:32400/identity', 'purpose': 'Primary media server, playback hub, and GPU-backed transcoding surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '32400'},
{'name': 'Tautulli', 'url': 'http://10.5.1.6:8181', 'health_url': 'http://10.5.1.6:8181', 'purpose': 'Plex activity analytics, user history, and watch-session drill-down.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8181'},
{'name': 'Audiobookshelf', 'url': 'http://10.5.1.6:13358', 'health_url': 'http://10.5.1.6:13358', 'purpose': 'Audiobook and podcast library with self-hosted playback and progress sync.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '13358'},
{'name': 'Calibre-Web', 'url': 'http://10.5.1.6:8183', 'health_url': 'http://10.5.1.6:8183', 'purpose': 'Ebook library browser and metadata management front-end.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8183'},
{'name': 'Seerr (Overseerr)', 'url': 'http://10.5.1.6:5055', 'health_url': 'http://10.5.1.6:5055', 'purpose': 'Request intake for media additions and library demand tracking.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '5055'},
],
},
{
'group': 'Smart Home / Access',
'items': [
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but Doris does not yet have a stable operator-safe route.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; local port currently refuses from NOMAD, so keep this manual until routed cleanly'},
{'name': 'Kima Hub', 'url': 'http://10.5.1.6:3333', 'health_url': 'http://10.5.1.6:3333', 'purpose': 'Custom IoT gateway and smart-home integration surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3333'},
{'name': 'Pi-hole VIP', 'url': 'http://10.5.1.53/ui/', 'health_url': 'http://10.5.1.53/admin/', 'purpose': 'Floating DNS/admin surface for the HA Pi-hole pair your clients should target.', 'exposure': 'LAN', 'state': 'ok', 'host': 'VIP', 'port': '53 / 80'},
{'name': 'Pi-hole PD', 'url': 'http://10.5.1.6/ui/', 'health_url': 'http://10.5.1.6/admin/', 'purpose': 'Primary PD Pi-hole node behind the floating DNS VIP.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '53 / 80'},
{'name': 'Pi-hole NOMAD', 'url': 'https://10.5.1.16/login', 'purpose': 'NOMAD backup Pi-hole replica for the HA DNS pair.', 'exposure': 'LAN', 'state': 'attention', 'host': 'NOMAD', 'port': '53 / 80/443', 'probe': False, 'badge': 'Manual', 'detail': 'documented active; local admin currently presents a self-signed TLS flow from NOMAD so Doris leaves this manual'},
{'name': 'Authelia', 'url': 'http://10.5.1.6:9091', 'health_url': 'http://10.5.1.6:9091', 'purpose': 'Identity and auth gate backing protected public routes.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9091'},
],
},
{
'group': 'Mapping / Radio / Mesh',
'items': [
{'name': 'MeshMonitor', 'url': 'https://meshmonitor.paccoco.com', 'health_url': 'http://10.5.1.6:8081', 'purpose': 'Meshtastic monitoring, node views, and map/radio telemetry.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8081'},
{'name': 'TileServer GL', 'url': 'http://10.5.1.6:8082', 'health_url': 'http://10.5.1.6:8082', 'purpose': 'Map tile serving for local geo and radio/mapping workflows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8082'},
{'name': 'MeshCore to MQTT relay', 'url': '', 'purpose': 'Containerized relay moving MeshCore data into MQTT for the radio stack.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'NOMAD', 'port': 'outbound MQTT only', 'probe': False, 'badge': 'Manual', 'detail': 'documented running as mctomqtt; verify via container/runtime logs when needed'},
],
},
{
'group': 'Game Servers',
'items': [
{'name': 'Pelican Panel', 'url': 'https://panel.paccoco.com', 'health_url': 'https://panel.paccoco.com', 'purpose': 'Game-server management panel for the NOMAD Wings stack.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '443'},
{'name': 'Wings Node API', 'url': 'https://node1.paccoco.com', 'health_url': 'https://node1.paccoco.com', 'purpose': 'Public-facing Wings node endpoint; 401 means the daemon is reachable and auth-protected.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8443 / 443'},
{'name': 'Minecraft Server 25565', 'url': '', 'purpose': 'First Wings-managed Java server on NOMAD.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '25565/tcp+udp', 'tcp_host': '10.5.1.16', 'tcp_port': 25565, 'badge': 'Healthy'},
{'name': 'Minecraft Server 25566', 'url': '', 'purpose': 'Second Wings-managed Java/NeoForge server on NOMAD.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '25566/tcp+udp', 'tcp_host': '10.5.1.16', 'tcp_port': 25566, 'badge': 'Healthy'},
],
},
{
@@ -74,7 +137,6 @@ SERVICE_DIRECTORY = [
{'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'},
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but no operator-safe route is exposed from NOMAD yet.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; add a stable route before probing from Doris'},
],
},
]
@@ -1085,6 +1147,7 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
'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,
@@ -1095,6 +1158,7 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
'recent_sessions':[],
'latest_session':None,
'account_windows':[],
'daily_tokens':[],
'generated_at':datetime.now(LOCAL_TZ).isoformat(),
}
if not HERMES_STATE_DB.exists():
@@ -1103,6 +1167,7 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
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)
@@ -1150,6 +1215,12 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
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
@@ -1191,6 +1262,28 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
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')
@@ -1306,6 +1399,88 @@ def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]]
return ("<section class='focus-grid'>" + ''.join(cards) + "</section>", section_state)
def hermes_delta_text(current:int|float|None, previous:int|float|None)->str:
try:
current_value=float(current or 0)
previous_value=float(previous or 0)
except Exception:
return 'No comparison window yet'
if previous_value <= 0 and current_value <= 0:
return 'No Hermes usage in either 24h window'
if previous_value <= 0:
return 'Up from a cold previous 24h window'
delta=((current_value-previous_value)/previous_value)*100.0
direction='up' if delta >= 0 else 'down'
return f"{abs(delta):.0f}% {direction} vs previous 24h"
def choose_feature_story(local_items:list[dict[str,Any]], homelab_items:list[dict[str,Any]], space_items:list[dict[str,Any]], signal_items:list[dict[str,Any]])->dict[str,Any] | None:
for pool in (local_items, homelab_items, space_items, signal_items):
if pool:
return pool[0]
return None
def build_feature_story(feature_item:dict[str,Any] | None)->str:
if not feature_item:
return ''
title=str(feature_item.get('title') or 'Lead item')
summary=(feature_item.get('summary') or '').strip()[:260]
source=str(feature_item.get('source') or 'Source')
when=(feature_item.get('published_at','')[:16].replace('T',' '))
category=str(feature_item.get('category') or 'other')
url=str(feature_item.get('url') or '')
feature_label='Lead local item' if category=='local' else ('Lead homelab item' if category in {'homelab','smart_home','radio'} else 'Lead signal')
why_text=why(feature_item)
return f"""
<section class='feature-story'>
<article class='feature-card {esc(category)}'>
<div class='feature-meta'><span>{esc(feature_label)}</span><span>{esc(source)}</span><span>{esc(when)} UTC</span></div>
<div class='feature-body'>
<div class='feature-copy'>
<span class='feature-kicker'>{esc(label(category))}</span>
<h2><a href='{esc(url)}' target='_blank' rel='noreferrer'>{esc(title)}</a></h2>
<p>{esc(summary)}</p>
<div class='feature-why'>Why it made lead: {esc(why_text)}</div>
</div>
<div class='feature-actions'>
<a href='{esc(url)}' target='_blank' rel='noreferrer' class='feature-open'>Open story</a>
<span class='feature-note'>Promoted so you get one strong item instead of four equal tiles.</span>
</div>
</div>
</article>
</section>"""
def build_operator_snapshot(todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
warn_items=[item for item in pulse if item.get('state')=='warn']
session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None
session_remaining=session_window.get('remaining_percent') if session_window else None
metrics=[
metric_card('Urgent queue', compact_number(len(urgent_todos)), 'Donetick chores due today / overdue', 'warn' if urgent_todos else 'ok'),
metric_card('Hermes burn', compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '', hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0)) if hermes.get('ok') else 'Telemetry unavailable', usage_window_state(session_remaining) if hermes.get('ok') else 'attention'),
metric_card('Session headroom', pct_text(session_remaining) if hermes.get('ok') else '', f"Resets {session_window.get('reset_local') or 'unknown'}" if session_window else 'No live budget window available', usage_window_state(session_remaining) if hermes.get('ok') else 'attention'),
metric_card('Operator inbox', f"{paperless.get('active_count',0)} docs / {len(warn_items)} lab warns", 'Paperless + homelab friction to clear next', 'warn' if paperless.get('flagged_count',0) or warn_items else ('attention' if paperless.get('active_count',0) else 'ok')),
]
return "<section class='snapshot-strip'>" + ''.join(metrics) + "</section>"
def render_hermes_trend_card(hermes:dict[str,Any])->str:
buckets=hermes.get('daily_tokens') or []
if not buckets:
return "<article class='summary-card'><h3>Usage trend</h3><p>No 7-day history available yet.</p></article>"
max_tokens=max((int(item.get('tokens') or 0) for item in buckets), default=0) or 1
peak=max(buckets, key=lambda item: int(item.get('tokens') or 0))
bars=[]
for item in buckets:
tokens=int(item.get('tokens') or 0)
height=max(14, round((tokens/max_tokens)*84)) if tokens else 10
bars.append(f"<div class='trend-bar-wrap'><span class='trend-value'>{esc(compact_number(tokens))}</span><div class='trend-bar' style='height:{height}px'></div><span class='trend-label'>{esc(item.get('label') or '')}</span></div>")
summary=f"Peak {peak.get('label')} at {compact_number(peak.get('tokens',0))} tokens; now {hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0))}."
return f"<article class='summary-card'><h3>Usage trend</h3><div class='trend-chart'>{''.join(bars)}</div><p>{esc(summary)}</p></article>"
def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
urgent_todos=len([item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'])
warn_count=len([item for item in pulse if item.get('state')=='warn'])
@@ -1393,6 +1568,7 @@ def build_hermes_section(hermes:dict[str,Any])->str:
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",
@@ -1403,6 +1579,7 @@ def build_hermes_section(hermes:dict[str,Any])->str:
<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>
@@ -1432,6 +1609,21 @@ def check_service_health(item:dict[str,Any])->dict[str,str]:
badge=str(item.get('badge') or ('Healthy' if state=='ok' else 'Manual'))
detail=str(item.get('detail') or 'manual operator check only')
return {'state':state,'badge':badge,'detail':detail}
tcp_host=str(item.get('tcp_host') or '').strip()
tcp_port=item.get('tcp_port')
if tcp_host and tcp_port:
started=time.time()
sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
try:
sock.connect((tcp_host, int(tcp_port)))
elapsed=max(1,int((time.time()-started)*1000))
return {'state':'ok','badge':str(item.get('badge') or 'Healthy'),'detail':f'tcp {int(tcp_port)} open · {elapsed} ms'}
except Exception as e:
elapsed=max(1,int((time.time()-started)*1000))
return {'state':'warn','badge':'Down','detail':f'tcp {int(tcp_port)} {type(e).__name__} · {elapsed} ms'}
finally:
sock.close()
target=str(item.get('health_url') or item.get('url') or '').strip()
if not target:
return {'state':'warn','badge':'Down','detail':'no health URL configured'}
@@ -1445,7 +1637,7 @@ def check_service_health(item:dict[str,Any])->dict[str,str]:
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:
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')
@@ -1598,8 +1790,13 @@ def render()->str:
hermes_html=build_hermes_section(hermes)
system_html=build_system_section(system)
hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else ''
snapshot_html=build_operator_snapshot(todos,pulse,paperless,hermes)
feature_item=choose_feature_story(groups['local'], groups['homelab'], groups['space'], top_briefing or groups['signals'])
feature_html=build_feature_story(feature_item)
session_window=find_usage_window(hermes.get('account_windows',[]),'session') if hermes.get('ok') else None
primary_html=''.join([
section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True),
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),
@@ -1618,7 +1815,8 @@ def render()->str:
collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics metrics-compact'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False),
])
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body data-donetick-focus-signature='{esc(focus_signature)}'><main>{top_nav('home')}
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div></div><div class='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></header>
<header class='hero'><div class='hero-main'><p class='eyebrow'>PC Doris Thatcher</p><div class='hero-title-row'><h1>Doris Dashboard</h1><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div></div><div class='hero-rail'><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></div></header>
<section class='snapshot-block'><div class='section-heading-inline'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section>
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""