From c149b1acf838fd78a76b06d5f6d3acab0cd6fbb7 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Thu, 21 May 2026 04:22:31 +0000 Subject: [PATCH] Expand Doris dashboard portal and services directory --- docs/planning/TODO.md | 1 + home/doris-dashboard/README.md | 1 + home/doris-dashboard/bin/dashboard_server.py | 157 ++++ .../doris-dashboard/bin/generate_dashboard.py | 822 ++++++++++++++++-- home/doris-dashboard/doris-dashboard.service | 1 + home/doris-dashboard/public/style.css | 171 +++- 6 files changed, 1067 insertions(+), 86 deletions(-) diff --git a/docs/planning/TODO.md b/docs/planning/TODO.md index 47b0827..4c57a37 100644 --- a/docs/planning/TODO.md +++ b/docs/planning/TODO.md @@ -16,6 +16,7 @@ - [x] Add Pangolin resource for `openwebui.paccoco.com` → OpenWebUI (port 8282) — completed 2026-05-15 - [x] Add Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15 - [ ] Install fresh editor on PD +- [ ] Add a stable Doris/Home Assistant route and convert the Services page card from manual to live-probed - [x] Set up private Gitea repo for .env file backups — 2026-05-14 ## AI Stack diff --git a/home/doris-dashboard/README.md b/home/doris-dashboard/README.md index 78bc1b4..cf0de8a 100644 --- a/home/doris-dashboard/README.md +++ b/home/doris-dashboard/README.md @@ -47,6 +47,7 @@ The dashboard turns that into one opinionated, local-only page that Doris can tu - It scores, deduplicates, and groups stories into dashboard sections. - It writes generated JSON artifacts under `data/` and the final static page under `public/`. - `bin/dashboard_server.py` serves the page and handles interactive actions like topic feedback and Paperless review state. +- The wind watchdog writes a quiet forecast notice into `~/.hermes/state/wind_alert_watchdog.json`; the dashboard surfaces that for longer-range wind events before they are close enough to deserve a direct ping. That means the dashboard is static-looking for the reader, but still has enough local interactivity for feedback and review flows. diff --git a/home/doris-dashboard/bin/dashboard_server.py b/home/doris-dashboard/bin/dashboard_server.py index 0e8b480..1a72020 100755 --- a/home/doris-dashboard/bin/dashboard_server.py +++ b/home/doris-dashboard/bin/dashboard_server.py @@ -3,18 +3,29 @@ from __future__ import annotations import json import os +import hashlib +import subprocess +import urllib.error +import urllib.parse +import urllib.request from functools import partial from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from datetime import datetime from pathlib import Path +from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parents[1] PUBLIC = ROOT / 'public' DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest')) DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json' PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json' +DONETICK_SECRET = Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc') +DONETICK_KEY = Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key') +GENERATOR = ROOT / 'bin' / 'generate_dashboard.py' HOST = os.environ.get('HOST', '0.0.0.0') PORT = int(os.environ.get('PORT', '8787')) +LOCAL_TZ = ZoneInfo('America/Chicago') def load_json(path: Path, default): @@ -29,10 +40,117 @@ def save_json(path: Path, value) -> None: path.write_text(json.dumps(value, indent=2) + '\n') +def decrypt_donetick() -> dict | None: + if not DONETICK_SECRET.exists() or not DONETICK_KEY.exists(): + return None + try: + res = subprocess.run([ + 'openssl','enc','-aes-256-cbc','-d','-pbkdf2', + '-in',str(DONETICK_SECRET), + '-k',DONETICK_KEY.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() -> tuple[dict[str, str] | None, dict | None]: + sec = decrypt_donetick() + if not sec: + return None, 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', + }, sec) + except Exception: + return None, sec + + +def refresh_dashboard() -> bool: + try: + subprocess.run(['python3', str(GENERATOR)], cwd=str(ROOT), timeout=60, check=True) + return True + except Exception: + return False + + +def parse_dt(value: str | None): + if not value: + return None + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except Exception: + return None + + +def due_state(due_raw: str | None) -> str: + dt = parse_dt(due_raw) + if not dt: + return 'nodue' + local = dt.astimezone(LOCAL_TZ) + now = datetime.now(LOCAL_TZ) + days = (local.date() - now.date()).days + if days < 0: + return 'overdue' + if days == 0: + return 'today' + if days == 1: + return 'tomorrow' + return 'upcoming' + + +def fetch_donetick_focus_signature() -> tuple[str, int]: + headers, sec = donetick_headers() + if not headers or not sec: + return 'unavailable', 0 + try: + req = urllib.request.Request(f"{sec['instance'].rstrip('/')}/api/v1/chores", headers=headers) + with urllib.request.urlopen(req, timeout=12) as r: + chores = json.loads(r.read() or b'{}').get('res', []) + urgent = [] + for chore in chores: + if chore.get('status') not in (0, None): + continue + state = due_state(chore.get('nextDueDate')) + if state not in {'overdue', 'today'}: + continue + urgent.append({ + 'id': str(chore.get('id') or ''), + 'name': str(chore.get('name') or ''), + 'due': str(chore.get('nextDueDate') or ''), + 'state': state, + }) + urgent.sort(key=lambda item: (item['due'] or '9999', item['name'].lower(), item['id'])) + payload = json.dumps(urgent, separators=(',', ':'), sort_keys=True) + return hashlib.sha1(payload.encode('utf-8')).hexdigest()[:16], len(urgent) + except Exception: + return 'error', 0 + + class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, directory=None, **kwargs): super().__init__(*args, directory=str(PUBLIC), **kwargs) + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + if parsed.path == '/api/donetick-focus-state': + return self._donetick_focus_state(parsed) + self.path = parsed.path + return super().do_GET() + def do_POST(self): length = int(self.headers.get('Content-Length', '0') or '0') raw = self.rfile.read(length) if length else b'{}' @@ -47,6 +165,8 @@ class Handler(SimpleHTTPRequestHandler): return self._topic_reset() if self.path == '/api/paperless-action': return self._paperless_action(payload) + if self.path == '/api/donetick-complete': + return self._donetick_complete(payload) return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'}) def _topic_feedback(self, payload: dict): @@ -89,6 +209,43 @@ class Handler(SimpleHTTPRequestHandler): save_json(PAPERLESS_STATE, state) return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status}) + def _donetick_complete(self, payload: dict): + item_id = str(payload.get('id') or payload.get('choreId') or '').strip() + if not item_id: + return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload', 'detail': 'expected id or choreId'}) + headers, sec = donetick_headers() + if not headers or not sec: + return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_auth_failed'}) + try: + req = urllib.request.Request( + f"{sec['instance'].rstrip('/')}/api/v1/chores/{item_id}/do", + data=b'{}', + headers=headers, + method='POST', + ) + with urllib.request.urlopen(req, timeout=15) as r: + response = json.loads(r.read() or b'{}') if (r.headers.get('Content-Type') or '').startswith('application/json') else {'status': r.status} + refreshed = refresh_dashboard() + return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'dashboard_refreshed': refreshed, 'response': response}) + except urllib.error.HTTPError as e: + detail = e.read().decode('utf-8', 'ignore')[:500] + return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_complete_failed', 'status': e.code, 'detail': detail}) + except Exception as e: + return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_complete_failed', 'detail': str(e)}) + + def _donetick_focus_state(self, parsed): + current = urllib.parse.parse_qs(parsed.query).get('current', [''])[0] + signature, urgent_count = fetch_donetick_focus_signature() + changed = bool(current and signature not in {'error', 'unavailable'} and current != signature) + refreshed = refresh_dashboard() if changed else False + return self._json(HTTPStatus.OK, { + 'ok': True, + 'signature': signature, + 'urgent_count': urgent_count, + 'changed': changed, + 'dashboard_refreshed': refreshed, + }) + def _json(self, status: HTTPStatus, payload: dict): body = json.dumps(payload).encode('utf-8') self.send_response(status) diff --git a/home/doris-dashboard/bin/generate_dashboard.py b/home/doris-dashboard/bin/generate_dashboard.py index dc5de62..182297d 100755 --- a/home/doris-dashboard/bin/generate_dashboard.py +++ b/home/doris-dashboard/bin/generate_dashboard.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 from __future__ import annotations -import html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml +import hashlib, html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml from collections import Counter -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlparse from zoneinfo import ZoneInfo @@ -18,6 +18,8 @@ LOCAL_TZ=ZoneInfo('America/Chicago') HERMES_HOME=Path.home()/'.hermes' HERMES_STATE_DB=HERMES_HOME/'state.db' HERMES_CONFIG=HERMES_HOME/'config.yaml' +DASHBOARD_JSON=DATA/'dashboard.json' +WIND_WATCHDOG_STATE_PATH=HERMES_HOME/'state'/'wind_alert_watchdog.json' LABELS={ 'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'), @@ -27,6 +29,59 @@ WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog 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'}, + ], + }, + { + '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': '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': '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': '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'}, + ], + }, + { + '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': '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': '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'}, + ], + }, + { + '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'}, + {'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'}, + ], + }, +] + + +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 @@ -64,6 +119,37 @@ def compact_number(value:int|float|None)->str: 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 @@ -100,12 +186,22 @@ 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=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3') + '&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 [] - 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'))),'source':'Open-Meteo'} + 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'} @@ -119,13 +215,27 @@ def decrypt_donetick()->dict[str,Any]|None: except Exception: return None -def fetch_donetick_todos()->list[dict[str,Any]]: +def donetick_headers()->dict[str,str]|None: sec=decrypt_donetick() - if not sec: return [] + 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'] - headers={'Authorization':'Bearer '+token,'Accept':'application/json'} + 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={} @@ -247,16 +357,62 @@ def run(cmd:list[str])->str: try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip() except Exception: return '' + +def dashboard_service_status()->dict[str,str]: + active=run(['systemctl','is-active','doris-dashboard.service']) + substate=run(['systemctl','show','-p','SubState','--value','doris-dashboard.service']) + pid=run(['systemctl','show','-p','ExecMainPID','--value','doris-dashboard.service']) + if active: + detail=active + if substate and substate != active: + detail=f'{active} ({substate})' + if active == 'active' and pid and pid != '0': + detail=f'{detail} · pid {pid}' + return {'value':detail,'state':'ok' if active == 'active' else 'warn'} + listener=run(['bash','-lc',"ss -ltn '( sport = :8787 )' | tail -n +2"]) + if listener: + return {'value':'web listener up on :8787','state':'ok'} + return {'value':'no systemd state and nothing listening on :8787','state':'warn'} + + +def dashboard_cron_status()->dict[str,str]: + info=load_json(DASHBOARD_JSON,{}) if DASHBOARD_JSON.exists() else {} + generated_raw=info.get('generated_at_local') or info.get('generated_at') or '' + generated=parse_dt(generated_raw) + if not generated and DASHBOARD_JSON.exists(): + try: + generated=datetime.fromtimestamp(DASHBOARD_JSON.stat().st_mtime, tz=timezone.utc).astimezone(LOCAL_TZ) + except Exception: + generated=None + if not generated: + return {'value':'no dashboard generation timestamp yet','state':'warn'} + local_generated=generated.astimezone(LOCAL_TZ) + now=datetime.now(LOCAL_TZ) + age_minutes=max(0,int((now-local_generated).total_seconds()//60)) + next_run=local_generated.replace(second=0,microsecond=0) + slots=(5,35) + probe=now.replace(second=0,microsecond=0) + while True: + if probe.minute in slots and probe > now: + next_run=probe + break + probe += timedelta(minutes=1) + age_text='just now' if age_minutes < 1 else (f'{age_minutes}m ago' if age_minutes < 60 else f'{age_minutes//60}h {age_minutes%60}m ago') + value=f'updated {age_text} · next {next_run.strftime("%-I:%M %p")}' + state='ok' if age_minutes <= 45 else 'warn' + return {'value':value,'state':state} + + def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]: usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace') disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)' - dash=run(['systemctl','is-active','doris-dashboard.service']) or 'unknown' - cron='present' if 'DORIS DASHBOARD' in run(['bash','-lc','crontab -l 2>/dev/null']) else 'missing' + 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,'state':'ok' if dash=='active' else 'warn'}, - {'label':'Dashboard cron','value':cron,'state':'ok' if cron=='present' else 'warn'}, + {'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'}, ] @@ -279,6 +435,8 @@ def system_snapshot()->list[dict[str,str]]: 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' @@ -293,6 +451,8 @@ def system_snapshot()->list[dict[str,str]]: 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('/') @@ -303,61 +463,156 @@ def system_snapshot()->list[dict[str,str]]: {'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 'ok'}, + {'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)); text=f"{item.get('title','')} {item.get('summary','')}".lower() - if item.get('category') in prefs.get('boost_categories',[]): score+=2 - if item.get('category') in prefs.get('quiet_categories',[]): score-=3 + 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 - if item.get('source')=='CISA Alerts': + 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(); sources=Counter(); categories=Counter() + out=[]; seen=set(); topic_seen=set(); sources=Counter(); categories=Counter() - def consider(limit_per_category:int|None)->None: + 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' + 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, then allows a second item per - # category, and only then permits a small spillover from the strongest buckets. - consider(1) - consider(2) - consider(3) + # 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','smart_home','gaming','cooking','security'] + 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) - out=[]; seen=set(exclude_urls); categories=Counter(); sources=Counter() + 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' - if not url or url in seen or sources[src]>=1 or categories[cat]>=per_category_limit: + 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 @@ -365,14 +620,14 @@ def select_exploration_items(items:list[dict[str,Any]],prefs:dict[str,Any],exclu continue if not allowed_boosted and cat in boosted: continue - out.append(i); seen.add(url); categories[cat]+=1; sources[src]+=1 + out.append(i); seen.add(url); topic_seen.add(tkey); categories[cat]+=1; sources[src]+=1 - # Keep this lane genuinely exploratory: preferred off-profile categories first, - # then broader off-profile picks, allowing a little depth if the pool is narrow. + # 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), 3) + consider(False, set(preferred), 2) consider(False, None, 1) - consider(False, None, 3) + consider(False, None, 2) return out def label(cat:str)->str: @@ -415,10 +670,12 @@ def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=T style='news-list' if compact else 'grid' return collapsible_section(title,sub,f"
{body}
",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='')->str: +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"

{esc(sub)}

" if sub else '' - return f"

{esc(title)}

{sub_html}
{inner_html}
" + state_class=f' state-{state}' if state else '' + smart_attr=f" data-smart-state='{esc(state)}'" if smart and state else '' + return f"

{esc(title)}

{sub_html}
{inner_html}
" def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]: out=list(items or []) @@ -550,7 +807,8 @@ def donetick_panel(items:list[dict[str,Any]],empty:str)->str: assignee_html=f"{esc(x.get('assignee') or '')}" if x.get('assignee') and x.get('assignee')!='Unassigned' else '' chips_html=f"
{chips}
" if chips else '' subtitle_html=f"
{esc(subtitle)}
" if subtitle else '' - rows.append(f"
{esc(x.get('due_title') or x.get('time') or 'No due date')}
{assignee_html}
{esc(x.get('title') or 'Untitled task')}
{chips_html}{subtitle_html}
") + done_button=f"" if x.get('id') else '' + rows.append(f"
{esc(x.get('due_title') or x.get('time') or 'No due date')}
{assignee_html}
{esc(x.get('title') or 'Untitled task')}
{chips_html}{subtitle_html}{done_button}
") return f"

Donetick

{''.join(rows)}
" def feedback_bootstrap()->str: @@ -576,6 +834,7 @@ def feedback_bootstrap()->str: 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)=>{ @@ -597,11 +856,13 @@ def feedback_bootstrap()->str: }); }); const badge=document.querySelector('[data-hidden-topics-count]'); - if (badge) badge.textContent=String((prefs.hidden||[]).length); + if (badge) badge.textContent=String(hiddenCards.length); }; sections.forEach(sec=>{ const key=sec.dataset.sectionKey; - if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key]; + 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)); @@ -638,6 +899,25 @@ def feedback_bootstrap()->str: 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; @@ -650,40 +930,139 @@ def feedback_bootstrap()->str: window.location.reload(); }).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;}); }); + const pollDonetickFocus=(()=>{ + let inFlight=false; + return ()=>{ + const current=document.body?.dataset?.donetickFocusSignature||''; + if(!current || document.hidden || inFlight) return; + inFlight=true; + getJson(`/api/donetick-focus-state?current=${encodeURIComponent(current)}`).then(data=>{ + if(data && data.signature){ + document.body.dataset.donetickFocusSignature=data.signature; + } + if(data && data.changed){ + setToast('Donetick changed outside the dashboard. Reloading focus cards…'); + window.setTimeout(()=>window.location.reload(),300); + } + }).catch(err=>{ + console.warn('donetick focus poll failed',err); + }).finally(()=>{ + inFlight=false; + }); + }; + })(); + window.setInterval(pollDonetickFocus, 60000); + window.setTimeout(pollDonetickFocus, 60000); const reset=document.querySelector('[data-reset-topic-prefs]'); - if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden topics and local story hides.'); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});} + 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(); })(); """ def metric_card(title:str,value:str,sub:str,cls:str='')->str: - return f"
{esc(title)}{esc(value)}{esc(sub)}
" + severity=cls if cls in SECTION_STATE_RANK else '' + severity_attr=f" data-severity='{esc(severity)}'" if severity else '' + return f"
{esc(title)}{esc(value)}{esc(sub)}
" + +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 [] - precip=daily.get('precipitation_sum') or [] - sunrise=daily.get('sunrise') or [] - sunset=daily.get('sunset') 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=[] - for idx, day in enumerate(days[:3]): + 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: - label=datetime.fromisoformat(day).strftime('%a') + 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 '—' - rise=(sunrise[idx][-5:] if idx < len(sunrise) and sunrise[idx] else '—') - set_=(sunset[idx][-5:] if idx < len(sunset) and sunset[idx] else '—') + condition=WEATHER_CODES.get(codes[idx],'Forecast') if idx < len(codes) else 'Forecast' cards.append(f"""
{esc(label)} - {esc(str(rain))} mm rain - {esc(rise)} sunrise · {esc(set_)} sunset + {esc(full_label)} +
{esc(c_to_f(high))} / {esc(c_to_f(low))}
+ {esc(condition)} +
Rain chance {esc(str(rain))}%
""") if not cards: return "" - return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"
{''.join(cards)}
",'forecast',open_by_default=False,extra_class='panel forecast-panel') + return collapsible_section('3-day forecast','Next three days: conditions, highs/lows, and rain chance.',f"
{''.join(cards)}
",'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"
{metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')}
",'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"Lead {esc(str(lead_hours))}h" if lead_hours not in (None,'') else '' + timing_html=f"{esc(notice_timing)}" if notice_timing else '' + notice_html=f""" +
+
+ {esc(notice_title)} + {esc(notice_state.upper())} +
+ {f"{esc(notice_headline)}" if notice_headline else ''} + {f"

{esc(notice_body)}

" if notice_body else ''} +
{lead_html}{timing_html}
+
""" + overview=f""" +
+
+
+ Clarksville + {esc(weather.get('source',''))} +
+
+ {esc(c_to_f(cur.get('temperature_2m')))} +
+
{esc(weather.get('condition','Current conditions'))}
+
Today {esc(c_to_f(today_high))} / {esc(c_to_f(today_low))}
+
+
+
+ Feels {esc(c_to_f(cur.get('apparent_temperature')))} + Humidity {esc(str(cur.get('relative_humidity_2m','—')))}% + Wind {esc(kmh_to_mph(cur.get('wind_speed_10m')))} + Rain {esc(str(today_rain))}% +
+
+ {notice_html} +
""" + 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() @@ -703,16 +1082,28 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]: 'cache_read_tokens':0, 'cache_write_tokens':0, 'reasoning_tokens':0, + 'last_24h_tokens':0, + 'last_24h_sessions':0, + 'last_24h_tool_calls':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':[], 'generated_at':datetime.now(LOCAL_TZ).isoformat(), } if not HERMES_STATE_DB.exists(): stats['error']='Hermes state DB not found' return stats - cutoff=time.time()-(days*86400) + now_ts=time.time() + cutoff=now_ts-(days*86400) + day_cutoff=now_ts-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 @@ -738,14 +1129,33 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]: '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 >= ? + """,(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 @@ -785,6 +1195,29 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]: 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 @@ -793,13 +1226,23 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]: return stats def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='')->str: - return f"
{esc(kicker)}

{esc(title)}

{esc(headline)}

{esc(body)}

" + severity='' + for token in str(tone).split(): + if token in SECTION_STATE_RANK: + severity=token + break + severity_attr=f" data-severity='{esc(severity)}'" if severity else '' + return f"
{esc(kicker)}

{esc(title)}

{esc(headline)}

{esc(body)}

" -def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any])->str: +def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->tuple[str,str]: urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'] warn_items=[item for item in pulse if item.get('state')=='warn'] local_story=local_items[0] if local_items else None + session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None + session_remaining=session_window.get('remaining_percent') if session_window else None + session_reset=str(session_window.get('reset_local') or 'unknown') if session_window else 'unknown' + hermes_model=(hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'your main model' if urgent_todos: chores_headline=f"{len(urgent_todos)} urgent / due today" first_urgent=urgent_todos[0] @@ -807,37 +1250,60 @@ def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]] else: chores_headline='No urgent chores' chores_body='Nothing due soon enough to nag you here.' + if hermes.get('ok'): + if session_remaining is not None: + hermes_headline=f"{hermes_model} · {pct_text(session_remaining)} session left" + hermes_body=f"{compact_number(hermes.get('last_24h_tokens',0))} tokens in 24h · {compact_number(hermes.get('last_24h_tool_calls',0))} tool calls · resets {session_reset}" + else: + hermes_headline=f"{hermes_model} · {compact_number(hermes.get('last_24h_tokens',0))} tokens today" + hermes_body=f"{compact_number(hermes.get('last_24h_sessions',0))} sessions in 24h · {compact_number(hermes.get('tool_calls',0))} tool calls in {hermes.get('period_days',30)} days" + else: + hermes_headline='Hermes telemetry unavailable' + hermes_body='Could not read local Hermes usage stats right now.' + local_state='attention' if local_story else 'ok' + chores_state='warn' if urgent_todos else 'ok' + paperless_state='warn' if paperless.get('flagged_count',0) else ('ok' if not paperless.get('active_count',0) else 'attention') + homelab_state='warn' if warn_items else 'ok' + hermes_state=usage_window_state(session_remaining) if hermes.get('ok') else 'attention' cards=[ focus_card( 'Local watch', 'Useful nearby', (local_story.get('title') if local_story else 'Local lane quiet'), (f"{local_story.get('source','Local feed')} • {label(local_story.get('category','local'))}" if local_story else 'Nothing local survived filtering hard enough to earn the homepage.'), - 'local' if local_story else '' + f'local {local_state}'.strip() ), focus_card( 'Chores with heat', 'Donetick', chores_headline, chores_body, - 'warn' if urgent_todos else 'ok' + chores_state + ), + focus_card( + 'Hermes now', + 'Usage / budget', + hermes_headline, + hermes_body, + hermes_state ), focus_card( 'Paperless follow-up', 'Queue check', (f"{paperless.get('active_count',0)} active · {paperless.get('flagged_count',0)} flagged" if paperless.get('active_count',0) or paperless.get('flagged_count',0) else 'No documents waiting'), str(paperless.get('headline') or 'No documents waiting'), - 'warn' if paperless.get('flagged_count',0) else ('ok' if not paperless.get('active_count',0) else '') + paperless_state ), focus_card( 'Homelab pulse', 'NOMAD checks', ('Stable' if not warn_items else f"{len(warn_items)} warning{'s' if len(warn_items)!=1 else ''}"), ('; '.join(f"{item.get('label')}: {item.get('value')}" for item in warn_items[:2]) if warn_items else 'Dashboard service, cron, digest freshness, and disk all look fine.'), - 'warn' if warn_items else 'ok' + homelab_state ), ] - return "
" + ''.join(cards) + "
" + section_state=combine_states([local_state,chores_state,hermes_state,paperless_state,homelab_state]) + return ("
" + ''.join(cards) + "
", section_state) 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: @@ -846,7 +1312,27 @@ def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any] 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' - return f"{urgent_todos} urgent chore{'s' if urgent_todos!=1 else ''}, {paperless_text}, {warn_count} homelab warning{'s' if warn_count!=1 else ''}, and Hermes has mostly been running on {hermes_model} while the {local_status}." + 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: @@ -856,18 +1342,40 @@ def list_rows(rows:list[str])->str: 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.' - return collapsible_section('System snapshot',subtitle,f"
{metrics}
",'system-snapshot',open_by_default=True) + state=combine_states([str(item.get('state') or 'ok') for item in snapshot]) + return collapsible_section('System snapshot',subtitle,f"
{metrics}
",'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="

Hermes telemetry unavailable

Local Hermes usage stats could not be read right now.

" 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) - metrics=''.join([ + 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('Tool calls',compact_number(hermes.get('tool_calls',0)),f"{compact_number(hermes.get('user_messages',0))} user · {compact_number(hermes.get('assistant_messages',0))} assistant",'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]: @@ -883,8 +1391,18 @@ def build_hermes_section(hermes:dict[str,Any])->str: 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"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"""
+
+

Operator read

+
    {list_rows(operator_rows)}
+

Model mix

    {list_rows(model_rows)}
@@ -904,9 +1422,154 @@ def build_hermes_section(hermes:dict[str,Any])->str:
""" body=f"
{metrics}
{detail_html}" - subtitle=f"Live read from ~/.hermes/state.db over the last {hermes.get('period_days',30)} days." + 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} + 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: + detail_parts.append('auth gate') + elif final_url.rstrip('/') != target.rstrip('/'): + detail_parts.append('redirected') + return {'state':'ok','badge':'Healthy','detail':' · '.join(detail_parts)} + except urllib.error.HTTPError as e: + elapsed=max(1,int((time.time()-started)*1000)) + code=e.code + if code in {401,403}: + return {'state':'ok','badge':'Healthy','detail':f'{code} · {elapsed} ms · auth required'} + if code in {404,429}: + return {'state':'attention','badge':'Degraded','detail':f'{code} · {elapsed} ms'} + if code >= 500: + return {'state':'warn','badge':'Down','detail':f'{code} · {elapsed} ms'} + return {'state':'attention','badge':'Degraded','detail':f'{code} · {elapsed} ms'} + except Exception as e: + elapsed=max(1,int((time.time()-started)*1000)) + return {'state':'warn','badge':'Down','detail':f'{type(e).__name__} · {elapsed} ms'} + + +def service_group_state(items:list[dict[str,Any]])->str: + return combine_states([str(item.get('health',{}).get('state') or item.get('state') or 'attention') for item in items]) + + +def top_nav(active:str='home')->str: + items=[ + ('Home','index.html','home'), + ('Services','services.html','services'), + ('Donetick','https://donetick.paccoco.com','external'), + ('Paperless','https://paperless.paccoco.com','external'), + ('n8n','https://n8n.paccoco.com','external'), + ('Grafana','https://grafana.paccoco.com','external'), + ('Gitea','https://gitea.paccoco.com','external'), + ('Schoolhouse','https://schoolhouse.paccoco.com','external'), + ] + links=[] + for label,url,key in items: + classes=['nav-link'] + attrs=[] + if key==active: + classes.append('active') + attrs.append("aria-current='page'") + if url.startswith('http'): + attrs.append("target='_blank'") + attrs.append("rel='noreferrer'") + links.append(f"{esc(label)}") + return "" + + +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"Host{esc(item.get('host'))}") + if item.get('port'): + facts.append(f"Port{esc(item.get('port'))}") + facts.append(f"Access{esc(exposure)}") + 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"Open" if url else "No direct route") + title_html=(f"{esc(item.get('name') or 'Service')}" if url else esc(item.get('name') or 'Service')) + cards.append( + f"
" + f"
{esc(group.get('group','Service'))}" + f"

{title_html}

" + f"
{esc(health.get('badge') or state.title())}{esc(exposure)}
" + f"

{esc(item.get('purpose') or '')}

" + f"
{''.join(facts)}
" + f"
{meta_text}
" + f"
{action_html}
" + f"
" + ) + groups.append(collapsible_section(str(group.get('group') or 'Services'),'Direct links into the stack.',f"
{''.join(cards)}
",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"
  • {esc(host)}{count} services
  • " for host,count in host_counts.most_common()) + exposure_bits=''.join(f"
  • {esc(name)}{count}
  • " for name,count in exposure_counts.most_common()) + return ( + "
    " + f"

    Coverage

    • {total}listed services
    • {state_counts.get('ok',0)}healthy now
    • {state_counts.get('attention',0)}manual/degraded
    • {state_counts.get('warn',0)}down
    " + f"

    Access split

      {exposure_bits}
    " + f"

    Host map

      {host_bits}
    " + "

    Operator rule

    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.

    " + "
    " + ) + + +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.',"

    How to use this

    Use Home for today, alerts, and the briefing. Use Services when you want the rest of the stack quickly without hunting bookmarks.

    Design rule

    Shared navigation and status live here; full workflows still stay in their own apps.

    ",'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"Doris Services
    {top_nav('services')}

    PC Doris Thatcher

    Services Directory

    Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}

    {esc(now.strftime('%a %b %-d, %-I:%M %p'))}Central time
    {candidate_count}News candidates
    {source_count}Sources
    {esc(hero_tokens)}30-day Hermes tokens
    {services_html}

    Operator portal links are curated from the homelab docs and current live dashboard context.

    Local-only on NOMAD unless John says otherwise.

    " + 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',[]) @@ -920,30 +1583,25 @@ def render()->str: 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) - if weather['ok']: - cur=weather['current']; daily=weather.get('daily',{}) - weather_metrics=''.join([ - metric_card('Clarksville',c_to_f(cur.get('temperature_2m')),weather.get('condition',''), 'weather'), - metric_card('Feels / humidity',f"{c_to_f(cur.get('apparent_temperature'))} / {cur.get('relative_humidity_2m','—')}%",'right now','weather'), - metric_card('Wind',kmh_to_mph(cur.get('wind_speed_10m')),'Open-Meteo','weather'), - metric_card('Rain chance',f"{weather.get('max_precip',0)}%",'next 24h','weather'), - metric_card('Sunrise / sunset',f"{(daily.get('sunrise') or ['—'])[0][-5:]} / {(daily.get('sunset') or ['—'])[0][-5:]}",'local time','weather')]) - else: - weather_metrics=metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn') + weather_html=weather_overview_panel(weather,wind_notice) moon_metrics=''.join([ metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'), metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')]) pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse) forecast_html=forecast_panel(weather) hero_summary=build_hero_summary(groups['local'],todos,pulse,paperless,hermes) - focus_html=build_focus_cards(groups['local'],todos,pulse,paperless) + focus_html,focus_state=build_focus_cards(groups['local'],todos,pulse,paperless,hermes) + focus_signature=donetick_focus_signature(todos) hermes_html=build_hermes_section(hermes) system_html=build_system_section(system) hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—' primary_html=''.join([ section('Top briefing','Compact scan of what is worth your time.',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), @@ -951,32 +1609,34 @@ def render()->str: 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=True), + 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"
    {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.')}
    ",'today-overview',open_by_default=False), - collapsible_section('Environment','Weather, sky, and outside-the-window context.',f"
    {weather_metrics}{moon_metrics}
    ",'at-a-glance',open_by_default=False), - forecast_html, + collapsible_section('Sky','Moon and outside-the-window context that still matters.',f"
    {moon_metrics}
    ",'at-a-glance',open_by_default=False), paperless_review_section(), collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"
    {pulse_metrics}
    ",'homelab-pulse',open_by_default=False), ]) - return f"""Doris Dashboard
    -

    PC Doris Thatcher

    Doris Dashboard

    {esc(hero_summary)}

    Hidden topics: 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
    + 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
    {primary_html}
    {feedback_bootstrap()}""" 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) + 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()) diff --git a/home/doris-dashboard/doris-dashboard.service b/home/doris-dashboard/doris-dashboard.service index 4150b15..1cc98b0 100644 --- a/home/doris-dashboard/doris-dashboard.service +++ b/home/doris-dashboard/doris-dashboard.service @@ -12,6 +12,7 @@ Environment=DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest WorkingDirectory=/opt/doris-dashboard ExecStartPre=/opt/doris-dashboard/bin/generate_dashboard.py ExecStart=/usr/bin/python3 /opt/doris-dashboard/bin/dashboard_server.py +ExecStartPost=/opt/doris-dashboard/bin/generate_dashboard.py Restart=on-failure RestartSec=5 diff --git a/home/doris-dashboard/public/style.css b/home/doris-dashboard/public/style.css index 36cc10f..6760c93 100644 --- a/home/doris-dashboard/public/style.css +++ b/home/doris-dashboard/public/style.css @@ -16,6 +16,7 @@ --local:#ffd166; --warn:#ff7b72; --ok:#5ee787; + --attention:#ffd166; --shadow:0 14px 34px rgba(0,0,0,.26); } @@ -151,6 +152,7 @@ footer, .metric.weather{border-top-color:var(--local)} .metric.space{border-top-color:var(--space)} .metric.ok{border-top-color:var(--ok)} +.metric.attention{border-top-color:var(--attention)} .metric.warn{border-top-color:var(--warn)} .dashboard-shell{ @@ -172,6 +174,18 @@ footer, border-radius:20px; overflow:hidden; } +.collapsible.state-ok{ + border-color:rgba(94,231,135,.5); + box-shadow:0 0 0 1px rgba(94,231,135,.16),var(--shadow); +} +.collapsible.state-attention{ + border-color:rgba(255,209,102,.55); + box-shadow:0 0 0 1px rgba(255,209,102,.18),var(--shadow); +} +.collapsible.state-warn{ + border-color:rgba(255,123,114,.6); + box-shadow:0 0 0 1px rgba(255,123,114,.2),var(--shadow); +} .collapsible > summary{ list-style:none; @@ -223,8 +237,8 @@ section header p{ } .focus-grid, -.summary-grid, -.forecast-grid{grid-template-columns:repeat(2,minmax(0,1fr))} +.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))} +.forecast-grid{grid-template-columns:repeat(3,minmax(0,1fr))} .today{grid-template-columns:1fr} .news-list{grid-template-columns:repeat(2,minmax(0,1fr))} .grid{grid-template-columns:repeat(2,minmax(0,1fr))} @@ -247,6 +261,7 @@ section header p{ .focus-card{border-top:3px solid var(--accent)} .focus-card.local{border-top-color:var(--local)} .focus-card.ok{border-top-color:var(--ok)} +.focus-card.attention{border-top-color:var(--attention)} .focus-card.warn{border-top-color:var(--warn)} .focus-kicker{ display:inline-block; @@ -329,6 +344,22 @@ section header p{ .task-card.overdue{border-color:rgba(255,123,114,.45)} .task-card.today{border-color:rgba(255,209,102,.42)} .task-card.tomorrow{border-color:rgba(142,202,230,.35)} +.task-done-btn{ + margin-top:8px; + width:100%; + appearance:none; + background:rgba(94,231,135,.08); + border:1px solid rgba(94,231,135,.3); + border-radius:10px; + color:#d8ffe5; + cursor:pointer; + font:inherit; + font-size:.82rem; + font-weight:700; + padding:7px 10px; +} +.task-done-btn:hover{border-color:rgba(94,231,135,.55);background:rgba(94,231,135,.14)} +.task-done-btn:disabled{cursor:progress;opacity:.72} .compact-card, .card{ @@ -383,10 +414,48 @@ section header p{ .topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),var(--shadow)} .forecast-panel{padding:0} +.weather-panel{padding:0} +.weather-overview{display:grid;gap:10px} +.weather-now-card{ + padding:14px; + background:linear-gradient(135deg,rgba(124,140,255,.12),rgba(79,209,165,.08)); + border-radius:16px; + border:1px solid rgba(124,140,255,.18); +} +.weather-now-top, +.weather-now-main, +.weather-detail-row{display:flex;gap:10px} +.weather-now-top{justify-content:space-between;align-items:center;margin-bottom:10px} +.weather-kicker{color:#b7c0ff;font-size:.74rem;text-transform:uppercase;letter-spacing:.1em} +.weather-source{color:var(--muted);font-size:.76rem} +.weather-now-main{align-items:flex-end;justify-content:space-between} +.weather-now-main strong{font-size:2rem;line-height:.95;letter-spacing:-.04em} +.weather-condition{font-size:1rem;font-weight:700;line-height:1.15} +.weather-range{margin-top:4px;color:#d9e4f7;font-size:.88rem} +.weather-detail-row{flex-wrap:wrap;margin-top:12px;color:var(--muted);font-size:.8rem} +.weather-detail-row span{padding:4px 8px;border-radius:999px;background:rgba(8,9,10,.24)} +.weather-alert-card{ + padding:11px 12px; + border-radius:14px; + background:rgba(255,255,255,.035); + border:1px solid var(--line-soft); +} +.weather-alert-card.attention{border-color:rgba(255,209,102,.45);background:rgba(255,209,102,.07)} +.weather-alert-card.warn{border-color:rgba(255,123,114,.48);background:rgba(255,123,114,.08)} +.weather-alert-top{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:6px} +.weather-alert-kicker{font-size:.75rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#dfe8fa} +.weather-alert-state{font-size:.72rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.weather-alert-card strong{display:block;font-size:.95rem;line-height:1.2} +.weather-alert-card p{margin:6px 0 0;color:#d7e3f5;font-size:.82rem;line-height:1.3} +.weather-alert-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;color:var(--muted);font-size:.77rem} +.weather-alert-meta span{padding:3px 8px;border-radius:999px;background:rgba(8,9,10,.22)} .forecast-card{padding:11px 12px;background:rgba(255,255,255,.035)} .forecast-day{color:#b7c0ff;font-size:.75rem;text-transform:uppercase;letter-spacing:.08em} -.forecast-card strong{font-size:.95rem} -.forecast-card em{font-style:normal;color:var(--muted);font-size:.82rem} +.forecast-card strong{display:block;margin-top:4px;font-size:.95rem} +.forecast-temps{margin-top:6px;font-size:1rem;font-weight:700;color:#f6fbff} +.forecast-temps span{color:var(--muted);font-weight:600} +.forecast-card em{display:block;margin-top:4px;font-style:normal;color:var(--muted);font-size:.82rem} +.forecast-meta{margin-top:8px;color:#d7e3f5;font-size:.8rem} .badge{ display:inline-block; @@ -460,7 +529,7 @@ footer p{margin:.25rem 0} .dashboard-toast[hidden]{display:none} .toast-action{margin-left:10px;background:transparent;border:0;color:#b7c0ff;font:inherit;font-weight:700;cursor:pointer;padding:0} -@media (max-width:1180px){ +@media (max-width:980px){ .dashboard-shell{grid-template-columns:1fr} .secondary-column{order:-1} .focus-grid,.summary-grid,.forecast-grid,.metrics.metrics-compact{grid-template-columns:repeat(2,minmax(0,1fr))} @@ -480,3 +549,95 @@ footer p{margin:.25rem 0} .paperless-actions{justify-content:flex-start;margin-top:8px} section header{display:block} } + + +.top-nav{ + display:flex; + justify-content:space-between; + align-items:center; + gap:14px; + margin-bottom:14px; + padding:12px 14px; + border-radius:18px; + background:rgba(17,21,29,.88); + border:1px solid var(--line); + box-shadow:var(--shadow); +} +.nav-brand{display:flex;flex-direction:column;gap:2px} +.nav-kicker{font-size:.68rem;letter-spacing:.16em;text-transform:uppercase;color:#b7c0ff} +.nav-links{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end} +.nav-link{ + text-decoration:none; + padding:7px 11px; + border-radius:999px; + border:1px solid var(--line); + background:rgba(255,255,255,.03); + color:var(--muted); + font-size:.88rem; +} +.nav-link:hover{border-color:rgba(124,140,255,.55);color:var(--text)} +.nav-link.active{color:var(--text);border-color:rgba(124,140,255,.6);background:rgba(124,140,255,.14)} +.services-shell{align-items:start} +.services-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +.service-card{ + border-radius:16px; + padding:12px; + background:rgba(255,255,255,.03); + border:1px solid var(--line-soft); +} +.service-card.ok{border-top:3px solid var(--ok)} +.service-card.attention{border-top:3px solid var(--attention)} +.service-card.warn{border-top:3px solid var(--warn)} +.service-top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start} + +.service-badge-stack{display:flex;flex-direction:column;gap:6px;align-items:flex-end} +.service-health{ + display:inline-flex; + align-items:center; + border-radius:999px; + padding:3px 8px; + font-size:.68rem; + letter-spacing:.08em; + text-transform:uppercase; + border:1px solid var(--line); +} +.service-health.ok{color:var(--ok);border-color:rgba(94,231,135,.38);background:rgba(94,231,135,.1)} +.service-health.attention{color:var(--attention);border-color:rgba(255,209,102,.38);background:rgba(255,209,102,.1)} +.service-health.warn{color:var(--warn);border-color:rgba(255,123,114,.38);background:rgba(255,123,114,.1)} +.service-meta{margin-top:8px;color:var(--muted);font-size:.76rem;line-height:1.25} +.service-group-badge,.service-badge{ + display:inline-flex; + align-items:center; + border-radius:999px; + padding:3px 8px; + font-size:.68rem; + letter-spacing:.08em; + text-transform:uppercase; + background:var(--panel-raise); + color:var(--muted); +} +.service-card h3{margin:.35rem 0 .25rem;font-size:.98rem;line-height:1.2} +.service-card p{margin:0;color:#d5deeb;font-size:.86rem;line-height:1.34} +.service-actions{margin-top:10px} +.service-open{ + display:inline-flex; + align-items:center; + text-decoration:none; + color:#b7c0ff; + font-weight:600; +} +.service-open:hover{text-decoration:underline} +.service-open.disabled{color:var(--muted);cursor:default;text-decoration:none} +.service-facts{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px} +.service-fact{display:inline-flex;gap:6px;align-items:center;border-radius:999px;padding:4px 8px;background:rgba(255,255,255,.04);border:1px solid var(--line-soft);font-size:.72rem;color:var(--muted)} +.service-fact strong{font-size:.66rem;letter-spacing:.08em;text-transform:uppercase;color:#b7c0ff} +.summary-card ul{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:8px} +.summary-card li{display:flex;justify-content:space-between;gap:10px;align-items:flex-start} +.summary-card li strong{font-size:.92rem} +.summary-card li span{color:var(--muted);font-size:.8rem;text-align:right} + +@media (max-width:980px){ + .top-nav{align-items:flex-start;flex-direction:column} + .nav-links{justify-content:flex-start} + .services-grid{grid-template-columns:1fr} +}