mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
dc55e6c45b
- 4-tier chat: HA control → Ollama → Groq → Claude - Push-based agent system with heartbeat/metrics - Network monitoring, alerts, Proxmox, Home Assistant - Windows + Linux agent installers - Stats cache cron, facts collector, KB engine
1901 lines
86 KiB
HTML
1901 lines
86 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||
<title>JARVIS — Integrated Defense and Logistics System</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Rajdhani:wght@300;400;500;600&family=Share+Tech+Mono&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#000810;
|
||
--bg2:#000d1a;
|
||
--cyan:#00d4ff;
|
||
--cyan2:#00a8cc;
|
||
--cyan3:rgba(0,212,255,0.15);
|
||
--orange:#ff6600;
|
||
--orange2:#ff4400;
|
||
--green:#00ff88;
|
||
--red:#ff2244;
|
||
--yellow:#ffd700;
|
||
--dim:rgba(0,212,255,0.4);
|
||
--dimmer:rgba(0,212,255,0.12);
|
||
--grid:rgba(0,180,255,0.07);
|
||
--text:#c8e6ff;
|
||
--text-dim:rgba(200,230,255,0.5);
|
||
--panel-bg:rgba(0,15,35,0.85);
|
||
--panel-border:rgba(0,212,255,0.2);
|
||
--font-display:'Orbitron',monospace;
|
||
--font-body:'Rajdhani',sans-serif;
|
||
--font-mono:'Share Tech Mono',monospace;
|
||
--r:8px;
|
||
}
|
||
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-body)}
|
||
|
||
/* ── GRID BACKGROUND ──────────────────────────────────────────────── */
|
||
body::before{
|
||
content:'';
|
||
position:fixed;inset:0;
|
||
background-image:
|
||
linear-gradient(var(--grid) 1px,transparent 1px),
|
||
linear-gradient(90deg,var(--grid) 1px,transparent 1px);
|
||
background-size:40px 40px;
|
||
z-index:0;
|
||
pointer-events:none;
|
||
}
|
||
body::after{
|
||
content:'';
|
||
position:fixed;inset:0;
|
||
background:radial-gradient(ellipse at 50% 50%,rgba(0,80,160,0.08) 0%,transparent 70%);
|
||
z-index:0;
|
||
pointer-events:none;
|
||
}
|
||
|
||
/* ── SCAN LINES ───────────────────────────────────────────────────── */
|
||
.scanlines{
|
||
position:fixed;inset:0;z-index:1;pointer-events:none;
|
||
background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px);
|
||
animation:scanMove 8s linear infinite;
|
||
}
|
||
@keyframes scanMove{0%{background-position:0 0}100%{background-position:0 100%}}
|
||
|
||
/* ── LOGIN SCREEN ─────────────────────────────────────────────────── */
|
||
#loginScreen{
|
||
position:fixed;inset:0;z-index:1000;
|
||
display:flex;align-items:center;justify-content:center;flex-direction:column;
|
||
background:var(--bg);
|
||
}
|
||
.login-reactor{
|
||
width:160px;height:160px;position:relative;margin-bottom:40px;cursor:pointer;
|
||
}
|
||
.login-reactor .ring{
|
||
position:absolute;border-radius:50%;border:2px solid var(--cyan);
|
||
top:50%;left:50%;transform:translate(-50%,-50%);
|
||
box-shadow:0 0 8px var(--cyan),inset 0 0 8px rgba(0,212,255,0.1);
|
||
animation:spinRing var(--spd,4s) linear infinite;
|
||
}
|
||
.login-reactor .r1{width:160px;height:160px;--spd:8s;border-color:rgba(0,212,255,0.3)}
|
||
.login-reactor .r2{width:130px;height:130px;--spd:6s;animation-direction:reverse}
|
||
.login-reactor .r3{width:100px;height:100px;--spd:4s;border-color:var(--orange);box-shadow:0 0 12px var(--orange)}
|
||
.login-reactor .r4{width:70px;height:70px;--spd:3s;animation-direction:reverse}
|
||
.login-reactor .core{
|
||
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
width:40px;height:40px;border-radius:50%;
|
||
background:radial-gradient(circle,#ffffff 0%,var(--cyan) 40%,var(--cyan2) 70%,transparent 100%);
|
||
box-shadow:0 0 20px var(--cyan),0 0 40px var(--cyan),0 0 80px rgba(0,212,255,0.3);
|
||
animation:corePulse 2s ease-in-out infinite;
|
||
}
|
||
@keyframes spinRing{from{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(360deg)}}
|
||
@keyframes corePulse{0%,100%{opacity:0.8;transform:translate(-50%,-50%) scale(1)}50%{opacity:1;transform:translate(-50%,-50%) scale(1.1)}}
|
||
#loginScreen h1{
|
||
font-family:var(--font-display);font-size:2.5rem;font-weight:900;letter-spacing:8px;
|
||
color:var(--cyan);text-shadow:0 0 20px var(--cyan),0 0 40px rgba(0,212,255,0.4);
|
||
margin-bottom:8px;
|
||
}
|
||
#loginScreen p{color:var(--text-dim);font-size:0.85rem;letter-spacing:4px;text-transform:uppercase;margin-bottom:40px}
|
||
.login-form{display:flex;flex-direction:column;gap:14px;width:320px}
|
||
.login-form input{
|
||
background:rgba(0,212,255,0.05);
|
||
border:1px solid var(--panel-border);
|
||
border-radius:var(--r);
|
||
padding:12px 16px;
|
||
color:var(--cyan);
|
||
font-family:var(--font-mono);font-size:0.95rem;
|
||
outline:none;
|
||
transition:border-color 0.2s,box-shadow 0.2s;
|
||
letter-spacing:1px;
|
||
}
|
||
.login-form input:focus{border-color:var(--cyan);box-shadow:0 0 12px rgba(0,212,255,0.2)}
|
||
.login-form input::placeholder{color:var(--dim);letter-spacing:1px}
|
||
.login-form button{
|
||
background:linear-gradient(135deg,rgba(0,212,255,0.15),rgba(0,212,255,0.05));
|
||
border:1px solid var(--cyan);
|
||
border-radius:var(--r);
|
||
padding:14px;
|
||
color:var(--cyan);
|
||
font-family:var(--font-display);font-size:0.8rem;font-weight:700;
|
||
letter-spacing:3px;text-transform:uppercase;
|
||
cursor:pointer;
|
||
transition:all 0.2s;
|
||
box-shadow:0 0 12px rgba(0,212,255,0.1);
|
||
}
|
||
.login-form button:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 20px rgba(0,212,255,0.3)}
|
||
#loginError{color:var(--red);font-size:0.85rem;text-align:center;letter-spacing:1px;min-height:20px}
|
||
|
||
/* ── MAIN APP (hidden until login) ───────────────────────────────── */
|
||
#app{position:fixed;inset:0;z-index:2;display:none;flex-direction:column}
|
||
|
||
/* ── TOP NAV BAR ─────────────────────────────────────────────────── */
|
||
#topBar{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:0 20px;height:48px;
|
||
background:rgba(0,8,22,0.9);
|
||
border-bottom:1px solid var(--panel-border);
|
||
flex-shrink:0;
|
||
}
|
||
.tb-logo{
|
||
font-family:var(--font-display);font-size:1rem;font-weight:900;letter-spacing:4px;
|
||
color:var(--cyan);text-shadow:0 0 10px var(--cyan);display:flex;align-items:center;gap:10px;
|
||
}
|
||
.tb-logo-dot{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite}
|
||
.tb-center{
|
||
display:flex;gap:24px;align-items:center;
|
||
}
|
||
.tb-stat{font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim)}
|
||
.tb-stat span{color:var(--cyan)}
|
||
.tb-right{display:flex;align-items:center;gap:16px}
|
||
#clock{font-family:var(--font-mono);font-size:1rem;color:var(--cyan);letter-spacing:2px}
|
||
#date-display{font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim)}
|
||
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);animation:blink 2s infinite}
|
||
@keyframes blink{0%,100%{opacity:1}50%{opacity:0.4}}
|
||
.btn-logout{
|
||
background:none;border:1px solid rgba(255,34,68,0.3);border-radius:4px;
|
||
color:rgba(255,34,68,0.7);font-family:var(--font-mono);font-size:0.7rem;
|
||
padding:4px 10px;cursor:pointer;letter-spacing:1px;transition:all 0.2s;
|
||
}
|
||
.btn-logout:hover{border-color:var(--red);color:var(--red);box-shadow:0 0 8px rgba(255,34,68,0.2)}
|
||
|
||
/* ── MAIN LAYOUT ─────────────────────────────────────────────────── */
|
||
#mainLayout{
|
||
flex:1;display:grid;
|
||
grid-template-columns:280px 1fr 280px;
|
||
grid-template-rows:1fr;
|
||
gap:10px;padding:10px;
|
||
overflow:hidden;
|
||
transition:grid-template-columns 0.45s cubic-bezier(0.4,0,0.2,1);
|
||
}
|
||
#mainLayout.focus-mode{grid-template-columns:0px 1fr 0px}
|
||
#leftPanel,#rightPanel{
|
||
transition:opacity 0.35s ease,transform 0.45s cubic-bezier(0.4,0,0.2,1);
|
||
overflow:hidden;
|
||
}
|
||
#mainLayout.focus-mode #leftPanel{opacity:0;transform:translateX(-20px);pointer-events:none}
|
||
#mainLayout.focus-mode #rightPanel{opacity:0;transform:translateX(20px);pointer-events:none}
|
||
/* Mobile fallback */
|
||
@media(max-width:900px){
|
||
#mainLayout{grid-template-columns:1fr;grid-template-rows:auto}
|
||
#leftPanel,#rightPanel{display:none}
|
||
}
|
||
/* Panel toggle button */
|
||
.btn-panels{
|
||
background:rgba(0,212,255,0.06);
|
||
border:1px solid rgba(0,212,255,0.25);
|
||
border-radius:4px;color:var(--cyan);
|
||
font-family:var(--font-display);font-size:0.55rem;
|
||
letter-spacing:1px;padding:4px 10px;cursor:pointer;
|
||
transition:all 0.2s;margin-right:6px;
|
||
}
|
||
.btn-panels:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)}
|
||
.btn-panels.focus-active{background:rgba(0,212,255,0.15);border-color:var(--cyan)}
|
||
/* Camera auto-mic button */
|
||
.btn-camera{
|
||
background:rgba(0,212,255,0.06);
|
||
border:1px solid rgba(0,212,255,0.25);
|
||
border-radius:4px;color:var(--cyan);
|
||
font-family:var(--font-display);font-size:0.55rem;
|
||
letter-spacing:1px;padding:4px 10px;cursor:pointer;
|
||
transition:all 0.2s;margin-right:6px;
|
||
}
|
||
.btn-camera:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)}
|
||
.btn-camera.cam-active{background:rgba(0,255,100,0.1);border-color:var(--green);color:var(--green)}
|
||
.btn-camera.cam-sensing{animation:camPulse 1.5s ease-in-out infinite}
|
||
.btn-agent{background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);font-family:var(--font-mono);font-size:0.62rem;letter-spacing:2px;padding:6px 10px;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all 0.2s}
|
||
.btn-agent:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)}
|
||
.btn-agent .agent-dot{width:7px;height:7px;border-radius:50%;background:var(--red);flex-shrink:0;transition:all 0.3s}
|
||
.btn-agent.agent-online .agent-dot{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||
#agentModal{position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;display:none;align-items:center;justify-content:center}
|
||
#agentModal.open{display:flex}
|
||
.agent-modal-box{background:var(--panel-bg);border:1px solid var(--panel-border);padding:24px 32px;max-width:500px;width:90%;font-family:var(--font-mono)}
|
||
.agent-modal-box h3{color:var(--cyan);font-size:0.75rem;letter-spacing:4px;margin:0 0 16px}
|
||
.agent-modal-box pre{background:rgba(0,0,0,0.4);padding:12px;font-size:0.65rem;color:var(--green);overflow-x:auto;margin:8px 0;white-space:pre-wrap;word-break:break-all}
|
||
.agent-modal-close{float:right;background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;padding:4px 10px;letter-spacing:2px}
|
||
.agent-modal-close:hover{border-color:var(--red);color:var(--red)}
|
||
.agent-dl-btn{display:inline-block;margin-top:12px;background:rgba(0,212,255,0.1);border:1px solid var(--cyan);color:var(--cyan);font-family:var(--font-mono);font-size:0.65rem;letter-spacing:2px;padding:8px 16px;cursor:pointer;text-decoration:none;transition:all 0.2s}
|
||
.agent-dl-btn:hover{background:rgba(0,212,255,0.2)}
|
||
@keyframes camPulse{0%,100%{box-shadow:0 0 4px rgba(0,255,100,0.3)}50%{box-shadow:0 0 12px rgba(0,255,100,0.6)}}
|
||
|
||
/* ── PANELS ───────────────────────────────────────────────────────── */
|
||
.panel{
|
||
background:var(--panel-bg);
|
||
border:1px solid var(--panel-border);
|
||
border-radius:var(--r);
|
||
padding:14px;
|
||
overflow:hidden;
|
||
position:relative;
|
||
backdrop-filter:blur(4px);
|
||
}
|
||
.panel::before{
|
||
content:'';position:absolute;top:0;left:0;right:0;height:1px;
|
||
background:linear-gradient(90deg,transparent,var(--cyan),transparent);
|
||
opacity:0.4;
|
||
}
|
||
.panel-title{
|
||
font-family:var(--font-display);font-size:0.6rem;font-weight:700;
|
||
letter-spacing:3px;color:var(--cyan);text-transform:uppercase;
|
||
margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;
|
||
}
|
||
.panel-title .indicator{
|
||
width:6px;height:6px;border-radius:50%;background:var(--cyan);
|
||
box-shadow:0 0 6px var(--cyan);animation:blink 3s infinite;
|
||
}
|
||
|
||
/* ── LEFT PANEL — SYSTEM ─────────────────────────────────────────── */
|
||
#leftPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
|
||
|
||
.metric-row{margin-bottom:10px}
|
||
.metric-label{
|
||
font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim);
|
||
display:flex;justify-content:space-between;margin-bottom:4px;
|
||
}
|
||
.metric-label span{color:var(--cyan)}
|
||
.metric-bar{
|
||
height:4px;border-radius:2px;
|
||
background:rgba(0,212,255,0.1);
|
||
overflow:hidden;position:relative;
|
||
}
|
||
.metric-bar-fill{
|
||
height:100%;border-radius:2px;
|
||
background:linear-gradient(90deg,var(--cyan2),var(--cyan));
|
||
box-shadow:0 0 6px var(--cyan);
|
||
transition:width 1s ease;
|
||
}
|
||
.metric-bar-fill.warn{background:linear-gradient(90deg,#cc6600,var(--orange));box-shadow:0 0 6px var(--orange)}
|
||
.metric-bar-fill.danger{background:linear-gradient(90deg,#cc0022,var(--red));box-shadow:0 0 6px var(--red)}
|
||
|
||
.service-row{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:5px 0;border-bottom:1px solid rgba(0,212,255,0.06);
|
||
font-family:var(--font-mono);font-size:0.72rem;
|
||
}
|
||
.service-row:last-child{border-bottom:none}
|
||
.svc-name{color:var(--text-dim)}
|
||
.svc-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||
.svc-dot.on{background:var(--green);box-shadow:0 0 4px var(--green)}
|
||
.svc-dot.off{background:var(--red);box-shadow:0 0 4px var(--red)}
|
||
|
||
.do-section{margin-top:8px}
|
||
.val-row{
|
||
display:flex;justify-content:space-between;
|
||
font-family:var(--font-mono);font-size:0.72rem;padding:3px 0;
|
||
}
|
||
.val-row .lbl{color:var(--text-dim)}
|
||
.val-row .val{color:var(--cyan)}
|
||
.val-row .val.ok{color:var(--green)}
|
||
.val-row .val.warn{color:var(--orange)}
|
||
.val-row .val.danger{color:var(--red)}
|
||
|
||
/* ── CENTER PANEL ────────────────────────────────────────────────── */
|
||
#centerPanel{
|
||
display:flex;flex-direction:column;align-items:center;gap:10px;overflow:hidden;
|
||
}
|
||
|
||
/* ARC REACTOR ─────────────────────────────────────────────────────── */
|
||
#arcReactor{position:relative;width:220px;height:220px;flex-shrink:0}
|
||
.arc-ring{
|
||
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
border-radius:50%;border-style:solid;border-color:var(--cyan);
|
||
animation:spinRing var(--spd,6s) linear infinite var(--dir,normal);
|
||
}
|
||
.arc-ring.r1{width:220px;height:220px;border-width:1px;border-color:rgba(0,212,255,0.15);--spd:20s}
|
||
.arc-ring.r2{width:195px;height:195px;border-width:1px;border-color:rgba(0,212,255,0.25);--spd:15s;--dir:reverse}
|
||
.arc-ring.r3{
|
||
width:170px;height:170px;border-width:2px;--spd:10s;
|
||
border-top-color:transparent;border-right-color:transparent;
|
||
box-shadow:0 0 6px var(--cyan);
|
||
}
|
||
.arc-ring.r4{
|
||
width:145px;height:145px;border-width:1px;--spd:8s;--dir:reverse;
|
||
border-bottom-color:transparent;border-left-color:transparent;
|
||
}
|
||
.arc-ring.r5{
|
||
width:115px;height:115px;border-width:2px;--spd:5s;
|
||
border-color:var(--orange);border-right-color:transparent;
|
||
box-shadow:0 0 8px var(--orange);
|
||
}
|
||
.arc-ring.r6{width:88px;height:88px;border-width:1px;--spd:4s;--dir:reverse;border-color:rgba(0,212,255,0.6)}
|
||
.arc-ring.r7{
|
||
width:62px;height:62px;border-width:2px;--spd:3s;
|
||
border-left-color:transparent;border-bottom-color:transparent;
|
||
}
|
||
.arc-core{
|
||
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
width:36px;height:36px;border-radius:50%;
|
||
background:radial-gradient(circle,#fff 0%,var(--cyan) 35%,var(--cyan2) 65%,transparent 100%);
|
||
box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 60px rgba(0,212,255,0.4),0 0 100px rgba(0,212,255,0.15);
|
||
animation:corePulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
/* HUD TICKS ───────────────────────────────────────────────────────── */
|
||
.hud-ticks{
|
||
position:absolute;inset:0;border-radius:50%;
|
||
}
|
||
.hud-ticks::before,.hud-ticks::after{
|
||
content:'';position:absolute;
|
||
background:var(--cyan);opacity:0.5;
|
||
}
|
||
.hud-ticks::before{left:50%;top:0;width:1px;height:12px;transform:translateX(-50%)}
|
||
.hud-ticks::after{left:0;top:50%;height:1px;width:12px;transform:translateY(-50%)}
|
||
|
||
/* ── CHAT AREA ───────────────────────────────────────────────────── */
|
||
#chatArea{
|
||
flex:1;width:100%;display:flex;flex-direction:column;gap:8px;overflow:hidden;
|
||
}
|
||
#chatLog{
|
||
flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;
|
||
padding:4px 0;
|
||
scrollbar-width:thin;scrollbar-color:var(--dim) transparent;
|
||
}
|
||
.msg{
|
||
padding:10px 14px;border-radius:var(--r);max-width:100%;
|
||
font-family:var(--font-body);font-size:0.9rem;line-height:1.5;
|
||
animation:msgIn 0.3s ease;
|
||
}
|
||
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||
.msg.user{
|
||
background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.15);
|
||
border-left:3px solid var(--cyan);
|
||
font-family:var(--font-mono);font-size:0.8rem;color:var(--text);
|
||
}
|
||
.msg.user::before{content:'► YOU ';color:var(--cyan);font-weight:700;font-size:0.7rem;letter-spacing:2px}
|
||
.msg.jarvis{
|
||
background:rgba(0,40,80,0.4);border:1px solid rgba(0,212,255,0.1);
|
||
border-left:3px solid var(--orange);
|
||
}
|
||
.msg.jarvis::before{content:'◆ JARVIS ';color:var(--orange);font-weight:700;font-size:0.7rem;letter-spacing:2px}
|
||
.msg.system{
|
||
border:1px solid rgba(255,215,0,0.2);background:rgba(255,215,0,0.03);
|
||
font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;
|
||
border-radius:4px;
|
||
}
|
||
|
||
/* ── CONTEXT CHIP ───────────────────────────────────────────────── */
|
||
#contextChip{
|
||
display:none;flex-shrink:0;
|
||
padding:5px 10px;margin-bottom:6px;
|
||
background:rgba(0,212,255,0.07);
|
||
border:1px solid rgba(0,212,255,0.35);
|
||
border-radius:var(--r);
|
||
display:flex;align-items:center;gap:8px;
|
||
font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;
|
||
}
|
||
#contextChip.visible{display:flex}
|
||
#contextLabel{color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#contextType{color:var(--text-dim)}
|
||
#contextClear{
|
||
background:none;border:none;color:var(--dim);cursor:pointer;
|
||
font-size:1rem;line-height:1;padding:0 2px;flex-shrink:0;
|
||
}
|
||
#contextClear:hover{color:var(--red)}
|
||
|
||
/* Clickable panel items */
|
||
.vm-card{cursor:pointer;transition:background 0.15s,border-color 0.15s}
|
||
.vm-card:hover{background:rgba(0,212,255,0.07);border-color:rgba(0,212,255,0.4)}
|
||
.vm-card.ctx-active{border-color:var(--cyan);background:rgba(0,212,255,0.1)}
|
||
.device-item{cursor:pointer;transition:background 0.15s}
|
||
.device-item:hover{background:rgba(0,212,255,0.05)}
|
||
.device-item.ctx-active{background:rgba(0,212,255,0.1);border-color:rgba(0,212,255,0.4)}
|
||
.alert-item{cursor:pointer}
|
||
.alert-item.ctx-active{border-color:var(--cyan) !important;box-shadow:0 0 8px rgba(0,212,255,0.15)}
|
||
.news-item{cursor:pointer;transition:background 0.15s}
|
||
.news-item:hover{background:rgba(0,212,255,0.04)}
|
||
.news-item.ctx-active{background:rgba(0,212,255,0.08);border-color:rgba(0,212,255,0.4)}
|
||
.ha-ask-btn{
|
||
background:none;border:1px solid rgba(0,212,255,0.2);border-radius:3px;
|
||
color:var(--text-dim);font-size:0.55rem;padding:1px 5px;cursor:pointer;
|
||
font-family:var(--font-display);letter-spacing:1px;flex-shrink:0;
|
||
transition:all 0.15s;
|
||
}
|
||
.ha-ask-btn:hover{border-color:var(--cyan);color:var(--cyan);background:rgba(0,212,255,0.1)}
|
||
|
||
/* ── VOICE INPUT ─────────────────────────────────────────────────── */
|
||
#inputArea{
|
||
display:flex;gap:10px;align-items:center;flex-shrink:0;
|
||
}
|
||
#textInput{
|
||
flex:1;background:rgba(0,212,255,0.04);
|
||
border:1px solid var(--panel-border);border-radius:var(--r);
|
||
padding:10px 14px;color:var(--text);
|
||
font-family:var(--font-mono);font-size:0.85rem;outline:none;
|
||
transition:border-color 0.2s;
|
||
}
|
||
#textInput:focus{border-color:var(--cyan);box-shadow:0 0 10px rgba(0,212,255,0.1)}
|
||
#textInput::placeholder{color:var(--dim)}
|
||
#sendBtn{
|
||
background:rgba(0,212,255,0.1);border:1px solid var(--dim);
|
||
border-radius:var(--r);padding:10px 16px;
|
||
color:var(--cyan);font-family:var(--font-display);font-size:0.65rem;font-weight:700;
|
||
letter-spacing:2px;cursor:pointer;transition:all 0.2s;white-space:nowrap;
|
||
}
|
||
#sendBtn:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 12px rgba(0,212,255,0.2)}
|
||
|
||
/* MIC BUTTON ──────────────────────────────────────────────────────── */
|
||
#micBtn{
|
||
width:48px;height:48px;border-radius:50%;
|
||
background:radial-gradient(circle,rgba(255,102,0,0.15),rgba(0,8,22,0.9));
|
||
border:2px solid var(--orange);
|
||
display:flex;align-items:center;justify-content:center;
|
||
cursor:pointer;transition:all 0.2s;flex-shrink:0;
|
||
box-shadow:0 0 10px rgba(255,102,0,0.2);
|
||
}
|
||
#micBtn:hover{box-shadow:0 0 20px rgba(255,102,0,0.4);transform:scale(1.05)}
|
||
#micBtn.listening{
|
||
border-color:var(--red);background:radial-gradient(circle,rgba(255,34,68,0.2),rgba(0,8,22,0.9));
|
||
box-shadow:0 0 25px rgba(255,34,68,0.5);
|
||
animation:micPulse 0.8s ease-in-out infinite;
|
||
}
|
||
@keyframes micPulse{0%,100%{box-shadow:0 0 25px rgba(255,34,68,0.5)}50%{box-shadow:0 0 40px rgba(255,34,68,0.8),0 0 60px rgba(255,34,68,0.3)}}
|
||
#micIcon{font-size:20px}
|
||
|
||
/* WAVEFORM ─────────────────────────────────────────────────────────── */
|
||
#waveform{
|
||
display:none;align-items:center;justify-content:center;gap:3px;height:30px;
|
||
}
|
||
#waveform.active{display:flex}
|
||
.wave-bar{
|
||
width:3px;border-radius:2px;background:var(--red);
|
||
animation:waveBounce var(--d,0.6s) ease-in-out infinite alternate;
|
||
box-shadow:0 0 4px var(--red);
|
||
}
|
||
@keyframes waveBounce{from{height:4px}to{height:24px}}
|
||
|
||
/* ── RIGHT PANEL ─────────────────────────────────────────────────── */
|
||
#rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
|
||
|
||
/* NETWORK DEVICE LIST ─────────────────────────────────────────────── */
|
||
.device-item{
|
||
display:flex;align-items:center;gap:8px;padding:6px 0;
|
||
border-bottom:1px solid rgba(0,212,255,0.06);
|
||
font-family:var(--font-mono);font-size:0.72rem;
|
||
}
|
||
.device-item:last-child{border-bottom:none}
|
||
.device-status{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||
.device-status.on{background:var(--green);box-shadow:0 0 4px var(--green)}
|
||
.device-status.off{background:var(--red);box-shadow:0 0 4px var(--red)}
|
||
.device-status.unk{background:var(--yellow);box-shadow:0 0 4px var(--yellow);opacity:0.6}
|
||
.device-info{flex:1;min-width:0}
|
||
.device-name{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.device-ip{color:var(--text-dim);font-size:0.65rem}
|
||
|
||
/* VM CARDS ─────────────────────────────────────────────────────────── */
|
||
.vm-card{
|
||
background:rgba(0,212,255,0.04);
|
||
border:1px solid rgba(0,212,255,0.12);border-radius:6px;
|
||
padding:8px 10px;margin-bottom:6px;
|
||
font-family:var(--font-mono);font-size:0.72rem;
|
||
}
|
||
.vm-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
|
||
.vm-name{color:var(--cyan);font-weight:600}
|
||
.vm-id{color:var(--text-dim);font-size:0.65rem}
|
||
.vm-metrics{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
||
.vm-metric{color:var(--text-dim)}
|
||
.vm-metric span{color:var(--text)}
|
||
|
||
/* HA DEVICES ────────────────────────────────────────────────────────── */
|
||
.ha-entity{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:5px 0;border-bottom:1px solid rgba(0,212,255,0.06);
|
||
font-family:var(--font-mono);font-size:0.72rem;
|
||
cursor:pointer;transition:background 0.15s;border-radius:4px;
|
||
}
|
||
.ha-entity:hover{background:rgba(0,212,255,0.06)}
|
||
.ha-name{color:var(--text);flex:1}
|
||
.ha-state{font-weight:600}
|
||
.ha-state.on{color:var(--green)}
|
||
.ha-state.off{color:var(--text-dim)}
|
||
|
||
/* ALERTS BADGE ─────────────────────────────────────────────────────── */
|
||
.alert-item{
|
||
padding:7px 10px;border-radius:6px;
|
||
font-family:var(--font-mono);font-size:0.72rem;
|
||
margin-bottom:6px;border-left:3px solid var(--yellow);
|
||
background:rgba(255,215,0,0.05);
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
}
|
||
.alert-item.critical{border-color:var(--red);background:rgba(255,34,68,0.05)}
|
||
.alert-item.info{border-color:var(--cyan);background:rgba(0,212,255,0.04)}
|
||
|
||
/* ── BOTTOM STATUS BAR ──────────────────────────────────────────── */
|
||
#bottomBar{
|
||
height:32px;flex-shrink:0;
|
||
background:rgba(0,8,22,0.9);
|
||
border-top:1px solid var(--panel-border);
|
||
display:flex;align-items:center;padding:0 20px;gap:24px;
|
||
font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);
|
||
}
|
||
#bottomBar span{color:var(--cyan)}
|
||
.bb-item{display:flex;align-items:center;gap:6px}
|
||
.bb-dot{width:5px;height:5px;border-radius:50%}
|
||
.bb-dot.online{background:var(--green);box-shadow:0 0 4px var(--green)}
|
||
.bb-dot.offline{background:var(--red)}
|
||
|
||
/* ── THINKING INDICATOR ──────────────────────────────────────────── */
|
||
.thinking{display:flex;gap:4px;align-items:center;padding:8px 14px}
|
||
.thinking-dot{
|
||
width:6px;height:6px;border-radius:50%;background:var(--orange);
|
||
animation:thinkBounce 0.6s ease-in-out infinite;
|
||
}
|
||
.thinking-dot:nth-child(2){animation-delay:0.15s}
|
||
.thinking-dot:nth-child(3){animation-delay:0.3s}
|
||
@keyframes thinkBounce{0%,100%{transform:translateY(0);opacity:0.5}50%{transform:translateY(-6px);opacity:1}}
|
||
|
||
/* ── SPEAKING ANIMATION ──────────────────────────────────────────── */
|
||
@keyframes speakPulse{
|
||
0%,100%{opacity:0.85;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 50px rgba(0,212,255,0.3)}
|
||
50%{opacity:1;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 25px var(--cyan),0 0 55px var(--cyan),0 0 100px rgba(0,212,255,0.6),0 0 150px rgba(0,212,255,0.25)}
|
||
}
|
||
#arcReactor.speaking .arc-core{
|
||
animation:speakPulse 0.45s ease-in-out infinite;
|
||
}
|
||
#arcReactor.speaking .arc-ring.r3{border-color:rgba(0,212,255,0.7)}
|
||
#arcReactor.speaking .arc-ring.r5{border-color:rgba(255,165,0,0.6)}
|
||
|
||
/* ── SCROLLBAR ───────────────────────────────────────────────────── */
|
||
::-webkit-scrollbar{width:4px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:var(--dim);border-radius:2px}
|
||
|
||
/* ── TABS (for right panel) ────────────────────────────────────── */
|
||
.tab-bar{display:flex;gap:0;margin-bottom:10px;border-bottom:1px solid var(--panel-border);flex-wrap:wrap}
|
||
.forecast-card{background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:4px;padding:5px 3px;text-align:center;min-width:0}
|
||
.forecast-card .fc-day{font-family:var(--font-display);font-size:0.55rem;color:var(--text-dim);letter-spacing:1px;font-weight:700}
|
||
.forecast-card .fc-temps{font-size:0.6rem;color:var(--cyan);font-family:var(--font-display);margin-top:2px}
|
||
.forecast-card .fc-rain{font-size:0.5rem;color:#4fc3f7;min-height:0.7rem}
|
||
.news-item{padding:6px 0;border-bottom:1px solid rgba(0,212,255,0.08)}
|
||
.news-item:last-child{border-bottom:none}
|
||
.news-source{font-size:0.5rem;color:var(--cyan);font-family:var(--font-display);letter-spacing:1px}
|
||
.news-title{font-size:0.62rem;color:var(--text-primary);line-height:1.3;margin-top:2px}
|
||
.news-time{font-size:0.5rem;color:var(--text-dim);margin-top:2px}
|
||
.news-cat-header{font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--cyan);margin:8px 0 4px;border-bottom:1px solid rgba(0,212,255,0.2);padding-bottom:3px}
|
||
.tab{
|
||
font-family:var(--font-display);font-size:0.55rem;font-weight:700;letter-spacing:2px;
|
||
padding:6px 10px;cursor:pointer;color:var(--text-dim);
|
||
border-bottom:2px solid transparent;margin-bottom:-1px;transition:all 0.2s;
|
||
}
|
||
.tab.active{color:var(--cyan);border-bottom-color:var(--cyan)}
|
||
.tab-pane{display:none}
|
||
.tab-pane.active{display:block}
|
||
|
||
/* ── UTILITY ─────────────────────────────────────────────────────── */
|
||
.loading-shimmer{
|
||
background:linear-gradient(90deg,rgba(0,212,255,0.04) 0%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 100%);
|
||
background-size:200% 100%;
|
||
animation:shimmer 1.5s infinite;border-radius:4px;height:12px;
|
||
}
|
||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||
.text-cyan{color:var(--cyan)}
|
||
.text-green{color:var(--green)}
|
||
.text-orange{color:var(--orange)}
|
||
.text-red{color:var(--red)}
|
||
.text-dim{color:var(--text-dim)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="scanlines"></div>
|
||
|
||
<!-- ── LOGIN ────────────────────────────────────────────────────────── -->
|
||
<div id="loginScreen">
|
||
<div class="login-reactor">
|
||
<div class="ring r1"></div><div class="ring r2"></div>
|
||
<div class="ring r3"></div><div class="ring r4"></div>
|
||
<div class="core"></div>
|
||
<div class="hud-ticks"></div>
|
||
</div>
|
||
<h1>JARVIS</h1>
|
||
<p>Just A Rather Very Intelligent System</p>
|
||
<form class="login-form" id="loginForm">
|
||
<input type="text" id="loginUser" placeholder="IDENTIFICATION" autocomplete="username" value="myron"/>
|
||
<input type="password" id="loginPass" placeholder="ACCESS CODE" autocomplete="current-password"/>
|
||
<button type="submit">INITIALIZE SYSTEM</button>
|
||
<div id="loginError"></div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ── MAIN APP ──────────────────────────────────────────────────────── -->
|
||
<div id="app">
|
||
<!-- Top Bar -->
|
||
<div id="topBar">
|
||
<div class="tb-logo">
|
||
<div class="tb-logo-dot"></div>
|
||
JARVIS SYSTEM
|
||
</div>
|
||
<div class="tb-center">
|
||
<div class="tb-stat">LOCAL <span id="tb-cpu">--</span>% CPU</div>
|
||
<div class="tb-stat">MEM <span id="tb-mem">--</span>%</div>
|
||
<div class="tb-stat">DO SERVER <span id="tb-do" class="text-dim">--</span></div>
|
||
<div class="tb-stat"><span id="tb-alerts" class="text-green">NO ALERTS</span></div>
|
||
</div>
|
||
<div class="tb-right">
|
||
<div>
|
||
<div id="clock">--:--:--</div>
|
||
<div id="date-display">LOADING...</div>
|
||
</div>
|
||
<div class="status-dot"></div>
|
||
<button id="cameraBtn" class="btn-camera" onclick="toggleCamera()" title="Auto-mic when face detected (hands-free)">◉ CAMERA</button>
|
||
<button id="panelToggleBtn" class="btn-panels" onclick="togglePanels()" title="Toggle side panels (or say 'focus mode')">◧ PANELS</button>
|
||
<button id="agentBtn" class="btn-agent" onclick="openAgentModal()" title="Install JARVIS Agent on this machine"><div class="agent-dot"></div>AGENT</button>
|
||
|
||
<button class="btn-logout" onclick="logout()">LOGOUT</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Layout -->
|
||
<div id="mainLayout">
|
||
<!-- LEFT: System Stats -->
|
||
<div id="leftPanel">
|
||
<div class="panel">
|
||
<div class="panel-title">LOCAL SYSTEM <div class="indicator"></div></div>
|
||
<div class="metric-row">
|
||
<div class="metric-label">CPU USAGE <span id="cpu-val">--%</span></div>
|
||
<div class="metric-bar"><div class="metric-bar-fill" id="cpu-bar" style="width:0%"></div></div>
|
||
</div>
|
||
<div class="metric-row">
|
||
<div class="metric-label">MEMORY <span id="mem-val">--%</span></div>
|
||
<div class="metric-bar"><div class="metric-bar-fill" id="mem-bar" style="width:0%"></div></div>
|
||
</div>
|
||
<div class="metric-row">
|
||
<div class="metric-label">DISK / <span id="disk-val">--%</span></div>
|
||
<div class="metric-bar"><div class="metric-bar-fill" id="disk-bar" style="width:0%"></div></div>
|
||
</div>
|
||
<div class="val-row"><div class="lbl">UPTIME</div><div class="val" id="uptime-val">--</div></div>
|
||
<div class="val-row"><div class="lbl">LOAD AVG</div><div class="val" id="load-val">--</div></div>
|
||
<div class="val-row"><div class="lbl">HOSTNAME</div><div class="val" id="host-val">--</div></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-title">SERVICES</div>
|
||
<div id="services-list">
|
||
<div class="loading-shimmer" style="margin-bottom:6px"></div>
|
||
<div class="loading-shimmer"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-title">DO SERVER <span style="font-size:0.55rem;color:var(--text-dim)">165.22.1.228</span></div>
|
||
<div id="do-stats">
|
||
<div class="loading-shimmer" style="margin-bottom:6px"></div>
|
||
<div class="loading-shimmer"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="panel-title">TOP PROCESSES</div>
|
||
<div id="procs-list">
|
||
<div class="loading-shimmer" style="margin-bottom:4px"></div>
|
||
<div class="loading-shimmer" style="margin-bottom:4px"></div>
|
||
<div class="loading-shimmer"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CENTER: Arc Reactor + Chat -->
|
||
<div id="centerPanel">
|
||
<div id="arcReactor">
|
||
<div class="arc-ring r1"></div><div class="arc-ring r2"></div>
|
||
<div class="arc-ring r3"></div><div class="arc-ring r4"></div>
|
||
<div class="arc-ring r5"></div><div class="arc-ring r6"></div>
|
||
<div class="arc-ring r7"></div>
|
||
<div class="arc-core"></div>
|
||
<div class="hud-ticks"></div>
|
||
</div>
|
||
|
||
<div id="chatArea">
|
||
<div id="chatLog">
|
||
<div class="msg system">◈ JARVIS ONLINE — AWAITING INSTRUCTIONS ◈</div>
|
||
</div>
|
||
|
||
<div id="waveform">
|
||
<div class="wave-bar" style="--d:0.4s"></div>
|
||
<div class="wave-bar" style="--d:0.5s"></div>
|
||
<div class="wave-bar" style="--d:0.35s"></div>
|
||
<div class="wave-bar" style="--d:0.6s"></div>
|
||
<div class="wave-bar" style="--d:0.45s"></div>
|
||
<div class="wave-bar" style="--d:0.55s"></div>
|
||
<div class="wave-bar" style="--d:0.4s"></div>
|
||
<div class="wave-bar" style="--d:0.5s"></div>
|
||
<div class="wave-bar" style="--d:0.38s"></div>
|
||
<div class="wave-bar" style="--d:0.52s"></div>
|
||
</div>
|
||
|
||
<div id="contextChip">
|
||
<span id="contextType">CONTEXT</span>
|
||
<span id="contextLabel">—</span>
|
||
<button id="contextClear" onclick="clearContext()" title="Clear context">×</button>
|
||
</div>
|
||
<div id="inputArea">
|
||
<button id="micBtn" onclick="toggleVoice()" title="Voice Command">
|
||
<span id="micIcon">🎤</span>
|
||
</button>
|
||
<input type="text" id="textInput" placeholder="Enter command or speak to JARVIS..."
|
||
autocomplete="off" onkeydown="if(event.key==='Enter')sendMessage()"/>
|
||
<button id="sendBtn" onclick="sendMessage()">TRANSMIT</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Network + VMs + HA -->
|
||
<div id="rightPanel">
|
||
<!-- Weather Widget -->
|
||
<div class="panel" style="flex:0 0 auto">
|
||
<div class="panel-title">WEATHER <span id="weather-loc" style="font-size:0.55rem;color:var(--text-dim)">FORT WORTH, TX</span></div>
|
||
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:8px">
|
||
<div style="flex:1">
|
||
<div style="display:flex;align-items:baseline;gap:8px">
|
||
<span style="font-size:1.8rem;font-family:var(--font-display);color:var(--cyan);line-height:1" id="weather-temp">--</span>
|
||
<span style="font-size:0.75rem;color:var(--text-dim)">°F</span>
|
||
</div>
|
||
<div style="font-size:0.7rem;color:var(--text-primary);margin-top:2px;font-family:var(--font-display);letter-spacing:1px" id="weather-desc">LOADING...</div>
|
||
<div style="font-size:0.58rem;color:var(--text-dim);margin-top:3px" id="weather-details"></div>
|
||
</div>
|
||
<div style="text-align:right;flex-shrink:0">
|
||
<div style="font-size:0.52rem;color:var(--text-dim);letter-spacing:1px">FEELS LIKE</div>
|
||
<div style="font-size:1rem;font-family:var(--font-display);color:var(--cyan)" id="weather-feels">--°F</div>
|
||
<div style="font-size:0.52rem;color:var(--text-dim);margin-top:4px;letter-spacing:1px">HUMIDITY</div>
|
||
<div style="font-size:0.75rem;font-family:var(--font-display);color:var(--text-primary)" id="weather-humidity">--%</div>
|
||
</div>
|
||
</div>
|
||
<div id="weather-forecast" style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px"></div>
|
||
</div>
|
||
|
||
<!-- Network Status -->
|
||
<div class="panel" style="flex:0 1 auto;max-height:35%;display:flex;flex-direction:column;min-height:100px">
|
||
<div class="panel-title">NETWORK STATUS <div class="indicator"></div><span id="net-agent-count" style="font-size:0.6rem;color:var(--cyan);margin-left:auto"></span><button onclick="addNetworkDevice()" title="Add device" style="background:none;border:none;color:var(--cyan);cursor:pointer;font-size:1rem;padding:0 4px;margin-left:4px;line-height:1">+</button></div>
|
||
<div id="network-list" style="overflow-y:auto;flex:1;padding-right:2px">
|
||
<div class="loading-shimmer" style="margin-bottom:6px"></div>
|
||
<div class="loading-shimmer" style="margin-bottom:6px"></div>
|
||
<div class="loading-shimmer"></div>
|
||
</div>
|
||
<button onclick="scanNetwork()" style="margin-top:8px;flex-shrink:0;width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:4px;color:var(--cyan);font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;cursor:pointer" id="scanBtn">RUN NETWORK SCAN</button>
|
||
</div>
|
||
|
||
<!-- Tab Panel -->
|
||
<div class="panel" style="flex:1;overflow:hidden;display:flex;flex-direction:column">
|
||
<div class="tab-bar">
|
||
<div class="tab active" onclick="switchTab('vms')">PROXMOX</div>
|
||
<div class="tab" onclick="switchTab('ha')">HOME</div>
|
||
<div class="tab" onclick="switchTab('alerts')">ALERTS</div>
|
||
<div class="tab" onclick="switchTab('news')">NEWS</div>
|
||
<div class="tab" onclick="switchTab('agents')">AGENTS</div>
|
||
</div>
|
||
<div id="tab-vms" class="tab-pane active" style="overflow-y:auto;flex:1">
|
||
<div id="vm-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-ha" class="tab-pane" style="overflow-y:auto;flex:1">
|
||
<div id="ha-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-alerts" class="tab-pane" style="overflow-y:auto;flex:1">
|
||
<div id="alerts-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-news" class="tab-pane" style="overflow-y:auto;flex:1">
|
||
<div id="news-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-agents" class="tab-pane" style="overflow-y:auto;flex:1">
|
||
<div id="agents-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom Bar -->
|
||
<div id="bottomBar">
|
||
<div class="bb-item">
|
||
<div class="bb-dot online"></div>
|
||
<span>JARVIS CORE</span> ONLINE
|
||
</div>
|
||
<div class="bb-item">
|
||
<div class="bb-dot" id="bb-do-dot"></div>
|
||
<span>DO SERVER</span> <span id="bb-do-status">CHECKING</span>
|
||
</div>
|
||
<div class="bb-item">
|
||
<div class="bb-dot" id="bb-pve-dot"></div>
|
||
<span>PROXMOX</span> <span id="bb-pve-status">CHECKING</span>
|
||
</div>
|
||
<div class="bb-item">
|
||
<div class="bb-dot" id="bb-ha-dot"></div>
|
||
<span>HOME ASSISTANT</span> <span id="bb-ha-status">CHECKING</span>
|
||
</div>
|
||
<div class="bb-item">
|
||
<div class="bb-dot" id="bb-agent-dot"></div>
|
||
<span>AGENTS</span> <span id="bb-agent-status">--</span>
|
||
</div>
|
||
|
||
<div style="margin-left:auto;font-size:0.65rem">
|
||
JARVIS v2.0 · <span id="session-user">--</span> · SECURITY LEVEL ALPHA
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="agentModal">
|
||
<div class="agent-modal-box">
|
||
<button class="agent-modal-close" onclick="document.getElementById('agentModal').classList.remove('open')">✕ CLOSE</button>
|
||
<h3 id="agentModalTitle">● JARVIS AGENT</h3>
|
||
<div id="agentModalContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden camera feed for face detection -->
|
||
<video id="faceVideo" autoplay muted playsinline
|
||
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px"></video>
|
||
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
|
||
|
||
<script>
|
||
// ── GLOBALS ──────────────────────────────────────────────────────────
|
||
let sessionToken = '';
|
||
let sessionUser = '';
|
||
let sessionId = 'session_' + Date.now();
|
||
let isListening = false;
|
||
let recognition = null;
|
||
let synth = window.speechSynthesis;
|
||
let selectedVoice = null;
|
||
let refreshTimer = null;
|
||
let isSpeaking = false;
|
||
let panelsVisible = true;
|
||
let cameraActive = false;
|
||
let faceLoopId = null;
|
||
let lastFaceSeen = 0;
|
||
let autoMicCooldown = 0;
|
||
let faceApiReady = false;
|
||
const FACE_MODEL_URL = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights';
|
||
|
||
// ── INIT ─────────────────────────────────────────────────────────────
|
||
window.addEventListener('load', () => {
|
||
updateClock();
|
||
setInterval(updateClock, 1000);
|
||
initVoice();
|
||
loadVoices();
|
||
|
||
// Check if already logged in
|
||
const saved = sessionStorage.getItem('jarvis_token');
|
||
if (saved) {
|
||
sessionToken = saved;
|
||
sessionUser = sessionStorage.getItem('jarvis_user') || '';
|
||
showApp(sessionUser);
|
||
}
|
||
});
|
||
|
||
function updateClock() {
|
||
const now = new Date();
|
||
document.getElementById('clock').textContent =
|
||
now.toLocaleTimeString('en-US',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||
document.getElementById('date-display').textContent =
|
||
now.toLocaleDateString('en-US',{weekday:'short',year:'numeric',month:'short',day:'numeric'}).toUpperCase();
|
||
}
|
||
|
||
// ── LOGIN ─────────────────────────────────────────────────────────────
|
||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const user = document.getElementById('loginUser').value;
|
||
const pass = document.getElementById('loginPass').value;
|
||
const errEl = document.getElementById('loginError');
|
||
errEl.textContent = '';
|
||
|
||
try {
|
||
const res = await api('auth', 'POST', {username:user, password:pass});
|
||
if (res.success) {
|
||
sessionToken = res.token;
|
||
sessionUser = res.display_name;
|
||
sessionStorage.setItem('jarvis_token', sessionToken);
|
||
sessionStorage.setItem('jarvis_user', sessionUser);
|
||
document.getElementById('session-user').textContent = sessionUser.toUpperCase();
|
||
showApp(sessionUser, res.greeting);
|
||
} else {
|
||
errEl.textContent = 'ACCESS DENIED';
|
||
}
|
||
} catch(err) {
|
||
errEl.textContent = 'CONNECTION FAILED';
|
||
}
|
||
});
|
||
|
||
function showApp(name, greeting) {
|
||
document.getElementById('loginScreen').style.display = 'none';
|
||
const app = document.getElementById('app');
|
||
app.style.display = 'flex';
|
||
document.getElementById('session-user').textContent = (name||'').toUpperCase();
|
||
|
||
if (greeting) {
|
||
addMessage('jarvis', greeting);
|
||
speak(greeting);
|
||
} else {
|
||
const g = `Welcome back, ${name}. All systems online and standing by.`;
|
||
addMessage('jarvis', g);
|
||
speak(g);
|
||
}
|
||
|
||
// Start data refresh
|
||
refreshAll();
|
||
refreshTimer = setInterval(refreshAll, 10000); // every 10s
|
||
loadNetwork();
|
||
loadProxmox();
|
||
loadHA();
|
||
checkAgentStatus();
|
||
loadAgents();
|
||
loadAlerts();
|
||
loadWeather();
|
||
loadNews();
|
||
}
|
||
|
||
async function logout() {
|
||
clearInterval(refreshTimer);
|
||
await api('auth', 'DELETE', {});
|
||
sessionStorage.clear();
|
||
location.reload();
|
||
}
|
||
|
||
// ── API HELPER ────────────────────────────────────────────────────────
|
||
async function api(endpoint, method='GET', body=null) {
|
||
const opts = {
|
||
method,
|
||
headers: {'Content-Type':'application/json','X-Session-Token':sessionToken},
|
||
credentials:'include',
|
||
};
|
||
if (body && method !== 'GET') opts.body = JSON.stringify(body);
|
||
const res = await fetch('/api/' + endpoint, opts);
|
||
if (res.status === 401) { logout(); return {}; }
|
||
return res.json();
|
||
}
|
||
|
||
// ── PANEL TOGGLE ─────────────────────────────────────────────────────
|
||
function togglePanels(silent) {
|
||
panelsVisible = !panelsVisible;
|
||
const layout = document.getElementById('mainLayout');
|
||
const btn = document.getElementById('panelToggleBtn');
|
||
if (panelsVisible) {
|
||
layout.classList.remove('focus-mode');
|
||
btn.classList.remove('focus-active');
|
||
btn.textContent = '◧ PANELS';
|
||
if (!silent) speak('Full view restored.');
|
||
} else {
|
||
layout.classList.add('focus-mode');
|
||
btn.classList.add('focus-active');
|
||
btn.textContent = '◫ FOCUS';
|
||
if (!silent) speak('Focus mode activated. Side panels hidden.');
|
||
}
|
||
}
|
||
|
||
// Keyboard shortcut: backslash to toggle panels
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === '\\' && document.activeElement.id !== 'textInput') {
|
||
togglePanels();
|
||
}
|
||
});
|
||
|
||
// ── CAMERA FACE DETECTION / AUTO-MIC ─────────────────────────────────
|
||
async function loadFaceApi() {
|
||
if (faceApiReady) return true;
|
||
try {
|
||
if (typeof faceapi === 'undefined') {
|
||
addMessage('system', 'Face detection library not available.');
|
||
return false;
|
||
}
|
||
await faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL);
|
||
faceApiReady = true;
|
||
return true;
|
||
} catch(e) {
|
||
addMessage('system', 'Could not load face detection model: ' + e.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function startCamera() {
|
||
if (cameraActive) return;
|
||
const btn = document.getElementById('cameraBtn');
|
||
btn.textContent = '◉ LOADING…';
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia(
|
||
{video:{facingMode:'user', width:{ideal:320}, height:{ideal:240}}, audio:false}
|
||
);
|
||
const video = document.getElementById('faceVideo');
|
||
video.srcObject = stream;
|
||
await video.play();
|
||
|
||
const ok = await loadFaceApi();
|
||
if (!ok) { stopCamera(); return; }
|
||
|
||
cameraActive = true;
|
||
btn.classList.add('cam-active');
|
||
btn.textContent = '◉ SENSING';
|
||
addMessage('system', 'Face detection active — JARVIS will auto-engage mic when you approach.');
|
||
|
||
faceLoopId = setInterval(async () => {
|
||
if (!cameraActive || isSpeaking) return;
|
||
try {
|
||
const detection = await faceapi.detectSingleFace(
|
||
document.getElementById('faceVideo'),
|
||
new faceapi.TinyFaceDetectorOptions({inputSize:160, scoreThreshold:0.45})
|
||
);
|
||
const now = Date.now();
|
||
if (detection) {
|
||
lastFaceSeen = now;
|
||
const {width, height} = detection.box;
|
||
const ratio = (width * height) / (320 * 240);
|
||
// Trigger if face fills >3% of frame and mic not already on and cooldown passed
|
||
if (ratio > 0.03 && !isListening && now > autoMicCooldown) {
|
||
autoMicCooldown = now + 9000; // 9s between auto-triggers
|
||
document.getElementById('cameraBtn').classList.add('cam-sensing');
|
||
startListening();
|
||
}
|
||
} else {
|
||
// No face — stop if auto-triggered and face gone >3s
|
||
if (isListening && now - lastFaceSeen > 3000) {
|
||
stopListening();
|
||
}
|
||
document.getElementById('cameraBtn').classList.remove('cam-sensing');
|
||
}
|
||
} catch(_) {}
|
||
}, 600);
|
||
|
||
} catch(e) {
|
||
btn.textContent = '◉ CAMERA';
|
||
if (e.name === 'NotAllowedError') {
|
||
addMessage('system', 'Camera permission denied. Grant camera access in browser settings to enable hands-free mode.');
|
||
} else {
|
||
addMessage('system', 'Camera unavailable: ' + e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
function stopCamera() {
|
||
cameraActive = false;
|
||
clearInterval(faceLoopId);
|
||
faceLoopId = null;
|
||
const video = document.getElementById('faceVideo');
|
||
if (video && video.srcObject) {
|
||
video.srcObject.getTracks().forEach(t => t.stop());
|
||
video.srcObject = null;
|
||
}
|
||
const btn = document.getElementById('cameraBtn');
|
||
if (btn) {
|
||
btn.classList.remove('cam-active', 'cam-sensing');
|
||
btn.textContent = '◉ CAMERA';
|
||
}
|
||
}
|
||
|
||
function toggleCamera() {
|
||
if (cameraActive) {
|
||
stopCamera();
|
||
addMessage('system', 'Face detection disabled.');
|
||
} else {
|
||
startCamera();
|
||
}
|
||
}
|
||
|
||
// ── REFRESH ALL ───────────────────────────────────────────────────────
|
||
let _refreshTick = 0;
|
||
let selectedContext = null;
|
||
const _panelCtx = {};
|
||
async function refreshAll() {
|
||
_refreshTick++;
|
||
|
||
try {
|
||
const s = await api('system');
|
||
renderSystem(s);
|
||
} catch(e) {}
|
||
|
||
try {
|
||
const n = await api('network');
|
||
renderNetworkStatus(n);
|
||
} catch(e) {}
|
||
|
||
try {
|
||
const d = await api('do');
|
||
renderDO(d);
|
||
} catch(e) {}
|
||
|
||
// Refresh right-panel tabs every 3rd tick (~30s)
|
||
if (_refreshTick % 3 === 0) {
|
||
try { await loadProxmox(); } catch(e) {}
|
||
try { await loadHA(); } catch(e) {}
|
||
try { await loadAlerts(); } catch(e) {}
|
||
try { await loadAgents(); } catch(e) {}
|
||
}
|
||
// Refresh weather + news every 18th tick (~3 min — cache updates every 30 min)
|
||
if (_refreshTick % 18 === 0) {
|
||
try { await loadWeather(); } catch(e) {}
|
||
try { await loadNews(); } catch(e) {}
|
||
}
|
||
}
|
||
|
||
// ── RENDER: SYSTEM ────────────────────────────────────────────────────
|
||
function renderSystem(s) {
|
||
if (!s || s.error) return;
|
||
const cpu = s.cpu || 0;
|
||
const mem = s.memory?.percent || 0;
|
||
const disk = s.disk?.percent || 0;
|
||
|
||
// Top bar
|
||
document.getElementById('tb-cpu').textContent = cpu;
|
||
document.getElementById('tb-mem').textContent = mem;
|
||
|
||
// Metric bars
|
||
setBar('cpu', cpu);
|
||
setBar('mem', mem);
|
||
setBar('disk', disk);
|
||
|
||
document.getElementById('cpu-val').textContent = cpu + '%';
|
||
document.getElementById('mem-val').textContent = mem + '%';
|
||
document.getElementById('disk-val').textContent = disk + '%';
|
||
document.getElementById('uptime-val').textContent = s.uptime || '--';
|
||
document.getElementById('load-val').textContent = s.load?.['1m'] || '--';
|
||
document.getElementById('host-val').textContent = s.hostname || 'jarvis';
|
||
|
||
// Services
|
||
if (s.services) {
|
||
const svcEl = document.getElementById('services-list');
|
||
svcEl.innerHTML = Object.entries(s.services).map(([k,v]) =>
|
||
`<div class="service-row">
|
||
<span class="svc-name">${k.toUpperCase()}</span>
|
||
<div class="svc-dot ${v?'on':'off'}" title="${v?'ACTIVE':'INACTIVE'}"></div>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
// Processes
|
||
if (s.processes?.length) {
|
||
document.getElementById('procs-list').innerHTML = s.processes.map(p =>
|
||
`<div class="val-row">
|
||
<div class="lbl" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.cmd}</div>
|
||
<div class="val">${p.cpu}%</div>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
function setBar(id, pct) {
|
||
const el = document.getElementById(id+'-bar');
|
||
if (!el) return;
|
||
el.style.width = Math.min(pct,100) + '%';
|
||
el.className = 'metric-bar-fill' + (pct>90?' danger':pct>75?' warn':'');
|
||
}
|
||
|
||
// ── RENDER: DO SERVER ─────────────────────────────────────────────────
|
||
function renderDO(d) {
|
||
const el = document.getElementById('do-stats');
|
||
const dot = document.getElementById('bb-do-dot');
|
||
const status = document.getElementById('bb-do-status');
|
||
|
||
if (!d || d.error) {
|
||
el.innerHTML = `<div class="text-dim" style="font-size:0.75rem">${d?.error || 'Unreachable'}</div>`;
|
||
dot.className='bb-dot offline'; status.textContent='OFFLINE';
|
||
document.getElementById('tb-do').className='text-red';
|
||
document.getElementById('tb-do').textContent='OFFLINE';
|
||
return;
|
||
}
|
||
|
||
const reachable = d.reachable;
|
||
dot.className = 'bb-dot ' + (reachable ? 'online' : 'offline');
|
||
status.textContent = reachable ? 'ONLINE' : 'OFFLINE';
|
||
document.getElementById('tb-do').className = reachable ? 'text-green' : 'text-red';
|
||
document.getElementById('tb-do').textContent = reachable ? 'ONLINE' : 'OFFLINE';
|
||
|
||
if (!reachable) { el.innerHTML='<div class="text-red" style="font-size:0.75rem">UNREACHABLE</div>'; return; }
|
||
|
||
const cpuClass = (d.cpu_pct||0)>80?'danger':(d.cpu_pct||0)>60?'warn':'ok';
|
||
const memClass = (d.memory?.percent||0)>80?'danger':(d.memory?.percent||0)>60?'warn':'ok';
|
||
|
||
el.innerHTML = `
|
||
<div class="val-row"><div class="lbl">CPU</div><div class="val ${cpuClass}">${d.cpu_pct??'--'}%</div></div>
|
||
<div class="val-row"><div class="lbl">MEMORY</div><div class="val ${memClass}">${d.memory?.percent??'--'}%</div></div>
|
||
<div class="val-row"><div class="lbl">DISK</div><div class="val">${d.disk_used_pct??'--'}</div></div>
|
||
<div class="val-row"><div class="lbl">UPTIME</div><div class="val">${d.uptime??'--'}</div></div>
|
||
<div class="val-row"><div class="lbl">LOAD</div><div class="val">${d.load_1m??'--'}</div></div>
|
||
${d.sites && Object.keys(d.sites).length ? `<div style="margin-top:8px;font-family:var(--font-mono);font-size:0.65rem;color:var(--text-dim)">SITES:</div>
|
||
${Object.entries(d.sites).map(([k,v])=>`<div class="val-row"><div class="lbl">${k.replace('.com','')}</div><div class="val">${v}</div></div>`).join('')}` : ''}
|
||
`;
|
||
}
|
||
|
||
async function loadNetwork() {
|
||
try {
|
||
const n = await api('network');
|
||
renderNetworkStatus(n);
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ── RENDER: NETWORK ───────────────────────────────────────────────────
|
||
function renderNetworkStatus(n) {
|
||
if (!n) return;
|
||
const el = document.getElementById('network-list');
|
||
if (!el) return;
|
||
const devices = n.devices || [];
|
||
const online = devices.filter(d => d.alive || d.status === 'online').length;
|
||
const countEl = document.getElementById('net-agent-count');
|
||
if (countEl) countEl.textContent = online + '/' + devices.length + ' ONLINE';
|
||
|
||
const agents = devices.filter(d => d.source === 'agent');
|
||
const others = devices.filter(d => d.source !== 'agent');
|
||
|
||
function renderDev(d) {
|
||
const alive = d.alive || d.status === 'online';
|
||
const ctxKey = d.source === 'agent' ? 'agent_' + d.agent_id : 'net_' + (d.ip||'').replace(/\./g,'_');
|
||
_panelCtx[ctxKey] = {type: d.source === 'agent' ? 'agent' : 'network',
|
||
label: d.name || d.ip, ip: d.ip, status: d.status || (alive ? 'online' : 'offline'),
|
||
agent_id: d.agent_id, hostname: d.name};
|
||
const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : '';
|
||
const badge = d.source === 'agent'
|
||
? `<span style="font-size:0.53rem;color:var(--cyan);letter-spacing:1px;margin-left:4px">${(d.agent_type||'AGENT').toUpperCase()}</span>` : '';
|
||
const del = d.deletable
|
||
? `<button onclick="deleteNetworkDevice('${d.ip}',event)" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:0.9rem;padding:0 2px;opacity:0.5;flex-shrink:0" title="Remove">×</button>` : '';
|
||
const bl = d.source === 'agent' ? 'border-left:2px solid ' + (alive ? 'var(--green)' : 'var(--red)') + ';' : '';
|
||
return `<div class="device-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" style="${bl}display:flex;align-items:center">
|
||
<div class="device-status ${alive?'on':'off'}" style="flex-shrink:0"></div>
|
||
<div class="device-info" style="flex:1;min-width:0">
|
||
<div class="device-name" style="display:flex;align-items:center">${d.name||d.ip}${badge}</div>
|
||
<div class="device-ip">${d.ip||''}${lat}</div>
|
||
</div>${del}
|
||
</div>`;
|
||
}
|
||
|
||
let out = '';
|
||
if (agents.length) {
|
||
const agOn = agents.filter(d => d.alive || d.status === 'online').length;
|
||
out += `<div style="font-family:var(--font-mono);font-size:0.53rem;color:var(--cyan);letter-spacing:2px;padding:2px 0 3px">AGENTS (${agOn}/${agents.length})</div>`;
|
||
out += agents.map(renderDev).join('');
|
||
}
|
||
if (others.length) {
|
||
if (agents.length) out += '<div style="border-top:1px solid var(--panel-border);margin:5px 0 3px"></div>';
|
||
out += `<div style="font-family:var(--font-mono);font-size:0.53rem;color:var(--text-dim);letter-spacing:2px;padding:2px 0 3px">DEVICES</div>`;
|
||
out += others.map(renderDev).join('');
|
||
}
|
||
if (!out) out = '<div style="color:var(--text-dim);font-size:0.7rem;text-align:center;padding:8px">No devices</div>';
|
||
el.innerHTML = out;
|
||
}
|
||
|
||
// ── NETWORK SCAN ──────────────────────────────────────────────────────
|
||
async function scanNetwork() {
|
||
const btn = document.getElementById('scanBtn');
|
||
btn.textContent = 'SCANNING...';
|
||
btn.disabled = true;
|
||
addMessage('jarvis', 'Initiating subnet scan on 10.48.200.0/24, Sir. This will take approximately 10 seconds.');
|
||
speak('Initiating network scan.');
|
||
|
||
try {
|
||
const data = await api('network/scan');
|
||
if (data.devices) {
|
||
const el = document.getElementById('network-list');
|
||
el.innerHTML = data.devices.map(d =>
|
||
`<div class="device-item">
|
||
<div class="device-status on"></div>
|
||
<div class="device-info">
|
||
<div class="device-name">${d.alias||d.hostname||d.ip}</div>
|
||
<div class="device-ip">${d.ip}${d.mac?' · '+d.mac:''}</div>
|
||
</div>
|
||
</div>`
|
||
).join('');
|
||
const msg = `Network scan complete. Found ${data.count} active device${data.count!==1?'s':''} on the 10.48.200.0/24 subnet.`;
|
||
addMessage('jarvis', msg);
|
||
speak(msg);
|
||
}
|
||
} catch(e) {
|
||
addMessage('jarvis', 'Network scan encountered an error, Sir.');
|
||
}
|
||
|
||
btn.textContent = 'RUN NETWORK SCAN';
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// ── PROXMOX ───────────────────────────────────────────────────────────
|
||
async function loadProxmox() {
|
||
const data = await api('proxmox');
|
||
const el = document.getElementById('vm-list');
|
||
const dot = document.getElementById('bb-pve-dot');
|
||
const status = document.getElementById('bb-pve-status');
|
||
|
||
if (!data.configured) {
|
||
el.innerHTML = `<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text-dim);line-height:1.5">
|
||
<div class="text-yellow" style="margin-bottom:8px">⚠ NOT CONFIGURED</div>
|
||
Set PROXMOX_HOST and PROXMOX_TOKEN_VAL in config.php to enable VM monitoring.
|
||
</div>`;
|
||
dot.className='bb-dot offline'; status.textContent='NOT CONFIGURED';
|
||
return;
|
||
}
|
||
|
||
dot.className='bb-dot online'; status.textContent='ONLINE';
|
||
|
||
const vms = [...(data.vms||[]), ...(data.containers||[])];
|
||
if (!vms.length) {
|
||
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No VMs found.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = vms.map(vm => {
|
||
const statusColor = vm.status==='running'?'var(--green)':vm.status==='stopped'?'var(--red)':'var(--yellow)';
|
||
const cpuClass = vm.cpu>80?'text-red':vm.cpu>60?'text-orange':'text-cyan';
|
||
const ctxKey = 'vm_' + vm.vmid;
|
||
_panelCtx[ctxKey] = {type:'vm', label:vm.name,
|
||
vmid:vm.vmid, name:vm.name, status:vm.status,
|
||
cpu:vm.cpu, mem_mb:vm.mem_mb, maxmem_mb:vm.maxmem_mb,
|
||
type_label:vm.type||'qemu', uptime:vm.uptime||0};
|
||
return `<div class="vm-card" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this VM">
|
||
<div class="vm-header">
|
||
<span class="vm-name">${vm.name}</span>
|
||
<span style="color:${statusColor};font-size:0.65rem">● ${(vm.status||'').toUpperCase()}</span>
|
||
</div>
|
||
<div class="vm-metrics">
|
||
<div class="vm-metric">CPU <span class="${cpuClass}">${vm.cpu}%</span></div>
|
||
<div class="vm-metric">RAM <span class="text-cyan">${vm.mem_mb||0}/${vm.maxmem_mb||0}MB</span></div>
|
||
<div class="vm-metric">ID <span class="text-dim">${vm.vmid}</span></div>
|
||
<div class="vm-metric">TYPE <span class="text-dim">${vm.type||'qemu'}</span></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── HOME ASSISTANT ────────────────────────────────────────────────────
|
||
async function loadHA() {
|
||
const data = await api('ha');
|
||
const el = document.getElementById('ha-list');
|
||
const dot = document.getElementById('bb-ha-dot');
|
||
const sta = document.getElementById('bb-ha-status');
|
||
|
||
if (!data.configured) {
|
||
el.innerHTML = `<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text-dim);line-height:1.5">
|
||
<div class="text-yellow" style="margin-bottom:8px">⚠ NOT CONFIGURED</div>
|
||
Set HA_URL and HA_TOKEN in config.php to enable smart home control.
|
||
</div>`;
|
||
dot.className='bb-dot offline'; sta.textContent='NOT CONFIGURED';
|
||
return;
|
||
}
|
||
|
||
dot.className='bb-dot online'; sta.textContent='ONLINE';
|
||
|
||
const entities = data.entities || {};
|
||
if (!Object.keys(entities).length) {
|
||
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No entities found.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const [domain, items] of Object.entries(entities)) {
|
||
if (!items.length) continue;
|
||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">${domain.toUpperCase()}</div>`;
|
||
html += items.slice(0,8).map(e => {
|
||
const isOn = ['on','home','open','locked','playing'].includes(e.state);
|
||
const ctxKey = 'ha_' + e.entity_id.replace(/[^a-z0-9]/gi,'_');
|
||
_panelCtx[ctxKey] = {type:'ha', label:e.name,
|
||
entity_id:e.entity_id, name:e.name, state:e.state, domain:domain};
|
||
return `<div class="ha-entity">
|
||
<span class="ha-name" onclick="toggleHA('${e.entity_id}','${domain}','${e.state}')">${e.name}</span>
|
||
<button class="ha-ask-btn" onclick="selectContext('${ctxKey}')" data-ctx-key="${ctxKey}" title="Ask Jarvis about this">ASK</button>
|
||
<span class="ha-state ${isOn?'on':'off'}" onclick="toggleHA('${e.entity_id}','${domain}','${e.state}')">${e.state.toUpperCase()}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
async function toggleHA(entityId, domain, currentState) {
|
||
const service = currentState==='on'?'turn_off':'turn_on';
|
||
if (domain==='scene') { return; }
|
||
try {
|
||
await api('ha/service', 'POST', {domain, service, entity_id: entityId});
|
||
setTimeout(loadHA, 1500);
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ── ALERTS ────────────────────────────────────────────────────────────
|
||
async function loadAlerts() {
|
||
const data = await api('alerts');
|
||
const el = document.getElementById('alerts-list');
|
||
const tb = document.getElementById('tb-alerts');
|
||
|
||
const alerts = data.alerts || [];
|
||
if (!alerts.length) {
|
||
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--green);text-align:center;margin-top:20px">✓ NO ACTIVE ALERTS</div>';
|
||
tb.textContent='NO ALERTS'; tb.className='text-green';
|
||
return;
|
||
}
|
||
|
||
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
|
||
tb.className='text-red';
|
||
|
||
el.innerHTML = alerts.map(a => {
|
||
const ctxKey = 'alert_' + a.id;
|
||
_panelCtx[ctxKey] = {type:'alert', label:a.title,
|
||
id:a.id, title:a.title, message:a.message, severity:a.severity};
|
||
return `<div class="alert-item ${a.severity}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this alert">
|
||
<div style="flex:1">
|
||
<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text)">${a.title}</div>
|
||
<div style="font-size:0.65rem;color:var(--text-dim)">${a.message}</div>
|
||
</div>
|
||
<button onclick="event.stopPropagation();resolveAlert(${a.id})" style="background:none;border:1px solid var(--dim);border-radius:3px;color:var(--text-dim);font-size:0.6rem;padding:2px 6px;cursor:pointer">✓</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function resolveAlert(id) {
|
||
await api('alerts/resolve', 'POST', {id});
|
||
loadAlerts();
|
||
}
|
||
|
||
// ── WEATHER ───────────────────────────────────────────────────────────
|
||
async function loadWeather() {
|
||
const d = await api('weather');
|
||
if (!d || !d.current) return;
|
||
const c = d.current;
|
||
document.getElementById('weather-temp').textContent = c.temp;
|
||
document.getElementById('weather-desc').textContent = (c.desc || '').toUpperCase();
|
||
document.getElementById('weather-feels').textContent = c.feels + '°F';
|
||
document.getElementById('weather-humidity').textContent = c.humidity + '%';
|
||
document.getElementById('weather-details').textContent =
|
||
'Wind ' + c.wind + ' mph · Cloud ' + c.cloud + '% · Vis ' + c.vis + ' mi';
|
||
|
||
const fc = d.forecast || [];
|
||
document.getElementById('weather-forecast').innerHTML = fc.slice(0, 4).map(day => `
|
||
<div class="forecast-card">
|
||
<div class="fc-day">${day.day}</div>
|
||
<div class="fc-icon" style="font-size:0.55rem;color:var(--cyan);padding:3px 0;line-height:1.3">${day.icon}</div>
|
||
<div class="fc-temps">${day.high}°<span style="color:var(--text-dim)">${day.low}°</span></div>
|
||
<div class="fc-rain">${day.rain_pct > 0 ? day.rain_pct+'%' : ''}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// ── NEWS ──────────────────────────────────────────────────────────────
|
||
async function loadNews() {
|
||
const d = await api('news');
|
||
const el = document.getElementById('news-list');
|
||
if (!d || !d.categories || Object.keys(d.categories).length === 0) {
|
||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">News loading...</div>';
|
||
return;
|
||
}
|
||
const catLabels = { headlines: '📰 TOP HEADLINES', technology: '💻 TECHNOLOGY' };
|
||
let html = '';
|
||
for (const [cat, articles] of Object.entries(d.categories)) {
|
||
if (!articles.length) continue;
|
||
html += `<div class="news-cat-header">${catLabels[cat] || cat.toUpperCase()}</div>`;
|
||
for (const a of articles.slice(0, 5)) {
|
||
const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30);
|
||
_panelCtx[ctxKey] = {type:'news', label:a.title,
|
||
title:a.title, source:a.source, pub:a.pub||'', category:cat};
|
||
html += `<div class="news-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this story">
|
||
<div class="news-source">${a.source}</div>
|
||
<div class="news-title">${a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title}</div>
|
||
${a.pub ? '<div class="news-time">' + a.pub + '</div>' : ''}
|
||
</div>`;
|
||
}
|
||
}
|
||
const ageMin = d.cache_age_s > 0 ? Math.round(d.cache_age_s/60) : 0;
|
||
html += `<div style="font-size:0.5rem;color:var(--text-dim);text-align:right;margin-top:8px">Updated ${ageMin}m ago</div>`;
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ── TABS ──────────────────────────────────────────────────────────────
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||
event.target.classList.add('active');
|
||
document.getElementById('tab-'+name).classList.add('active');
|
||
if (name === 'news') loadNews();
|
||
if (name === 'agents') loadAgents();
|
||
if (name === 'alerts') loadAlerts();
|
||
}
|
||
|
||
// ── CHAT ──────────────────────────────────────────────────────────────
|
||
function addMessage(role, text) {
|
||
const log = document.getElementById('chatLog');
|
||
const div = document.createElement('div');
|
||
div.className = 'msg ' + role;
|
||
div.textContent = text;
|
||
log.appendChild(div);
|
||
log.scrollTop = log.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
function showThinking() {
|
||
const log = document.getElementById('chatLog');
|
||
const div = document.createElement('div');
|
||
div.className = 'msg jarvis';
|
||
div.innerHTML = '<div class="thinking"><div class="thinking-dot"></div><div class="thinking-dot"></div><div class="thinking-dot"></div></div>';
|
||
div.id = 'thinking-bubble';
|
||
log.appendChild(div);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
// ── PANEL CONTEXT SELECTION ───────────────────────────────────────────
|
||
function selectContext(key) {
|
||
const ctx = _panelCtx[key];
|
||
if (!ctx) return;
|
||
|
||
// Clear previous active highlight
|
||
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
|
||
|
||
selectedContext = ctx;
|
||
|
||
// Highlight clicked element
|
||
const el = document.querySelector('[data-ctx-key="' + key + '"]');
|
||
if (el) el.classList.add('ctx-active');
|
||
|
||
// Show chip
|
||
const chip = document.getElementById('contextChip');
|
||
const typeLabels = {vm:'VM', network:'DEVICE', alert:'ALERT', news:'NEWS', ha:'HOME'};
|
||
document.getElementById('contextType').textContent = typeLabels[ctx.type] || ctx.type.toUpperCase();
|
||
document.getElementById('contextLabel').textContent = ctx.label;
|
||
chip.classList.add('visible');
|
||
|
||
// Focus input for immediate question
|
||
document.getElementById('textInput').focus();
|
||
}
|
||
|
||
function clearContext() {
|
||
selectedContext = null;
|
||
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
|
||
const chip = document.getElementById('contextChip');
|
||
chip.classList.remove('visible');
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const input = document.getElementById('textInput');
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
|
||
// Local panel-toggle voice commands (handled without API call)
|
||
const t = text.toLowerCase();
|
||
if (/\b(focus\s*mode|hide\s*(panels?|stats?|statistics)|full\s*screen\s*jarvis)\b/.test(t)) {
|
||
input.value = '';
|
||
addMessage('user', text);
|
||
if (panelsVisible) togglePanels(true);
|
||
addMessage('jarvis', 'Focus mode activated, Sir. Side panels hidden.');
|
||
speak('Focus mode activated, Sir. Side panels hidden.');
|
||
return;
|
||
}
|
||
if (/\b(show\s*(panels?|stats?|statistics|full\s*view)|full\s*(view|mode)|restore\s*panels?)\b/.test(t)) {
|
||
input.value = '';
|
||
addMessage('user', text);
|
||
if (!panelsVisible) togglePanels(true);
|
||
addMessage('jarvis', 'Full view restored, Sir. All panels visible.');
|
||
speak('Full view restored, Sir. All panels visible.');
|
||
return;
|
||
}
|
||
|
||
input.value = '';
|
||
addMessage('user', text);
|
||
showThinking();
|
||
|
||
try {
|
||
const payload = {message:text, session_id:sessionId};
|
||
if (selectedContext) {
|
||
payload.context = selectedContext;
|
||
clearContext();
|
||
}
|
||
const data = await api('chat', 'POST', payload);
|
||
const bubble = document.getElementById('thinking-bubble');
|
||
if (bubble) bubble.remove();
|
||
|
||
if (data.reply) {
|
||
addMessage('jarvis', data.reply);
|
||
speak(data.reply);
|
||
}
|
||
} catch(e) {
|
||
const bubble = document.getElementById('thinking-bubble');
|
||
if (bubble) bubble.remove();
|
||
addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.');
|
||
}
|
||
}
|
||
|
||
// ── VOICE RECOGNITION ─────────────────────────────────────────────────
|
||
function initVoice() {
|
||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (!SR) {
|
||
// Chrome blocks speech API on untrusted HTTPS (self-signed certs)
|
||
if (window.isSecureContext === false) {
|
||
console.warn('Speech Recognition blocked: page is not a secure context (self-signed cert?)');
|
||
} else {
|
||
console.warn('Speech Recognition not supported in this browser');
|
||
}
|
||
return;
|
||
}
|
||
recognition = new SR();
|
||
recognition.continuous = false;
|
||
recognition.interimResults = false;
|
||
recognition.lang = 'en-US';
|
||
|
||
recognition.onresult = (e) => {
|
||
const transcript = e.results[0][0].transcript;
|
||
document.getElementById('textInput').value = transcript;
|
||
stopListening();
|
||
sendMessage();
|
||
};
|
||
|
||
recognition.onend = () => {
|
||
if (isListening) stopListening();
|
||
};
|
||
|
||
recognition.onerror = (e) => {
|
||
stopListening();
|
||
if (e.error === 'not-allowed') {
|
||
addMessage('system', 'Microphone access denied. Please allow microphone permission in your browser, then reload.');
|
||
} else if (e.error === 'audio-capture') {
|
||
addMessage('system', 'No microphone detected. Please connect a microphone and try again.');
|
||
} else if (e.error !== 'no-speech') {
|
||
addMessage('system', 'Voice error: ' + e.error);
|
||
}
|
||
};
|
||
}
|
||
|
||
function toggleVoice() {
|
||
if (isListening) stopListening(); else startListening();
|
||
}
|
||
|
||
function startListening() {
|
||
if (!recognition) {
|
||
if (!window.isSecureContext) {
|
||
addMessage('system', 'Voice recognition requires a trusted HTTPS connection. Please access JARVIS via https://jarvis.orbishosting.com for voice support.');
|
||
} else {
|
||
addMessage('system', 'Voice recognition requires Chrome or Edge browser.');
|
||
}
|
||
return;
|
||
}
|
||
isListening = true;
|
||
document.getElementById('micBtn').classList.add('listening');
|
||
document.getElementById('micIcon').textContent = '🔴';
|
||
document.getElementById('waveform').classList.add('active');
|
||
try {
|
||
recognition.start();
|
||
} catch(e) {
|
||
stopListening();
|
||
addMessage('system', 'Could not start microphone: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function stopListening() {
|
||
isListening = false;
|
||
document.getElementById('micBtn').classList.remove('listening');
|
||
document.getElementById('micIcon').textContent = '🎤';
|
||
document.getElementById('waveform').classList.remove('active');
|
||
try { recognition.stop(); } catch(e) {}
|
||
}
|
||
|
||
// ── SPEECH SYNTHESIS ──────────────────────────────────────────────────
|
||
function loadVoices() {
|
||
const set = () => {
|
||
const voices = synth.getVoices();
|
||
// Priority: Australian male → Australian → British male → British → any English
|
||
selectedVoice =
|
||
voices.find(v => v.name === 'Nathan') // macOS Australian male
|
||
|| voices.find(v => v.name === 'Google Australian English') // Chrome Australian
|
||
|| voices.find(v => v.name === 'Karen') // macOS Australian female
|
||
|| voices.find(v => v.lang === 'en-AU') // any Australian
|
||
|| voices.find(v => v.name === 'Daniel') // macOS British male
|
||
|| voices.find(v => v.name === 'Google UK English Male') // Chrome British male
|
||
|| voices.find(v => v.lang === 'en-GB') // any British
|
||
|| voices.find(v => v.lang.startsWith('en')) // any English
|
||
|| voices[0]
|
||
|| null;
|
||
};
|
||
set();
|
||
synth.onvoiceschanged = set;
|
||
}
|
||
|
||
function speak(text) {
|
||
if (!synth || !text) return;
|
||
synth.cancel();
|
||
const utter = new SpeechSynthesisUtterance(text);
|
||
if (selectedVoice) utter.voice = selectedVoice;
|
||
utter.rate = 0.92;
|
||
utter.pitch = 0.85;
|
||
utter.volume = 1;
|
||
|
||
utter.onstart = () => {
|
||
document.getElementById('arcReactor').classList.add('speaking');
|
||
};
|
||
utter.onend = () => {
|
||
document.getElementById('arcReactor').classList.remove('speaking');
|
||
};
|
||
|
||
synth.speak(utter);
|
||
}
|
||
|
||
// ── AGENT DETECTION & BROWSER INSTALL ─────────────────────────────────
|
||
let _agentOnline = false;
|
||
|
||
function detectOS() {
|
||
const ua = navigator.userAgent;
|
||
const p = (navigator.platform || '').toLowerCase();
|
||
if (p.includes('mac')) return 'mac';
|
||
if (p.includes('win') || ua.includes('Windows')) return 'windows';
|
||
if (p.includes('linux') || ua.includes('Linux')) return 'linux';
|
||
return 'unknown';
|
||
}
|
||
|
||
async function checkAgentStatus() {
|
||
const dot = document.getElementById('bb-agent-dot');
|
||
const sta = document.getElementById('bb-agent-status');
|
||
const btn = document.getElementById('agentBtn');
|
||
if (!dot || !sta) return;
|
||
try {
|
||
const data = await api('agent/list');
|
||
const agents = data.agents || [];
|
||
const online = agents.filter(a => a.status === 'online');
|
||
dot.className = 'bb-dot ' + (online.length > 0 ? 'online' : 'offline');
|
||
sta.textContent = online.length > 0 ? online.length + ' ONLINE' : 'NONE';
|
||
const cnt = document.getElementById('net-agent-count');
|
||
if (cnt) cnt.textContent = online.length + ' AGENT' + (online.length !== 1 ? 'S' : '') + ' ONLINE';
|
||
const myIp = data.my_ip || '';
|
||
const myAgent = online.find(a => a.ip_address === myIp);
|
||
_agentOnline = !!myAgent;
|
||
if (btn) {
|
||
if (_agentOnline) {
|
||
btn.classList.add('agent-online');
|
||
btn.title = 'Agent active: ' + myAgent.hostname;
|
||
} else {
|
||
btn.classList.remove('agent-online');
|
||
btn.title = 'Click to install JARVIS Agent on this machine';
|
||
}
|
||
}
|
||
// Also refresh the AGENTS tab if it's visible
|
||
if (document.getElementById('tab-agents').classList.contains('active')) {
|
||
renderAgentsTab(agents, data.metrics || {});
|
||
}
|
||
} catch(e) {
|
||
if (dot) dot.className = 'bb-dot offline';
|
||
if (sta) sta.textContent = 'ERROR';
|
||
}
|
||
}
|
||
|
||
async function loadAgents() {
|
||
const [listData, metricsData] = await Promise.all([
|
||
api('agent/list'),
|
||
api('agent/status')
|
||
]);
|
||
const agents = listData.agents || [];
|
||
const metrics = metricsData.metrics || {};
|
||
renderAgentsTab(agents, metrics);
|
||
}
|
||
|
||
async function addNetworkDevice() {
|
||
const ip = prompt('IP address (e.g. 10.48.200.43):');
|
||
if (!ip) return;
|
||
const name = prompt('Device name (e.g. Yealink Phone):');
|
||
if (!name) return;
|
||
const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device';
|
||
const r = await api('network/add', 'POST', {ip, alias: name, type});
|
||
if (r.error) { alert('Error: ' + r.error); return; }
|
||
loadNetwork();
|
||
}
|
||
|
||
async function deleteNetworkDevice(ip, evt) {
|
||
evt.stopPropagation();
|
||
if (!confirm('Remove ' + ip + ' from the network list?')) return;
|
||
const r = await api('network/delete', 'POST', {ip});
|
||
if (r.error) { alert('Error: ' + r.error); return; }
|
||
loadNetwork();
|
||
}
|
||
|
||
function renderAgentsTab(agents, metrics) {
|
||
const el = document.getElementById('agents-list');
|
||
if (!el) return;
|
||
if (!agents.length) {
|
||
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;margin-top:20px">NO AGENTS REGISTERED</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = agents.map(ag => {
|
||
const m = metrics[ag.agent_id] || {};
|
||
const sys = m.system || {};
|
||
const alive = ag.status === 'online';
|
||
const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--';
|
||
const mem = sys.memory ? Math.round(sys.memory.percent) : '--';
|
||
const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--';
|
||
const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--';
|
||
const disks = sys.disk || [];
|
||
const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null;
|
||
const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE');
|
||
const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--';
|
||
|
||
const gauge = (val, unit='%', warn=80, crit=90) => {
|
||
const v = typeof val === 'number' ? val : parseInt(val);
|
||
if (isNaN(v)) return `<span style="color:var(--text-dim)">--</span>`;
|
||
const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)';
|
||
return `<div style="display:flex;align-items:center;gap:4px">
|
||
<div style="width:50px;height:5px;background:rgba(255,255,255,0.1);border-radius:3px;flex-shrink:0">
|
||
<div style="width:${Math.min(v,100)}%;height:100%;background:${col};border-radius:3px;transition:width 0.5s"></div>
|
||
</div>
|
||
<span style="color:${col};font-size:0.65rem">${v}${unit}</span>
|
||
</div>`;
|
||
};
|
||
|
||
const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true)
|
||
.map(s => `<span style="color:${s.status==='active'?'var(--green)':'var(--red)'};font-size:0.58rem;margin-right:6px">${s.service}: ${s.status}</span>`)
|
||
.join('');
|
||
|
||
const ctxKey = 'agent_' + ag.agent_id;
|
||
_panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id,
|
||
hostname: ag.hostname, status: ag.status, cpu, mem};
|
||
|
||
return `<div class="alert-item ${alive ? '' : 'critical'}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')"
|
||
style="flex-direction:column;align-items:stretch;border-left:3px solid ${alive ? 'var(--green)' : 'var(--red)'}">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<div style="width:8px;height:8px;border-radius:50%;background:${alive ? 'var(--green)' : 'var(--red)'};box-shadow:${alive ? '0 0 6px var(--green)' : 'none'};flex-shrink:0"></div>
|
||
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${ag.hostname}</span>
|
||
<span style="font-size:0.58rem;color:var(--text-dim)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</span>
|
||
<span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
|
||
</div>
|
||
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px">
|
||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
|
||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
|
||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
|
||
</div>` : ''}
|
||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
|
||
${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function openAgentModal() {
|
||
const os = detectOS();
|
||
const title = document.getElementById('agentModalTitle');
|
||
const content = document.getElementById('agentModalContent');
|
||
const modal = document.getElementById('agentModal');
|
||
const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
|
||
const baseUrl = 'https://jarvis.orbishosting.com/agent';
|
||
const jUrl = 'https://jarvis.orbishosting.com';
|
||
|
||
if (_agentOnline) {
|
||
title.textContent = '● AGENT CONNECTED';
|
||
content.innerHTML = '<div style="color:var(--green);font-size:0.75rem;margin-bottom:12px">✓ JARVIS Agent is running on this machine.</div>' +
|
||
'<div style="color:var(--text-dim);font-size:0.65rem">Reporting: CPU · Memory · Disk · Services · Uptime</div>';
|
||
} else {
|
||
const inst = {
|
||
mac: {
|
||
label:'macOS',
|
||
cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
|
||
dl: baseUrl+'/install-mac.sh',
|
||
note:'Run in Terminal. Installs as a launchd background service.'
|
||
},
|
||
windows: {
|
||
label:'Windows',
|
||
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile install.ps1\n.\\install-windows.ps1 -JarvisUrl '+jUrl+' -Key '+regKey,
|
||
dl: baseUrl+'/install-windows.ps1',
|
||
note:'Run PowerShell as Administrator. Installs as Task Scheduler service.'
|
||
},
|
||
linux: {
|
||
label:'Linux',
|
||
cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
|
||
dl: baseUrl+'/install.sh',
|
||
note:'Run in terminal. Installs as a systemd service.'
|
||
},
|
||
unknown: {
|
||
label:'Your System',
|
||
cmd:'# Download from JARVIS server:\nhttps://jarvis.orbishosting.com/agent/',
|
||
dl: 'https://jarvis.orbishosting.com/agent/',
|
||
note:'Select your platform from the GitHub repo.'
|
||
}
|
||
};
|
||
const i = inst[os] || inst.unknown;
|
||
title.textContent = '● INSTALL AGENT · ' + i.label.toUpperCase();
|
||
content.innerHTML =
|
||
'<div style="color:var(--text-dim);font-size:0.65rem;margin-bottom:12px">'+i.note+'</div>' +
|
||
'<pre id="agentCmdPre">'+i.cmd+'</pre>' +
|
||
'<a class="agent-dl-btn" href="'+i.dl+'" target="_blank">↓ DOWNLOAD INSTALLER</a>' +
|
||
'<div style="color:var(--text-dim);font-size:0.6rem;margin-top:16px;opacity:0.7">After install, this indicator turns green within 30 seconds.</div>';
|
||
}
|
||
modal.classList.add('open');
|
||
}
|
||
|
||
document.addEventListener('click', function(e) {
|
||
if (e.target === document.getElementById('agentModal'))
|
||
document.getElementById('agentModal').classList.remove('open');
|
||
});
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|