Files
jarvis/public_html/index.html
T
myron 1c3a9fd49e Improve network map with floating bubble nodes and 6-ring layout
- Replace tiny dot nodes with frosted-glass bubbles with ambient glow and float animation
- Add 6th ring for netscan-discovered network devices (cap 28)
- Split named/DB devices and discovered devices into separate rings
- Push rFrac to 0.82 to fill the overlay window
- Increase all ring caps and node radii
- Add FortiGate NAT IP to providers ACL
- Fix TCP SIP drop issue via transport=udp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 03:26:48 +00:00

3705 lines
169 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 — animated drift ─────────────────────────────── */
@keyframes gridDrift{from{background-position:0 0,0 0}to{background-position:40px 40px,40px 40px}}
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;
animation:gridDrift 18s linear infinite;
}
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%}}
/* ── SCANLINE SWEEP ───────────────────────────────────────────────── */
.scanline-sweep{
position:fixed;top:0;left:0;right:0;height:120px;
background:linear-gradient(180deg,transparent 0%,rgba(0,212,255,0.04) 40%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 60%,transparent 100%);
pointer-events:none;z-index:2;
animation:sweepDown 7s linear infinite;
box-shadow:0 0 12px rgba(0,212,255,0.15);
}
@keyframes sweepDown{
0%{transform:translateY(-120px);opacity:0}
3%{opacity:1}
97%{opacity:0.7}
100%{transform:translateY(100vh);opacity:0}
}
/* ── PARTICLE CANVAS ──────────────────────────────────────────────── */
#particleCanvas{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.7}
/* ── PANEL FLOAT + GLOW ───────────────────────────────────────────── */
@keyframes panelFloat{
0%,100%{transform:translateY(var(--pty,0px)) rotateX(var(--prx,0deg)) rotateY(var(--pry,0deg));box-shadow:0 4px 20px rgba(0,0,0,0.4),0 0 0px rgba(0,212,255,0)}
50%{transform:translateY(calc(var(--pty,0px) - 7px)) rotateX(var(--prx,0deg)) rotateY(var(--pry,0deg));box-shadow:0 16px 40px rgba(0,0,0,0.5),0 0 30px rgba(0,212,255,0.06),0 0 60px rgba(0,212,255,0.02)}
}
/* Panel flash when data updates */
@keyframes panelFlash{0%{box-shadow:0 0 0 1px rgba(0,212,255,0.8),0 0 20px rgba(0,212,255,0.3)}100%{box-shadow:none}}
.panel.data-flash{animation:panelFlash 0.5s ease-out,panelFloat 7s ease-in-out infinite}
/* Alert pulse — red ambient glow on body when alerts active */
@keyframes alertPulse{0%,100%{opacity:0}50%{opacity:1}}
#alertOverlay{position:fixed;inset:0;pointer-events:none;z-index:1;background:radial-gradient(ellipse at 50% 50%,rgba(255,30,60,0.07) 0%,transparent 70%);animation:alertPulse 3s ease-in-out infinite;display:none}
/* Glitch keyframes */
@keyframes glitch1{
0%,100%{clip-path:inset(0 0 100% 0);transform:translate(0)}
10%{clip-path:inset(10% 0 60% 0);transform:translate(-3px,1px)}
20%{clip-path:inset(40% 0 30% 0);transform:translate(3px,-1px)}
30%{clip-path:inset(70% 0 10% 0);transform:translate(-2px,2px)}
40%{clip-path:inset(0 0 100% 0);transform:translate(0)}
}
@keyframes glitch2{
0%,100%{clip-path:inset(0 0 100% 0);transform:translate(0)}
10%{clip-path:inset(60% 0 10% 0);transform:translate(3px,-1px)}
25%{clip-path:inset(20% 0 50% 0);transform:translate(-3px,1px)}
35%{clip-path:inset(80% 0 5% 0);transform:translate(2px,2px)}
45%{clip-path:inset(0 0 100% 0);transform:translate(0)}
}
.tb-logo-text{position:relative;display:inline-block}
.tb-logo-text::before,.tb-logo-text::after{
content:attr(data-text);position:absolute;top:0;left:0;
color:var(--cyan);font-family:var(--font-display);font-size:inherit;font-weight:inherit;letter-spacing:inherit;
pointer-events:none;opacity:0;
}
.tb-logo-text::before{color:rgba(255,0,80,0.8);text-shadow:2px 0 rgba(255,0,80,0.5)}
.tb-logo-text::after{color:rgba(0,255,255,0.8);text-shadow:-2px 0 rgba(0,255,255,0.5)}
.tb-logo-text.glitching::before{animation:glitch1 0.25s steps(1) forwards;opacity:1}
.tb-logo-text.glitching::after{animation:glitch2 0.25s steps(1) forwards;opacity:1}
/* Metric bar shimmer */
@keyframes barShimmer{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}
.metric-bar-fill::after{
content:'';position:absolute;top:0;left:0;width:30%;height:100%;
background:linear-gradient(90deg,transparent,rgba(255,255,255,0.25),transparent);
animation:barShimmer 2.5s ease-in-out infinite;
border-radius:inherit;
}
.metric-bar-fill{position:relative;overflow:hidden}
/* Panel hover rise */
.panel:hover{
transform:translateY(calc(var(--pty,0px) - 11px)) !important;
border-color:rgba(0,212,255,0.45) !important;
box-shadow:0 20px 50px rgba(0,0,0,0.55),0 0 40px rgba(0,212,255,0.1) !important;
transition:transform 0.3s ease,border-color 0.3s ease,box-shadow 0.3s ease;
}
/* Sparkline canvas */
.sparkline-wrap{margin:4px 0 8px;height:32px;position:relative}
.sparkline-wrap canvas{display:block;width:100%;height:32px}
/* ── HUD CORNER BRACKETS ──────────────────────────────────────────── */
/* ── MINI ARC REACTOR ─────────────────────────────────────────────── */
.tb-reactor{width:30px;height:30px;position:relative;flex-shrink:0}
.tbr-ring{position:absolute;border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)}
.tbr-r1{width:30px;height:30px;border:1px solid rgba(0,212,255,0.35);animation:spinRing 9s linear infinite}
.tbr-r2{width:20px;height:20px;border:1px solid var(--orange);box-shadow:0 0 6px var(--orange);animation:spinRing 4s linear infinite reverse}
.tbr-core{position:absolute;width:8px;height:8px;border-radius:50%;background:radial-gradient(circle,#fff 0%,var(--cyan) 50%,var(--cyan2) 100%);box-shadow:0 0 10px var(--cyan),0 0 20px rgba(0,212,255,0.5);top:50%;left:50%;transform:translate(-50%,-50%);animation:corePulse 2s ease-in-out infinite}
/* ── 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;
transition:filter 0.4s ease;
will-change:transform;
}
.tb-logo.face-tracking{
filter:drop-shadow(0 0 12px rgba(0,212,255,0.9)) drop-shadow(0 0 24px rgba(0,212,255,0.4));
}
/* Face scan crosshair overlay */
#faceScanOverlay{
position:fixed;pointer-events:none;z-index:9;
width:60px;height:60px;
display:none;
}
#faceScanOverlay::before,#faceScanOverlay::after{
content:'';position:absolute;
border-color:rgba(0,212,255,0.7);border-style:solid;
}
#faceScanOverlay::before{
top:0;left:0;width:16px;height:16px;
border-width:2px 0 0 2px;
}
#faceScanOverlay::after{
bottom:0;right:0;width:16px;height:16px;
border-width:0 2px 2px 0;
}
#faceScanOverlay .fso-tr{position:absolute;top:0;right:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-left:0;border-bottom:0}
#faceScanOverlay .fso-bl{position:absolute;bottom:0;left:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-right:0;border-top:0}
#faceScanOverlay .fso-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:4px;border-radius:50%;background:rgba(0,212,255,0.6);box-shadow:0 0 6px var(--cyan)}
#faceScanOverlay .fso-label{position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);font-family:var(--font-mono);font-size:0.45rem;color:rgba(0,212,255,0.7);letter-spacing:1px;white-space:nowrap}
@keyframes fsoSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
#faceScanOverlay .fso-ring{position:absolute;top:50%;left:50%;width:40px;height:40px;margin:-20px;border-radius:50%;border:1px solid rgba(0,212,255,0.3);border-top-color:rgba(0,212,255,0.7);animation:fsoSpin 1.2s linear infinite}
.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;
perspective:1200px;
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);
animation:panelFloat 7s ease-in-out infinite;
will-change:transform;
}
.panel::before{
content:'';position:absolute;top:0;left:0;right:0;height:1px;
background:linear-gradient(90deg,transparent,var(--cyan),transparent);
opacity:0.4;
z-index:2;
}
/* HUD corner brackets */
.panel::after{
content:'';position:absolute;inset:0;pointer-events:none;z-index:2;
background:
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top left / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top left / 1px 14px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top right / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top right / 1px 14px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom left / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom left / 1px 14px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom right / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom right / 1px 14px no-repeat;
opacity:0.55;
}
.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:'';display:none}
.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)}
/* Planner mini panel */
/* 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)}}
#micBtn.muted{border-color:var(--text-dim);background:radial-gradient(circle,rgba(200,230,255,0.05),rgba(0,8,22,0.9));box-shadow:0 0 8px rgba(200,230,255,0.1);}
#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-table{width:100%;border-collapse:collapse}
.ha-thead th{
font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;
color:var(--text-dim);padding:4px 2px 6px;border-bottom:1px solid rgba(0,212,255,0.15);
text-align:left;
}
.ha-thead th:nth-child(3){text-align:center}
.ha-thead th:nth-child(4){text-align:center}
.ha-row{transition:background 0.12s}
.ha-row:hover{background:rgba(0,212,255,0.05)}
.ha-row td{
padding:4px 2px;border-bottom:1px solid rgba(0,212,255,0.05);
font-family:var(--font-mono);font-size:0.70rem;vertical-align:middle;
}
.ha-col-domain{font-size:0.85rem;text-align:center;width:20px;padding-right:4px!important}
.ha-col-name{color:var(--text);max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ha-col-state{text-align:center;width:30px;font-size:0.62rem;font-weight:700;white-space:nowrap}
.ha-col-state.on{color:var(--green)}
.ha-col-state.off{color:var(--text-dim)}
.ha-col-ctrl{text-align:center;width:36px}
/* toggle switch */
.ha-toggle{
position:relative;display:inline-block;width:30px;height:15px;cursor:pointer;
}
.ha-toggle input{opacity:0;width:0;height:0;position:absolute}
.ha-slider{
position:absolute;inset:0;border-radius:8px;
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.14);
transition:background 0.18s,border-color 0.18s;
}
.ha-slider::before{
content:'';position:absolute;left:2px;top:2px;
width:9px;height:9px;border-radius:50%;
background:var(--text-dim);transition:transform 0.18s,background 0.18s;
}
.ha-toggle input:checked + .ha-slider{background:rgba(0,255,100,0.22);border-color:var(--green)}
.ha-toggle input:checked + .ha-slider::before{transform:translateX(15px);background:var(--green)}
/* scene activate button */
.ha-scene-btn{
background:transparent;border:1px solid var(--cyan);border-radius:3px;
color:var(--cyan);font-size:0.58rem;padding:1px 4px;cursor:pointer;
font-family:var(--font-mono);transition:background 0.15s;
}
.ha-scene-btn:hover{background:rgba(0,212,255,0.15)}
/* 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)}
/* ① HUD CORNER RINGS */
#hudCornersCanvas{position:fixed;inset:0;z-index:3;pointer-events:none}
/* ② DATA STREAM COLUMNS */
#dataStreamCanvas{position:fixed;inset:0;z-index:0;pointer-events:none}
/* ③ NETWORK TOPOLOGY */
#topoCanvas{display:block;width:100%;flex-shrink:0;cursor:default;border-bottom:1px solid var(--panel-border);margin-bottom:6px}
/* ④ BOOT SEQUENCE */
@keyframes bootLeft{0%{opacity:0;transform:translateX(-70px)}100%{opacity:1;transform:none}}
@keyframes bootRight{0%{opacity:0;transform:translateX(70px)}100%{opacity:1;transform:none}}
@keyframes bootDown{0%{opacity:0;transform:translateY(-18px)}100%{opacity:1;transform:none}}
@keyframes bootCenter{0%{opacity:0;transform:scale(0.94) translateY(14px)}100%{opacity:1;transform:none}}
.boot-left{animation:bootLeft 0.55s cubic-bezier(0.4,0,0.2,1) both}
.boot-right{animation:bootRight 0.55s cubic-bezier(0.4,0,0.2,1) both}
.boot-top{animation:bootDown 0.4s ease both}
.boot-center{animation:bootCenter 0.65s cubic-bezier(0.4,0,0.2,1) both}
/* ⑤ BREATHING EDGE VIGNETTE */
#vignetteOverlay{position:fixed;inset:0;pointer-events:none;z-index:1;
background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(0,2,18,0.6) 100%);
animation:vignettePulse 5s ease-in-out infinite}
#vignetteOverlay.alert-vignette{background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(20,0,8,0.65) 100%)}
@keyframes vignettePulse{0%,100%{opacity:0.75}50%{opacity:1}}
/* ⑥ EKG HEARTBEAT */
#ekgWrap{flex:1;max-width:180px;display:flex;align-items:center;overflow:hidden}
#ekgCanvas{display:block;width:100%;height:22px;opacity:0.8}
/* ⑦ AUDIO RING */
.tb-reactor{position:relative}
#audioRingCanvas{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:60px;height:60px;pointer-events:none;z-index:4}
/* ⑧ TYPEWRITER CURSOR */
@keyframes cursorBlink{0%,100%{opacity:1}49%{opacity:1}50%,99%{opacity:0}}
.type-cursor{display:inline-block;width:6px;height:0.82em;background:var(--cyan);margin-left:1px;
vertical-align:text-bottom;animation:cursorBlink 0.7s step-end infinite}
/* ⑨ STATIC NOISE BURST */
@keyframes staticBurst{0%{opacity:0}10%{opacity:1}90%{opacity:1}100%{opacity:0}}
.panel-noise-layer{position:absolute;inset:0;pointer-events:none;z-index:20;border-radius:var(--r);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.1'/%3E%3C/svg%3E");
background-size:100% 100%;mix-blend-mode:screen;animation:staticBurst 0.28s ease forwards}
/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */
#netMapOverlay{position:fixed;top:0;left:0;width:100vw;height:100vh;
z-index:200;display:none;flex-direction:column;
background:rgba(0,4,18,0.96);border:1px solid rgba(0,212,255,0.28);
border-top:none;border-left:none;transform-origin:0 0;backdrop-filter:blur(14px);
overflow:hidden;box-shadow:6px 6px 40px rgba(0,0,0,0.75),0 0 50px rgba(0,212,255,0.05)}
#netMapOverlay::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:1;
background:
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 1px 18px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 1px 18px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 1px 18px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 1px 18px no-repeat}
#netMapOverlay.nm-open{display:flex;animation:nmExplode 0.45s cubic-bezier(0.4,0,0.2,1) forwards}
#netMapOverlay.nm-closing{animation:nmCollapse 0.3s cubic-bezier(0.4,0,0.2,1) forwards}
@keyframes nmExplode{0%{transform:scale(0.04,0.06);opacity:0}60%{opacity:1}100%{transform:scale(1);opacity:1}}
@keyframes nmCollapse{0%{transform:scale(1);opacity:1}100%{transform:scale(0.04,0.06);opacity:0}}
#nmHeader{display:flex;align-items:center;justify-content:space-between;padding:7px 16px;
flex-shrink:0;border-bottom:1px solid rgba(0,212,255,0.16);background:rgba(0,8,28,0.6);z-index:2;position:relative}
#nmTitle{font-family:var(--font-display);font-size:0.62rem;letter-spacing:4px;color:var(--cyan);display:flex;align-items:center;gap:10px}
#nmTitle .nm-pulse{width:6px;height:6px;border-radius:50%;background:var(--cyan);box-shadow:0 0 7px var(--cyan);animation:corePulse 1.5s infinite}
#nmStats{font-family:var(--font-mono);font-size:0.58rem;color:var(--text-dim);display:flex;gap:16px}
#nmStats span{color:var(--cyan)}
#nmClose{background:none;border:1px solid rgba(0,212,255,0.3);color:var(--text-dim);font-family:var(--font-mono);font-size:0.56rem;padding:3px 10px;cursor:pointer;letter-spacing:2px;transition:all 0.2s}
#nmClose:hover{border-color:var(--red);color:var(--red)}
#nmCanvas{flex:1;display:block;z-index:2;position:relative}
#nmLegend{display:flex;gap:16px;align-items:center;padding:5px 16px;flex-shrink:0;
border-top:1px solid rgba(0,212,255,0.1);font-family:var(--font-mono);font-size:0.54rem;
color:var(--text-dim);background:rgba(0,8,28,0.6);z-index:2;position:relative}
.nm-leg-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:4px;flex-shrink:0}
#nmNodeInfo{position:absolute;pointer-events:none;z-index:10;background:rgba(0,8,30,0.95);
border:1px solid rgba(0,212,255,0.4);padding:7px 11px;font-family:var(--font-mono);
font-size:0.6rem;display:none;min-width:150px;box-shadow:0 0 18px rgba(0,212,255,0.12)}
#nmNodeInfo .ni-title{color:var(--cyan);font-size:0.62rem;letter-spacing:2px;margin-bottom:3px}
#nmNodeInfo .ni-row{color:var(--text-dim);margin:2px 0}
/* ── SLEEP MODE ──────────────────────────────────────────────────────── */
#sleepOverlay{
position:fixed;inset:0;z-index:500;display:none;
flex-direction:column;align-items:center;justify-content:center;
background:rgba(0,2,10,0.94);
backdrop-filter:blur(6px);
}
#sleepOverlay.active{display:flex}
@keyframes sleepPulse{0%,100%{opacity:0.25;transform:scale(1)}50%{opacity:0.6;transform:scale(1.06)}}
@keyframes sleepCoreGlow{0%,100%{box-shadow:0 0 30px rgba(0,212,255,0.15),0 0 60px rgba(0,212,255,0.05)}50%{box-shadow:0 0 50px rgba(0,212,255,0.3),0 0 100px rgba(0,212,255,0.1)}}
.sleep-reactor{position:relative;width:120px;height:120px;margin-bottom:40px}
.sleep-ring{position:absolute;border-radius:50%;border:1px solid rgba(0,212,255,0.2);top:50%;left:50%;transform:translate(-50%,-50%)}
.sleep-ring.sr1{width:120px;height:120px;animation:spinRing 18s linear infinite;border-color:rgba(0,212,255,0.12)}
.sleep-ring.sr2{width:85px;height:85px;animation:spinRing 12s linear infinite reverse;border-color:rgba(0,212,255,0.18)}
.sleep-ring.sr3{width:52px;height:52px;animation:spinRing 8s linear infinite;border-color:rgba(0,80,160,0.3)}
.sleep-core{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:24px;height:24px;border-radius:50%;
background:radial-gradient(circle,rgba(0,150,220,0.6) 0%,rgba(0,80,160,0.3) 60%,transparent 100%);
animation:sleepCoreGlow 4s ease-in-out infinite,sleepPulse 4s ease-in-out infinite}
.sleep-label{font-family:var(--font-display);font-size:0.6rem;letter-spacing:6px;
color:rgba(0,212,255,0.35);animation:sleepPulse 4s ease-in-out infinite;margin-bottom:12px}
.sleep-sub{font-family:var(--font-mono);font-size:0.55rem;letter-spacing:3px;
color:rgba(0,212,255,0.2);animation:sleepPulse 4s ease-in-out infinite;animation-delay:0.5s}
/* App dims on sleep */
#app.sleeping #mainLayout,#app.sleeping #topBar,#app.sleeping #bottomBar{
pointer-events:none;
filter:brightness(0.08) saturate(0.3);
transition:filter 1.2s ease;
}
#app.sleeping #sleepOverlay{display:flex}
</style>
</head>
<body>
<canvas id="particleCanvas"></canvas>
<canvas id="dataStreamCanvas"></canvas>
<canvas id="hudCornersCanvas"></canvas>
<div id="alertOverlay"></div>
<div id="vignetteOverlay"></div>
<div id="faceScanOverlay">
<div class="fso-ring"></div>
<div class="fso-tr"></div>
<div class="fso-bl"></div>
<div class="fso-dot"></div>
<div class="fso-label">TRACKING</div>
</div>
<div class="scanlines"></div>
<div class="scanline-sweep"></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-reactor"><div class="tbr-ring tbr-r1"></div><div class="tbr-ring tbr-r2"></div><div class="tbr-core"></div><canvas id="audioRingCanvas" width="60" height="60"></canvas></div>
<span class="tb-logo-text" data-text="JARVIS SYSTEM">JARVIS SYSTEM</span>
</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 class="tb-stat" id="tb-planner" style="display:none"><span id="tb-planner-text" class="text-yellow"></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">JARVIS SERVER <span style="font-size:0.5rem;color:var(--text-dim)">165.22.1.228</span><div class="indicator"></div></div>
<!-- Metric bars + sparklines -->
<div class="metric-row">
<div class="metric-label">CPU <span id="cpu-val">--%</span></div>
<div class="metric-bar"><div class="metric-bar-fill" id="cpu-bar" style="width:0%"></div></div>
<div class="sparkline-wrap"><canvas id="spark-cpu"></canvas></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 class="sparkline-wrap"><canvas id="spark-mem"></canvas></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 class="sparkline-wrap"><canvas id="spark-disk"></canvas></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</div><div class="val" id="load-val">--</div></div>
<div class="val-row"><div class="lbl">HOST</div><div class="val" id="host-val">--</div></div>
<!-- Services -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">SERVICES</div>
<div id="services-list">
<div class="loading-shimmer" style="margin-bottom:4px"></div>
</div>
<!-- Site health -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">WEBSITES</div>
<div id="sites-list">
<div class="loading-shimmer" style="margin-bottom:4px"></div>
</div>
<!-- Top processes -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">PROCESSES</div>
<div id="procs-list">
<div class="loading-shimmer" style="margin-bottom:4px"></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>
<canvas id="topoCanvas" height="100"></canvas>
<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('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 class="tab" onclick="switchTab('sites')">SITES</div>
</div>
<div id="tab-vms" class="tab-pane" style="overflow-y:auto;flex:1">
<div id="vm-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-ha" class="tab-pane active" 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 id="ekgWrap" style="margin-left:auto"><canvas id="ekgCanvas"></canvas></div>
<div style="font-size:0.65rem;flex-shrink:0">
JARVIS v2.0 · SECURITY LEVEL ALPHA · UPDATED <span id="last-refresh">--:--:--</span>
</div>
</div>
</div>
<!-- NETWORK MAP OVERLAY -->
<div id="netMapOverlay">
<div id="nmHeader">
<div id="nmTitle"><div class="nm-pulse"></div>◈ NETWORK TOPOLOGY — LIVE</div>
<div id="nmStats">NODES <span id="nm-node-count"></span> &nbsp;·&nbsp; ONLINE <span id="nm-online-count"></span> &nbsp;·&nbsp; AGENTS <span id="nm-agent-count"></span></div>
<div style="display:flex;align-items:center;gap:12px">
<span style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);letter-spacing:1px">SAY "CLOSE MAP"</span>
<button id="nmClose" onclick="closeNetMap()">✕ CLOSE</button>
</div>
</div>
<canvas id="nmCanvas"></canvas>
<div id="nmLegend">
<span><span class="nm-leg-dot" style="background:#00ff88;box-shadow:0 0 4px #00ff88"></span>PROXMOX</span>
<span><span class="nm-leg-dot" style="background:#ffd700;box-shadow:0 0 4px #ffd700"></span>SERVICES</span>
<span><span class="nm-leg-dot" style="background:#00beff;box-shadow:0 0 4px #00beff"></span>AGENTS</span>
<span><span class="nm-leg-dot" style="background:rgba(0,160,200,0.9)"></span>DEVICES</span>
<span><span class="nm-leg-dot" style="background:rgba(0,110,170,0.9)"></span>NETWORK</span>
<span><span class="nm-leg-dot" style="background:#ff2244;box-shadow:0 0 4px #ff2244"></span>OFFLINE</span>
<span style="margin-left:auto;opacity:0.4;font-size:0.5rem">CYAN = DATA IN · ORANGE = CMD OUT</span>
</div>
<div id="nmNodeInfo"><div class="ni-title" id="ni-name"></div><div class="ni-row" id="ni-ip"></div><div class="ni-row" id="ni-status"></div><div class="ni-row" id="ni-type"></div></div>
</div>
<!-- SLEEP OVERLAY -->
<div id="sleepOverlay">
<div class="sleep-reactor">
<div class="sleep-ring sr1"></div>
<div class="sleep-ring sr2"></div>
<div class="sleep-ring sr3"></div>
<div class="sleep-core"></div>
</div>
<div class="sleep-label">JARVIS — STANDBY</div>
<div class="sleep-sub">SAY "WAKE UP JARVIS" TO RESUME</div>
</div>
<div id="sitesModal" style="position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:none;align-items:flex-start;justify-content:center;padding:24px;overflow-y:auto">
<div style="background:var(--panel-bg);border:1px solid var(--panel-border);width:100%;max-width:960px;font-family:var(--font-mono)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid var(--panel-border)">
<div style="color:var(--cyan);font-size:0.75rem;letter-spacing:4px">◈ SITES MANAGER — EMAIL SETTINGS</div>
<button onclick="closeSitesModal()" style="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 12px;letter-spacing:2px">✕ CLOSE</button>
</div>
<div style="padding:20px 24px">
<!-- Global API Key -->
<div style="background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.2);padding:16px;margin-bottom:20px">
<div style="color:var(--cyan);font-size:0.62rem;letter-spacing:3px;margin-bottom:10px">▸ CYBERMAIL API KEY — PUSH TO ALL SITES</div>
<div style="display:flex;gap:10px;align-items:center">
<input id="global-api-key" type="password"
style="flex:1;background:#0a0f1a;border:1px solid rgba(0,212,255,0.25);color:var(--text);font-family:var(--font-mono);font-size:0.7rem;padding:8px 12px;outline:none"
placeholder="sk_live_...">
<button onclick="pushApiKey()"
style="background:rgba(0,212,255,0.12);border:1px solid var(--cyan);color:var(--cyan);font-family:var(--font-mono);font-size:0.6rem;letter-spacing:2px;padding:8px 18px;cursor:pointer;white-space:nowrap">
PUSH TO ALL
</button>
</div>
<div id="push-status" style="font-size:0.6rem;color:var(--text-dim);margin-top:6px;min-height:16px"></div>
</div>
<!-- Site Cards Grid -->
<div id="sites-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem">LOADING...</div>
</div>
</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>
// ── PARTICLE CANVAS ───────────────────────────────────────────────────
(function initParticles() {
const canvas = document.getElementById('particleCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const N = 65;
const CONNECT_DIST = 130;
let W, H, particles = [];
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
function spawn() {
particles = [];
for (let i = 0; i < N; i++) {
particles.push({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.25,
vy: (Math.random() - 0.5) * 0.25,
r: Math.random() * 1.2 + 0.4,
a: Math.random() * 0.35 + 0.08,
});
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < N; i++) {
const p = particles[i];
for (let j = i + 1; j < N; j++) {
const q = particles[j];
const dx = p.x - q.x, dy = p.y - q.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < CONNECT_DIST) {
ctx.strokeStyle = `rgba(0,180,255,${0.09 * (1 - d / CONNECT_DIST)})`;
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
}
}
ctx.fillStyle = `rgba(0,200,255,${p.a})`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
p.x += p.vx; p.y += p.vy;
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
}
requestAnimationFrame(draw);
}
resize(); spawn(); draw();
window.addEventListener('resize', () => { resize(); spawn(); });
})();
// ── PANEL FLOAT STAGGER — different phase per panel ───────────────────
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.panel').forEach((p, i) => {
p.style.animationDelay = `-${(i * 1.37).toFixed(2)}s`;
});
});
// ── MOUSE PARALLAX — panels tilt toward cursor ────────────────────────
(function initParallax() {
const MAX_TILT = 3.5; // degrees
let mouseX = 0, mouseY = 0, raf = null;
window.addEventListener('mousemove', e => {
mouseX = e.clientX / window.innerWidth - 0.5; // -0.5 to 0.5
mouseY = e.clientY / window.innerHeight - 0.5;
if (!raf) raf = requestAnimationFrame(applyTilt);
});
function applyTilt() {
raf = null;
const rx = mouseY * MAX_TILT;
const ry = -mouseX * MAX_TILT;
document.querySelectorAll('.panel').forEach(p => {
p.style.setProperty('--prx', rx.toFixed(2) + 'deg');
p.style.setProperty('--pry', ry.toFixed(2) + 'deg');
});
// Column-level parallax (skip in focus mode)
if (!document.getElementById('mainLayout')?.classList.contains('focus-mode')) {
const lp = document.getElementById('leftPanel');
const rp = document.getElementById('rightPanel');
const cp = document.getElementById('centerPanel');
if (lp) lp.style.transform = `translateX(${mouseX * 5}px) translateY(${mouseY * 3}px)`;
if (rp) rp.style.transform = `translateX(${-mouseX * 5}px) translateY(${mouseY * 3}px)`;
if (cp) cp.style.transform = `translateX(${mouseX * 2}px)`;
}
}
})();
// ── SPARKLINES ────────────────────────────────────────────────────────
const _sparkData = {cpu: [], mem: [], disk: []};
const SPARK_MAX = 25;
function pushSparkData(key, val) {
_sparkData[key].push(val);
if (_sparkData[key].length > SPARK_MAX) _sparkData[key].shift();
}
function drawSparkline(canvasId, data, color) {
const canvas = document.getElementById(canvasId);
if (!canvas || !data.length) return;
const wrap = canvas.parentElement;
canvas.width = wrap.clientWidth || 240;
canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const W = canvas.width, H = canvas.height;
const min = 0, max = 100;
const step = W / (SPARK_MAX - 1);
// Fill area under line
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, color.replace(')', ',0.35)').replace('rgb','rgba'));
grad.addColorStop(1, color.replace(')', ',0)').replace('rgb','rgba'));
ctx.beginPath();
data.forEach((v, i) => {
const x = i * step;
const y = H - ((v - min) / (max - min)) * H;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo((data.length - 1) * step, H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.beginPath();
data.forEach((v, i) => {
const x = i * step;
const y = H - ((v - min) / (max - min)) * H;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
// Current value dot
if (data.length > 1) {
const last = data[data.length - 1];
const x = (data.length - 1) * step;
const y = H - ((last - min) / (max - min)) * H;
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// ── PANEL DATA FLASH ──────────────────────────────────────────────────
function flashPanel(panelEl) {
if (!panelEl) return;
panelEl.classList.remove('data-flash');
void panelEl.offsetWidth; // reflow to restart animation
panelEl.classList.add('data-flash');
setTimeout(() => panelEl.classList.remove('data-flash'), 600);
}
// ── ALERT PULSE ───────────────────────────────────────────────────────
function setAlertState(hasAlerts) {
const ov = document.getElementById('alertOverlay');
if (ov) ov.style.display = hasAlerts ? 'block' : 'none';
const vg = document.getElementById('vignetteOverlay');
if (vg) vg.classList.toggle('alert-vignette', hasAlerts);
}
// ── FACE TRACKING — reactor follows face position ─────────────────────
let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5
let _faceCurrX = 0, _faceCurrY = 0;
let _faceVisible = false;
let _faceTrackRaf = null;
const FACE_MAX_X = 48; // max px left/right travel
const FACE_MAX_Y = 10; // max px up/down travel
const FACE_LERP = 0.07; // smoothing (lower = slower/smoother)
function _faceTrackLoop() {
_faceTrackRaf = requestAnimationFrame(_faceTrackLoop);
// Lerp toward target (or back to 0 when no face)
const targetX = _faceVisible ? _faceTargetX : 0;
const targetY = _faceVisible ? _faceTargetY : 0;
_faceCurrX += (targetX - _faceCurrX) * FACE_LERP;
_faceCurrY += (targetY - _faceCurrY) * FACE_LERP;
const tx = _faceCurrX * FACE_MAX_X;
const ty = _faceCurrY * FACE_MAX_Y;
const logo = document.querySelector('.tb-logo');
if (logo) logo.style.transform = `translateX(${tx.toFixed(2)}px) translateY(${ty.toFixed(2)}px)`;
}
function updateFaceTarget(box, videoW, videoH) {
// Face center, normalized — flip X because front camera is mirrored
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
_faceTargetX = 0.5 - (cx / videoW); // flipped: move right when face is left
_faceTargetY = (cy / videoH) - 0.5; // positive = face low = logo drops slightly
_faceVisible = true;
// Position the scan overlay over the detected face in the viewport.
// The video feed is 320×240 but hidden; map to viewport coords.
const scaleX = window.innerWidth / videoW;
const scaleY = window.innerHeight / videoH;
const faceVx = box.x * scaleX;
const faceVy = box.y * scaleY;
const faceVw = box.width * scaleX;
const faceVh = box.height * scaleY;
const ov = document.getElementById('faceScanOverlay');
if (ov) {
ov.style.display = 'block';
// Center overlay on face, flipped X to match mirror
const ovX = window.innerWidth - faceVx - faceVw / 2 - 30;
const ovY = faceVy + faceVh / 2 - 30;
ov.style.left = Math.max(0, Math.min(window.innerWidth - 60, ovX)) + 'px';
ov.style.top = Math.max(0, Math.min(window.innerHeight - 80, ovY)) + 'px';
}
}
function clearFaceTarget() {
_faceVisible = false;
const ov = document.getElementById('faceScanOverlay');
if (ov) ov.style.display = 'none';
}
function startFaceTracking() {
const logo = document.querySelector('.tb-logo');
if (logo) logo.classList.add('face-tracking');
if (!_faceTrackRaf) _faceTrackLoop();
}
function stopFaceTracking() {
clearFaceTarget();
const logo = document.querySelector('.tb-logo');
if (logo) { logo.classList.remove('face-tracking'); logo.style.transform = ''; }
// Let the loop coast to zero naturally rather than snapping — cancel after settling
setTimeout(() => {
if (!_faceVisible && _faceTrackRaf) {
cancelAnimationFrame(_faceTrackRaf);
_faceTrackRaf = null;
}
}, 1500);
}
// ── GLITCH EFFECT ─────────────────────────────────────────────────────
(function initGlitch() {
function triggerGlitch() {
const el = document.querySelector('.tb-logo-text');
if (!el) return;
el.classList.add('glitching');
setTimeout(() => el.classList.remove('glitching'), 280);
setTimeout(triggerGlitch, 35000 + Math.random() * 25000);
}
setTimeout(triggerGlitch, 20000);
})();
// ① HUD CORNER RINGS ──────────────────────────────────────────────────
(function initHudCorners() {
const canvas = document.getElementById('hudCornersCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H, t = 0;
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
function drawCorner(cx, cy, a0, a1) {
const R = 62, R2 = R + 16;
// Edge lines
ctx.strokeStyle = 'rgba(0,212,255,0.35)'; ctx.lineWidth = 1;
const edgeLen = 28;
// two short lines along the screen edges from the corner point
const midA = (a0 + a1) / 2;
const ax = Math.cos(a0), ay = Math.sin(a0);
const bx = Math.cos(a1), by = Math.sin(a1);
ctx.beginPath(); ctx.moveTo(cx + ax*(R+6), cy + ay*(R+6)); ctx.lineTo(cx + ax*(R+6+edgeLen), cy + ay*(R+6+edgeLen)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx + bx*(R+6), cy + by*(R+6)); ctx.lineTo(cx + bx*(R+6+edgeLen), cy + by*(R+6+edgeLen)); ctx.stroke();
// Primary arc
ctx.beginPath(); ctx.arc(cx, cy, R, a0, a1);
ctx.strokeStyle = 'rgba(0,212,255,0.5)'; ctx.lineWidth = 1.2; ctx.stroke();
// Outer arc
ctx.beginPath(); ctx.arc(cx, cy, R2, a0 + 0.12, a1 - 0.12);
ctx.strokeStyle = 'rgba(0,212,255,0.18)'; ctx.lineWidth = 0.6; ctx.stroke();
// Tick marks
const ticks = 14;
for (let i = 0; i <= ticks; i++) {
const a = a0 + (a1 - a0) * (i / ticks);
const big = i % 7 === 0;
const len = big ? 9 : (i % 2 === 0 ? 5 : 3);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * (R - 2), cy + Math.sin(a) * (R - 2));
ctx.lineTo(cx + Math.cos(a) * (R - 2 - len), cy + Math.sin(a) * (R - 2 - len));
ctx.strokeStyle = big ? 'rgba(0,212,255,0.8)' : 'rgba(0,212,255,0.3)';
ctx.lineWidth = big ? 1 : 0.5; ctx.stroke();
}
// Animated scanning dot
const dotA = a0 + ((a1 - a0) * ((t * 0.35) % 1));
ctx.beginPath(); ctx.arc(cx + Math.cos(dotA) * R, cy + Math.sin(dotA) * R, 2.5, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,1)';
ctx.shadowColor = 'rgba(0,212,255,0.9)'; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowBlur = 0;
// Small numeric labels
ctx.font = '7px Share Tech Mono,monospace'; ctx.fillStyle = 'rgba(0,212,255,0.55)';
ctx.fillText(Math.round((Math.abs(a0) / (Math.PI*2)) * 360) + '°',
cx + Math.cos(a0) * (R + 20), cy + Math.sin(a0) * (R + 20));
}
function draw() {
ctx.clearRect(0, 0, W, H); t += 0.01;
drawCorner(0, 0, 0, Math.PI*0.5);
drawCorner(W, 0, Math.PI*0.5, Math.PI);
drawCorner(0, H, Math.PI*1.5, Math.PI*2);
drawCorner(W, H, Math.PI, Math.PI*1.5);
requestAnimationFrame(draw);
}
resize(); draw();
window.addEventListener('resize', resize);
})();
// ② DATA STREAM COLUMNS ───────────────────────────────────────────────
(function initDataStream() {
const canvas = document.getElementById('dataStreamCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H;
const CHARS = '0123456789ABCDEF◈◉▸▹⟩⟨⬡░▒';
const COL_COUNT = 22;
let cols = [];
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
cols = [];
for (let i = 0; i < COL_COUNT; i++) {
const x = (i / COL_COUNT) * W + Math.random() * (W / COL_COUNT) * 0.6;
cols.push({ x, y: Math.random() * H, speed: Math.random() * 0.7 + 0.25,
len: Math.floor(Math.random() * 14 + 5), chars: [],
alpha: Math.random() * 0.035 + 0.015, tick: 0 });
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.font = '11px Share Tech Mono,monospace';
for (const c of cols) {
c.y += c.speed;
if (c.y - c.len * 14 > H) { c.y = -c.len * 14; c.alpha = Math.random() * 0.035 + 0.015; }
if (++c.tick > 7) { c.tick = 0; c.chars[0] = CHARS[Math.floor(Math.random() * CHARS.length)]; }
for (let i = 0; i < c.len; i++) {
if (!c.chars[i]) c.chars[i] = CHARS[Math.floor(Math.random() * CHARS.length)];
const cy = c.y - i * 14;
if (cy < -14 || cy > H + 14) continue;
const a = c.alpha * (1 - i / c.len);
ctx.fillStyle = i === 0 ? `rgba(180,240,255,${Math.min(a*4,0.15)})` : `rgba(0,185,225,${a})`;
ctx.fillText(c.chars[i], c.x, cy);
}
}
requestAnimationFrame(draw);
}
resize(); draw();
window.addEventListener('resize', resize);
})();
// ③ NETWORK TOPOLOGY ──────────────────────────────────────────────────
let _topoNodes = [], _topoT = 0, _topoRunning = false;
function renderTopology(devices) {
const canvas = document.getElementById('topoCanvas');
if (!canvas) return;
const W = canvas.parentElement?.clientWidth || 260;
canvas.width = W; canvas.height = 118;
_topoNodes = devices.slice(0, 18).map((d, i, arr) => {
const angle = (i / arr.length) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 36;
return {
x: W/2 + Math.cos(angle) * rx * (0.6 + (i%3)*0.18),
y: 52 + Math.sin(angle) * ry * (0.65 + (i%2)*0.25),
label: (d.name || d.ip || '?').split('.')[0].substring(0, 9),
on: !!(d.alive || d.status === 'online'),
agent: d.source === 'agent',
phase: Math.random() * Math.PI * 2,
};
});
if (!_topoRunning) { _topoRunning = true; _drawTopo(); }
}
function _drawTopo() {
requestAnimationFrame(_drawTopo);
const canvas = document.getElementById('topoCanvas');
if (!canvas || !_topoNodes.length) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
_topoT += 0.018;
ctx.clearRect(0, 0, W, H);
const floatY = n => Math.sin(_topoT * 0.9 + n.phase) * 2.5;
// Connections
for (let i = 0; i < _topoNodes.length; i++) {
for (let j = i+1; j < _topoNodes.length; j++) {
const a = _topoNodes[i], b = _topoNodes[j];
if (!a.on || !b.on) continue;
const ax = a.x, ay = a.y + floatY(a), bx = b.x, by = b.y + floatY(b);
const dist = Math.hypot(bx-ax, by-ay);
if (dist > W * 0.55) continue;
const lg = ctx.createLinearGradient(ax, ay, bx, by);
const col = a.agent ? '0,255,136' : '0,212,255';
lg.addColorStop(0, `rgba(${col},0.25)`); lg.addColorStop(0.5, `rgba(${col},0.1)`); lg.addColorStop(1, `rgba(${col},0.25)`);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
ctx.strokeStyle = lg; ctx.lineWidth = 0.8; ctx.stroke();
// travelling pulse
const p = (_topoT * 0.4 + a.phase) % 1;
ctx.beginPath(); ctx.arc(ax+(bx-ax)*p, ay+(by-ay)*p, 1.8, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,0.85)'; ctx.fill();
}
}
// Bubble nodes
for (const n of _topoNodes) {
const pulse = 0.5 + Math.sin(_topoT * 1.4 + n.phase) * 0.3;
const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80';
const r = n.agent ? 10 : 7;
const nx = n.x, ny = n.y + floatY(n);
if (n.on) {
// Ambient bloom
const bloom = ctx.createRadialGradient(nx, ny, r*0.4, nx, ny, r*3);
bloom.addColorStop(0, `rgba(${col},${(pulse*0.18).toFixed(3)})`);
bloom.addColorStop(1, `rgba(${col},0)`);
ctx.beginPath(); ctx.arc(nx, ny, r*3, 0, Math.PI*2); ctx.fillStyle = bloom; ctx.fill();
}
// Frosted glass fill
const fg = ctx.createRadialGradient(nx, ny - r*0.3, 0, nx, ny, r);
const fa = n.on ? 0.18 + pulse*0.1 : 0.07;
fg.addColorStop(0, `rgba(${col},${(fa*1.8).toFixed(3)})`);
fg.addColorStop(0.65, `rgba(${col},${fa.toFixed(3)})`);
fg.addColorStop(1, `rgba(${col},${(fa*0.2).toFixed(3)})`);
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.fillStyle = fg; ctx.fill();
// Border
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2);
ctx.strokeStyle = `rgba(${col},${n.on ? (0.5 + pulse*0.32).toFixed(3) : '0.2'})`;
ctx.lineWidth = 1; ctx.stroke();
// Label below bubble
ctx.font = '6px Share Tech Mono,monospace';
ctx.textAlign = 'center';
ctx.fillStyle = n.on ? `rgba(${col},0.82)` : 'rgba(255,100,100,0.5)';
ctx.fillText(n.label, nx, ny + r + 7);
ctx.textAlign = 'left';
}
}
// ④ EKG HEARTBEAT ────────────────────────────────────────────────────
(function initEKG() {
const canvas = document.getElementById('ekgCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H, data = [], phase = 0;
function resize() {
const wrap = document.getElementById('ekgWrap');
W = canvas.width = wrap ? Math.max(100, wrap.clientWidth) : 180;
H = canvas.height = 22;
data = new Array(W).fill(0);
}
function ekgVal(p) {
const c = p % 1;
if (c < 0.28) return 0;
if (c < 0.38) return Math.sin((c-0.28)/0.10*Math.PI) * 0.22; // P bump
if (c < 0.43) return 0; // PR flat
if (c < 0.45) return -(c-0.43)/0.02 * 0.18; // Q dip
if (c < 0.465){ const f=(c-0.45)/0.015; return f<0.5?f*2*0.95:(2-f*2)*0.95; } // R spike
if (c < 0.49) return -(c-0.465)/0.025 * 0.22; // S dip
if (c < 0.52) return -(0.22-(c-0.49)/0.03*0.22); // back to baseline
if (c < 0.70) return Math.sin((c-0.52)/0.18*Math.PI) * 0.28; // T wave
return 0;
}
function draw() {
phase += 0.0038;
data.shift(); data.push(ekgVal(phase));
ctx.clearRect(0, 0, W, H);
// Glow layer
ctx.beginPath();
data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); });
ctx.strokeStyle='rgba(0,220,100,0.2)'; ctx.lineWidth=4; ctx.stroke();
// Line
ctx.beginPath();
data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); });
ctx.strokeStyle='rgba(0,255,120,0.85)'; ctx.lineWidth=1.2; ctx.stroke();
requestAnimationFrame(draw);
}
resize(); draw();
window.addEventListener('resize', resize);
})();
// ⑤ AUDIO WAVEFORM RING ───────────────────────────────────────────────
(function initAudioRing() {
const canvas = document.getElementById('audioRingCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const CX = 30, CY = 30, BARS = 28, INNER = 20;
let t = 0;
function draw() {
t += 0.055;
ctx.clearRect(0, 0, 60, 60);
const listening = window.isListening;
const speaking = window.isSpeaking;
if (!listening && !speaking) { requestAnimationFrame(draw); return; }
for (let i = 0; i < BARS; i++) {
const angle = (i / BARS) * Math.PI * 2;
let amp;
if (speaking) {
amp = 0.35 + Math.abs(Math.sin(t*4.1+i*0.9))*0.5 + Math.abs(Math.sin(t*9+i*1.7))*0.15;
} else {
amp = 0.08 + Math.abs(Math.sin(t*1.1+i*0.6))*0.18;
}
const barLen = 3 + amp * 11;
ctx.beginPath();
ctx.moveTo(CX+Math.cos(angle)*INNER, CY+Math.sin(angle)*INNER);
ctx.lineTo(CX+Math.cos(angle)*(INNER+barLen), CY+Math.sin(angle)*(INNER+barLen));
const alpha = speaking ? 0.55+amp*0.45 : 0.25+amp*0.35;
ctx.strokeStyle = speaking ? `rgba(0,255,175,${alpha})` : `rgba(0,212,255,${alpha})`;
ctx.lineWidth = 1.8; ctx.stroke();
}
requestAnimationFrame(draw);
}
draw();
})();
// ⑥ STATIC NOISE BURSTS ───────────────────────────────────────────────
(function initStaticBursts() {
function burst() {
const panels = document.querySelectorAll('#app .panel');
if (panels.length) {
const p = panels[Math.floor(Math.random() * panels.length)];
const n = document.createElement('div');
n.className = 'panel-noise-layer';
p.appendChild(n);
setTimeout(() => n.remove(), 320);
}
setTimeout(burst, 75000 + Math.random() * 55000);
}
setTimeout(burst, 40000 + Math.random() * 30000);
})();
// ⑦ AMBIENT COLOR CYCLE ───────────────────────────────────────────────
(function initAmbientColor() {
let t = 0;
const root = document.documentElement;
function tick() {
t += 0.00025;
const g = Math.round(210 + Math.sin(t) * 22);
const b = Math.round(248 + Math.cos(t * 1.15) * 28);
root.style.setProperty('--cyan', `rgb(0,${g},${b})`);
root.style.setProperty('--cyan2', `rgb(0,${Math.round(g*.82)},${Math.round(b*.87)})`);
requestAnimationFrame(tick);
}
tick();
})();
// ── SLEEP MODE ────────────────────────────────────────────────────────────────
var isAsleep = false;
var _sleepRefreshTimer = null;
var SLEEP_CMDS = /\b(good\s*night(\s*jarvis)?|go\s*to\s*sleep|sleep\s*mode|shut\s*(down|off)\s*(jarvis|for\s*the\s*night)|go\s*offline|going\s*offline|jarvis\s*(go\s*)?(offline|sleep|shutdown)|stand\s*by\s*mode|power\s*down(\s*jarvis)?|signing\s*off)\b/i;
function enterSleepMode() {
if (isAsleep) return;
isAsleep = true;
// Pause voice mode
voiceMode = false;
voiceMuted = false;
updateMicBtn();
// Slow or pause the refresh loop — keep mic alive for wake word
clearInterval(refreshTimer);
refreshTimer = null;
// Light polling every 2 min just to stay alive
_sleepRefreshTimer = setInterval(function() {
// heartbeat only — keep session alive without hammering APIs
try { fetch('/api/auth', {method:'GET', headers:{'Authorization':'Bearer '+sessionToken}}); } catch(e) {}
}, 120000);
// Dim the UI
var app = document.getElementById('app');
if (app) app.classList.add('sleeping');
// Flash title to confirm
document.title = 'JARVIS — STANDBY';
addMessage('jarvis', 'Understood. Going offline. Say "wake up JARVIS" when you need me.');
}
function wakeFromSleep() {
if (!isAsleep) return;
isAsleep = false;
// Restore full polling
clearInterval(_sleepRefreshTimer);
_sleepRefreshTimer = null;
refreshAll();
refreshTimer = setInterval(refreshAll, 10000);
// Remove dim overlay
var app = document.getElementById('app');
if (app) app.classList.remove('sleeping');
document.title = 'JARVIS — Integrated Defense and Logistics System';
// Boot sequence
var topBar=document.getElementById('topBar'), lp=document.getElementById('leftPanel');
var rp=document.getElementById('rightPanel'), cp=document.getElementById('centerPanel');
[topBar,lp,rp,cp].forEach(function(el){if(el){el.style.opacity='0';}});
requestAnimationFrame(function(){
setTimeout(function(){if(topBar){topBar.style.opacity='';topBar.classList.add('boot-top');}},0);
setTimeout(function(){if(lp){lp.style.opacity='';lp.classList.add('boot-left');}},140);
setTimeout(function(){if(rp){rp.style.opacity='';rp.classList.add('boot-right');}},200);
setTimeout(function(){if(cp){cp.style.opacity='';cp.classList.add('boot-center');}},260);
setTimeout(function(){[topBar,lp,rp,cp].forEach(function(el){if(el)el.classList.remove('boot-top','boot-left','boot-right','boot-center');});},1400);
});
// Enter voice mode and greet
enterVoiceMode('wake');
}
function _focusWindow() {
// Attempt to bring browser window to front
try { window.focus(); } catch(e) {}
// Flash title to grab attention if tab is backgrounded
var _origTitle = 'JARVIS — Integrated Defense and Logistics System';
var _flashCount = 0;
var _titleFlash = setInterval(function() {
document.title = _flashCount % 2 === 0 ? '⚡ JARVIS — ONLINE' : _origTitle;
if (++_flashCount >= 8) { clearInterval(_titleFlash); document.title = _origTitle; }
}, 400);
// Notify if already have permission — never request during voice activity
if ('Notification' in window && Notification.permission === 'granted') {
try { new Notification('JARVIS', { body: 'Wake word detected.', tag: 'jarvis-wake', requireInteraction: false }); } catch(e) {}
}
}
// ── NETWORK MAP ──────────────────────────────────────────────────────────────
var _nmNodes=[], _nmEdges=[], _nmParticles=[], _nmRaf=null, _nmT=0, _nmHoverNode=null;
var _nmRot=[0,0,0,0,0,0];
var NM_RINGS=[
{name:'hub', label:'', rFrac:0, speed:0, rgb:'0,212,255', nodeR:30, cap:1 },
{name:'proxmox', label:'PROXMOX', rFrac:0.16, speed:0.006, rgb:'0,255,136', nodeR:22, cap:4 },
{name:'services',label:'SERVICES', rFrac:0.30, speed:-0.004, rgb:'255,215,0', nodeR:20, cap:7 },
{name:'agents', label:'AGENTS', rFrac:0.45, speed:0.0025, rgb:'0,190,255', nodeR:17, cap:12 },
{name:'devices', label:'DEVICES', rFrac:0.62, speed:-0.002, rgb:'0,160,200', nodeR:14, cap:14 },
{name:'network', label:'NETWORK', rFrac:0.82, speed:0.0015, rgb:'0,110,170', nodeR:11, cap:28 },
];
var NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network\s*(map|topology|viz|visual|graph)|topology|node\s*map)\b|\bnetwork\s*(map|topology|viz|visual|graph)\b/i;
var NM_CLOSE_RE = /\b(close|hide|dismiss|exit|collapse)\b.*\b(network|map|topology|overlay)\b|\b(close|hide|dismiss)\s*map\b/i;
function _nmClassify(d){
var h=(d.name||'').toLowerCase();
if(d.source==='agent'){
if(d.agent_type==='proxmox'||h.indexOf('pve')>=0||h.indexOf('proxmox')>=0) return 'proxmox';
if(d.agent_type==='homeassistant'||h.indexOf('homeassist')>=0||h.indexOf('_ha')>=0||
h.indexOf('ollama')>=0||h.indexOf('ai')>=0||h.indexOf('fusion')>=0||h.indexOf('pbx')>=0||
h.indexOf('jellyfin')>=0||h.indexOf('homebridge')>=0) return 'services';
return 'agents';
}
// Named/pinned DB devices and static hosts get the inner device ring
if(d.source==='db'||d.source==='static') return 'devices';
// Netscan-discovered devices go to the outer network ring
return 'network';
}
function _nmRgb(n){
if(!n.online) return '255,50,80';
if(n.ringIdx===1) return '0,255,136';
if(n.ringIdx===2) return '255,215,0';
if(n.ringIdx===3) return '0,190,255';
if(n.ringIdx===4) return '0,160,200';
if(n.ringIdx===5) return '0,110,170';
return '0,212,255';
}
function _nmNodePos(n,W,H){
if(n.ringIdx===0) return {x:W/2, y:H/2};
var rd=NM_RINGS[n.ringIdx], rot=_nmRot[n.ringIdx]||0;
var r=Math.min(W/2,H/2)*rd.rFrac;
return {x:W/2+Math.cos(n.angle+rot)*r, y:H/2+Math.sin(n.angle+rot)*r};
}
async function openNetMap(){
var ov=document.getElementById('netMapOverlay'); if(!ov) return;
ov.classList.remove('nm-closing'); ov.classList.add('nm-open');
var devices=[];
try{ var n=await api('network'); devices=n.devices||[]; }catch(e){}
_nmBuild(devices); _nmDraw();
}
function closeNetMap(){
var ov=document.getElementById('netMapOverlay'); if(!ov) return;
ov.classList.add('nm-closing');
setTimeout(function(){ ov.classList.remove('nm-open','nm-closing'); }, 350);
if(_nmRaf){ cancelAnimationFrame(_nmRaf); _nmRaf=null; }
}
function _nmBuild(devices){
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
// Hub
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'165.22.1.228',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
// Bucket
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
for(var i=0;i<devices.length;i++) buckets[_nmClassify(devices[i])].push(devices[i]);
// Sort netscan devices: online first, then those with meaningful hostnames
buckets.network.sort(function(a,b){
var sa=a.alive?1:0, sb=b.alive?1:0;
if(sb!==sa) return sb-sa;
var ha=(a.name&&a.name!==a.ip&&a.name.indexOf('10.48')!==0)?1:0;
var hb=(b.name&&b.name!==b.ip&&b.name.indexOf('10.48')!==0)?1:0;
return hb-ha;
});
var rings=['proxmox','services','agents','devices','network'];
for(var ri=0;ri<rings.length;ri++){
var rname=rings[ri], rd=NM_RINGS[ri+1], list=buckets[rname].slice(0,rd.cap);
for(var j=0;j<list.length;j++){
var d=list[j];
var baseA=(ri%2===0?-Math.PI/2:-Math.PI/3);
var angle=baseA+(j/Math.max(list.length,1))*Math.PI*2;
_nmNodes.push({
id:d.agent_id||d.ip||(rname+j),
label:(d.name||d.ip||'?').replace(/_[a-f0-9]{6,}$/,'').substring(0,11),
sub:d.ip||'', online:!!(d.alive||d.status==='online'),
agent:d.source==='agent', ringIdx:ri+1, angle:angle,
r:rd.nodeR, pulse:Math.random()*Math.PI*2,
});
}
}
// Edges + particles
for(var i=1;i<_nmNodes.length;i++){
var n=_nmNodes[i], str=n.agent?0.9:0.45;
_nmEdges.push({from:i,to:0,strength:str});
if(n.online){
var cnt=n.agent?3:2;
for(var p=0;p<cnt;p++) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'in',speed:0.002+Math.random()*0.0025,r:1.8+Math.random()*0.9});
if(n.agent&&Math.random()>0.45) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'out',speed:0.0013+Math.random()*0.0017,r:1.5+Math.random()*0.7});
}
}
// Stats
var online=_nmNodes.filter(function(n){return n.online;}).length;
var agt=_nmNodes.filter(function(n){return n.agent;}).length;
function sg(id){return document.getElementById(id);}
if(sg('nm-node-count')) sg('nm-node-count').textContent=_nmNodes.length;
if(sg('nm-online-count')) sg('nm-online-count').textContent=online;
if(sg('nm-agent-count')) sg('nm-agent-count').textContent=agt;
}
function _nmDraw(){
if(_nmRaf) cancelAnimationFrame(_nmRaf);
var canvas=document.getElementById('nmCanvas'); if(!canvas) return;
var ov=document.getElementById('netMapOverlay');
canvas.width = ov ? ov.clientWidth : 820;
canvas.height= ov ? ov.clientHeight-48 : 490;
var W=canvas.width, H=canvas.height, cx=W/2, cy=H/2, minR=Math.min(cx,cy);
var ctx=canvas.getContext('2d');
function frame(){
if(!document.getElementById('netMapOverlay').classList.contains('nm-open')) return;
_nmRaf=requestAnimationFrame(frame);
_nmT+=0.016;
for(var i=0;i<NM_RINGS.length;i++) _nmRot[i]=(_nmRot[i]||0)+NM_RINGS[i].speed;
ctx.clearRect(0,0,W,H);
// Dot grid
ctx.fillStyle='rgba(0,180,255,0.05)';
for(var gx=26;gx<W;gx+=38) for(var gy=26;gy<H;gy+=38){ctx.beginPath();ctx.arc(gx,gy,0.7,0,Math.PI*2);ctx.fill();}
// Ring tracks
for(var ri=1;ri<NM_RINGS.length;ri++){
var rd=NM_RINGS[ri], r=minR*rd.rFrac;
var hasOn=false;
for(var i=0;i<_nmNodes.length;i++) if(_nmNodes[i].ringIdx===ri&&_nmNodes[i].online){hasOn=true;break;}
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.strokeStyle=hasOn?'rgba('+rd.rgb+',0.12)':'rgba(60,60,80,0.08)';
ctx.lineWidth=0.8; ctx.setLineDash([3,9]); ctx.stroke(); ctx.setLineDash([]);
// Ticks
for(var a=0;a<Math.PI*2;a+=Math.PI/6){
ctx.beginPath();ctx.moveTo(cx+Math.cos(a)*(r-3),cy+Math.sin(a)*(r-3));ctx.lineTo(cx+Math.cos(a)*(r+3),cy+Math.sin(a)*(r+3));
ctx.strokeStyle='rgba('+rd.rgb+',0.18)';ctx.lineWidth=0.6;ctx.stroke();
}
// Label at 3-o'clock
var zOn=0,zTot=0;
for(var i=0;i<_nmNodes.length;i++){if(_nmNodes[i].ringIdx===ri){zTot++;if(_nmNodes[i].online)zOn++;}}
ctx.font='700 8px Share Tech Mono,monospace'; ctx.textAlign='left';
ctx.fillStyle='rgba('+rd.rgb+',0.4)'; ctx.fillText(rd.label,cx+r+5,cy+2);
if(zTot>0){ctx.font='6.5px Share Tech Mono,monospace';ctx.fillStyle='rgba('+rd.rgb+',0.28)';ctx.fillText(zOn+'/'+zTot,cx+r+5,cy+11);}
ctx.textAlign='left';
}
// Compute node positions
var pos=[];
for(var i=0;i<_nmNodes.length;i++) pos.push(_nmNodePos(_nmNodes[i],W,H));
// Spokes
for(var ei=0;ei<_nmEdges.length;ei++){
var e=_nmEdges[ei], pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) continue;
var n=_nmNodes[e.from], rgb=_nmRgb(n);
// Apply float offset to spoke endpoints
var fya=Math.sin(_nmT*0.85+_nmNodes[e.from].pulse)*4;
var fyb=Math.sin(_nmT*0.85+_nmNodes[e.to].pulse)*4;
var lg=ctx.createLinearGradient(pa.x,pa.y+fya,pb.x,pb.y+fyb);
if(n.online){lg.addColorStop(0,'rgba('+rgb+',0.22)');lg.addColorStop(0.5,'rgba('+rgb+',0.08)');lg.addColorStop(1,'rgba(0,212,255,0.15)');}
else{lg.addColorStop(0,'rgba(80,20,30,0.07)');lg.addColorStop(1,'rgba(80,20,30,0.07)');}
ctx.beginPath();ctx.moveTo(pa.x,pa.y+fya);ctx.lineTo(pb.x,pb.y+fyb);
ctx.strokeStyle=lg;ctx.lineWidth=e.strength*1.1;ctx.stroke();
}
// Particles
for(var pi=0;pi<_nmParticles.length;pi++){
var p=_nmParticles[pi]; p.t=(p.t+p.speed)%1;
var e=_nmEdges[p.edge]; if(!e) continue;
var pa=pos[e.from],pb=pos[e.to]; if(!pa||!pb) continue;
if(!_nmNodes[e.from].online) continue;
var t=p.dir==='in'?p.t:1-p.t;
var px=pa.x+(pb.x-pa.x)*t, py=pa.y+(pb.y-pa.y)*t;
var fade=Math.min(t*8,(1-t)*8,1);
ctx.beginPath();ctx.arc(px,py,p.r,0,Math.PI*2);
if(p.dir==='in'){ctx.fillStyle='rgba(0,210,255,'+(((0.6+Math.sin(_nmT*3+p.t*10)*0.3)*fade).toFixed(3))+')';ctx.shadowColor='rgba(0,200,255,0.7)';}
else{ctx.fillStyle='rgba(255,130,0,'+(((0.5+Math.sin(_nmT*4+p.t*8)*0.25)*fade).toFixed(3))+')';ctx.shadowColor='rgba(255,110,0,0.6)';}
ctx.shadowBlur=6;ctx.fill();ctx.shadowBlur=0;
}
// Bubble nodes
for(var ni=0;ni<_nmNodes.length;ni++){
var n=_nmNodes[ni], p=pos[ni], rgb=_nmRgb(n);
var pulse=0.5+Math.sin(_nmT*1.4+n.pulse)*0.3;
var isHub=n.ringIdx===0, isHov=_nmHoverNode===ni;
// Float offset — each node drifts on Y with unique phase
var fy=Math.sin(_nmT*0.85+n.pulse)*4;
var px=p.x, py=p.y+fy;
var baseR=n.r*(isHov?1.28:1.0);
// Ambient glow bloom
if(n.online){
var bloomR=baseR*2.6+Math.sin(_nmT*1.1+n.pulse)*3;
var bloom=ctx.createRadialGradient(px,py,baseR*0.4,px,py,bloomR);
bloom.addColorStop(0,'rgba('+rgb+','+(pulse*0.2).toFixed(3)+')');
bloom.addColorStop(1,'rgba('+rgb+',0)');
ctx.beginPath();ctx.arc(px,py,bloomR,0,Math.PI*2);ctx.fillStyle=bloom;ctx.fill();
// Sonar ping — expanding ring that fades out
var pingR=baseR+(((_nmT*0.6+n.pulse)%1)*baseR*2.5);
var pingA=(1-(pingR-baseR)/(baseR*2.5))*0.3;
ctx.beginPath();ctx.arc(px,py,pingR,0,Math.PI*2);
ctx.strokeStyle='rgba('+rgb+','+pingA.toFixed(3)+')';
ctx.lineWidth=0.7;ctx.stroke();
}
// Frosted glass fill
var fg=ctx.createRadialGradient(px,py-baseR*0.28,0,px,py,baseR);
var fa=n.online?0.17+pulse*0.1:0.06;
fg.addColorStop(0,'rgba('+rgb+','+(fa*2.0).toFixed(3)+')');
fg.addColorStop(0.55,'rgba('+rgb+','+fa.toFixed(3)+')');
fg.addColorStop(1,'rgba('+rgb+','+(fa*0.15).toFixed(3)+')');
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=fg;ctx.fill();
// Glassy highlight sheen (top-left arc)
if(n.online){
var sh=ctx.createRadialGradient(px-baseR*0.3,py-baseR*0.35,0,px,py,baseR);
sh.addColorStop(0,'rgba(255,255,255,0.12)');sh.addColorStop(0.45,'rgba(255,255,255,0.03)');sh.addColorStop(1,'rgba(255,255,255,0)');
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=sh;ctx.fill();
}
// Border
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);
ctx.strokeStyle='rgba('+rgb+','+(n.online?(0.45+pulse*0.35).toFixed(3):'0.18')+')';
ctx.lineWidth=isHub?1.8:1.1;ctx.stroke();
// Hub crosshairs (softer)
if(isHub){
ctx.strokeStyle='rgba('+rgb+',0.15)';ctx.lineWidth=0.6;
var ext=50;
var hlines=[[px-ext,py,px-baseR-3,py],[px+baseR+3,py,px+ext,py],[px,py-ext,px,py-baseR-3],[px,py+baseR+3,px,py+ext]];
for(var li=0;li<hlines.length;li++){ctx.beginPath();ctx.moveTo(hlines[li][0],hlines[li][1]);ctx.lineTo(hlines[li][2],hlines[li][3]);ctx.stroke();}
}
// Status dot
if(!isHub){
ctx.beginPath();ctx.arc(px+baseR*0.62,py-baseR*0.62,2.5,0,Math.PI*2);
ctx.fillStyle=n.online?'rgba(0,255,120,0.95)':'rgba(255,50,80,0.95)';ctx.fill();
}
// Label — centered below bubble
var lblY=py+baseR+11;
ctx.font=(isHub?'700 11':'8')+'px Share Tech Mono,monospace';
ctx.textAlign='center';
ctx.fillStyle=n.online?'rgba('+rgb+',0.95)':'rgba(220,90,90,0.7)';
ctx.fillText(n.label,px,lblY);
if(n.sub&&(isHub||isHov)){
ctx.font='6.5px Share Tech Mono,monospace';
ctx.fillStyle='rgba(140,195,215,0.55)';
ctx.fillText(n.sub,px,lblY+10);
}
ctx.textAlign='left';
}
}
frame();
canvas.onmousemove=function(e){
var rect=canvas.getBoundingClientRect(), mx=e.clientX-rect.left, my=e.clientY-rect.top;
var found=-1;
for(var i=0;i<_nmNodes.length;i++){var p=_nmNodePos(_nmNodes[i],W,H);if(Math.sqrt((p.x-mx)*(p.x-mx)+(p.y-my)*(p.y-my))<_nmNodes[i].r+10){found=i;break;}}
_nmHoverNode=found>=0?found:null;
var info=document.getElementById('nmNodeInfo'); if(!info) return;
if(found>=0){
var n=_nmNodes[found],rgb=_nmRgb(n);
document.getElementById('ni-name').textContent=n.label; document.getElementById('ni-name').style.color='rgb('+rgb+')';
document.getElementById('ni-ip').textContent='IP: '+(n.sub||'—');
document.getElementById('ni-status').textContent='STATUS: '+(n.online?'ONLINE':'OFFLINE');
document.getElementById('ni-type').textContent='RING: '+(NM_RINGS[n.ringIdx]?NM_RINGS[n.ringIdx].name.toUpperCase():'HUB');
info.style.display='block'; info.style.left=(mx+14)+'px'; info.style.top=(my-6)+'px';
} else { info.style.display='none'; }
};
canvas.onmouseleave=function(){_nmHoverNode=null;var i=document.getElementById('nmNodeInfo');if(i)i.style.display='none';};
}
// ── 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;
let lastActivity = Date.now();
const IDLE_RELOAD_MS = 5 * 60 * 1000; // 5 min inactivity → full reload
let voiceMode = false; // true = JARVIS awake (listening for commands)
let voiceMuted = false; // true = awake but mic muted
let voiceLastCmd = 0;
const VOICE_SLEEP_MS = 30 * 60 * 1000; // 30 min voice inactivity → sleep
const VOICE_ACTIVE_MS = 17000; // 17s active window after each command
let voiceActive = 0; // timestamp of last issued command
// Phase 1: full phrase required to wake from sleep
const WAKE_PHRASES = ["wake up jarvis", "daddy's home", "wake up, jarvis", "daddys home"];
// Phase 2: command prefix — "jarvis <command>"; then 17s free-listen window
const CMD_PREFIX = 'jarvis';
const FACE_MODEL_URL = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights';
// ── INIT ─────────────────────────────────────────────────────────────
window.addEventListener("load", () => {
["mousemove","keydown","touchstart","click"].forEach(e =>
window.addEventListener(e, () => { lastActivity = Date.now(); }, {passive:true})
);
updateClock();
setInterval(updateClock, 1000);
initVoice();
loadVoices();
// Check if already logged in — prefer PHP-injected global, fall back to sessionStorage
const saved = (typeof __jarvisToken !== 'undefined' ? __jarvisToken : null)
|| sessionStorage.getItem('jarvis_token');
const savedUser = (typeof __jarvisUser !== 'undefined' ? __jarvisUser : null)
|| sessionStorage.getItem('jarvis_user') || '';
const autoReload = sessionStorage.getItem('jarvis_autoreload') === '1';
sessionStorage.removeItem('jarvis_autoreload');
if (saved) {
sessionToken = saved;
sessionUser = savedUser;
try { sessionStorage.setItem('jarvis_token', saved); sessionStorage.setItem('jarvis_user', savedUser); } catch(e) {}
showApp(savedUser, null, autoReload);
}
});
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);
showApp(sessionUser, res.greeting);
} else {
errEl.textContent = 'ACCESS DENIED';
}
} catch(err) {
errEl.textContent = 'CONNECTION FAILED';
}
});
function showApp(name, greeting, silent = false) {
document.getElementById('loginScreen').style.display = 'none';
const app = document.getElementById('app');
app.style.display = 'flex';
// HUD boot sequence — staggered slide-in
const topBar = document.getElementById('topBar');
const leftPanel = document.getElementById('leftPanel');
const rightPanel = document.getElementById('rightPanel');
const centerPanel= document.getElementById('centerPanel');
[topBar, leftPanel, rightPanel, centerPanel].forEach(el => el && (el.style.opacity = '0'));
requestAnimationFrame(() => {
if (topBar) { topBar.style.opacity=''; topBar.style.animationDelay='0s'; topBar.classList.add('boot-top'); }
setTimeout(()=>{ if(leftPanel) { leftPanel.style.opacity=''; leftPanel.style.animationDelay='0s'; leftPanel.classList.add('boot-left'); }}, 120);
setTimeout(()=>{ if(rightPanel) { rightPanel.style.opacity=''; rightPanel.style.animationDelay='0s'; rightPanel.classList.add('boot-right'); }}, 180);
setTimeout(()=>{ if(centerPanel){ centerPanel.style.opacity='';centerPanel.style.animationDelay='0s';centerPanel.classList.add('boot-center');}}, 240);
setTimeout(()=>{ [topBar,leftPanel,rightPanel,centerPanel].forEach(el=>el?.classList.remove('boot-top','boot-left','boot-right','boot-center')); }, 1200);
});
if (!silent) {
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
setInterval(() => {
if (!isAsleep && Date.now() - lastActivity > IDLE_RELOAD_MS) {
sessionStorage.setItem('jarvis_autoreload', '1');
location.reload();
}
}, 30000);
setInterval(() => {
if (voiceMode && voiceLastCmd > 0 && Date.now() - voiceLastCmd > VOICE_SLEEP_MS) {
exitVoiceMode();
}
}, 60000);
// Watchdog: reset isSpeaking if stuck; heartbeat keeps mic alive
setInterval(() => {
if (isSpeaking && !_ttsAudio && !window.speechSynthesis?.speaking) {
isSpeaking = false;
if (isListening) _scheduleRecStart(200);
}
}, 4000);
// Heartbeat: if mic should be on but recognition has gone quiet, nudge it
setInterval(() => {
if (isListening && !isSpeaking) {
try {
recognition.start(); // throws if already running — that's fine
} catch(_) {}
}
}, 12000);
startListening();
loadNetwork();
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';
startFaceTracking();
addMessage('system', 'Face detection active — reactor tracking engaged.');
faceLoopId = setInterval(async () => {
if (!cameraActive) return;
// Run detection even while speaking — needed for tracking + prevents lastFaceSeen staling out
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 ratio = (detection.box.width * detection.box.height) / (320 * 240);
// Always drive the reactor
updateFaceTarget(detection.box, 320, 240);
// Only auto-trigger voice when not already speaking/active, cooldown passed
if (ratio > 0.03 && !voiceMode && !isSpeaking && now > autoMicCooldown) {
autoMicCooldown = now + 9000;
document.getElementById('cameraBtn').classList.add('cam-sensing');
enterVoiceMode();
}
} else {
// While JARVIS is speaking, keep lastFaceSeen fresh so the exit timer doesn't tick down
if (isSpeaking) { lastFaceSeen = now; }
else { clearFaceTarget(); }
document.getElementById('cameraBtn').classList.remove('cam-sensing');
// Exit voice mode only if: face gone >12s AND no command in that same window AND not speaking
const noFaceMs = now - lastFaceSeen;
const noCommandMs = now - (voiceLastCmd || 0);
if (voiceMode && !isSpeaking && noFaceMs > 12000 && noCommandMs > 12000) {
exitVoiceMode();
}
}
} 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';
}
stopFaceTracking();
}
function toggleCamera() {
if (cameraActive) {
stopCamera();
addMessage('system', 'Face detection disabled.');
} else {
startCamera();
}
}
// ── REFRESH ALL ───────────────────────────────────────────────────────
let _refreshTick = 0;
let selectedContext = null;
const _panelCtx = {};
let _haEntities = {};
const _svcLabels = {lshttpd:'WEB',mysql:'MYSQL',redis:'REDIS',memcached:'MEMCACHE',postfix:'POSTFIX',dovecot:'DOVECOT','jarvis-agent':'AGENT'};
async function refreshAll() {
_refreshTick++;
const el = document.getElementById('last-refresh');
if (el) el.textContent = new Date().toLocaleTimeString('en-US',{hour12:false});
// Fire core calls in parallel — cuts refresh latency from ~3s to ~600ms
const [s, n, d] = await Promise.all([
api('system').catch(() => null),
api('network').catch(() => null),
api('do').catch(() => null),
]);
if (s) renderSystem(s);
if (n) renderNetworkStatus(n);
if (d) renderDO(d);
// Agent status every tick (fire and forget — doesn't block)
checkAgentStatus().catch(() => {});
// Refresh right-panel tabs every 3rd tick (~30s) — all parallel
if (_refreshTick % 3 === 0) {
Promise.all([
loadHA().catch(() => {}),
loadAlerts().catch(() => {}),
loadAgents().catch(() => {}),
loadProxmox().catch(() => {}),
loadPlannerSummary().catch(() => {}),
]);
}
// Refresh weather + news every 18th tick (~3 min)
if (_refreshTick % 18 === 0) {
Promise.all([
loadWeather().catch(() => {}),
loadNews().catch(() => {}),
]);
}
}
// ── ANIMATED NUMBER COUNTER ───────────────────────────────────────────
const _prevVals = {};
function tickTo(id, newVal, unit='%', decimals=0) {
const el = document.getElementById(id);
if (!el) return;
const prev = _prevVals[id] ?? newVal;
_prevVals[id] = newVal;
if (Math.abs(newVal - prev) < 0.5) { el.textContent = newVal.toFixed(decimals) + unit; return; }
const start = performance.now(), dur = 700;
(function frame(now) {
const p = Math.min((now - start) / dur, 1);
const ease = 1 - Math.pow(1 - p, 3);
el.textContent = (prev + (newVal - prev) * ease).toFixed(decimals) + unit;
if (p < 1) requestAnimationFrame(frame);
})(performance.now());
}
// ── 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 (animated)
tickTo('tb-cpu', cpu, '');
tickTo('tb-mem', mem, '');
// Metric bars
setBar('cpu', cpu);
setBar('mem', mem);
setBar('disk', disk);
tickTo('cpu-val', cpu);
tickTo('mem-val', mem);
tickTo('disk-val', disk);
// Sparklines
pushSparkData('cpu', cpu);
pushSparkData('mem', mem);
pushSparkData('disk', disk);
drawSparkline('spark-cpu', _sparkData.cpu, 'rgb(0,212,255)');
drawSparkline('spark-mem', _sparkData.mem, 'rgb(0,255,136)');
drawSparkline('spark-disk', _sparkData.disk, 'rgb(255,166,0)');
// Flash the system panel on data arrival
flashPanel(document.querySelector('#leftPanel .panel'));
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">${_svcLabels[k]||k.toUpperCase()}</span>
<div class="svc-dot ${v?'on':'off'}" title="${k}: ${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 (site health only — metrics merged into system panel) ───
function renderDO(d) {
const dot = document.getElementById('bb-do-dot');
const status = document.getElementById('bb-do-status');
const sitesEl = document.getElementById('sites-list');
if (!d || d.error || !d.reachable) {
if (dot) dot.className = 'bb-dot offline';
if (status) status.textContent = 'OFFLINE';
document.getElementById('tb-do').className = 'text-red';
document.getElementById('tb-do').textContent = 'OFFLINE';
if (sitesEl) sitesEl.innerHTML = '<div class="text-dim" style="font-size:0.72rem">Unavailable</div>';
return;
}
dot.className = 'bb-dot online';
status.textContent = 'ONLINE';
document.getElementById('tb-do').className = 'text-green';
document.getElementById('tb-do').textContent = 'ONLINE';
if (sitesEl && d.sites && Object.keys(d.sites).length) {
sitesEl.innerHTML = Object.entries(d.sites).map(([k, v]) => {
const cls = v === 'up' ? 'ok' : v === 'down' ? 'danger' : 'warn';
const lbl = k.replace(/^https?:\/\//, '').replace(/\.orbishosting\.com$/, '').replace(/\.com$/, '');
return `<div class="val-row">
<div class="lbl" style="font-size:0.62rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${lbl}</div>
<div class="val ${cls}">${v.toUpperCase()}</div>
</div>`;
}).join('');
}
}
async function loadNetwork() {
try {
const n = await api('network');
renderNetworkStatus(n);
} catch(e) {}
}
// ── RENDER: NETWORK ───────────────────────────────────────────────────
function renderNetworkStatus(n) {
if (!n) return;
renderTopology(n.devices || []);
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 = 'QUEUING...';
btn.disabled = true;
try {
const data = await api('network/scan');
const count = data.count ?? 0;
const msg = data.queued
? `Network scan dispatched to PVE1 probe, Sir. Currently showing ${count} active device${count!==1?'s':''} — panel will refresh with live results in approximately 40 seconds.`
: `Showing last known network data: ${count} active device${count!==1?'s':''} on 10.48.200.0/24. PVE1 probe scans automatically every 3 minutes.`;
addMessage('jarvis', msg);
speak(count + ' devices online.');
// Refresh the network panel with current data
loadNetwork();
// Auto-refresh again after 45s to catch PVE1 scan results
if (data.queued) setTimeout(loadNetwork, 45000);
} catch(e) {
addMessage('jarvis', 'Network scan request failed, 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 || {};
_haEntities = entities;
if (!Object.keys(entities).length) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No entities found.</div>';
return;
}
renderHATable(entities);
}
const _domainIcon = {
light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}',
media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}',
lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}',
lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙'
};
function renderHATable(entities) {
const el = document.getElementById('ha-list');
if (!el) return;
let rows = '';
let totalShown = 0;
for (const [domain, items] of Object.entries(entities)) {
const icon = _domainIcon[domain] || '•';
const available = items.filter(e => e.state !== 'unavailable' && e.state !== 'unknown');
if (!available.length) continue;
available.forEach(e => {
totalShown++;
const isOn = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night'].includes(e.state);
const isScene = domain === 'scene';
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};
const stateLabel = isScene ? '—' : (isOn ? 'ON' : 'OFF');
const stateClass = isOn ? 'on' : 'off';
const eid = e.entity_id.replace(/'/g,"\\'");
const ctrl = isScene
? `<button class="ha-scene-btn" onclick="toggleHA('${eid}','${domain}','${e.state}')">▶ RUN</button>`
: `<label class="ha-toggle"><input type="checkbox"${isOn?' checked':''} onchange="toggleHA('${eid}','${domain}','${e.state}')"><span class="ha-slider"></span></label>`;
rows += `<tr class="ha-row">
<td class="ha-col-domain" title="${domain}">${icon}</td>
<td class="ha-col-name" title="${e.name}">${e.name}</td>
<td class="ha-col-state ${stateClass}">${stateLabel}</td>
<td class="ha-col-ctrl">${ctrl}</td>
</tr>`;
});
}
if (!totalShown) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem;margin-top:8px">No available entities.</div>';
return;
}
el.innerHTML = `<table class="ha-table"><thead class="ha-thead"><tr>
<th></th><th>DEVICE</th><th>STATE</th><th>CTRL</th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
async function toggleHA(entityId, domain, currentState) {
let service;
const ON_STATES = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night','active'];
const wasOn = ON_STATES.includes(currentState);
if (domain === 'scene') {
service = 'turn_on';
} else if (domain === 'alarm_control_panel') {
service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm';
} else {
service = wasOn ? 'turn_off' : 'turn_on';
}
try {
await api('ha/service', 'POST', {domain, service, entity_id: entityId});
// Optimistic update — flip state immediately so toggle doesn't snap back
if (_haEntities[domain]) {
const ent = _haEntities[domain].find(e => e.entity_id === entityId);
if (ent && domain !== 'scene') ent.state = wasOn ? 'off' : 'on';
}
renderHATable(_haEntities);
// Full sync after 4s — HA executes + agent pushes new state
setTimeout(loadHA, 4000);
} catch(e) {}
}
// ── PLANNER SUMMARY (top bar badge only) ─────────────────────────────────
async function loadPlannerSummary() {
const d = await api('planner/today');
const el = document.getElementById('tb-planner');
const tx = document.getElementById('tb-planner-text');
if (el && tx) {
const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length;
const appts = (d.appts_today || []).length;
if (!tasksDue && !appts) { el.style.display = 'none'; }
else {
const parts = [];
if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : ''));
if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : ''));
tx.textContent = parts.join(' · ');
el.style.display = '';
}
}
// Render planner mini panel
const pEl = document.getElementById('planner-tasks');
const badge = document.getElementById('planner-badge');
if (!pEl) return;
const priClass = {urgent:'pri-urgent',high:'pri-high',normal:'pri-normal',low:'pri-low'};
const fmtTime = s => { if(!s) return ''; const d=new Date(s); return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); };
const fmtDate = s => { if(!s) return ''; const d=new Date(s+'T00:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); };
const tasks = [...(d.tasks_overdue||[]).map(t=>({...t,_overdue:true})), ...(d.tasks_today||[])];
const appts = d.appts_today || [];
let html = '';
if (!tasks.length && !appts.length) {
html = '<div style="color:var(--text-dim);font-size:0.6rem;padding:4px 0">No tasks or appointments today.</div>';
} else {
if (appts.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin-bottom:3px">TODAY\'S SCHEDULE</div>';
html += appts.map(a => `<div class="appt-row"><span class="appt-time">${fmtTime(a.start_at)}</span><span>${a.title}</span>${a.location?'<span style="color:var(--text-dim);font-size:0.55rem"> · '+a.location+'</span>':''}</div>`).join('');
}
if (tasks.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin:5px 0 3px">TASKS DUE</div>';
html += tasks.map(t => `<div class="task-item"><span class="pri-dot ${priClass[t.priority]||'pri-normal'}"></span><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${t.title}</span>${t._overdue?'<span style="color:#f66;font-size:0.55rem">OVERDUE</span>':''}</div>`).join('');
}
if (d.pending_count > tasks.length) {
html += `<div style="color:var(--text-dim);font-size:0.55rem;padding:3px 0">${d.pending_count} pending total</div>`;
}
}
pEl.innerHTML = html;
const total = tasks.length + appts.length;
if (badge) badge.textContent = total ? total + ' TODAY' : '';
}
// ── 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';
setAlertState(false);
return;
}
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
tb.className='text-red';
setAlertState(true);
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) {
if (name === 'sites') { openSitesModal(); return; }
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
const pane = document.getElementById('tab-'+name);
if (pane) pane.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;
log.appendChild(div);
if (role === 'jarvis' && text && text.length > 0) {
// Adaptive speed: fast for short, slower for long (feels intentional either way)
const msPerChar = Math.max(9, Math.min(25, 1600 / text.length));
const cursor = document.createElement('span');
cursor.className = 'type-cursor';
div.appendChild(cursor);
let i = 0;
const type = () => {
if (i < text.length) {
cursor.insertAdjacentText('beforebegin', text[i++]);
log.scrollTop = log.scrollHeight;
setTimeout(type, msPerChar + (text[i-1] === '.' || text[i-1] === ',' ? msPerChar * 4 : 0));
} else {
cursor.remove();
}
};
setTimeout(type, 0);
} else {
div.textContent = text;
}
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 commands — no API round-trip
var t2 = text.toLowerCase();
// Sleep command
if (SLEEP_CMDS.test(t2)) {
input.value = '';
addMessage('user', text);
enterSleepMode();
return;
}
if (NM_OPEN_RE.test(t2)) {
input.value=''; addMessage('user',text);
addMessage('jarvis','Launching network topology display.');
speak('Launching network topology display.');
openNetMap(); return;
}
if (NM_CLOSE_RE.test(t2)) {
input.value=''; addMessage('user',text);
var isOpen=document.getElementById('netMapOverlay')&&document.getElementById('netMapOverlay').classList.contains('nm-open');
if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');}
else addMessage('jarvis','Network map is not currently active.');
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) {
if (window.isSecureContext === false) {
console.warn('Speech Recognition blocked: not a secure context');
} else {
console.warn('Speech Recognition not supported in this browser');
}
return;
}
recognition = new SR();
recognition.continuous = false; // restart-per-utterance — most reliable in Chrome
recognition.interimResults = false;
recognition.lang = 'en-US';
recognition.maxAlternatives = 1;
recognition.onresult = (e) => {
if (isSpeaking) return;
const transcript = (e.results[0][0].transcript || '').trim();
if (!transcript) return;
const lc = transcript.toLowerCase();
// Sleeping: ONLY respond to master wake phrases
if (isAsleep) {
if (WAKE_PHRASES.some(p => lc.includes(p))) wakeFromSleep();
return;
}
if (!voiceMode) {
if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode();
} else if (!voiceMuted) {
voiceLastCmd = Date.now();
voiceActive = Date.now();
const cmd = lc.startsWith(CMD_PREFIX)
? transcript.substring(CMD_PREFIX.length).trim()
: transcript;
if (cmd) {
// Check for sleep command by voice
if (SLEEP_CMDS.test(cmd)) {
addMessage('user', transcript);
enterSleepMode();
return;
}
_showTranscript(cmd);
document.getElementById('textInput').value = cmd;
sendMessage();
}
}
};
recognition.onend = () => {
// Restart immediately unless TTS is playing or mic is off
if (isListening && !isSpeaking) {
_scheduleRecStart(100);
}
};
recognition.onerror = (e) => {
if (e.error === 'not-allowed') {
isListening = false;
updateMicBtn();
addMessage('system', 'Microphone access denied. Please allow microphone permission in your browser, then reload.');
} else if (e.error === 'audio-capture') {
isListening = false;
updateMicBtn();
addMessage('system', 'No microphone detected. Please connect a microphone and try again.');
}
// no-speech / aborted / network: onend will fire and restart
};
}
function _showTranscript(text) {
const el = document.getElementById('textInput');
if (el) { el.placeholder = '▶ ' + text.substring(0, 60); setTimeout(() => { el.placeholder = 'Enter command or speak to JARVIS...'; }, 3000); }
}
function enterVoiceMode(source) {
voiceMode = true;
voiceMuted = false;
voiceLastCmd = Date.now();
voiceActive = Date.now();
updateMicBtn();
// Focus/notify when woken from minimized or sleep
_focusWindow();
if (source === 'wake') {
const g = 'All systems back online, ' + (sessionUser || 'Sir') + '. Good to have you back.';
addMessage('jarvis', g);
speak(g);
} else {
speak('Yes, ' + (sessionUser || 'Sir') + '?');
}
}
function exitVoiceMode() {
voiceMode = false;
voiceMuted = false;
updateMicBtn();
}
function updateMicBtn() {
const btn = document.getElementById('micBtn');
const icon = document.getElementById('micIcon');
const wave = document.getElementById('waveform');
if (!btn) return;
if (!voiceMode) {
btn.classList.remove('listening', 'muted');
btn.title = 'Click to activate, or say: wake up JARVIS / daddy\'s home';
icon.textContent = '🎤';
wave.classList.remove('active');
} else if (voiceMuted) {
btn.classList.remove('listening');
btn.classList.add('muted');
btn.title = 'Muted — click to unmute';
icon.textContent = '🔇';
wave.classList.remove('active');
} else {
btn.classList.add('listening');
btn.classList.remove('muted');
btn.title = 'Listening — click to mute';
icon.textContent = '🟢';
wave.classList.add('active');
}
}
function toggleVoice() {
if (!voiceMode) {
enterVoiceMode();
} else {
voiceMuted = !voiceMuted;
if (!voiceMuted) voiceLastCmd = Date.now();
updateMicBtn();
}
}
let _recTimer = null;
function _scheduleRecStart(ms = 100) {
clearTimeout(_recTimer);
_recTimer = setTimeout(() => {
if (isListening && !isSpeaking) {
try { recognition.start(); } catch(_) {}
}
}, ms);
}
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;
_scheduleRecStart(50);
}
function stopListening() {
isListening = false;
voiceMode = false;
voiceMuted = false;
updateMicBtn();
clearTimeout(_recTimer);
try { recognition.abort(); } catch(_) {}
}
// ── 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;
}
let _ttsAudio = null;
async function speak(text) {
if (!text) return;
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
synth?.cancel();
isSpeaking = true;
// Pause recognition while JARVIS speaks to avoid mic feedback
try { recognition?.abort(); } catch(_) {}
const reactor = document.getElementById('arcReactor');
reactor?.classList.add('speaking');
const _resumeMic = () => {
isSpeaking = false;
reactor?.classList.remove('speaking');
// onend will fire from the abort we did before TTS, and restart cleanly
if (isListening) _scheduleRecStart(900);
};
try {
const res = await fetch('/api/tts', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Session-Token': sessionToken},
body: JSON.stringify({text: text.substring(0, 400)}),
});
if (!res.ok) throw new Error('tts');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = () => { URL.revokeObjectURL(url); _ttsAudio = null; _resumeMic(); };
_ttsAudio.onerror = () => { _ttsAudio = null; _resumeMic(); };
await _ttsAudio.play();
} catch(e) {
_resumeMic();
_speakFallback(text);
}
}
function _speakFallback(text) {
if (!synth || !text) return;
synth.cancel();
isSpeaking = true;
const utter = new SpeechSynthesisUtterance(text);
if (selectedVoice) utter.voice = selectedVoice;
utter.rate = 0.92; utter.pitch = 0.85; utter.volume = 1;
const reactor = document.getElementById('arcReactor');
utter.onstart = () => reactor?.classList.add('speaking');
utter.onend = () => {
reactor?.classList.remove('speaking');
isSpeaking = false;
if (isListening) _scheduleRecStart(900);
};
synth.speak(utter);
}
// ── AGENT DETECTION & BROWSER INSTALL ─────────────────────────────────
let _agentOnline = false;
let _myAgent = null;
function detectOS() {
const ua = navigator.userAgent;
const p = (navigator.platform || '').toLowerCase();
// Tablets — check before desktop OS (iPads spoof MacIntel)
if (/iPad|Android/.test(ua) || (p.includes('mac') && navigator.maxTouchPoints > 1)) return 'tablet';
if (/iPhone/.test(ua)) return 'tablet';
if (p.includes('win') || ua.includes('Windows')) return 'windows';
if (p.includes('mac') || ua.includes('Macintosh')) return 'mac';
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 || '';
// Match by exact IP first, then by same /24 subnet (handles NAT behind same router)
const mySubnet = myIp.split('.').slice(0,3).join('.');
_myAgent = online.find(a => a.ip_address === myIp)
|| online.find(a => a.ip_address && a.ip_address.startsWith(mySubnet + '.'));
_agentOnline = !!_myAgent;
if (btn) {
const isTablet = detectOS() === 'tablet';
if (isTablet) {
btn.title = 'JARVIS Agent — not available for tablets';
btn.style.opacity = '0.5';
} else 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 (os === 'tablet') {
title.textContent = '● JARVIS — TABLET / MOBILE';
content.innerHTML =
'<div style="color:var(--cyan);font-size:0.75rem;margin-bottom:12px">✓ You\'re viewing JARVIS on a tablet or mobile device.</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.6">The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).<br><br>' +
'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.</div>';
} else if (_agentOnline) {
title.textContent = '● AGENT CONNECTED';
content.innerHTML =
'<div style="color:var(--green);font-size:0.75rem;margin-bottom:12px">✓ JARVIS Agent is active on this machine.</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.8">' +
'<b style="color:var(--text)">Host:</b> ' + (_myAgent?.hostname||'—') + '<br>' +
'<b style="color:var(--text)">IP:</b> ' + (_myAgent?.ip_address||'—') + '<br>' +
'<b style="color:var(--text)">Type:</b> ' + (_myAgent?.agent_type||'—').toUpperCase() + '<br>' +
'<b style="color:var(--text)">Reporting:</b> CPU · Memory · Disk · Services · Uptime</div>';
} else {
const inst = {
windows: {
label:'Windows',
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
dl: baseUrl+'/install-windows.ps1',
note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
},
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.'
},
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:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
dl: 'https://jarvis.orbishosting.com/agent/',
note:'Choose your platform installer from the JARVIS agent directory.'
}
};
const i = inst[os] || inst.unknown;
const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase();
title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM');
content.innerHTML =
'<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:1px;margin-bottom:8px">DETECTED: ' + osBadge + '</div>' +
'<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, the AGENT 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');
});
// ── SITES MANAGER ────────────────────────────────────────────────────
let sitesData = {};
function openSitesModal() {
document.getElementById('sitesModal').style.display = 'flex';
loadSites();
}
function closeSitesModal() {
document.getElementById('sitesModal').style.display = 'none';
}
// Close on backdrop click
document.getElementById('sitesModal').addEventListener('click', function(e) {
if (e.target === this) closeSitesModal();
});
async function loadSites() {
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem;letter-spacing:2px">LOADING SITE SETTINGS...</div>';
const res = await api('sites');
if (!res.success) {
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:#f44;font-size:0.65rem">FAILED TO LOAD SETTINGS</div>';
return;
}
sitesData = res.sites;
// Pre-fill global key from first site
const firstKey = Object.values(res.sites)[0]?.api_key || '';
document.getElementById('global-api-key').value = firstKey;
renderSiteCards();
}
function renderSiteCards() {
const grid = document.getElementById('sites-grid');
let html = '';
for (const [id, s] of Object.entries(sitesData)) {
html += `
<div style="background:rgba(0,212,255,0.02);border:1px solid rgba(0,212,255,0.12);padding:16px">
<div style="margin-bottom:12px">
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:2px">${s.name.toUpperCase()}</div>
<div style="color:var(--text-dim);font-size:0.58rem">${s.url}</div>
</div>
<div style="margin-bottom:10px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM EMAIL</div>
<input id="${id}-from_email" type="text" value="${s.from_email || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="margin-bottom:10px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM NAME</div>
<input id="${id}-from_name" type="text" value="${s.from_name || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="margin-bottom:12px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">ADMIN NOTIFICATION EMAIL</div>
<input id="${id}-admin_email" type="text" value="${s.admin_email || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="display:flex;align-items:center;gap:10px">
<button onclick="saveSite('${id}')"
style="background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-mono);font-size:0.58rem;letter-spacing:2px;padding:6px 16px;cursor:pointer">
SAVE
</button>
<span id="${id}-status" style="font-size:0.58rem;color:var(--text-dim)"></span>
</div>
</div>`;
}
grid.innerHTML = html;
}
async function pushApiKey() {
const key = document.getElementById('global-api-key').value.trim();
const status = document.getElementById('push-status');
if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; }
status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...';
const res = await api('sites', 'POST', {action:'push_key', api_key:key});
if (res.success) {
const ok = Object.values(res.results).filter(Boolean).length;
const total = Object.keys(res.results).length;
status.style.color = ok === total ? 'var(--cyan)' : '#fa0';
status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`;
for (const id of Object.keys(sitesData)) sitesData[id].api_key = key;
} else {
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
}
}
async function saveSite(id) {
const status = document.getElementById(id + '-status');
status.style.color='var(--text-dim)'; status.textContent='SAVING...';
const res = await api('sites', 'POST', {
action: 'save',
site: id,
from_email: document.getElementById(id+'-from_email').value.trim(),
from_name: document.getElementById(id+'-from_name').value.trim(),
admin_email: document.getElementById(id+'-admin_email').value.trim(),
});
if (res.success) {
status.style.color='var(--cyan)'; status.textContent='✓ SAVED';
setTimeout(() => { status.textContent=''; }, 3000);
} else {
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
}
}
</script>
</body>
</html>