+
+ {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}
"
+
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"