feat: improve Doris dashboard lead warrant controls
This commit is contained in:
@@ -20,6 +20,7 @@ PUBLIC = ROOT / 'public'
|
||||
DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||
DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json'
|
||||
PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json'
|
||||
LEAD_WARRANT_STATE = ROOT / 'data' / 'lead_warrant_state.json'
|
||||
DONETICK_SECRET = Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc')
|
||||
DONETICK_KEY = Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key')
|
||||
GENERATOR = ROOT / 'bin' / 'generate_dashboard.py'
|
||||
@@ -87,6 +88,26 @@ def refresh_dashboard() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def load_lead_warrant_state() -> dict:
|
||||
state = load_json(LEAD_WARRANT_STATE, {})
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
acknowledged = state.get('acknowledged', [])
|
||||
ignored_sources = state.get('ignored_sources', [])
|
||||
ignored_topics = state.get('ignored_topics', [])
|
||||
if not isinstance(acknowledged, list):
|
||||
acknowledged = []
|
||||
if not isinstance(ignored_sources, list):
|
||||
ignored_sources = []
|
||||
if not isinstance(ignored_topics, list):
|
||||
ignored_topics = []
|
||||
return {
|
||||
'acknowledged': [str(x).strip() for x in acknowledged if str(x).strip()][:250],
|
||||
'ignored_sources': [str(x).strip().lower() for x in ignored_sources if str(x).strip()][:100],
|
||||
'ignored_topics': [str(x).strip().lower() for x in ignored_topics if str(x).strip()][:100],
|
||||
}
|
||||
|
||||
|
||||
def parse_dt(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
@@ -167,6 +188,10 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self._paperless_action(payload)
|
||||
if self.path == '/api/donetick-complete':
|
||||
return self._donetick_complete(payload)
|
||||
if self.path == '/api/lead-warrant-ack':
|
||||
return self._lead_warrant_ack(payload)
|
||||
if self.path == '/api/lead-warrant-ignore':
|
||||
return self._lead_warrant_ignore(payload)
|
||||
return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'})
|
||||
|
||||
def _topic_feedback(self, payload: dict):
|
||||
@@ -233,6 +258,58 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_complete_failed', 'detail': str(e)})
|
||||
|
||||
def _lead_warrant_ack(self, payload: dict):
|
||||
key = str(payload.get('key') or '').strip()
|
||||
if not key:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload', 'detail': 'expected key'})
|
||||
state = load_lead_warrant_state()
|
||||
acknowledged = [x for x in state.get('acknowledged', []) if x != key]
|
||||
acknowledged.append(key)
|
||||
save_json(LEAD_WARRANT_STATE, {
|
||||
'acknowledged': acknowledged[-250:],
|
||||
'ignored_sources': state.get('ignored_sources', []),
|
||||
'ignored_topics': state.get('ignored_topics', []),
|
||||
})
|
||||
refreshed = refresh_dashboard()
|
||||
return self._json(HTTPStatus.OK, {'ok': True, 'key': key, 'dashboard_refreshed': refreshed})
|
||||
|
||||
def _lead_warrant_ignore(self, payload: dict):
|
||||
key = str(payload.get('key') or '').strip()
|
||||
mode = str(payload.get('mode') or '').strip().lower()
|
||||
topic = str(payload.get('topic') or '').strip().lower()
|
||||
source = str(payload.get('source') or '').strip().lower()
|
||||
if not key or mode not in {'topic', 'source'}:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload', 'detail': 'expected key and mode'})
|
||||
state = load_lead_warrant_state()
|
||||
acknowledged = [x for x in state.get('acknowledged', []) if x != key]
|
||||
acknowledged.append(key)
|
||||
ignored_sources = [x for x in state.get('ignored_sources', []) if x]
|
||||
ignored_topics = [x for x in state.get('ignored_topics', []) if x]
|
||||
if mode == 'topic':
|
||||
if not topic:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload', 'detail': 'expected topic'})
|
||||
ignored_topics = [x for x in ignored_topics if x != topic]
|
||||
ignored_topics.append(topic)
|
||||
else:
|
||||
if not source:
|
||||
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload', 'detail': 'expected source'})
|
||||
ignored_sources = [x for x in ignored_sources if x != source]
|
||||
ignored_sources.append(source)
|
||||
save_json(LEAD_WARRANT_STATE, {
|
||||
'acknowledged': acknowledged[-250:],
|
||||
'ignored_sources': ignored_sources[-100:],
|
||||
'ignored_topics': ignored_topics[-100:],
|
||||
})
|
||||
refreshed = refresh_dashboard()
|
||||
return self._json(HTTPStatus.OK, {
|
||||
'ok': True,
|
||||
'key': key,
|
||||
'mode': mode,
|
||||
'ignored_source': source if mode == 'source' else '',
|
||||
'ignored_topic': topic if mode == 'topic' else '',
|
||||
'dashboard_refreshed': refreshed,
|
||||
})
|
||||
|
||||
def _donetick_focus_state(self, parsed):
|
||||
current = urllib.parse.parse_qs(parsed.query).get('current', [''])[0]
|
||||
signature, urgent_count = fetch_donetick_focus_signature()
|
||||
|
||||
@@ -13,13 +13,17 @@ ROOT=Path(__file__).resolve().parents[1]
|
||||
DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest'))
|
||||
PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
|
||||
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
|
||||
LEAD_WARRANT_STATE_PATH=DATA/'lead_warrant_state.json'
|
||||
DIGEST_FEEDBACK_PATH=DIGEST_ROOT/'data'/'feedback.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'
|
||||
HERMES_CRON_JOBS=HERMES_HOME/'cron'/'jobs.json'
|
||||
DASHBOARD_JSON=DATA/'dashboard.json'
|
||||
WIND_WATCHDOG_STATE_PATH=HERMES_HOME/'state'/'wind_alert_watchdog.json'
|
||||
CRON_GROUP_TARGET='telegram:-1003632706797'
|
||||
|
||||
LABELS={
|
||||
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
|
||||
@@ -35,6 +39,7 @@ SERVICE_DIRECTORY = [
|
||||
'items': [
|
||||
{'name': 'Doris Dashboard', 'url': 'index.html', 'health_url': 'http://127.0.0.1:8787/', 'purpose': 'Morning briefing, alerts, and operator homepage.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
|
||||
{'name': 'Services Directory', 'url': 'services.html', 'health_url': 'http://127.0.0.1:8787/services.html', 'purpose': 'Front door into the rest of the stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
|
||||
{'name': 'Doris Kitchen Imports', 'url': 'http://10.5.1.16:8092/imports', 'health_url': 'http://10.5.1.16:8092/imports', 'purpose': 'Flag bad KitchenOwl recipe imports for Doris to repair later.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8092'},
|
||||
{'name': 'Homepage', 'url': 'http://10.5.1.6:3300', 'health_url': 'http://10.5.1.6:3300', 'purpose': 'Legacy PD portal and bookmarks page for the broader stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3300'},
|
||||
],
|
||||
},
|
||||
@@ -148,6 +153,78 @@ def load_json(p:Path,d:Any)->Any:
|
||||
try: return json.loads(p.read_text()) if p.exists() else d
|
||||
except Exception: return d
|
||||
|
||||
def story_key(item:dict[str,Any] | None)->str:
|
||||
if not item:
|
||||
return ''
|
||||
url=str(item.get('url') or '').strip()
|
||||
if url:
|
||||
return url
|
||||
ident=str(item.get('id') or '').strip()
|
||||
if ident:
|
||||
return ident
|
||||
title=str(item.get('title') or '').strip().lower()
|
||||
source=str(item.get('source') or '').strip().lower()
|
||||
return f"{source}::{title}".strip(':')
|
||||
|
||||
def load_lead_warrant_state()->dict[str,Any]:
|
||||
state=load_json(LEAD_WARRANT_STATE_PATH,{})
|
||||
if not isinstance(state,dict):
|
||||
state={}
|
||||
acknowledged=state.get('acknowledged',[])
|
||||
ignored_sources=state.get('ignored_sources',[])
|
||||
ignored_topics=state.get('ignored_topics',[])
|
||||
if not isinstance(acknowledged,list):
|
||||
acknowledged=[]
|
||||
if not isinstance(ignored_sources,list):
|
||||
ignored_sources=[]
|
||||
if not isinstance(ignored_topics,list):
|
||||
ignored_topics=[]
|
||||
return {
|
||||
'acknowledged':[str(x).strip() for x in acknowledged if str(x).strip()][:250],
|
||||
'ignored_sources':[str(x).strip().lower() for x in ignored_sources if str(x).strip()][:100],
|
||||
'ignored_topics':[str(x).strip().lower() for x in ignored_topics if str(x).strip()][:100],
|
||||
}
|
||||
|
||||
def load_dashboard_feedback()->list[dict[str,Any]]:
|
||||
items=load_json(DIGEST_FEEDBACK_PATH,[])
|
||||
return items if isinstance(items,list) else []
|
||||
|
||||
def lead_feedback_blocks(item:dict[str,Any], feedback:list[dict[str,Any]])->bool:
|
||||
source=str(item.get('source') or '').strip().lower()
|
||||
category=str(item.get('category') or '').strip().lower()
|
||||
domain=(urlparse(str(item.get('url') or '')).netloc or '').lower().removeprefix('www.')
|
||||
for entry in feedback:
|
||||
if str(entry.get('vote') or '').strip().lower() != 'down':
|
||||
continue
|
||||
term=str(entry.get('term') or '').strip().lower()
|
||||
if term == f'source:{source}' and source:
|
||||
return True
|
||||
if term == f'category:{category}' and category:
|
||||
return True
|
||||
if term == f'domain:{domain}' and domain:
|
||||
return True
|
||||
return False
|
||||
|
||||
def good_lead_candidate(item:dict[str,Any], prefs:dict[str,Any], feedback:list[dict[str,Any]], ignored_sources:set[str], ignored_topics:set[str])->bool:
|
||||
if not item:
|
||||
return False
|
||||
source=str(item.get('source') or '').strip().lower()
|
||||
category=str(item.get('category') or '').strip().lower()
|
||||
domain=(urlparse(str(item.get('url') or '')).netloc or '').lower().removeprefix('www.')
|
||||
if source in ignored_sources or category in ignored_topics:
|
||||
return False
|
||||
if lead_feedback_blocks(item, feedback):
|
||||
return False
|
||||
if domain in {'reddit.com','news.ycombinator.com'}:
|
||||
return False
|
||||
score=score_item(item,prefs)
|
||||
quiet={str(x).strip().lower() for x in prefs.get('quiet_categories',[])}
|
||||
if category in quiet and score < 3:
|
||||
return False
|
||||
if category not in {'local','homelab','smart_home','radio','astronomy','us','world','tech','security'}:
|
||||
return False
|
||||
return score >= 5
|
||||
|
||||
def strip_markup(text:str)->str:
|
||||
text=re.sub(r'<[^>]+>',' ',text or '')
|
||||
return re.sub(r'\s+',' ',html.unescape(text)).strip()
|
||||
@@ -306,6 +383,7 @@ def fetch_donetick_todos()->list[dict[str,Any]]:
|
||||
users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])}
|
||||
except Exception: pass
|
||||
out=[]
|
||||
base_url=str(sec.get('instance') or '').rstrip('/')
|
||||
for c in chores:
|
||||
if c.get('status') not in (0,None): continue
|
||||
due=c.get('nextDueDate') or ''
|
||||
@@ -325,6 +403,7 @@ def fetch_donetick_todos()->list[dict[str,Any]]:
|
||||
'chips':visible_label_chips(c),
|
||||
'source':'Donetick',
|
||||
'id':c.get('id'),
|
||||
'url':(f"{base_url}/chores/{c.get('id')}" if base_url and c.get('id') else ''),
|
||||
'_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1),
|
||||
'_sort_missing':0 if due_local else 1,
|
||||
'_sort_due':due_local.timestamp() if due_local else 99999999999,
|
||||
@@ -464,6 +543,46 @@ def dashboard_cron_status()->dict[str,str]:
|
||||
return {'value':value,'state':state}
|
||||
|
||||
|
||||
def hermes_cron_summary()->dict[str,Any]:
|
||||
data=load_json(HERMES_CRON_JOBS, {'jobs': []}) if HERMES_CRON_JOBS.exists() else {'jobs': []}
|
||||
jobs=data.get('jobs') or []
|
||||
enabled=[job for job in jobs if job.get('enabled')]
|
||||
if not jobs:
|
||||
return {
|
||||
'headline':'No cron jobs configured',
|
||||
'body':'Hermes has no scheduled jobs on this box yet.',
|
||||
'state':'attention',
|
||||
}
|
||||
now=datetime.now(timezone.utc)
|
||||
failing=[job for job in enabled if str(job.get('last_status') or '').lower() not in {'', 'ok', 'scheduled'}]
|
||||
stale=[]
|
||||
for job in enabled:
|
||||
next_run=parse_dt(str(job.get('next_run_at') or ''))
|
||||
if next_run and next_run < (now - timedelta(minutes=20)):
|
||||
stale.append(job)
|
||||
group_jobs=[job for job in enabled if str(job.get('deliver') or '') == CRON_GROUP_TARGET]
|
||||
dm_jobs=[job for job in enabled if str(job.get('deliver') or '') in {'telegram:John','origin','telegram:8568847571'}]
|
||||
if failing:
|
||||
headline=f"{len(failing)} cron failure{'s' if len(failing)!=1 else ''}"
|
||||
sample=', '.join(str(job.get('name') or 'unnamed') for job in failing[:2])
|
||||
body=f"Needs attention: {sample}. {len(group_jobs)} routine to group · {len(dm_jobs)} critical to DM"
|
||||
state='warn'
|
||||
elif stale:
|
||||
headline=f"{len(stale)} cron job{'s' if len(stale)!=1 else ''} look stale"
|
||||
sample=', '.join(str(job.get('name') or 'unnamed') for job in stale[:2])
|
||||
body=f"Past next run: {sample}. {len(group_jobs)} routine to group · {len(dm_jobs)} critical to DM"
|
||||
state='attention'
|
||||
else:
|
||||
headline=f"{len(enabled)} job{'s' if len(enabled)!=1 else ''} healthy"
|
||||
body=f"{len(group_jobs)} routine to group · {len(dm_jobs)} critical to DM · {len(enabled)-len(group_jobs)-len(dm_jobs)} local/other"
|
||||
state='ok'
|
||||
return {
|
||||
'headline':headline,
|
||||
'body':body,
|
||||
'state':state,
|
||||
}
|
||||
|
||||
|
||||
def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
|
||||
usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace')
|
||||
disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)'
|
||||
@@ -736,7 +855,7 @@ def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default
|
||||
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
|
||||
state_class=f' state-{state}' if state else ''
|
||||
smart_attr=f" data-smart-state='{esc(state)}'" if smart and state else ''
|
||||
return f"<details class='collapsible {esc(extra_class)}{state_class}' data-section-key='{esc(key)}'{smart_attr}{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||||
return f"<details id='{esc(key)}' class='collapsible {esc(extra_class)}{state_class}' data-section-key='{esc(key)}'{smart_attr}{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||||
|
||||
def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]:
|
||||
out=list(items or [])
|
||||
@@ -847,13 +966,20 @@ def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
|
||||
body=''.join(rows)
|
||||
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||||
|
||||
def donetick_task_anchor(task:dict[str,Any])->str:
|
||||
task_id=str(task.get('id') or '').strip()
|
||||
return f"donetick-task-{task_id}" if task_id else ''
|
||||
|
||||
|
||||
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||||
if not items:
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||||
return f"<section id='donetick-panel' class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||||
rows=[]
|
||||
for x in items[:6]:
|
||||
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
|
||||
subtitle_text=(x.get('subtitle') or '').strip()
|
||||
task_url=str(x.get('url') or '')
|
||||
anchor_id=donetick_task_anchor(x)
|
||||
chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')}
|
||||
subtitle_parts=[]
|
||||
for part in [p.strip() for p in subtitle_text.split('•') if p.strip()]:
|
||||
@@ -869,8 +995,10 @@ def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||||
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 ''
|
||||
done_button=f"<button type='button' class='task-done-btn' data-donetick-complete data-task-id='{esc(x.get('id') or '')}'>Mark done</button>" if x.get('id') else ''
|
||||
rows.append(f"<article class='task-card {state}' data-task-id='{esc(x.get('id') or '')}'><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}{done_button}</article>")
|
||||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||||
click_attrs=f" data-task-url='{esc(task_url)}' tabindex='0' role='link'" if task_url else ''
|
||||
anchor_attr=f" id='{esc(anchor_id)}'" if anchor_id else ''
|
||||
rows.append(f"<article class='task-card {state}' data-task-id='{esc(x.get('id') or '')}'{anchor_attr}{click_attrs}><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}{done_button}</article>")
|
||||
return f"<section id='donetick-panel' class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||||
|
||||
def feedback_bootstrap()->str:
|
||||
return """<script>
|
||||
@@ -929,6 +1057,41 @@ def feedback_bootstrap()->str:
|
||||
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
|
||||
});
|
||||
});
|
||||
const openHashTarget=()=>{
|
||||
const hash=(window.location.hash||'').trim();
|
||||
if(!hash || hash==='#') return;
|
||||
const target=document.querySelector(hash);
|
||||
if(!target) return;
|
||||
let node=target;
|
||||
while(node){
|
||||
if(node.tagName==='DETAILS') node.open=true;
|
||||
node=node.parentElement;
|
||||
}
|
||||
window.setTimeout(()=>target.scrollIntoView({behavior:'smooth', block:'start'}), 30);
|
||||
};
|
||||
window.addEventListener('hashchange', openHashTarget);
|
||||
window.setTimeout(openHashTarget, 30);
|
||||
const openTaskCard=(card,newTab=false)=>{
|
||||
if(!card) return;
|
||||
const href=(card.dataset.taskUrl||'').trim();
|
||||
if(!href) return;
|
||||
if(newTab) window.open(href,'_blank','noopener,noreferrer');
|
||||
else window.open(href,'_blank','noopener,noreferrer');
|
||||
};
|
||||
document.addEventListener('click',(ev)=>{
|
||||
const ignored=ev.target.closest('button,a,input,select,textarea,label');
|
||||
if(ignored) return;
|
||||
const taskCard=ev.target.closest('.task-card[data-task-url]');
|
||||
if(!taskCard) return;
|
||||
openTaskCard(taskCard, ev.metaKey || ev.ctrlKey || ev.button===1);
|
||||
});
|
||||
document.addEventListener('keydown',(ev)=>{
|
||||
const taskCard=ev.target.closest('.task-card[data-task-url]');
|
||||
if(!taskCard) return;
|
||||
if(ev.key!=='Enter' && ev.key!==' ') return;
|
||||
ev.preventDefault();
|
||||
openTaskCard(taskCard, ev.metaKey || ev.ctrlKey);
|
||||
});
|
||||
document.addEventListener('click', (ev)=>{
|
||||
const btn=ev.target.closest('[data-feedback]');
|
||||
if(!btn) return;
|
||||
@@ -991,6 +1154,46 @@ def feedback_bootstrap()->str:
|
||||
window.location.reload();
|
||||
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
|
||||
});
|
||||
document.addEventListener('click',(ev)=>{
|
||||
const btn=ev.target.closest('[data-lead-warrant-ack]');
|
||||
if(!btn) return;
|
||||
const key=(btn.dataset.leadKey||btn.closest('[data-lead-warrant-card]')?.dataset.leadKey||'').trim();
|
||||
if(!key) return;
|
||||
btn.disabled=true;
|
||||
const previous=btn.textContent;
|
||||
btn.textContent='Acknowledging…';
|
||||
postJson('/api/lead-warrant-ack',{key}).then(()=>{
|
||||
setToast('Lead warrant acknowledged. Pulling the next lead…');
|
||||
window.setTimeout(()=>window.location.reload(),350);
|
||||
}).catch(err=>{
|
||||
console.warn('lead warrant ack failed',err);
|
||||
btn.disabled=false;
|
||||
btn.textContent=previous || 'Acknowledge + next lead';
|
||||
setToast('Lead warrant refresh failed. Try again in a moment.');
|
||||
});
|
||||
});
|
||||
document.addEventListener('click',(ev)=>{
|
||||
const btn=ev.target.closest('[data-lead-warrant-ignore]');
|
||||
if(!btn) return;
|
||||
const card=btn.closest('[data-lead-warrant-card]');
|
||||
const mode=(btn.dataset.ignoreMode||'').trim();
|
||||
const key=(btn.dataset.leadKey||card?.dataset.leadKey||'').trim();
|
||||
const topic=(btn.dataset.leadTopic||card?.dataset.leadTopic||'').trim();
|
||||
const source=(btn.dataset.leadSource||card?.dataset.leadSource||'').trim();
|
||||
if(!key || !mode) return;
|
||||
btn.disabled=true;
|
||||
const previous=btn.textContent;
|
||||
btn.textContent=mode==='topic' ? 'Ignoring topic…' : 'Ignoring source…';
|
||||
postJson('/api/lead-warrant-ignore',{key,mode,topic,source}).then(()=>{
|
||||
setToast(mode==='topic' ? 'Topic ignored for lead rotation. Pulling a different lead…' : 'Source ignored for lead rotation. Pulling a different lead…');
|
||||
window.setTimeout(()=>window.location.reload(),350);
|
||||
}).catch(err=>{
|
||||
console.warn('lead warrant ignore failed',err);
|
||||
btn.disabled=false;
|
||||
btn.textContent=previous || (mode==='topic' ? 'Ignore topic' : 'Ignore source');
|
||||
setToast('Lead warrant ignore failed. Try again in a moment.');
|
||||
});
|
||||
});
|
||||
const pollDonetickFocus=(()=>{
|
||||
let inFlight=false;
|
||||
return ()=>{
|
||||
@@ -1317,20 +1520,25 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
stats['error']='Hermes telemetry is temporarily unavailable.'
|
||||
return stats
|
||||
|
||||
def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='')->str:
|
||||
def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='',href:str='',target:str='')->str:
|
||||
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"<article class='focus-card {esc(tone)}'{severity_attr}><span class='focus-kicker'>{esc(kicker)}</span><h3>{esc(title)}</h3><strong>{esc(headline)}</strong><p>{esc(body)}</p></article>"
|
||||
card_html=f"<article class='focus-card {esc(tone)}'{severity_attr}><span class='focus-kicker'>{esc(kicker)}</span><h3>{esc(title)}</h3><strong>{esc(headline)}</strong><p>{esc(body)}</p></article>"
|
||||
if not href:
|
||||
return card_html
|
||||
target_attr=f" target='{esc(target)}' rel='noreferrer'" if target else ''
|
||||
return f"<a class='focus-card-link' href='{esc(href)}'{target_attr}>{card_html}</a>"
|
||||
|
||||
|
||||
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
|
||||
cron_summary=hermes_cron_summary()
|
||||
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'
|
||||
@@ -1338,10 +1546,13 @@ def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]]
|
||||
if urgent_todos:
|
||||
chores_headline=f"{len(urgent_todos)} urgent / due today"
|
||||
first_urgent=urgent_todos[0]
|
||||
first_urgent_anchor=donetick_task_anchor(first_urgent)
|
||||
chores_href=f"#{first_urgent_anchor}" if first_urgent_anchor else '#donetick-panel'
|
||||
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.'
|
||||
chores_href='#donetick-panel'
|
||||
if hermes.get('ok'):
|
||||
if session_remaining is not None:
|
||||
hermes_headline=f"{hermes_model} · {pct_text(session_remaining)} session left"
|
||||
@@ -1357,44 +1568,59 @@ def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]]
|
||||
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'
|
||||
cron_state=str(cron_summary.get('state') or '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.'),
|
||||
f'local {local_state}'.strip()
|
||||
f'local {local_state}'.strip(),
|
||||
href=str(local_story.get('url') or '') if local_story else '' ,
|
||||
target='_blank' if local_story and local_story.get('url') else ''
|
||||
),
|
||||
focus_card(
|
||||
'Chores with heat',
|
||||
'Donetick',
|
||||
chores_headline,
|
||||
chores_body,
|
||||
chores_state
|
||||
chores_state,
|
||||
href=chores_href
|
||||
),
|
||||
focus_card(
|
||||
'Hermes now',
|
||||
'Usage / budget',
|
||||
hermes_headline,
|
||||
hermes_body,
|
||||
hermes_state
|
||||
hermes_state,
|
||||
href='#hermes-usage'
|
||||
),
|
||||
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'),
|
||||
paperless_state
|
||||
paperless_state,
|
||||
href='#paperless-review'
|
||||
),
|
||||
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.'),
|
||||
homelab_state
|
||||
homelab_state,
|
||||
href='#homelab-pulse'
|
||||
),
|
||||
focus_card(
|
||||
'Automation lane',
|
||||
'Hermes cron',
|
||||
str(cron_summary.get('headline') or 'Cron summary unavailable'),
|
||||
str(cron_summary.get('body') or 'Could not read cron job state.'),
|
||||
cron_state,
|
||||
href='services.html'
|
||||
),
|
||||
]
|
||||
section_state=combine_states([local_state,chores_state,hermes_state,paperless_state,homelab_state])
|
||||
section_state=combine_states([local_state,chores_state,hermes_state,paperless_state,homelab_state,cron_state])
|
||||
return ("<section class='focus-grid'>" + ''.join(cards) + "</section>", section_state)
|
||||
|
||||
|
||||
@@ -1413,10 +1639,35 @@ def hermes_delta_text(current:int|float|None, previous:int|float|None)->str:
|
||||
return f"{abs(delta):.0f}% {direction} vs previous 24h"
|
||||
|
||||
|
||||
def choose_feature_story(local_items:list[dict[str,Any]], homelab_items:list[dict[str,Any]], space_items:list[dict[str,Any]], signal_items:list[dict[str,Any]])->dict[str,Any] | None:
|
||||
for pool in (local_items, homelab_items, space_items, signal_items):
|
||||
if pool:
|
||||
return pool[0]
|
||||
def choose_feature_story(preferred_items:list[dict[str,Any]], fallback_items:list[dict[str,Any]], prefs:dict[str,Any])->dict[str,Any] | None:
|
||||
state=load_lead_warrant_state()
|
||||
feedback=load_dashboard_feedback()
|
||||
acknowledged=set(state.get('acknowledged',[]))
|
||||
ignored_sources=set(state.get('ignored_sources',[]))
|
||||
ignored_topics=set(state.get('ignored_topics',[]))
|
||||
candidates=[]
|
||||
fallback=[]
|
||||
seen=set()
|
||||
for pool_name,pool in (('preferred',preferred_items),('fallback',fallback_items)):
|
||||
for item in pool:
|
||||
key=story_key(item)
|
||||
dedupe=key or f"{str(item.get('source') or '').strip().lower()}::{str(item.get('title') or '').strip().lower()}"
|
||||
if dedupe in seen:
|
||||
continue
|
||||
seen.add(dedupe)
|
||||
fallback.append(item)
|
||||
if good_lead_candidate(item,prefs,feedback,ignored_sources,ignored_topics):
|
||||
candidates.append(item)
|
||||
for item in candidates:
|
||||
if story_key(item) not in acknowledged:
|
||||
return item
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
for item in fallback:
|
||||
if story_key(item) not in acknowledged:
|
||||
return item
|
||||
if fallback:
|
||||
return fallback[0]
|
||||
return None
|
||||
|
||||
|
||||
@@ -1451,6 +1702,67 @@ def build_feature_story(feature_item:dict[str,Any] | None)->str:
|
||||
</section>"""
|
||||
|
||||
|
||||
def build_hero_caseboard(feature_item:dict[str,Any] | None, todos:list[dict[str,Any]], paperless:dict[str,Any])->str:
|
||||
lead_title='Lead signal pending'
|
||||
lead_summary='No strong lead item surfaced yet, so the board is standing by for the next worthwhile signal.'
|
||||
lead_meta=['Signals desk']
|
||||
lead_url=''
|
||||
lead_source=''
|
||||
lead_topic=''
|
||||
if feature_item:
|
||||
lead_title=str(feature_item.get('title') or lead_title)
|
||||
lead_summary=((feature_item.get('summary') or '').strip() or str(why(feature_item) or lead_summary))[:220]
|
||||
lead_meta=[label(str(feature_item.get('category') or 'other')), str(feature_item.get('source') or 'Source')]
|
||||
lead_url=str(feature_item.get('url') or '')
|
||||
lead_source=str(feature_item.get('source') or '')
|
||||
lead_topic=str(feature_item.get('category') or '')
|
||||
lead_key=story_key(feature_item)
|
||||
|
||||
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
|
||||
next_task=urgent_todos[0] if urgent_todos else (todos[0] if todos else {})
|
||||
task_title=str(next_task.get('title') or 'No urgent chores')
|
||||
task_meta=' · '.join(x for x in [str(next_task.get('due_title') or ''), str(next_task.get('assignee') or '')] if x) or 'Donetick is not yelling at you right now.'
|
||||
task_body=(str(next_task.get('subtitle') or '') or str(next_task.get('body') or '') or 'No immediate field action queued.')[:140]
|
||||
paperless_headline=(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')
|
||||
paperless_body=str(paperless.get('headline') or 'Paperless queue is clear.')[:140]
|
||||
lead_meta_html=''.join(f"<span>{esc(part)}</span>" for part in lead_meta if part)
|
||||
lead_cta=(f"<a href='{esc(lead_url)}' target='_blank' rel='noreferrer' class='hero-dossier-link'>Open lead item</a>" if lead_url else "<span class='hero-dossier-link disabled'>Awaiting live lead</span>")
|
||||
lead_ack=(f"<button type='button' class='paperless-btn hero-dossier-btn' data-lead-warrant-ack data-lead-key='{esc(lead_key)}'>Acknowledge + next lead</button>" if lead_key else "<button type='button' class='paperless-btn hero-dossier-btn' disabled>No live lead yet</button>")
|
||||
lead_ignore_topic=(f"<button type='button' class='paperless-btn hero-dossier-btn' data-lead-warrant-ignore data-ignore-mode='topic' data-lead-key='{esc(lead_key)}' data-lead-topic='{esc(lead_topic)}'>Ignore topic</button>" if lead_key and lead_topic else '')
|
||||
lead_ignore_source=(f"<button type='button' class='paperless-btn hero-dossier-btn' data-lead-warrant-ignore data-ignore-mode='source' data-lead-key='{esc(lead_key)}' data-lead-source='{esc(lead_source)}'>Ignore source</button>" if lead_key and lead_source else '')
|
||||
return f"""
|
||||
<section class='hero-board dossier-grid'>
|
||||
<div class='hero-poster-stack'>
|
||||
{hot_fuzz_art('operator brief')}
|
||||
<div class='brand-badges hero-badges'><span class='pill badge-hotfuzz for-the-greater-good'>For the Greater Good</span><span class='pill'>Evidence locker: operator brief</span></div>
|
||||
<div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div>
|
||||
</div>
|
||||
<div class='hero-dossier-stack'>
|
||||
<article class='hero-dossier-card lead-warrant evidence-board' data-lead-warrant-card data-lead-key='{esc(lead_key)}' data-lead-topic='{esc(lead_topic)}' data-lead-source='{esc(lead_source)}'>
|
||||
<div class='case-legend'><span class='eyebrow'>Lead warrant</span><span class='hero-chip'>Operator brief</span></div>
|
||||
<h2>{esc(lead_title)}</h2>
|
||||
<p>{esc(lead_summary)}</p>
|
||||
<div class='hero-dossier-meta'>{lead_meta_html}</div>
|
||||
<div class='hero-dossier-actions'>{lead_cta}{lead_ack}{lead_ignore_topic}{lead_ignore_source}</div>
|
||||
</article>
|
||||
<div class='hero-slip-grid'>
|
||||
<article class='hero-dossier-card evidence-board'>
|
||||
<span class='hero-slip-label'>Next field action</span>
|
||||
<strong>{esc(task_title)}</strong>
|
||||
<p>{esc(task_meta)}</p>
|
||||
<small>{esc(task_body)}</small>
|
||||
</article>
|
||||
<article class='hero-dossier-card evidence-board'>
|
||||
<span class='hero-slip-label'>Paperless queue</span>
|
||||
<strong>{esc(paperless_headline)}</strong>
|
||||
<p>{esc(paperless_body)}</p>
|
||||
<small>Keep the desk clear before it turns into a paperwork siege.</small>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>"""
|
||||
|
||||
|
||||
def build_operator_snapshot(todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes: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']
|
||||
@@ -1664,16 +1976,16 @@ 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:
|
||||
def top_nav(active:str)->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'),
|
||||
('Donetick','https://donetick.paccoco.com','donetick'),
|
||||
('Paperless','https://paperless.paccoco.com','paperless'),
|
||||
('n8n','https://n8n.paccoco.com','n8n'),
|
||||
('Grafana','https://grafana.paccoco.com','grafana'),
|
||||
('Gitea','https://gitea.paccoco.com','gitea'),
|
||||
('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'),
|
||||
]
|
||||
links=[]
|
||||
for label,url,key in items:
|
||||
@@ -1686,7 +1998,12 @@ def top_nav(active:str='home')->str:
|
||||
attrs.append("target='_blank'")
|
||||
attrs.append("rel='noreferrer'")
|
||||
links.append(f"<a class='{' '.join(classes)}' href='{esc(url)}' {' '.join(attrs)}>{esc(label)}</a>")
|
||||
return "<nav class='top-nav'><div class='nav-brand'><span class='nav-kicker'>Doris</span><strong>Operator Portal</strong></div><div class='nav-links'>" + ''.join(links) + "</div></nav>"
|
||||
return "<nav class='top-nav'><div class='nav-brand'><span class='nav-kicker'>N.W.A. Case File</span><strong>Doris Constabulary</strong></div><div class='nav-links'>" + ''.join(links) + "</div></nav>"
|
||||
|
||||
|
||||
def hot_fuzz_art(label:str)->str:
|
||||
safe_label=esc(label.upper())
|
||||
return f"""<div class='hot-fuzz-art' aria-hidden='true'><div class='film-grain'></div><div class='cinematic-glow'></div><div class='casefile-stamp operator-evidence-tag'>Operator Evidence Board</div><svg viewBox='0 0 320 220' class='poster-illustration' role='presentation'><defs><linearGradient id='dashboard-sunset' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='#f2c14e'></stop><stop offset='50%' stop-color='#d53600'></stop><stop offset='100%' stop-color='#910f3f'></stop></linearGradient></defs><rect x='8' y='8' width='304' height='204' rx='24' class='poster-frame'></rect><circle cx='228' cy='72' r='58' class='poster-halo'></circle><path d='M18 170 L112 100 L134 124 L40 198 Z' class='poster-tape tape-left'></path><path d='M202 82 L302 32 L314 60 L214 112 Z' class='poster-tape tape-right'></path><path d='M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z' class='constable-silhouette lead'></path><path d='M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z' class='constable-silhouette partner'></path><circle cx='84' cy='60' r='28' class='swan-stamp'></circle><path d='M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z' class='swan-mark'></path><path d='M126 82 h68 v14 h-68z' class='dossier-strip'></path><path d='M126 104 h82 v10 h-82z' class='dossier-strip'></path><path d='M126 122 h54 v10 h-54z' class='dossier-strip'></path><text x='160' y='204' class='poster-callout'>{safe_label}</text></svg></div>"""
|
||||
|
||||
|
||||
def build_services_inventory()->tuple[list[dict[str,Any]],list[dict[str,Any]]]:
|
||||
@@ -1763,7 +2080,7 @@ def render_services_page(hero_summary:str,candidate_count:int,source_count:int,h
|
||||
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.',"<section class='summary-grid'><article class='summary-card'><h3>How to use this</h3><p>Use Home for today, alerts, and the briefing. Use Services when you want the rest of the stack quickly without hunting bookmarks.</p></article><article class='summary-card'><h3>Design rule</h3><p>Shared navigation and status live here; full workflows still stay in their own apps.</p></article></section>",'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"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Doris Services</title><link rel='stylesheet' href='style.css'></head><body><main>{top_nav('services')}<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Services Directory</h1><p class='sub'>Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}</p></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time</span></div><div><strong>{candidate_count}</strong><span>News candidates</span></div><div><strong>{source_count}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header><div class='dashboard-shell services-shell'><section class='primary-column'>{services_html}</section><aside class='secondary-column'>{utility_html}</aside></div><footer><p>Operator portal links are curated from the homelab docs and current live dashboard context.</p><p>Local-only on NOMAD unless John says otherwise.</p></footer></main></body></html>"
|
||||
return f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Doris Services</title><link rel='stylesheet' href='style.css'></head><body><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('services')}<header class='hero casefile-header evidence-board'><div><p class='eyebrow'>Sandford services desk</p><div class='case-legend'><h1>Services Directory</h1><span class='hero-chip'>Operator board</span></div><p class='sub'>Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}</p>{hot_fuzz_art('services')}<div class='brand-badges'><span class='pill badge-hotfuzz for-the-greater-good'>For the Greater Good</span><span class='pill'>Evidence locker: services</span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time</span></div><div><strong>{candidate_count}</strong><span>News candidates</span></div><div><strong>{source_count}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header><div class='dashboard-shell services-shell dossier-grid'><section class='primary-column'>{services_html}</section><aside class='secondary-column'>{utility_html}</aside></div><footer><p>Operator portal links are curated from the homelab docs and current live dashboard context.</p><p>Local-only on NOMAD unless John says otherwise.</p></footer></main></body></html>"
|
||||
|
||||
def render()->str:
|
||||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||
@@ -1794,7 +2111,8 @@ def render()->str:
|
||||
system_html=build_system_section(system)
|
||||
hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—'
|
||||
snapshot_html=build_operator_snapshot(todos,pulse,paperless,hermes)
|
||||
feature_item=choose_feature_story(groups['local'], groups['homelab'], groups['space'], top_briefing or groups['signals'])
|
||||
feature_item=choose_feature_story(top_briefing, selected, prefs)
|
||||
hero_caseboard_html=build_hero_caseboard(feature_item,todos,paperless)
|
||||
feature_html=build_feature_story(feature_item)
|
||||
session_window=find_usage_window(hermes.get('account_windows',[]),'session') if hermes.get('ok') else None
|
||||
primary_html=''.join([
|
||||
@@ -1817,10 +2135,10 @@ def render()->str:
|
||||
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 data-donetick-focus-signature='{esc(focus_signature)}'><main>{top_nav('home')}
|
||||
<header class='hero'><div class='hero-main'><p class='eyebrow'>PC Doris Thatcher</p><div class='hero-title-row'><h1>Doris Dashboard</h1><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '—')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div></div><div class='hero-rail'><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></div></header>
|
||||
<section class='snapshot-block'><div class='section-heading-inline'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section>
|
||||
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
|
||||
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 data-donetick-focus-signature='{esc(focus_signature)}'><div class='incident-tape' aria-hidden='true'></div><main>{top_nav('home')}
|
||||
<header class='hero casefile-header evidence-board'><div class='hero-main'><p class='eyebrow'>Sandford operator desk</p><div class='case-legend'><h1>Doris Dashboard</h1><span class='hero-chip'>Operator board</span></div><div class='hero-title-row'><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '—')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p>{hero_caseboard_html}</div><div class='hero-rail'><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></div></header>
|
||||
<section class='snapshot-block evidence-board'><div class='section-heading-inline case-legend'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section>
|
||||
<div class='dashboard-shell dossier-grid'><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:
|
||||
|
||||
Reference in New Issue
Block a user