chore: sync homelab ops, identity, and monitoring docs
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request
|
||||
import html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -15,6 +15,9 @@ 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')
|
||||
HERMES_HOME=Path.home()/'.hermes'
|
||||
HERMES_STATE_DB=HERMES_HOME/'state.db'
|
||||
HERMES_CONFIG=HERMES_HOME/'config.yaml'
|
||||
|
||||
LABELS={
|
||||
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
|
||||
@@ -50,6 +53,37 @@ def kmh_to_mph(k:float|int|None)->str:
|
||||
if k is None: return '—'
|
||||
return f"{round(k*0.621371)} mph"
|
||||
|
||||
def compact_number(value:int|float|None)->str:
|
||||
if value in (None,''): return '—'
|
||||
try: n=float(value)
|
||||
except Exception: return str(value)
|
||||
abs_n=abs(n)
|
||||
if abs_n >= 1_000_000_000: return f"{n/1_000_000_000:.1f}B"
|
||||
if abs_n >= 1_000_000: return f"{n/1_000_000:.1f}M"
|
||||
if abs_n >= 1_000: return f"{n/1_000:.1f}K"
|
||||
if n.is_integer(): return f"{int(n):,}"
|
||||
return f"{n:,.1f}"
|
||||
|
||||
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
|
||||
for key in keys:
|
||||
try: total += int((row[key] if isinstance(row, sqlite3.Row) else row.get(key,0)) or 0)
|
||||
except Exception: pass
|
||||
return total
|
||||
|
||||
def read_hermes_model_config()->dict[str,str]:
|
||||
default='unknown'; provider='unknown'
|
||||
try:
|
||||
data=yaml.safe_load(HERMES_CONFIG.read_text(encoding='utf-8')) or {}
|
||||
except Exception:
|
||||
return {'default_model':default,'provider':provider}
|
||||
model_cfg=data.get('model') if isinstance(data,dict) else {}
|
||||
if isinstance(model_cfg,dict):
|
||||
default=str(model_cfg.get('default') or default)
|
||||
provider=str(model_cfg.get('provider') or provider)
|
||||
return {'default_model':default or 'unknown','provider':provider or 'unknown'}
|
||||
|
||||
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)
|
||||
@@ -227,6 +261,51 @@ def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
|
||||
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
|
||||
]
|
||||
|
||||
|
||||
def system_snapshot()->list[dict[str,str]]:
|
||||
uptime_text='unknown'
|
||||
try:
|
||||
uptime_seconds=float(Path('/proc/uptime').read_text().split()[0])
|
||||
hours=int(uptime_seconds//3600)
|
||||
minutes=int((uptime_seconds%3600)//60)
|
||||
uptime_text=(f'{hours}h {minutes}m' if hours else f'{minutes}m')
|
||||
except Exception:
|
||||
pass
|
||||
load_text='unknown'
|
||||
load_state='ok'
|
||||
try:
|
||||
load1,load5,load15=os.getloadavg()
|
||||
load_text=f'{load1:.2f} / {load5:.2f} / {load15:.2f}'
|
||||
cpu_count=os.cpu_count() or 1
|
||||
if load1 > cpu_count*1.25:
|
||||
load_state='warn'
|
||||
except Exception:
|
||||
load_state='warn'
|
||||
mem_text='unknown'
|
||||
mem_state='ok'
|
||||
try:
|
||||
parts=Path('/proc/meminfo').read_text().splitlines()
|
||||
raw={line.split(':',1)[0]: int(line.split(':',1)[1].strip().split()[0]) for line in parts if ':' in line}
|
||||
total=raw.get('MemTotal',0)
|
||||
avail=raw.get('MemAvailable',0)
|
||||
used=max(total-avail,0)
|
||||
pct=(used/total*100) if total else 0
|
||||
mem_text=f'{pct:.0f}% used ({used/1024/1024:.1f} / {total/1024/1024:.1f} GiB)'
|
||||
if pct >= 85:
|
||||
mem_state='warn'
|
||||
except Exception:
|
||||
mem_state='warn'
|
||||
root_usage=shutil.disk_usage('/')
|
||||
root_pct=root_usage.used/root_usage.total*100 if root_usage.total else 0
|
||||
root_text=f'{root_pct:.0f}% used ({root_usage.free/1024**3:.1f} GiB free)'
|
||||
return [
|
||||
{'label':'Host','value':run(['hostname']) or 'nomad','state':'ok'},
|
||||
{'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'},
|
||||
]
|
||||
|
||||
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
|
||||
@@ -313,7 +392,7 @@ def why(i:dict[str,Any])->str:
|
||||
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]
|
||||
summary=(i.get('summary') or '').strip()[:120 if compact else 260]
|
||||
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>"
|
||||
@@ -371,6 +450,21 @@ def load_paperless_review()->list[dict[str,Any]]:
|
||||
def load_paperless_state()->dict[str,Any]:
|
||||
return load_json(PAPERLESS_STATE_PATH,{}) or {}
|
||||
|
||||
def paperless_focus_summary(items:list[dict[str,Any]]|None=None,state:dict[str,Any]|None=None)->dict[str,Any]:
|
||||
items=items if items is not None else load_paperless_review()
|
||||
state=state if state is not None else load_paperless_state()
|
||||
active=[]; flagged=[]
|
||||
for item in items:
|
||||
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
|
||||
if status in {'dismissed','good_to_go'}:
|
||||
continue
|
||||
if status in {'acknowledged','needs_attention'}:
|
||||
flagged.append(item)
|
||||
else:
|
||||
active.append(item)
|
||||
headline=(flagged[0] if flagged else (active[0] if active else {})).get('title') or 'No documents waiting'
|
||||
return {'active_count':len(active),'flagged_count':len(flagged),'headline':headline}
|
||||
|
||||
def paperless_review_section()->str:
|
||||
items=load_paperless_review()
|
||||
state=load_paperless_state()
|
||||
@@ -408,7 +502,8 @@ def paperless_review_section()->str:
|
||||
flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8])
|
||||
count=len(active)
|
||||
flag_count=len(needs_attention)
|
||||
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
|
||||
empty_active="<p class='empty'>No active documents waiting.</p>"
|
||||
body=f"<div class='paperless-grid'>{active_cards or empty_active}</div>"
|
||||
if overflow_active:
|
||||
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
|
||||
if flag_count:
|
||||
@@ -427,7 +522,10 @@ def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
|
||||
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>")
|
||||
assignee_html=f"<span class='muted'>{esc(assignee)}</span>" if assignee else ''
|
||||
headline_html=f"<div class='panel-item-title'>{esc(headline)}</div>" if headline else ''
|
||||
detail_html=f"<div class='panel-item-body'>{esc(detail)}</div>" if detail else ''
|
||||
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{assignee_html}</div>{headline_html}{detail_html}</li>")
|
||||
body=''.join(rows)
|
||||
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||||
|
||||
@@ -449,7 +547,10 @@ def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||||
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-state-dot' aria-hidden='true'></div><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(subtitle)}</div>' if subtitle else ''}</article>")
|
||||
assignee_html=f"<span class='task-assignee'>{esc(x.get('assignee') or '')}</span>" if x.get('assignee') and x.get('assignee')!='Unassigned' else ''
|
||||
chips_html=f"<div class='task-chips'>{chips}</div>" if chips else ''
|
||||
subtitle_html=f"<div class='task-subtitle'>{esc(subtitle)}</div>" if subtitle else ''
|
||||
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-state-dot' aria-hidden='true'></div><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{assignee_html}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{chips_html}{subtitle_html}</article>")
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||||
|
||||
def feedback_bootstrap()->str:
|
||||
@@ -584,10 +685,232 @@ def forecast_panel(weather:dict[str,Any])->str:
|
||||
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 fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
config=read_hermes_model_config()
|
||||
stats={
|
||||
'ok':False,
|
||||
'period_days':days,
|
||||
'default_model':config.get('default_model','unknown'),
|
||||
'provider':config.get('provider','unknown'),
|
||||
'sessions':0,
|
||||
'messages':0,
|
||||
'tool_calls':0,
|
||||
'user_messages':0,
|
||||
'assistant_messages':0,
|
||||
'total_tokens':0,
|
||||
'input_tokens':0,
|
||||
'output_tokens':0,
|
||||
'cache_read_tokens':0,
|
||||
'cache_write_tokens':0,
|
||||
'reasoning_tokens':0,
|
||||
'models':[],
|
||||
'platforms':[],
|
||||
'top_tools':[],
|
||||
'recent_sessions':[],
|
||||
'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)
|
||||
try:
|
||||
conn=sqlite3.connect(f"file:{HERMES_STATE_DB}?mode=ro", uri=True)
|
||||
conn.row_factory=sqlite3.Row
|
||||
cur=conn.cursor()
|
||||
overview=cur.execute("""
|
||||
SELECT
|
||||
count(*) AS sessions,
|
||||
sum(coalesce(input_tokens,0)) AS input_tokens,
|
||||
sum(coalesce(output_tokens,0)) AS output_tokens,
|
||||
sum(coalesce(cache_read_tokens,0)) AS cache_read_tokens,
|
||||
sum(coalesce(cache_write_tokens,0)) AS cache_write_tokens,
|
||||
sum(coalesce(reasoning_tokens,0)) AS reasoning_tokens,
|
||||
sum(coalesce(api_call_count,0)) AS api_calls
|
||||
FROM sessions
|
||||
WHERE started_at >= ?
|
||||
""",(cutoff,)).fetchone()
|
||||
stats.update({
|
||||
'sessions':int((overview['sessions'] or 0) if overview else 0),
|
||||
'input_tokens':int((overview['input_tokens'] or 0) if overview else 0),
|
||||
'output_tokens':int((overview['output_tokens'] or 0) if overview else 0),
|
||||
'cache_read_tokens':int((overview['cache_read_tokens'] or 0) if overview else 0),
|
||||
'cache_write_tokens':int((overview['cache_write_tokens'] or 0) if overview else 0),
|
||||
'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']
|
||||
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)
|
||||
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)
|
||||
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
|
||||
FROM sessions
|
||||
WHERE started_at >= ?
|
||||
GROUP BY model
|
||||
ORDER BY total_tokens DESC, sessions DESC
|
||||
LIMIT 5
|
||||
""",(cutoff,))]
|
||||
stats['platforms']=[dict(r) for r in cur.execute("""
|
||||
SELECT source, 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 >= ?
|
||||
GROUP BY source
|
||||
ORDER BY total_tokens DESC, sessions DESC
|
||||
LIMIT 5
|
||||
""",(cutoff,))]
|
||||
stats['top_tools']=[dict(r) for r in cur.execute("""
|
||||
SELECT tool_name, count(*) AS calls
|
||||
FROM messages
|
||||
WHERE role='tool' AND tool_name IS NOT NULL AND tool_name != '' AND timestamp >= ?
|
||||
GROUP BY tool_name
|
||||
ORDER BY calls DESC, tool_name ASC
|
||||
LIMIT 8
|
||||
""",(cutoff,))]
|
||||
stats['recent_sessions']=[dict(r) for r in cur.execute("""
|
||||
SELECT source, model, started_at,
|
||||
(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens,
|
||||
coalesce(api_call_count,0) AS api_calls
|
||||
FROM sessions
|
||||
WHERE started_at >= ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 5
|
||||
""",(cutoff,))]
|
||||
for item in stats['recent_sessions']:
|
||||
started=item.get('started_at')
|
||||
try: item['started_local']=datetime.fromtimestamp(float(started), LOCAL_TZ).strftime('%a %-I:%M %p')
|
||||
except Exception: item['started_local']='recent'
|
||||
stats['ok']=True
|
||||
conn.close()
|
||||
return stats
|
||||
except Exception:
|
||||
stats['error']='Hermes telemetry is temporarily unavailable.'
|
||||
return stats
|
||||
|
||||
def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='')->str:
|
||||
return f"<article class='focus-card {esc(tone)}'><span class='focus-kicker'>{esc(kicker)}</span><h3>{esc(title)}</h3><strong>{esc(headline)}</strong><p>{esc(body)}</p></article>"
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
if urgent_todos:
|
||||
chores_headline=f"{len(urgent_todos)} urgent / due today"
|
||||
first_urgent=urgent_todos[0]
|
||||
chores_body=(first_urgent.get('title') or 'You have work waiting') + (f" • {first_urgent.get('due_title')}" if first_urgent.get('due_title') else '')
|
||||
else:
|
||||
chores_headline='No urgent chores'
|
||||
chores_body='Nothing due soon enough to nag you here.'
|
||||
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 ''
|
||||
),
|
||||
focus_card(
|
||||
'Chores with heat',
|
||||
'Donetick',
|
||||
chores_headline,
|
||||
chores_body,
|
||||
'warn' if urgent_todos else 'ok'
|
||||
),
|
||||
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 '')
|
||||
),
|
||||
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'
|
||||
),
|
||||
]
|
||||
return "<section class='focus-grid'>" + ''.join(cards) + "</section>"
|
||||
|
||||
|
||||
def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
|
||||
urgent_todos=len([item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'])
|
||||
warn_count=len([item for item in pulse if item.get('state')=='warn'])
|
||||
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}."
|
||||
|
||||
|
||||
def list_rows(rows:list[str])->str:
|
||||
return ''.join(f"<li>{row}</li>" for row in rows) if rows else "<li class='empty'>Nothing to show.</li>"
|
||||
|
||||
|
||||
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"<section class='metrics metrics-compact'>{metrics}</section>",'system-snapshot',open_by_default=True)
|
||||
|
||||
|
||||
def build_hermes_section(hermes:dict[str,Any])->str:
|
||||
if not hermes.get('ok'):
|
||||
body="<section class='summary-grid'><article class='summary-card'><h3>Hermes telemetry unavailable</h3><p>Local Hermes usage stats could not be read right now.</p></article></section>"
|
||||
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([
|
||||
metric_card('Default model',str(hermes.get('default_model','unknown')),str(hermes.get('provider','unknown')),'space'),
|
||||
metric_card(f"{hermes.get('period_days',30)}-day tokens",compact_number(hermes.get('total_tokens',0)),'all session rows','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'),
|
||||
])
|
||||
model_rows=[]
|
||||
for item in hermes.get('models',[])[:5]:
|
||||
model_rows.append(f"{esc(item.get('model') or 'unknown')} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens")
|
||||
platform_rows=[]
|
||||
for item in hermes.get('platforms',[])[:5]:
|
||||
platform_rows.append(f"{esc((item.get('source') or 'unknown').title())} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens")
|
||||
tool_rows=[]
|
||||
for item in hermes.get('top_tools',[])[:8]:
|
||||
tool_rows.append(f"{esc(item.get('tool_name') or 'tool')} · {compact_number(item.get('calls',0))} calls")
|
||||
recent_rows=[]
|
||||
for item in hermes.get('recent_sessions',[])[:5]:
|
||||
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")
|
||||
detail_html=f"""
|
||||
<section class='summary-grid'>
|
||||
<article class='summary-card'>
|
||||
<h3>Model mix</h3>
|
||||
<ul>{list_rows(model_rows)}</ul>
|
||||
</article>
|
||||
<article class='summary-card'>
|
||||
<h3>Platform split</h3>
|
||||
<ul>{list_rows(platform_rows)}</ul>
|
||||
</article>
|
||||
<article class='summary-card'>
|
||||
<h3>Top tools</h3>
|
||||
<ul>{list_rows(tool_rows)}</ul>
|
||||
</article>
|
||||
<article class='summary-card'>
|
||||
<h3>Recent sessions</h3>
|
||||
<ul>{list_rows(recent_rows)}</ul>
|
||||
</article>
|
||||
</section>
|
||||
"""
|
||||
body=f"<section class='metrics hermes-metrics'>{metrics}</section>{detail_html}"
|
||||
subtitle=f"Live read from ~/.hermes/state.db over the last {hermes.get('period_days',30)} days."
|
||||
return collapsible_section('Hermes usage',subtitle,body,'hermes-usage',open_by_default=True)
|
||||
|
||||
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':[]}
|
||||
selected=select_items(candidates,prefs); top_briefing=selected[:4]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
||||
exploration=select_exploration_items(candidates,prefs,{i.get('url','') for i in selected if i.get('url')},4)
|
||||
for i in selected:
|
||||
if i.get('url','') in top_urls: continue
|
||||
@@ -596,7 +919,8 @@ def render()->str:
|
||||
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)
|
||||
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); hermes=fetch_hermes_usage(30); system=system_snapshot()
|
||||
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',{})
|
||||
@@ -613,31 +937,45 @@ def render()->str:
|
||||
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)
|
||||
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),
|
||||
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),
|
||||
section('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks right now.',compact=True),
|
||||
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),
|
||||
system_html,
|
||||
hermes_html,
|
||||
collapsible_section('Agenda','Calendar, Donetick, and pinned notes.',f"<section class='today'>{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.')}</section>",'today-overview',open_by_default=False),
|
||||
collapsible_section('Environment','Weather, sky, and outside-the-window context.',f"<section class='metrics metrics-compact'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=False),
|
||||
forecast_html,
|
||||
paperless_review_section(),
|
||||
collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics metrics-compact'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False),
|
||||
])
|
||||
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><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 · auto-refreshes at :05 / :35</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=False)}
|
||||
{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'][:5],'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'][:3],'No local signal right now.',compact=True)}
|
||||
{section('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks 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>"""
|
||||
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden 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>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header>
|
||||
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
|
||||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||||
|
||||
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())
|
||||
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)
|
||||
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')
|
||||
(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')
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user