chore: sync homelab ops, identity, and monitoring docs
This commit is contained in:
1
home/doris-dashboard/.gitignore
vendored
1
home/doris-dashboard/.gitignore
vendored
@@ -11,3 +11,4 @@ data/todos.json
|
||||
data/weather.json
|
||||
data/paperless_review.json
|
||||
data/paperless_review_state.json
|
||||
data/hermes_usage.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,482 @@
|
||||
:root{color-scheme:dark;--bg:#0c111b;--panel:#151b27;--panel2:#202939;--text:#eef4ff;--muted:#9cabbe;--line:#2d394b;--accent:#8fd3ff;--space:#9f8cff;--home:#4fd1a5;--local:#ffd166;--warn:#ff6b6b;--ok:#5ee787}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#213455,#0c111b 42rem);color:var(--text)}a{color:inherit}main{max-width:1220px;margin:0 auto;padding:18px}.hero{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:end;margin-bottom:18px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:.78rem;margin:0 0 8px}h1{font-size:clamp(2.2rem,6vw,4.5rem);line-height:.94;margin:0}.sub{color:var(--muted);font-size:1rem;max-width:760px}.hero-actions{display:flex;gap:12px;align-items:center;margin-top:10px;flex-wrap:wrap}.reset-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:8px 12px;font:inherit;cursor:pointer}.reset-btn:hover{border-color:var(--accent)}.status,.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.status div,.metric,.panel,.card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.status strong,.metric strong{display:block;font-size:1.1rem}.status span,.metric span,.metric em,.meta,footer,.empty{color:var(--muted)}.metric{min-height:92px;border-top:4px solid var(--accent)}.metric strong{font-size:1.35rem;margin:6px 0}.metric em{font-style:normal;font-size:.84rem}.metric.weather{border-top-color:var(--local)}.metric.space{border-top-color:var(--space)}.metric.ok{border-top-color:var(--ok)}.metric.warn{border-top-color:var(--warn)}.today{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:12px;margin:0}.panel h2,section h2{margin:0 0 8px}ul{padding-left:18px;margin:0}.panel ul{list-style:none;padding-left:0;display:grid;gap:8px}.panel li{margin:0}section{margin:18px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{min-height:245px;display:flex;flex-direction:column;gap:10px;border-top:4px solid var(--accent)}.card.astronomy{border-top-color:var(--space)}.card.homelab,.card.smart_home,.card.radio{border-top-color:var(--home)}.card.local{border-top-color:var(--local)}.card.security{border-top-color:var(--warn)}.meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.74rem}.meta span{padding:3px 8px;border-radius:999px;background:var(--panel2)}h3{font-size:1rem;line-height:1.2;margin:0}h3 a{text-decoration:none}h3 a:hover{text-decoration:underline}p{line-height:1.35}.why{margin-top:auto;color:#c8d5ea;background:rgba(143,211,255,.08);padding:10px;border-radius:12px;font-size:.88rem}footer{border-top:1px solid var(--line);padding:18px 0 26px}.collapsible{margin:16px 0;border:1px solid var(--line);border-radius:20px;background:rgba(21,27,39,.9);box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px);overflow:hidden}.collapsible>summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;justify-content:space-between;gap:12px;align-items:center}.collapsible>summary::-webkit-details-marker{display:none}.collapsible-heading h2{margin:0}.collapsible-heading p{margin:4px 0 0;color:var(--muted)}.collapsible-body{padding:0 14px 14px}.collapse-indicator{color:var(--muted);transition:transform .15s ease}.collapsible[open] .collapse-indicator{transform:rotate(180deg)}@media(max-width:1050px){.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr 1fr}.hero{grid-template-columns:1fr}}@media(max-width:650px){main{padding:14px}.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr}.card{min-height:auto}}
|
||||
:root{
|
||||
color-scheme:dark;
|
||||
--bg:#08090a;
|
||||
--bg-accent:#121826;
|
||||
--panel:#11151d;
|
||||
--panel-soft:#171d27;
|
||||
--panel-raise:#1d2430;
|
||||
--text:#f3f6fb;
|
||||
--muted:#94a3b8;
|
||||
--line:rgba(255,255,255,.08);
|
||||
--line-soft:rgba(255,255,255,.05);
|
||||
--accent:#7c8cff;
|
||||
--accent-soft:rgba(124,140,255,.16);
|
||||
--space:#9f8cff;
|
||||
--home:#4fd1a5;
|
||||
--local:#ffd166;
|
||||
--warn:#ff7b72;
|
||||
--ok:#5ee787;
|
||||
--shadow:0 14px 34px rgba(0,0,0,.26);
|
||||
}
|
||||
|
||||
/* Compact article layout: keep the number of stories, reduce scroll debt. */
|
||||
.news-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.compact-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px;box-shadow:0 6px 18px rgba(0,0,0,.14)}.compact-card.astronomy{border-left-color:var(--space)}.compact-card.homelab,.compact-card.smart_home,.compact-card.radio{border-left-color:var(--home)}.compact-card.local{border-left-color:var(--local)}.compact-card.security{border-left-color:var(--warn)}.compact-card .meta{margin-bottom:4px}.compact-card h3{font-size:.98rem;line-height:1.18;margin:0 0 3px}.compact-card p{margin:0;color:#c8d5ea;font-size:.88rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.compact-card .meta span{padding:2px 7px}.compact-card:nth-child(n+7){display:none}section header{display:flex;justify-content:space-between;gap:12px;align-items:end}section header p{margin:0}.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}.feedback-btn{background:transparent;border:1px solid var(--line);border-radius:999px;color:var(--text);padding:2px 8px;font:inherit;cursor:pointer}.feedback-btn:hover{border-color:var(--accent);transform:translateY(-1px)}.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),0 6px 18px rgba(0,0,0,.14)}@media(max-width:650px){.compact-card p{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}section header{display:block}}
|
||||
.feedback-btn[aria-pressed='true']{font-weight:700}.feedback-btn.active-up{border-color:rgba(94,231,135,.65);background:rgba(94,231,135,.12);color:#c8ffd9}.feedback-btn.active-down{border-color:rgba(255,107,107,.6);background:rgba(255,107,107,.12);color:#ffd9d9}.dashboard-toast{position:fixed;right:16px;bottom:16px;z-index:1000;max-width:min(420px,calc(100vw - 24px));background:rgba(12,17,27,.96);border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:16px;padding:12px 14px;color:var(--text);box-shadow:0 14px 34px rgba(0,0,0,.35)}.dashboard-toast[hidden]{display:none}.toast-action{margin-left:10px;background:transparent;border:0;color:var(--accent);font:inherit;font-weight:700;cursor:pointer;padding:0}
|
||||
.muted{color:var(--muted);font-size:.86rem;margin:0 .25rem}.panel-item{background:rgba(32,41,57,.55);border:1px solid var(--line);border-radius:14px;padding:10px 12px}.panel-item-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.panel-item-title{margin-top:2px;font-weight:600;color:#dbe7f7}.panel-item-body{margin-top:4px;color:#c8d5ea;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.panel .empty{padding:12px 0}.donetick-panel{padding:12px 14px}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.task-card{background:rgba(32,41,57,.58);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.task-top{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:4px}.task-due{font-size:.8rem;font-weight:800;letter-spacing:.02em;text-transform:none;color:#dbe7f7}.task-assignee{font-size:.72rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--muted)}.task-name{font-size:1rem;font-weight:700;line-height:1.08;color:var(--text);margin-bottom:6px}.task-subtitle{font-size:.78rem;line-height:1.16;color:var(--muted)}.task-chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:5px}.task-chip{display:inline-block;padding:3px 8px;border-radius:999px;color:#fff;font-size:.72rem;font-weight:700;line-height:1.05}.task-card.overdue{border-color:rgba(255,107,107,.55)}.task-card.today{border-color:rgba(255,209,102,.5)}.task-card.tomorrow{border-color:rgba(142,202,230,.45)}.task-card.nodue{opacity:.92}
|
||||
.donetick-panel .task-grid{gap:8px}.task-card{padding:9px 10px;border-radius:14px}.task-top{gap:6px;margin-bottom:3px}.task-state-dot{width:9px;height:9px;border-radius:999px;background:var(--muted);flex:0 0 auto;box-shadow:0 0 0 2px rgba(255,255,255,.03)}.task-card.overdue .task-state-dot{background:var(--warn)}.task-card.today .task-state-dot{background:var(--local)}.task-card.tomorrow .task-state-dot,.task-card.upcoming .task-state-dot{background:#8ecae6}.task-card.nodue .task-state-dot{background:#6c7a90}.task-due{font-size:.76rem;line-height:1.1}.task-name{font-size:.95rem;line-height:1.15;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.task-subtitle{font-size:.74rem;line-height:1.1;opacity:.9;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.task-chips{gap:4px;margin-bottom:4px}.task-chip{padding:2px 7px;font-size:.68rem}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
margin:0;
|
||||
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left,rgba(124,140,255,.16),transparent 28rem),
|
||||
radial-gradient(circle at top right,rgba(79,209,165,.08),transparent 22rem),
|
||||
var(--bg);
|
||||
color:var(--text);
|
||||
}
|
||||
|
||||
/* Forecast */
|
||||
.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem}
|
||||
a{color:inherit}
|
||||
main{max-width:1380px;margin:0 auto;padding:18px}
|
||||
|
||||
/* Paperless review queue */
|
||||
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:1fr;gap:10px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),0 10px 24px rgba(0,0,0,.22)}.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.paperless-card h4{margin:.35rem 0;font-size:.96rem;line-height:1.2}.paperless-card .summary{margin:.2rem 0;color:#d7e3f5;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.paperless-meta,.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.8rem;line-height:1.2}.paperless-card .link{display:inline-block;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:5px 9px;font:inherit;cursor:pointer}.paperless-btn.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked,.paperless-more{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary,.paperless-more summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:720px){.paperless-head{display:block}.paperless-actions{justify-content:flex-start;margin-top:8px}}
|
||||
.hero,
|
||||
.status div,
|
||||
.metric,
|
||||
.panel,
|
||||
.card,
|
||||
.compact-card,
|
||||
.focus-card,
|
||||
.summary-card,
|
||||
.paperless-card,
|
||||
.forecast-card,
|
||||
.panel-item,
|
||||
.task-card,
|
||||
.collapsible{
|
||||
background:rgba(17,21,29,.92);
|
||||
border:1px solid var(--line);
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
|
||||
.hero{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.25fr) 430px;
|
||||
gap:14px;
|
||||
align-items:stretch;
|
||||
border-radius:24px;
|
||||
padding:16px 18px;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
|
||||
.eyebrow{
|
||||
margin:0 0 6px;
|
||||
color:#b7c0ff;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.18em;
|
||||
font-size:.72rem;
|
||||
}
|
||||
|
||||
h1{
|
||||
margin:0;
|
||||
font-size:clamp(2rem,4.6vw,3.5rem);
|
||||
line-height:.94;
|
||||
letter-spacing:-.04em;
|
||||
}
|
||||
|
||||
.sub{
|
||||
margin:10px 0 0;
|
||||
color:var(--muted);
|
||||
font-size:.97rem;
|
||||
line-height:1.45;
|
||||
max-width:70ch;
|
||||
}
|
||||
|
||||
.hero-actions{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
flex-wrap:wrap;
|
||||
margin-top:12px;
|
||||
}
|
||||
|
||||
.reset-btn,
|
||||
.feedback-btn,
|
||||
.paperless-btn{
|
||||
appearance:none;
|
||||
background:rgba(255,255,255,.03);
|
||||
border:1px solid var(--line);
|
||||
border-radius:999px;
|
||||
color:var(--text);
|
||||
cursor:pointer;
|
||||
font:inherit;
|
||||
}
|
||||
|
||||
.reset-btn{padding:7px 11px}
|
||||
.reset-btn:hover,
|
||||
.feedback-btn:hover,
|
||||
.paperless-btn:hover{border-color:rgba(124,140,255,.55)}
|
||||
|
||||
.status,
|
||||
.metrics{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.metrics.metrics-compact{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
|
||||
.status div,
|
||||
.metric{
|
||||
border-radius:18px;
|
||||
padding:11px 12px;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.status strong,
|
||||
.metric strong{display:block}
|
||||
.status strong{font-size:1rem}
|
||||
.metric{
|
||||
min-height:78px;
|
||||
border-top:3px solid var(--accent);
|
||||
}
|
||||
.metric strong{
|
||||
margin:6px 0 4px;
|
||||
font-size:1.12rem;
|
||||
line-height:1.08;
|
||||
}
|
||||
.metric span,
|
||||
.metric em,
|
||||
.status span,
|
||||
.meta,
|
||||
.empty,
|
||||
footer,
|
||||
.muted{
|
||||
color:var(--muted);
|
||||
}
|
||||
.metric em{font-style:normal;font-size:.78rem}
|
||||
.metric.weather{border-top-color:var(--local)}
|
||||
.metric.space{border-top-color:var(--space)}
|
||||
.metric.ok{border-top-color:var(--ok)}
|
||||
.metric.warn{border-top-color:var(--warn)}
|
||||
|
||||
.dashboard-shell{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.5fr) minmax(320px,.9fr);
|
||||
gap:14px;
|
||||
align-items:start;
|
||||
}
|
||||
|
||||
.primary-column,
|
||||
.secondary-column{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:12px;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.collapsible{
|
||||
border-radius:20px;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.collapsible > summary{
|
||||
list-style:none;
|
||||
cursor:pointer;
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
padding:12px 14px;
|
||||
}
|
||||
|
||||
.collapsible > summary::-webkit-details-marker{display:none}
|
||||
.collapsible-heading{min-width:0}
|
||||
.collapsible-heading h2,
|
||||
.panel h2,
|
||||
section h2{
|
||||
margin:0;
|
||||
font-size:1.02rem;
|
||||
line-height:1.15;
|
||||
letter-spacing:-.02em;
|
||||
}
|
||||
|
||||
.collapsible-heading p,
|
||||
section header p{
|
||||
margin:4px 0 0;
|
||||
color:var(--muted);
|
||||
font-size:.83rem;
|
||||
line-height:1.32;
|
||||
}
|
||||
|
||||
.collapse-indicator{
|
||||
color:var(--muted);
|
||||
transition:transform .16s ease;
|
||||
transform-origin:center;
|
||||
}
|
||||
.details[open] .collapse-indicator,
|
||||
.collapsible[open] .collapse-indicator{transform:rotate(180deg)}
|
||||
.collapsible-body{padding:0 14px 14px}
|
||||
|
||||
.focus-grid,
|
||||
.summary-grid,
|
||||
.today,
|
||||
.news-list,
|
||||
.grid,
|
||||
.task-grid,
|
||||
.forecast-grid{
|
||||
display:grid;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.focus-grid,
|
||||
.summary-grid,
|
||||
.forecast-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.today{grid-template-columns:1fr}
|
||||
.news-list{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.task-grid{grid-template-columns:1fr}
|
||||
|
||||
.focus-card,
|
||||
.summary-card,
|
||||
.panel,
|
||||
.compact-card,
|
||||
.paperless-card,
|
||||
.forecast-card,
|
||||
.panel-item,
|
||||
.task-card,
|
||||
.card{
|
||||
border-radius:16px;
|
||||
}
|
||||
|
||||
.focus-card,
|
||||
.summary-card{padding:12px}
|
||||
.focus-card{border-top:3px solid var(--accent)}
|
||||
.focus-card.local{border-top-color:var(--local)}
|
||||
.focus-card.ok{border-top-color:var(--ok)}
|
||||
.focus-card.warn{border-top-color:var(--warn)}
|
||||
.focus-kicker{
|
||||
display:inline-block;
|
||||
margin-bottom:8px;
|
||||
font-size:.72rem;
|
||||
letter-spacing:.12em;
|
||||
text-transform:uppercase;
|
||||
color:#b7c0ff;
|
||||
}
|
||||
.focus-card h3,
|
||||
.summary-card h3{margin:0 0 5px;font-size:.93rem}
|
||||
.focus-card strong{display:block;font-size:1rem;line-height:1.2;margin-bottom:5px}
|
||||
.focus-card p,
|
||||
.summary-card p,
|
||||
.summary-card li{margin:0;color:#d5deeb;font-size:.88rem;line-height:1.34}
|
||||
.summary-card ul{margin:0;padding-left:18px}
|
||||
.summary-card li+li{margin-top:6px}
|
||||
.hermes-metrics{margin-bottom:10px}
|
||||
|
||||
.panel{padding:12px}
|
||||
.panel ul{list-style:none;padding-left:0;margin:0;display:grid;gap:8px}
|
||||
.panel-item{
|
||||
padding:9px 10px;
|
||||
background:rgba(255,255,255,.03);
|
||||
border-color:var(--line-soft);
|
||||
}
|
||||
.panel-item-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline}
|
||||
.panel-item-top strong{font-size:.82rem}
|
||||
.panel-item-title{margin-top:3px;font-weight:600;color:#dce5f6;font-size:.93rem;line-height:1.2}
|
||||
.panel-item-body{
|
||||
margin-top:4px;
|
||||
color:#c7d1e2;
|
||||
font-size:.84rem;
|
||||
line-height:1.28;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:2;
|
||||
-webkit-box-orient:vertical;
|
||||
overflow:hidden;
|
||||
}
|
||||
.panel .empty{padding:6px 2px}
|
||||
|
||||
.donetick-panel{padding:12px}
|
||||
.task-card{
|
||||
padding:9px 10px;
|
||||
background:rgba(255,255,255,.035);
|
||||
border-color:var(--line-soft);
|
||||
min-width:0;
|
||||
}
|
||||
.task-top{display:flex;justify-content:space-between;gap:6px;align-items:center;margin-bottom:3px}
|
||||
.task-state-dot{width:8px;height:8px;border-radius:999px;background:var(--muted);flex:0 0 auto;box-shadow:0 0 0 2px rgba(255,255,255,.03)}
|
||||
.task-card.overdue .task-state-dot{background:var(--warn)}
|
||||
.task-card.today .task-state-dot{background:var(--local)}
|
||||
.task-card.tomorrow .task-state-dot,
|
||||
.task-card.upcoming .task-state-dot{background:#8ecae6}
|
||||
.task-card.nodue .task-state-dot{background:#6c7a90}
|
||||
.task-due{font-size:.74rem;font-weight:700;line-height:1.1;color:#dde6f6}
|
||||
.task-assignee{font-size:.68rem;padding:2px 7px;border-radius:999px;background:var(--panel-raise);color:var(--muted)}
|
||||
.task-name{
|
||||
font-size:.93rem;
|
||||
font-weight:700;
|
||||
line-height:1.14;
|
||||
color:var(--text);
|
||||
margin-bottom:4px;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:2;
|
||||
-webkit-box-orient:vertical;
|
||||
overflow:hidden;
|
||||
}
|
||||
.task-subtitle{
|
||||
font-size:.73rem;
|
||||
line-height:1.14;
|
||||
color:var(--muted);
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:1;
|
||||
-webkit-box-orient:vertical;
|
||||
overflow:hidden;
|
||||
}
|
||||
.task-chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px}
|
||||
.task-chip{display:inline-block;padding:2px 7px;border-radius:999px;color:#fff;font-size:.67rem;font-weight:700;line-height:1.05}
|
||||
.task-card.overdue{border-color:rgba(255,123,114,.45)}
|
||||
.task-card.today{border-color:rgba(255,209,102,.42)}
|
||||
.task-card.tomorrow{border-color:rgba(142,202,230,.35)}
|
||||
|
||||
.compact-card,
|
||||
.card{
|
||||
padding:10px 11px;
|
||||
border-left:3px solid var(--accent);
|
||||
}
|
||||
.compact-card.astronomy,.card.astronomy{border-left-color:var(--space)}
|
||||
.compact-card.homelab,.compact-card.smart_home,.compact-card.radio,
|
||||
.card.homelab,.card.smart_home,.card.radio{border-left-color:var(--home)}
|
||||
.compact-card.local,.card.local{border-left-color:var(--local)}
|
||||
.compact-card.security,.card.security{border-left-color:var(--warn)}
|
||||
.compact-card .meta,
|
||||
.card .meta{
|
||||
margin-bottom:5px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:6px;
|
||||
font-size:.72rem;
|
||||
}
|
||||
.compact-card .meta span,
|
||||
.card .meta span{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
padding:2px 7px;
|
||||
border-radius:999px;
|
||||
background:rgba(255,255,255,.035);
|
||||
border:1px solid var(--line-soft);
|
||||
}
|
||||
.compact-card h3,
|
||||
.card h3{
|
||||
margin:0 0 4px;
|
||||
font-size:.94rem;
|
||||
line-height:1.2;
|
||||
}
|
||||
.compact-card p,
|
||||
.card p{
|
||||
margin:0;
|
||||
color:#c7d1e2;
|
||||
font-size:.83rem;
|
||||
line-height:1.28;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:2;
|
||||
-webkit-box-orient:vertical;
|
||||
overflow:hidden;
|
||||
}
|
||||
.compact-card:nth-child(n+7){display:none}
|
||||
.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:7px}
|
||||
.feedback-btn{padding:2px 8px;background:transparent}
|
||||
.feedback-btn[aria-pressed='true']{font-weight:700}
|
||||
.feedback-btn.active-up{border-color:rgba(94,231,135,.65);background:rgba(94,231,135,.12);color:#c8ffd9}
|
||||
.feedback-btn.active-down{border-color:rgba(255,107,107,.6);background:rgba(255,107,107,.12);color:#ffd9d9}
|
||||
.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),var(--shadow)}
|
||||
|
||||
.forecast-panel{padding:0}
|
||||
.forecast-card{padding:11px 12px;background:rgba(255,255,255,.035)}
|
||||
.forecast-day{color:#b7c0ff;font-size:.75rem;text-transform:uppercase;letter-spacing:.08em}
|
||||
.forecast-card strong{font-size:.95rem}
|
||||
.forecast-card em{font-style:normal;color:var(--muted);font-size:.82rem}
|
||||
|
||||
.badge{
|
||||
display:inline-block;
|
||||
margin-left:.35rem;
|
||||
padding:2px 7px;
|
||||
border-radius:999px;
|
||||
background:var(--panel-raise);
|
||||
color:#b7c0ff;
|
||||
font-size:.78rem;
|
||||
}
|
||||
.paperless-grid{display:grid;grid-template-columns:1fr;gap:8px}
|
||||
.paperless-card{padding:10px 11px;border-left:3px solid var(--accent)}
|
||||
.paperless-card.warn{border-left-color:var(--warn)}
|
||||
.paperless-card.info{border-left-color:var(--local)}
|
||||
.paperless-card.ok{border-left-color:var(--ok)}
|
||||
.paperless-card.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),var(--shadow)}
|
||||
.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
|
||||
.paperless-card h4{margin:.3rem 0;font-size:.92rem;line-height:1.18}
|
||||
.paperless-card .summary{
|
||||
margin:.2rem 0;
|
||||
color:#d7e3f5;
|
||||
font-size:.84rem;
|
||||
line-height:1.26;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:2;
|
||||
-webkit-box-orient:vertical;
|
||||
overflow:hidden;
|
||||
}
|
||||
.paperless-meta,
|
||||
.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.76rem;line-height:1.18}
|
||||
.paperless-card .link{display:inline-block;color:#b7c0ff;text-decoration:none}
|
||||
.paperless-card .link:hover{text-decoration:underline}
|
||||
.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}
|
||||
.paperless-btn{padding:5px 8px}
|
||||
.paperless-btn.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}
|
||||
.paperless-acked,
|
||||
.paperless-more{margin-top:10px;border-top:1px solid var(--line-soft);padding-top:10px}
|
||||
.paperless-acked summary,
|
||||
.paperless-more summary{cursor:pointer;color:var(--muted);font-size:.84rem}
|
||||
.urgency{font-weight:700}
|
||||
.urgency.warn{color:var(--warn)}
|
||||
.urgency.info{color:var(--local)}
|
||||
.urgency.ok{color:var(--ok)}
|
||||
|
||||
section header{display:flex;justify-content:space-between;gap:12px;align-items:end}
|
||||
section header p{margin:0}
|
||||
ul{margin:0}
|
||||
|
||||
footer{
|
||||
margin:14px 0 6px;
|
||||
padding:0 2px;
|
||||
font-size:.8rem;
|
||||
line-height:1.4;
|
||||
}
|
||||
footer p{margin:.25rem 0}
|
||||
|
||||
.dashboard-toast{
|
||||
position:fixed;
|
||||
right:16px;
|
||||
bottom:16px;
|
||||
z-index:1000;
|
||||
max-width:min(420px,calc(100vw - 24px));
|
||||
background:rgba(8,9,10,.97);
|
||||
border:1px solid var(--line);
|
||||
border-left:3px solid var(--accent);
|
||||
border-radius:14px;
|
||||
padding:12px 14px;
|
||||
color:var(--text);
|
||||
box-shadow:0 16px 40px rgba(0,0,0,.36);
|
||||
}
|
||||
.dashboard-toast[hidden]{display:none}
|
||||
.toast-action{margin-left:10px;background:transparent;border:0;color:#b7c0ff;font:inherit;font-weight:700;cursor:pointer;padding:0}
|
||||
|
||||
@media (max-width:1180px){
|
||||
.dashboard-shell{grid-template-columns:1fr}
|
||||
.secondary-column{order:-1}
|
||||
.focus-grid,.summary-grid,.forecast-grid,.metrics.metrics-compact{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
}
|
||||
|
||||
@media (max-width:920px){
|
||||
.hero{grid-template-columns:1fr}
|
||||
.status,.metrics,.metrics.metrics-compact,.focus-grid,.summary-grid,.news-list,.grid,.forecast-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
@media (max-width:720px){
|
||||
main{padding:12px}
|
||||
.hero{padding:14px}
|
||||
.collapsible > summary{padding:11px 12px}
|
||||
.collapsible-body{padding:0 12px 12px}
|
||||
.paperless-head{display:block}
|
||||
.paperless-actions{justify-content:flex-start;margin-top:8px}
|
||||
section header{display:block}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user