mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Admin: add HA entities, News CRUD, Proxmox VMs tabs; news.php merges custom pinned entries
This commit is contained in:
+21
-4
@@ -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([
|
||||
$out = [
|
||||
'categories' => [],
|
||||
'total' => 0,
|
||||
'cache_age_s' => -1,
|
||||
'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);
|
||||
|
||||
@@ -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>
|
||||
<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';
|
||||
|
||||
Reference in New Issue
Block a user