feat(home): import doris dashboard from workspace repo
Migrates workspace-only dashboard work from commits 6f0c5cd, c851073, and d04eff3 into the canonical truenas-stacks repo.
This commit is contained in:
541
home/doris-dashboard/bin/generate_dashboard.py
Executable file
541
home/doris-dashboard/bin/generate_dashboard.py
Executable file
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Any
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||
PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
|
||||
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
|
||||
LAT=36.53; LON=-87.36
|
||||
LOCAL_TZ=ZoneInfo('America/Chicago')
|
||||
|
||||
LABELS={
|
||||
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
|
||||
'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'),
|
||||
'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')}
|
||||
WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'}
|
||||
LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'}
|
||||
PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'}
|
||||
|
||||
def esc(v:Any)->str: return html.escape(str(v or ''),quote=True)
|
||||
def load_json(p:Path,d:Any)->Any:
|
||||
try: return json.loads(p.read_text()) if p.exists() else d
|
||||
except Exception: return d
|
||||
|
||||
def strip_markup(text:str)->str:
|
||||
text=re.sub(r'<[^>]+>',' ',text or '')
|
||||
return re.sub(r'\s+',' ',html.unescape(text)).strip()
|
||||
|
||||
def parse_time(v:str)->datetime:
|
||||
try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc)
|
||||
except Exception: return datetime.now(timezone.utc)
|
||||
|
||||
def parse_dt(v:str|None)->datetime|None:
|
||||
if not v: return None
|
||||
try: return datetime.fromisoformat(v.replace('Z','+00:00'))
|
||||
except Exception: return None
|
||||
|
||||
def c_to_f(c:float|int|None)->str:
|
||||
if c is None: return '—'
|
||||
return f"{round(c*9/5+32)}°F"
|
||||
|
||||
def kmh_to_mph(k:float|int|None)->str:
|
||||
if k is None: return '—'
|
||||
return f"{round(k*0.621371)} mph"
|
||||
|
||||
def moon_phase(dt:datetime)->dict[str,str]:
|
||||
# Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days.
|
||||
known=datetime(2000,1,6,18,14,tzinfo=timezone.utc)
|
||||
days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400
|
||||
age=days%29.53058867
|
||||
illum=(1-math.cos(2*math.pi*age/29.53058867))/2
|
||||
names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')]
|
||||
name,emoji='New Moon','🌑'
|
||||
for limit,n,e in names:
|
||||
if age<limit: name,emoji=n,e; break
|
||||
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
|
||||
|
||||
def fetch_weather()->dict[str,Any]:
|
||||
url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}'
|
||||
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature'
|
||||
'&hourly=temperature_2m,precipitation_probability'
|
||||
'&daily=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3')
|
||||
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'}
|
||||
except Exception as e:
|
||||
return {'ok':False,'error':str(e),'source':'Open-Meteo'}
|
||||
|
||||
def decrypt_donetick()->dict[str,Any]|None:
|
||||
secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc')
|
||||
keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key')
|
||||
if not secret.exists() or not keyfile.exists(): return None
|
||||
try:
|
||||
res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True)
|
||||
return json.loads(res.stdout).get('donetick')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def fetch_donetick_todos()->list[dict[str,Any]]:
|
||||
sec=decrypt_donetick()
|
||||
if not sec: return []
|
||||
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'}
|
||||
req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers)
|
||||
with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[])
|
||||
users={}
|
||||
try:
|
||||
ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers)
|
||||
with urllib.request.urlopen(ureq,timeout=12) as r:
|
||||
users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])}
|
||||
except Exception: pass
|
||||
out=[]
|
||||
for c in chores:
|
||||
if c.get('status') not in (0,None): continue
|
||||
due=c.get('nextDueDate') or ''
|
||||
assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}")
|
||||
due_title,state,due_local=due_title_and_state(due)
|
||||
out.append({
|
||||
'title':c.get('name','Untitled task'),
|
||||
'time':due[:16].replace('T',' '),
|
||||
'body':strip_markup(c.get('description','')),
|
||||
'assignee':assignee,
|
||||
'priority':priority_num(c.get('priority')) or 0,
|
||||
'priority_text':priority_text(c.get('priority')) or '',
|
||||
'due':due,
|
||||
'due_title':due_title,
|
||||
'state':state,
|
||||
'subtitle':subtitle_text(c),
|
||||
'chips':visible_label_chips(c),
|
||||
'source':'Donetick',
|
||||
'id':c.get('id'),
|
||||
'_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1),
|
||||
'_sort_missing':0 if due_local else 1,
|
||||
'_sort_due':due_local.timestamp() if due_local else 99999999999,
|
||||
'_sort_name':str(c.get('name','')).lower(),
|
||||
})
|
||||
out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name','')))
|
||||
for item in out:
|
||||
for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'):
|
||||
item.pop(k,None)
|
||||
return out[:10]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def priority_text(priority:Any)->str|None:
|
||||
try:
|
||||
p=int(priority)
|
||||
return f'P{p}' if p in (1,2,3,4) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def priority_num(priority:Any)->int|None:
|
||||
try:
|
||||
p=int(priority)
|
||||
return p if p in (1,2,3,4) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def recurrence_text(task:dict[str,Any])->str|None:
|
||||
ftype=str(task.get('frequencyType','') or '').strip().lower()
|
||||
freq=task.get('frequency')
|
||||
try: freq_num=int(freq) if freq is not None else None
|
||||
except Exception: freq_num=None
|
||||
if not ftype: return None
|
||||
if ftype=='daily': return 'Daily' if freq_num in (None,1) else f'Every {freq_num} days'
|
||||
if ftype=='weekly': return 'Weekly' if freq_num in (None,1) else f'Every {freq_num} weeks'
|
||||
if ftype=='monthly': return 'Monthly' if freq_num in (None,1) else f'Every {freq_num} months'
|
||||
if ftype=='yearly': return 'Yearly' if freq_num in (None,1) else f'Every {freq_num} years'
|
||||
if ftype=='once': return 'One-time'
|
||||
return ftype.capitalize()
|
||||
|
||||
def due_title_and_state(due_raw:str|None)->tuple[str,str,datetime|None]:
|
||||
dt=parse_dt(due_raw)
|
||||
if not dt: return ('No due date','nodue',None)
|
||||
local=dt.astimezone(LOCAL_TZ)
|
||||
now=datetime.now(LOCAL_TZ)
|
||||
days=(local.date()-now.date()).days
|
||||
time_text=local.strftime('%-I:%M %p')
|
||||
if days < 0:
|
||||
if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local)
|
||||
return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local)
|
||||
if days == 0: return (f'Due today • {time_text}','today',local)
|
||||
if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local)
|
||||
if days <= 6: return (f"Due {local.strftime('%a')} • {time_text}",'upcoming',local)
|
||||
return (f"Due {local.strftime('%b %-d')} • {time_text}",'upcoming',local)
|
||||
|
||||
def dedupe_parts(parts:list[str])->list[str]:
|
||||
seen=set(); out=[]
|
||||
for p in parts:
|
||||
if not p: continue
|
||||
key=p.strip().lower()
|
||||
if key in seen: continue
|
||||
seen.add(key); out.append(p)
|
||||
return out
|
||||
|
||||
def subtitle_text(task:dict[str,Any])->str:
|
||||
parts=[]
|
||||
labels=task.get('labelsV2') or []
|
||||
label_names=[str(lbl.get('name','')).strip() for lbl in labels if lbl.get('name')]
|
||||
prominent=[x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS]
|
||||
low=[x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS]
|
||||
parts.extend(prominent[:3])
|
||||
ptxt=priority_text(task.get('priority'))
|
||||
if ptxt: parts.append(ptxt)
|
||||
rtxt=recurrence_text(task)
|
||||
if rtxt:
|
||||
label_keys={x.strip().lower() for x in label_names}
|
||||
if rtxt.strip().lower() not in label_keys: parts.append(rtxt)
|
||||
if not prominent and low: parts.extend(low[:1])
|
||||
return ' • '.join(dedupe_parts(parts))
|
||||
|
||||
def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]:
|
||||
chips=[]
|
||||
for lbl in task.get('labelsV2') or []:
|
||||
name=str(lbl.get('name','')).strip()
|
||||
if not name or name.lower() in LOW_VALUE_LABELS: continue
|
||||
color=str(lbl.get('color','')).strip() or '#607d8b'
|
||||
chips.append({'name':name,'color':color})
|
||||
return chips[:4]
|
||||
|
||||
def run(cmd:list[str])->str:
|
||||
try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip()
|
||||
except Exception: return ''
|
||||
|
||||
def 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'
|
||||
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':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'},
|
||||
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
|
||||
]
|
||||
|
||||
def 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
|
||||
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':
|
||||
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)
|
||||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||||
out=[]; seen=set(); sources=Counter()
|
||||
for i in recent:
|
||||
url=i.get('url',''); src=i.get('source','')
|
||||
if url in seen or sources[src]>=2: continue
|
||||
out.append(i); seen.add(url); sources[src]+=1
|
||||
if len(out)>=max_items: break
|
||||
return out
|
||||
|
||||
def label(cat:str)->str:
|
||||
e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}'
|
||||
|
||||
def slugify(text:str)->str:
|
||||
return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section'
|
||||
|
||||
def why(i:dict[str,Any])->str:
|
||||
cat=i.get('category'); src=i.get('source','')
|
||||
if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.'
|
||||
if cat=='homelab': return 'Practical self-hosting / infrastructure signal.'
|
||||
if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.'
|
||||
if cat=='local': return 'Local or regional relevance.'
|
||||
if cat=='radio': return 'Ham-radio interest signal.'
|
||||
if cat=='security': return 'Kept only because it appears practically relevant.'
|
||||
return f'Interesting signal from {src}.'
|
||||
|
||||
def card(i:dict[str,Any], compact:bool=False)->str:
|
||||
summary=(i.get('summary') or '').strip()[:170 if compact else 300]
|
||||
when=(i.get('published_at','')[:16].replace('T',' '))
|
||||
cls='compact-card' if compact else 'card'
|
||||
why_html='' if compact else f"<div class='why'>Why: {esc(why(i))}</div>"
|
||||
topic=(i.get('category') or 'other').strip() or 'other'
|
||||
source=(i.get('source') or '').strip()
|
||||
domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.')
|
||||
feedback_html='' if not compact else f"<div class='feedback'><button type='button' class='feedback-btn' data-feedback='up' title='More like this topic'>👍</button><button type='button' class='feedback-btn' data-feedback='down' title='Hide this topic'>👎</button></div>"
|
||||
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
|
||||
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
|
||||
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
|
||||
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
|
||||
|
||||
def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str:
|
||||
body=''.join(card(i,compact=compact) for i in items) if items else f"<p class='empty'>{esc(empty)}</p>"
|
||||
style='news-list' if compact else 'grid'
|
||||
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",slugify(title),open_by_default=True)
|
||||
|
||||
def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='')->str:
|
||||
open_attr=' open' if open_by_default else ''
|
||||
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
|
||||
return f"<details class='collapsible {esc(extra_class)}' data-section-key='{esc(key)}'{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||||
|
||||
def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]:
|
||||
out=list(items or [])
|
||||
title='Paperless notes/tags audit'
|
||||
if not any((x.get('title') or '').strip().lower()==title.lower() for x in out):
|
||||
out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"})
|
||||
return out
|
||||
|
||||
def load_paperless_review()->list[dict[str,Any]]:
|
||||
p=DATA/'paperless_review.json'
|
||||
remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json')
|
||||
ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys')
|
||||
if remote:
|
||||
tmp=DATA/'paperless_review.remote.tmp'
|
||||
try:
|
||||
cmd=['scp','-q','-o','ConnectTimeout=8']
|
||||
if ssh_key and Path(ssh_key).exists():
|
||||
cmd.extend(['-i',ssh_key])
|
||||
cmd.extend([remote,str(tmp)])
|
||||
res=subprocess.run(cmd,capture_output=True,text=True,timeout=12)
|
||||
if res.returncode==0 and tmp.exists(): tmp.replace(p)
|
||||
elif tmp.exists(): tmp.unlink()
|
||||
except Exception:
|
||||
try: tmp.unlink()
|
||||
except Exception: pass
|
||||
try: return json.loads(p.read_text())
|
||||
except Exception: return []
|
||||
|
||||
def load_paperless_state()->dict[str,Any]:
|
||||
return load_json(PAPERLESS_STATE_PATH,{}) or {}
|
||||
|
||||
def paperless_review_section()->str:
|
||||
items=load_paperless_review()
|
||||
state=load_paperless_state()
|
||||
if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel')
|
||||
urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'}
|
||||
active=[]; acknowledged=[]
|
||||
for item in items:
|
||||
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
|
||||
if status=='dismissed':
|
||||
continue
|
||||
(acknowledged if status=='acknowledged' else active).append(item)
|
||||
def render_card(item:dict[str,Any], acknowledged_card:bool=False)->str:
|
||||
status_class=' acknowledged' if acknowledged_card else ''
|
||||
return f"""<article class='paperless-card {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
|
||||
<div class='meta'><span class='urgency {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}' data-urgency='{esc(item.get('triage',{}).get('urgency','none'))}'>{esc(item.get('triage',{}).get('urgency','none').upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div>
|
||||
<h4>{esc(item.get('title') or item.get('triage',{}).get('suggested_title','Untitled'))}</h4>
|
||||
<p class='summary'>{esc(item.get('triage',{}).get('summary',''))}</p>
|
||||
<p class='why'>Type: {esc(item.get('triage',{}).get('document_type',''))} | Correspondent: {esc(item.get('current',{}).get('correspondent',''))} | {esc(item.get('triage',{}).get('reason',''))}</p>
|
||||
<div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='acknowledge'>{'Unack' if acknowledged_card else 'Acknowledge'}</button><button type='button' class='paperless-btn danger' data-paperless-action='dismiss'>Dismiss</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div>
|
||||
</article>"""
|
||||
active_cards=''.join(render_card(item,False) for item in active[:8])
|
||||
acked_cards=''.join(render_card(item,True) for item in acknowledged[:8])
|
||||
count=len(active)
|
||||
ack_count=len(acknowledged)
|
||||
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
|
||||
if ack_count:
|
||||
body += f"<details class='paperless-acked'><summary>Acknowledged <span class='badge'>{esc(str(ack_count))}</span></summary><div class='paperless-grid acked-grid'>{acked_cards}</div></details>"
|
||||
if count+ack_count>8:
|
||||
body += f"<p class='empty'>Showing {esc(str(min(count,8)))} active and {esc(str(min(ack_count,8)))} acknowledged items from the local queue snapshot.</p>"
|
||||
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{ack_count} acknowledged · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=count>0,extra_class='panel')
|
||||
|
||||
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
|
||||
if not items:
|
||||
body=f"<li class='empty'>{esc(empty)}</li>"
|
||||
else:
|
||||
rows=[]
|
||||
for x in items[:8]:
|
||||
kicker=x.get('time') or x.get('title') or x.get('label')
|
||||
headline=x.get('title') if x.get('time') else ''
|
||||
assignee=x.get('assignee') or ''
|
||||
detail=x.get('body') or x.get('summary') or x.get('value') or ''
|
||||
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{f'<span class=\'muted\'>{esc(assignee)}</span>' if assignee else ''}</div>{f'<div class=\'panel-item-title\'>{esc(headline)}</div>' if headline else ''}{f'<div class=\'panel-item-body\'>{esc(detail)}</div>' if detail else ''}</li>")
|
||||
body=''.join(rows)
|
||||
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||||
|
||||
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||||
if not items:
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||||
rows=[]
|
||||
for x in items[:6]:
|
||||
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
|
||||
subtitle=' • '.join([p for p in [x.get('subtitle') or '', x.get('assignee') or ''] if p])
|
||||
state=esc(x.get('state') or 'upcoming')
|
||||
due_style=''
|
||||
if state=='overdue': due_style=' style="color:#ff6b6b;"'
|
||||
elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"'
|
||||
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(x.get("subtitle") or "")}</div>' if x.get('subtitle') else ''}</article>")
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||||
|
||||
def feedback_bootstrap()->str:
|
||||
return """<script>
|
||||
(()=>{
|
||||
const KEY='doris-dashboard-topic-prefs-v2';
|
||||
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
|
||||
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"liked":[],"hidden":[]}')}catch(e){return {liked:[],hidden:[]}}};
|
||||
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
|
||||
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({liked:[...new Set(prefs.liked||[])], hidden:[...new Set(prefs.hidden||[])]}));
|
||||
const prefs=safeRead();
|
||||
const collapsed=safeCollapse();
|
||||
const cards=[...document.querySelectorAll('[data-topic]')];
|
||||
const sections=[...document.querySelectorAll('details[data-section-key]')];
|
||||
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 sync=()=>{
|
||||
cards.forEach(card=>{
|
||||
const topic=card.dataset.topic||'other';
|
||||
const hidden=(prefs.hidden||[]).includes(topic);
|
||||
const liked=(prefs.liked||[]).includes(topic);
|
||||
card.style.display=hidden?'none':'';
|
||||
card.classList.toggle('topic-liked', liked);
|
||||
});
|
||||
const badge=document.querySelector('[data-hidden-topics-count]');
|
||||
if (badge) badge.textContent=String((prefs.hidden||[]).length);
|
||||
};
|
||||
sections.forEach(sec=>{
|
||||
const key=sec.dataset.sectionKey;
|
||||
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));
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', (ev)=>{
|
||||
const btn=ev.target.closest('[data-feedback]');
|
||||
if(!btn) return;
|
||||
const card=btn.closest('[data-topic]');
|
||||
if(!card) return;
|
||||
const topic=card.dataset.topic||'other';
|
||||
if(btn.dataset.feedback==='down'){
|
||||
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
|
||||
prefs.hidden.push(topic);
|
||||
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
|
||||
} else {
|
||||
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
|
||||
prefs.liked.push(topic);
|
||||
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
|
||||
}
|
||||
save(prefs); sync();
|
||||
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
|
||||
});
|
||||
document.addEventListener('click',(ev)=>{
|
||||
const btn=ev.target.closest('[data-paperless-action]');
|
||||
if(!btn) return;
|
||||
const card=btn.closest('[data-paperless-id]');
|
||||
if(!card) return;
|
||||
const id=card.dataset.paperlessId;
|
||||
const action=btn.dataset.paperlessAction;
|
||||
btn.disabled=true;
|
||||
postJson('/api/paperless-action',{id,action}).then(()=>{
|
||||
if(action==='dismiss'){
|
||||
card.remove();
|
||||
} else {
|
||||
card.classList.toggle('acknowledged');
|
||||
}
|
||||
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
|
||||
});
|
||||
const reset=document.querySelector('[data-reset-topic-prefs]');
|
||||
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; prefs.liked=[]; save(prefs); sync(); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
|
||||
sync();
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
|
||||
return f"<div class='metric {esc(cls)}'><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
|
||||
|
||||
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 []
|
||||
cards=[]
|
||||
for idx, day in enumerate(days[:3]):
|
||||
try:
|
||||
label=datetime.fromisoformat(day).strftime('%a')
|
||||
except Exception:
|
||||
label=day
|
||||
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 '—')
|
||||
cards.append(f"""<article class='forecast-card'>
|
||||
<span class='forecast-day'>{esc(label)}</span>
|
||||
<strong>{esc(str(rain))} mm rain</strong>
|
||||
<em>{esc(rise)} sunrise · {esc(set_)} sunset</em>
|
||||
</article>""")
|
||||
if not cards:
|
||||
return ""
|
||||
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=False,extra_class='panel forecast-panel')
|
||||
|
||||
def render()->str:
|
||||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
|
||||
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
||||
for i in selected:
|
||||
if i.get('url','') in top_urls: continue
|
||||
cat=i.get('category')
|
||||
if cat=='astronomy': groups['space'].append(i)
|
||||
elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i)
|
||||
elif cat=='local': groups['local'].append(i)
|
||||
else: groups['signals'].append(i)
|
||||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates)
|
||||
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')
|
||||
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)
|
||||
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
|
||||
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
|
||||
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=True)}
|
||||
{collapsible_section('Today','Calendar, Donetick, and notes.',f"<section class='today'>{list_panel('Today',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}</section>",'today-overview',open_by_default=True)}
|
||||
{forecast_html}
|
||||
{paperless_review_section()}
|
||||
{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False)}
|
||||
{section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True)}
|
||||
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:6],'No sky signal right now.',compact=True)}
|
||||
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
|
||||
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:4],'No local signal right now.',compact=True)}
|
||||
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
|
||||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||||
|
||||
def main()->int:
|
||||
PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True)
|
||||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos())
|
||||
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/'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')
|
||||
if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n')
|
||||
(PUBLIC/'index.html').write_text(render(),encoding='utf-8')
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
Reference in New Issue
Block a user