From f73ce6cd57a2649a8cc284939336661af6743768 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sat, 30 May 2026 03:31:50 +0000 Subject: [PATCH] Admin: add HA entities, News CRUD, Proxmox VMs tabs; news.php merges custom pinned entries --- api/endpoints/news.php | 31 +++-- public_html/admin/index.php | 243 ++++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 7 deletions(-) diff --git a/api/endpoints/news.php b/api/endpoints/news.php index 0982b71..b2fb3bd 100644 --- a/api/endpoints/news.php +++ b/api/endpoints/news.php @@ -1,5 +1,5 @@ [], - 'total' => 0, + $out = [ + 'categories' => [], + 'total' => 0, 'cache_age_s' => -1, - 'message' => 'News feed warming up — available within 5 minutes.', - ]); + 'message' => 'News feed warming up — available within 5 minutes.', + ]; } + +// Prepend custom/pinned news items added via admin portal +$custom = JarvisDB::query( + "SELECT fact_key as title, fact_value as url, updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC" +); +if (!empty($custom)) { + $pinned = array_map(fn($r) => [ + 'title' => $r['title'], + 'url' => $r['url'] ?: null, + 'source' => 'JARVIS', + 'published' => $r['updated_at'], + 'pinned' => true, + ], $custom); + // Insert pinned as first category + $out['categories'] = array_merge(['pinned' => $pinned], $out['categories'] ?? []); +} + +echo json_encode($out); diff --git a/public_html/admin/index.php b/public_html/admin/index.php index a458f05..744155c 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -224,6 +224,72 @@ if ($action) { case 'sites_list': j(JarvisDB::query("SELECT fact_key,fact_value,updated_at FROM kb_facts WHERE category='sites' ORDER BY fact_key")); + // ── HOME ASSISTANT ENTITIES ─────────────────────────────────────────── + case 'ha_list': + $raw = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='ha_entities'"); + if (!$raw) j(['entities'=>[],'domains'=>[],'ts'=>null]); + $cache = json_decode($raw['data'], true) ?? []; + $domain = $_GET['domain'] ?? ''; + $search = strtolower(trim($_GET['search'] ?? '')); + $all = []; + foreach ($cache['entities'] ?? [] as $dom => $ents) { + if ($domain && $dom !== $domain) continue; + foreach ($ents as $e) { + if ($search && strpos(strtolower($e['name']??''),$search)===false && strpos(strtolower($e['entity_id']??''),$search)===false) continue; + $e['domain'] = $dom; + $all[] = $e; + } + } + usort($all, fn($a,$b) => strcmp($a['name']??'',$b['name']??'')); + j(['entities'=>array_slice($all,0,500),'domains'=>array_keys($cache['entities']??[]),'total'=>count($all),'ts'=>$raw['ts']]); + + case 'ha_toggle': + $eid = trim($_POST['entity_id'] ?? ''); if (!$eid) bad('Missing entity_id'); + $state = trim($_POST['state'] ?? ''); + if (!defined('HA_URL')||!defined('HA_TOKEN')) bad('HA not configured'); + $domain = explode('.',$eid)[0]; + $svc = match($domain) { + 'light','switch','input_boolean','fan' => ($state==='on'?'turn_off':'turn_on'), + default => ($state==='on'?'turn_off':'turn_on') + }; + $ch = curl_init(HA_URL.'/api/services/'.$domain.'/'.$svc); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true, + CURLOPT_HTTPHEADER=>['Authorization: Bearer '.HA_TOKEN,'Content-Type: application/json'], + CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$eid]),CURLOPT_TIMEOUT=>8]); + $res = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); + j(['ok'=>$code<300,'code'=>$code]); + + // ── NEWS ───────────────────────────────────────────────────────────── + case 'news_list': + $cached = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='news'"); + $news = $cached ? (json_decode($cached['data'],true)??[]) : []; + $custom = JarvisDB::query("SELECT id,fact_key title,fact_value url,updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC"); + j(['news'=>$news,'custom'=>$custom,'cache_age'=>$cached?time()-(int)$cached['ts']:null]); + + case 'news_custom_save': + $id = (int)($_POST['id']??0); + $t = trim($_POST['title']??''); if(!$t) bad('Title required'); + $url = trim($_POST['url']??''); + if($id) { + JarvisDB::execute('UPDATE kb_facts SET fact_key=?,fact_value=? WHERE id=? AND category="custom_news"',[$t,$url,$id]); + } else { + JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES ("custom_news",?,?)',[$t,$url]); + } + j(['ok'=>true]); + + case 'news_custom_delete': + $id=(int)($_POST['id']??0); if(!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM kb_facts WHERE id=? AND category="custom_news"',[$id]); + j(['ok'=>true]); + + // ── PROXMOX VMs ─────────────────────────────────────────────────────── + case 'vms_list': + $raw = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='proxmox'"); + if (!$raw) j(['vms'=>[],'ts'=>null]); + $pve = json_decode($raw['data'],true) ?? []; + $vms = array_merge($pve['vms']??[], $pve['containers']??[]); + j(['vms'=>$vms,'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]); + // ── USERS ──────────────────────────────────────────────────────────── case 'users_list': j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username')); @@ -407,6 +473,10 @@ select.filter-sel:focus{border-color:var(--cyan)} + + + + @@ -495,6 +565,48 @@ select.filter-sel:focus{border-color:var(--cyan)}
LOADING...
+ +
+
HOME ASSISTANT ENTITIES +
+
+
+ DOMAIN: + +   + +
+
LOADING...
+
+ + +
+
NEWS MANAGEMENT +
+ + +
+
+
+
+
PINNED / CUSTOM NEWS
+
LOADING...
+
+
+
LIVE FEED (auto-refreshed)
+
LOADING...
+
+
+
+ + +
+
PROXMOX VMs +
+
+
LOADING...
+
+
SITE HEALTH
@@ -611,6 +723,9 @@ function loadTab(tab) { alerts: loadAlerts, facts: ()=>{ loadFactCategories(); loadFacts(); }, intents: loadIntents, + ha: loadHA, + news: loadNews, + vms: loadVMs, sites: loadSites, users: loadUsers, })[tab]?.(); @@ -1000,6 +1115,134 @@ document.getElementById('modalBg').addEventListener('click', e => { if (e.target document.addEventListener('keydown', e => { if (e.key==='Escape') closeModal(); }); document.addEventListener('keydown', e => { if (e.key==='Enter' && e.ctrlKey && document.getElementById('modalBg').classList.contains('open')) modalSave(); }); +// ── HOME ASSISTANT ──────────────────────────────────────────────────────────── +let _haEntities = []; + +async function loadHA() { + document.getElementById('ha-tbl').innerHTML = '
LOADING...
'; + const domain = document.getElementById('ha-domain')?.value || ''; + const data = await api('ha_list', {domain}); + _haEntities = data.entities || []; + // Populate domain filter + const sel = document.getElementById('ha-domain'); + const cur = sel.value; + sel.innerHTML = '' + (data.domains||[]).map(d=>``).join(''); + if (cur) sel.value = cur; + const age = data.ts ? Math.floor((Date.now()/1000)-data.ts) : null; + document.getElementById('ha-count').textContent = `${_haEntities.length} ENTITIES${age!=null?' · CACHE '+age+'s AGO':''}`; + renderHATable(_haEntities); +} + +function filterHATable() { + const q = document.getElementById('ha-search')?.value.toLowerCase() || ''; + renderHATable(q ? _haEntities.filter(e => (e.name||'').toLowerCase().includes(q)||(e.entity_id||'').toLowerCase().includes(q)) : _haEntities); +} + +function renderHATable(entities) { + if (!entities.length) { document.getElementById('ha-tbl').innerHTML='
NO ENTITIES
'; return; } + const domainColors = {light:'#ffcc00',switch:'#00d4ff',binary_sensor:'#39ff14',sensor:'#9b9bff',media_player:'#ff8800',alarm_control_panel:'#ff3333',camera:'#888'}; + let rows = entities.map(e => { + const on = ['on','home','open','playing','mowing','active'].includes(e.state); + const dc = domainColors[e.domain] || 'var(--dim)'; + const toggleable = ['light','switch','input_boolean','fan'].includes(e.domain); + return ` + ${esc(e.domain)} + ${esc(e.name||e.entity_id)} + ${esc(e.entity_id)} + ${esc(e.state)} + ${toggleable?``:''} + `; + }).join(''); + document.getElementById('ha-tbl').innerHTML = ` + + ${rows}
DOMAINNAMEENTITY IDSTATEACTION
`; +} + +async function haToggle(eid, state, btn) { + btn.disabled=true; btn.textContent='...'; + const fd=new FormData(); fd.append('action','ha_toggle'); fd.append('entity_id',eid); fd.append('state',state); + try { + const r=await fetch(location.href,{method:'POST',body:fd}); + const d=await r.json(); + if(d.ok) { toast('Toggled '+eid,'ok'); setTimeout(loadHA,1500); } + else toast('Toggle failed','err'); + } catch(e){ toast('Failed','err'); } + btn.disabled=false; +} + +// ── NEWS ────────────────────────────────────────────────────────────────────── +async function loadNews() { + document.getElementById('news-custom').innerHTML='
LOADING...
'; + document.getElementById('news-live').innerHTML='
LOADING...
'; + const data = await api('news_list'); + + // Custom entries + const custom = data.custom||[]; + if (!custom.length) { + document.getElementById('news-custom').innerHTML='
NO CUSTOM ENTRIES
'; + } else { + document.getElementById('news-custom').innerHTML = custom.map(c=>` +
+
+
${esc(c.title)}
+ ${c.url?`
${esc(c.url)}
`:''} +
+ + +
`).join(''); + } + + // Live feed + const cats = data.news?.categories || {}; + const all = Object.values(cats).flat().slice(0,30); + if (!all.length) { + document.getElementById('news-live').innerHTML='
NO FEED DATA
'; + } else { + document.getElementById('news-live').innerHTML = all.map(n=>` +
+
${esc(n.title||'')}
+
${esc(n.source||'')}${n.published?' · '+n.published:''}
+
`).join(''); + } +} + +function newsCustomModal(id=0, title='', url='') { + openModal(id?'EDIT CUSTOM NEWS':'ADD CUSTOM NEWS', ` +
+
+ + `, () => { + apiPost('news_custom_save',{id:document.getElementById('nc-id').value,title:document.getElementById('nc-t').value,url:document.getElementById('nc-u').value}, + ()=>{ toast('Saved','ok'); closeModal(); loadNews(); }); + }); +} + +// ── PROXMOX VMs ─────────────────────────────────────────────────────────────── +async function loadVMs() { + document.getElementById('vms-tbl').innerHTML='
LOADING...
'; + const data = await api('vms_list'); + const vms = data.vms||[]; + if (!vms.length) { document.getElementById('vms-tbl').innerHTML='
NO VM DATA (Proxmox cache may be empty)
'; return; } + const ns = data.node_status||{}; + let rows = vms.map(v => { + const run = v.status==='running'; + const cpu = v.cpu_pct!=null?Math.round(v.cpu_pct)+'%':'—'; + const mem = v.mem_pct!=null?Math.round(v.mem_pct)+'%':'—'; + return ` + ${v.vmid||'—'} + ${esc(v.name||'—')} + ${esc(v.type||'vm').toUpperCase()} + ${run?'RUNNING':''+esc(v.status).toUpperCase()+''} + ${cpu}${mem} + ${v.uptime_human||'—'} + `; + }).join(''); + document.getElementById('vms-tbl').innerHTML = ` + ${ns.cpu_pct!=null?`
PVE NODE — CPU: ${Math.round(ns.cpu_pct)}% · RAM: ${Math.round(ns.mem_pct||0)}% · UPTIME: ${ns.uptime||'—'}
`:''} + + ${rows}
VMIDNAMETYPESTATUSCPUMEMUPTIME
`; +} + // ── AUTO-LOGIN CHECK (PHP session) ─────────────────────────────────────────── document.getElementById('loginWrap').style.display='none';