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)}
KNOWLEDGE
KB FACTS
KB INTENTS
+ LIVE
+ HOME ASSISTANT
+ NEWS
+ PROXMOX VMs
INFO
SITES
USERS
@@ -495,6 +565,48 @@ select.filter-sel:focus{border-color:var(--cyan)}
+
+
+
HOME ASSISTANT ENTITIES
+
+
+
+ DOMAIN:
+
+
+
+
+
+
+
+
+
+
NEWS MANAGEMENT
+
+
+
+
+
+
+
+
PINNED / CUSTOM NEWS
+
+
+
+
LIVE FEED (auto-refreshed)
+
+
+
+
+
+
+
+
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 = `
+ | DOMAIN | NAME | ENTITY ID | STATE | ACTION |
+ ${rows}
`;
+}
+
+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||'—'}
`:''}
+
| VMID | NAME | TYPE | STATUS | CPU | MEM | UPTIME |
+ ${rows}
`;
+}
+
// ── AUTO-LOGIN CHECK (PHP session) ───────────────────────────────────────────
document.getElementById('loginWrap').style.display='none';