Admin: add HA entities, News CRUD, Proxmox VMs tabs; news.php merges custom pinned entries

This commit is contained in:
2026-05-30 03:31:50 +00:00
parent 50c06722bb
commit f73ce6cd57
2 changed files with 267 additions and 7 deletions
+24 -7
View File
@@ -1,5 +1,5 @@
<?php
// News endpoint — serves from api_cache (refreshed every 30 min by cron)
// News endpoint — serves from api_cache + custom pinned news from admin kb_facts
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
@@ -9,12 +9,29 @@ $cached = JarvisDB::query(
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
echo json_encode($out);
} else {
echo json_encode([
'categories' => [],
'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);
+243
View File
@@ -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)}
<div class="nav-section">KNOWLEDGE</div>
<div class="nav-item" data-tab="facts" onclick="nav(this)">KB FACTS</div>
<div class="nav-item" data-tab="intents" onclick="nav(this)">KB INTENTS</div>
<div class="nav-section">LIVE</div>
<div class="nav-item" data-tab="ha" onclick="nav(this)">HOME ASSISTANT</div>
<div class="nav-item" data-tab="news" onclick="nav(this)">NEWS</div>
<div class="nav-item" data-tab="vms" onclick="nav(this)">PROXMOX VMs</div>
<div class="nav-section">INFO</div>
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
@@ -495,6 +565,48 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="tbl-wrap" id="intents-tbl"><div class="loading">LOADING...</div></div>
</div>
<!-- HOME ASSISTANT -->
<div class="tab" id="tab-ha">
<div class="page-title">HOME ASSISTANT ENTITIES
<div class="actions"><button class="btn btn-sm" onclick="loadHA()">REFRESH</button></div>
</div>
<div class="filters">
<span class="lbl">DOMAIN:</span>
<select class="filter-sel" id="ha-domain" onchange="loadHA()"><option value="">ALL</option></select>
&nbsp;<input id="ha-search" placeholder="search name or entity_id..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:220px;outline:none" oninput="filterHATable()" onchange="filterHATable()">
<span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="ha-tbl"><div class="loading">LOADING...</div></div>
</div>
<!-- NEWS -->
<div class="tab" id="tab-news">
<div class="page-title">NEWS MANAGEMENT
<div class="actions">
<button class="btn btn-sm btn-green" onclick="newsCustomModal()">+ ADD CUSTOM</button>
<button class="btn btn-sm" onclick="loadNews()">REFRESH</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">PINNED / CUSTOM NEWS</div>
<div id="news-custom"><div class="loading">LOADING...</div></div>
</div>
<div>
<div style="color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">LIVE FEED (auto-refreshed)</div>
<div id="news-live"><div class="loading">LOADING...</div></div>
</div>
</div>
</div>
<!-- PROXMOX VMs -->
<div class="tab" id="tab-vms">
<div class="page-title">PROXMOX VMs
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
</div>
<div class="tbl-wrap" id="vms-tbl"><div class="loading">LOADING...</div></div>
</div>
<!-- SITES -->
<div class="tab" id="tab-sites">
<div class="page-title">SITE HEALTH</div>
@@ -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 = '<div class="loading">LOADING...</div>';
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 = '<option value="">ALL DOMAINS</option>' + (data.domains||[]).map(d=>`<option value="${esc(d)}" ${d===cur?'selected':''}>${esc(d).toUpperCase()} </option>`).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='<div class="empty">NO ENTITIES</div>'; 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 `<tr>
<td><span style="color:${dc};font-size:0.6rem">${esc(e.domain)}</span></td>
<td>${esc(e.name||e.entity_id)}</td>
<td style="font-size:0.65rem;color:var(--dim)">${esc(e.entity_id)}</td>
<td><span class="badge ${on?'badge-green':'badge-dim'}">${esc(e.state)}</span></td>
<td>${toggleable?`<button class="btn btn-xs" onclick="haToggle('${esc(e.entity_id)}','${esc(e.state)}',this)">${on?'TURN OFF':'TURN ON'}</button>`:''}</td>
</tr>`;
}).join('');
document.getElementById('ha-tbl').innerHTML = `<table>
<thead><tr><th>DOMAIN</th><th>NAME</th><th>ENTITY ID</th><th>STATE</th><th>ACTION</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
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='<div class="loading">LOADING...</div>';
document.getElementById('news-live').innerHTML='<div class="loading">LOADING...</div>';
const data = await api('news_list');
// Custom entries
const custom = data.custom||[];
if (!custom.length) {
document.getElementById('news-custom').innerHTML='<div class="empty">NO CUSTOM ENTRIES</div>';
} else {
document.getElementById('news-custom').innerHTML = custom.map(c=>`
<div style="background:var(--surface);border:1px solid var(--border);padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:8px">
<div style="flex:1">
<div style="font-size:0.75rem">${esc(c.title)}</div>
${c.url?`<div style="font-size:0.6rem;color:var(--dim)">${esc(c.url)}</div>`:''}
</div>
<button class="btn btn-xs btn-yellow" onclick='newsCustomModal(${c.id},"${esc(c.title)}","${esc(c.url||"")}")'>EDIT</button>
<button class="btn btn-xs btn-red" onclick="apiPost('news_custom_delete',{id:${c.id}},()=>{toast('Deleted','ok');loadNews()})">DEL</button>
</div>`).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='<div class="empty">NO FEED DATA</div>';
} else {
document.getElementById('news-live').innerHTML = all.map(n=>`
<div style="border-bottom:1px solid var(--border);padding:8px 0;font-size:0.72rem">
<div>${esc(n.title||'')}</div>
<div style="color:var(--dim);font-size:0.6rem">${esc(n.source||'')}${n.published?' · '+n.published:''}</div>
</div>`).join('');
}
}
function newsCustomModal(id=0, title='', url='') {
openModal(id?'EDIT CUSTOM NEWS':'ADD CUSTOM NEWS', `
<div class="form-row"><label>HEADLINE / TITLE</label><input id="nc-t" value="${esc(title)}"></div>
<div class="form-row"><label>URL (optional)</label><input id="nc-u" value="${esc(url)}" placeholder="https://..."></div>
<input type="hidden" id="nc-id" value="${id}">
`, () => {
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='<div class="loading">LOADING...</div>';
const data = await api('vms_list');
const vms = data.vms||[];
if (!vms.length) { document.getElementById('vms-tbl').innerHTML='<div class="empty">NO VM DATA (Proxmox cache may be empty)</div>'; 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 `<tr>
<td>${v.vmid||'—'}</td>
<td><strong>${esc(v.name||'—')}</strong></td>
<td><span class="badge badge-dim">${esc(v.type||'vm').toUpperCase()}</span></td>
<td>${run?'<span class="badge badge-green">RUNNING</span>':'<span class="badge badge-red">'+esc(v.status).toUpperCase()+'</span>'}</td>
<td>${cpu}</td><td>${mem}</td>
<td class="ts">${v.uptime_human||'—'}</td>
</tr>`;
}).join('');
document.getElementById('vms-tbl').innerHTML = `
${ns.cpu_pct!=null?`<div style="font-size:0.65rem;color:var(--dim);padding:8px 12px">PVE NODE — CPU: ${Math.round(ns.cpu_pct)}% · RAM: ${Math.round(ns.mem_pct||0)}% · UPTIME: ${ns.uptime||'—'}</div>`:''}
<table><thead><tr><th>VMID</th><th>NAME</th><th>TYPE</th><th>STATUS</th><th>CPU</th><th>MEM</th><th>UPTIME</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
// ── AUTO-LOGIN CHECK (PHP session) ───────────────────────────────────────────
<?php if (loggedIn()): ?>
document.getElementById('loginWrap').style.display='none';