Expand Doris dashboard portal and services directory

This commit is contained in:
Fizzlepoof
2026-05-21 04:22:31 +00:00
parent 2e99873634
commit c149b1acf8
6 changed files with 1067 additions and 86 deletions

View File

@@ -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.

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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}
}