From 6d033eaaca0e7e3c43836919caf2bea1e8ae4bb6 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Thu, 21 May 2026 04:40:16 +0000 Subject: [PATCH] feat: expand Doris homelab services directory --- home/doris-dashboard/.gitignore | 2 + .../doris-dashboard/bin/generate_dashboard.py | 228 ++++++++++++++++-- home/doris-dashboard/public/style.css | 195 ++++++++++++++- 3 files changed, 408 insertions(+), 17 deletions(-) diff --git a/home/doris-dashboard/.gitignore b/home/doris-dashboard/.gitignore index 0fde4f2..ac5945f 100644 --- a/home/doris-dashboard/.gitignore +++ b/home/doris-dashboard/.gitignore @@ -9,6 +9,8 @@ data/homelab.json data/sky.json data/todos.json data/weather.json +data/wind_alert.json data/paperless_review.json data/paperless_review_state.json data/hermes_usage.json +public/services.html diff --git a/home/doris-dashboard/bin/generate_dashboard.py b/home/doris-dashboard/bin/generate_dashboard.py index 182297d..96529e9 100755 --- a/home/doris-dashboard/bin/generate_dashboard.py +++ b/home/doris-dashboard/bin/generate_dashboard.py @@ -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 ("
" + ''.join(cards) + "
", 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""" +
+
+
{esc(feature_label)}{esc(source)}{esc(when)} UTC
+
+
+ {esc(label(category))} +

{esc(title)}

+

{esc(summary)}

+
Why it made lead: {esc(why_text)}
+
+
+ Open story + Promoted so you get one strong item instead of four equal tiles. +
+
+
+
""" + + +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 "
" + ''.join(metrics) + "
" + + +def render_hermes_trend_card(hermes:dict[str,Any])->str: + buckets=hermes.get('daily_tokens') or [] + if not buckets: + return "

Usage trend

No 7-day history available yet.

" + 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"
{esc(compact_number(tokens))}
{esc(item.get('label') or '')}
") + 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"

Usage trend

{''.join(bars)}

{esc(summary)}

" + + 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:

Operator read

+ {render_hermes_trend_card(hermes)}

Model mix

@@ -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"
{pulse_metrics}
",'homelab-pulse',open_by_default=False), ]) return f"""Doris Dashboard
{top_nav('home')} -

PC Doris Thatcher

Doris Dashboard

{esc(hero_summary)}

Hidden stories: 0
{esc(now.strftime('%a %b %-d, %-I:%M %p'))}Central time · refreshes every 30m
{len(candidates)}Candidates
{len(set(i.get('source','') for i in candidates))}Sources
{esc(hero_tokens)}30-day Hermes tokens
+

PC Doris Thatcher

Doris Dashboard

Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '—')}24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—')} tokens

{esc(hero_summary)}

Hidden stories: 0
{esc(now.strftime('%a %b %-d, %-I:%M %p'))}Central time · refreshes every 30m
{len(candidates)}Candidates
{len(set(i.get('source','') for i in candidates))}Sources
{esc(hero_tokens)}30-day Hermes tokens
+

Today's operator snapshot

What matters before the scroll

Glanceable heat across chores, Hermes, and the lab.
{snapshot_html}
{primary_html}
{feedback_bootstrap()}""" diff --git a/home/doris-dashboard/public/style.css b/home/doris-dashboard/public/style.css index 6760c93..006e512 100644 --- a/home/doris-dashboard/public/style.css +++ b/home/doris-dashboard/public/style.css @@ -55,12 +55,47 @@ main{max-width:1380px;margin:0 auto;padding:18px} .hero{ display:grid; - grid-template-columns:minmax(0,1.25fr) 430px; + grid-template-columns:minmax(0,1.4fr) minmax(320px,.85fr); gap:14px; align-items:stretch; border-radius:24px; padding:16px 18px; - margin-bottom:16px; + margin-bottom:14px; +} + +.hero-main, +.hero-rail{min-width:0} + +.hero-main{ + display:flex; + flex-direction:column; + justify-content:space-between; + gap:10px; +} + +.hero-title-row{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:14px; + flex-wrap:wrap; +} + +.hero-chips{ + display:flex; + flex-wrap:wrap; + gap:8px; + justify-content:flex-end; +} + +.hero-chip{ + border:1px solid rgba(124,140,255,.28); + background:rgba(124,140,255,.10); + color:#d9deff; + border-radius:999px; + padding:7px 10px; + font-size:.8rem; + white-space:nowrap; } .eyebrow{ @@ -113,6 +148,30 @@ h1{ .status, .metrics{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:10px; +} + +.snapshot-block{ + margin:0 0 16px; +} + +.section-heading-inline{ + display:flex; + justify-content:space-between; + align-items:flex-end; + gap:12px; + margin:0 0 10px; + flex-wrap:wrap; +} + +.section-heading-inline h2{ + margin:2px 0 0; + font-size:1.08rem; +} + +.snapshot-strip{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; @@ -641,3 +700,135 @@ footer p{margin:.25rem 0} .nav-links{justify-content:flex-start} .services-grid{grid-template-columns:1fr} } + + +.feature-story{margin:0 0 2px} + +.feature-card{ + padding:16px; + border-radius:20px; + position:relative; + overflow:hidden; + background:linear-gradient(135deg,rgba(124,140,255,.18),rgba(79,209,165,.08)),rgba(17,21,29,.96); +} + +.feature-card::after{ + content:''; + position:absolute; + inset:auto -30px -30px auto; + width:180px; + height:180px; + background:radial-gradient(circle,rgba(124,140,255,.16),transparent 68%); + pointer-events:none; +} + +.feature-meta{ + display:flex; + gap:10px; + flex-wrap:wrap; + color:var(--muted); + font-size:.82rem; + margin-bottom:10px; +} + +.feature-kicker{ + display:inline-flex; + margin-bottom:8px; + color:#c7d0ff; + text-transform:uppercase; + letter-spacing:.14em; + font-size:.73rem; +} + +.feature-body{ + display:grid; + grid-template-columns:minmax(0,1.35fr) 230px; + gap:16px; + align-items:end; +} + +.feature-copy h2{ + margin:0 0 8px; + font-size:1.45rem; + line-height:1.08; +} + +.feature-copy p{ + margin:0; + color:#d7deea; + line-height:1.5; +} + +.feature-why{ + margin-top:12px; + color:var(--muted); + font-size:.86rem; +} + +.feature-actions{ + display:flex; + flex-direction:column; + gap:10px; + align-items:flex-start; +} + +.feature-open{ + display:inline-flex; + align-items:center; + justify-content:center; + text-decoration:none; + border-radius:999px; + padding:10px 14px; + background:#eef2ff; + color:#0f172a; + font-weight:700; +} + +.feature-note{ + color:var(--muted); + font-size:.83rem; + line-height:1.4; +} + +.trend-chart{ + display:flex; + align-items:flex-end; + gap:8px; + min-height:120px; + margin:8px 0 10px; +} + +.trend-bar-wrap{ + flex:1; + min-width:0; + display:flex; + flex-direction:column; + align-items:center; + justify-content:flex-end; + gap:6px; +} + +.trend-value, +.trend-label{ + font-size:.73rem; + color:var(--muted); +} + +.trend-bar{ + width:100%; + max-width:28px; + border-radius:999px 999px 10px 10px; + background:linear-gradient(180deg,#8ea0ff 0%,#4fd1a5 100%); + box-shadow:0 8px 20px rgba(79,209,165,.18); +} + +@media (max-width:980px){ + .focus-grid,.summary-grid,.forecast-grid,.metrics.metrics-compact,.snapshot-strip{grid-template-columns:repeat(2,minmax(0,1fr))} + .feature-body{grid-template-columns:1fr} +} + +@media (max-width:920px){ + .hero-title-row,.section-heading-inline{align-items:flex-start} + .hero-chips{justify-content:flex-start} + .snapshot-strip{grid-template-columns:1fr} +}