Files
jarvis/public_html/index.html
T
myron dc55e6c45b Initial commit: JARVIS AI dashboard v2.3
- 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
2026-05-25 13:22:57 +00:00

1901 lines
86 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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&nbsp;<span id="tb-cpu">--</span>% CPU</div>
<div class="tb-stat">MEM&nbsp;<span id="tb-mem">--</span>%</div>
<div class="tb-stat">DO SERVER&nbsp;<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')">&#x2715; CLOSE</button>
<h3 id="agentModalTitle">&#9679; 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}&deg;<span style="color:var(--text-dim)">${day.low}&deg;</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>