diff --git a/home/doris-dashboard/bin/dashboard_server.py b/home/doris-dashboard/bin/dashboard_server.py index 1a72020..7085213 100755 --- a/home/doris-dashboard/bin/dashboard_server.py +++ b/home/doris-dashboard/bin/dashboard_server.py @@ -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() diff --git a/home/doris-dashboard/bin/generate_dashboard.py b/home/doris-dashboard/bin/generate_dashboard.py index 53202b0..f215c2b 100755 --- a/home/doris-dashboard/bin/generate_dashboard.py +++ b/home/doris-dashboard/bin/generate_dashboard.py @@ -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"
{esc(sub)}
" 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"