Files
jarvis/public_html/index.html
T
myron 8c96ebbc42 Network topology overlay: voice/chat triggered full-screen viz with directional flow particles
- Voice: say show network map / network topology / show connections to open
- Voice: say close map / dismiss / close network to close
- Same commands work in chat text input
- Explode animation: overlay expands from top-left reactor position with clip-path wipe
- Collapse animation: folds back to reactor on close
- Visualization: live node graph with bezier curved edges, hub (JARVIS) at center
  - Inner ring: all registered agents (agents color-coded by type: proxmox=green, HA=gold, etc)
  - Outer ring: netscan-discovered devices
  - Rotating orbit rings on hub and agent nodes
  - Pulsing radial glow per node keyed to online status
  - Hub cross-hair targeting lines
- Directional particle flow:
  - CYAN particles: data/heartbeats flowing FROM agents TO JARVIS hub
  - ORANGE particles: commands flowing FROM JARVIS hub TO agents
  - All particles travel curved bezier paths, fade at endpoints, glow with shadows
- Mouse hover: node info card shows name/IP/status/type
- Stats bar: total nodes, online count, agent count
- Background: faint hex grid overlay for sci-fi depth
2026-06-02 00:38:08 +00:00

3607 lines
161 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:'► YOU ';color:var(--cyan);font-weight:700;font-size:0.7rem;letter-spacing:2px}
.msg.jarvis{
background:rgba(0,40,80,0.4);border:1px solid rgba(0,212,255,0.1);
border-left:3px solid var(--orange);
}
.msg.jarvis::before{content:'◆ JARVIS ';color:var(--orange);font-weight:700;font-size:0.7rem;letter-spacing:2px}
.msg.system{
border:1px solid rgba(255,215,0,0.2);background:rgba(255,215,0,0.03);
font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;
border-radius:4px;
}
/* ── CONTEXT CHIP ───────────────────────────────────────────────── */
#contextChip{
display:none;flex-shrink:0;
padding:5px 10px;margin-bottom:6px;
background:rgba(0,212,255,0.07);
border:1px solid rgba(0,212,255,0.35);
border-radius:var(--r);
display:flex;align-items:center;gap:8px;
font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;
}
#contextChip.visible{display:flex}
#contextLabel{color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#contextType{color:var(--text-dim)}
#contextClear{
background:none;border:none;color:var(--dim);cursor:pointer;
font-size:1rem;line-height:1;padding:0 2px;flex-shrink:0;
}
#contextClear:hover{color:var(--red)}
/* 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}}
/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */
#netMapOverlay{
position:fixed;top:48px;left:0;
width:100vw;height:calc(100vh - 80px);
z-index:200;
display:none;flex-direction:column;
background:rgba(0,4,16,0.97);
border-right:1px solid rgba(0,212,255,0.35);
border-bottom:1px solid rgba(0,212,255,0.35);
transform-origin:0 0;
backdrop-filter:blur(12px);
overflow:hidden;
}
#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 / 20px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 1px 20px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 20px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 1px 20px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 20px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 1px 20px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 20px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 1px 20px no-repeat;
}
#netMapOverlay.nm-open{display:flex;animation:nmExplode 0.5s cubic-bezier(0.4,0,0.2,1) forwards}
#netMapOverlay.nm-closing{animation:nmCollapse 0.32s cubic-bezier(0.4,0,0.2,1) forwards}
@keyframes nmExplode{0%{transform:scale(0.04,0.06);opacity:0;clip-path:inset(0 100% 100% 0)}60%{opacity:1}100%{transform:scale(1,1);opacity:1;clip-path:inset(0 0% 0% 0)}}
@keyframes nmCollapse{0%{transform:scale(1,1);opacity:1}100%{transform:scale(0.04,0.06);opacity:0}}
#nmHeader{
display:flex;align-items:center;justify-content:space-between;
padding:8px 18px;flex-shrink:0;
border-bottom:1px solid rgba(0,212,255,0.18);
background:rgba(0,8,28,0.6);
z-index:2;position:relative;
}
#nmTitle{font-family:var(--font-display);font-size:0.65rem;letter-spacing:4px;color:var(--cyan);display:flex;align-items:center;gap:12px}
#nmTitle .nm-pulse{width:7px;height:7px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite}
#nmStats{font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);display:flex;gap:20px}
#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.58rem;padding:4px 12px;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:18px;align-items:center;
padding:6px 18px;flex-shrink:0;
border-top:1px solid rgba(0,212,255,0.12);
font-family:var(--font-mono);font-size:0.56rem;color:var(--text-dim);
background:rgba(0,8,28,0.6);z-index:2;position:relative;
}
.nm-leg-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;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:8px 12px;font-family:var(--font-mono);font-size:0.62rem;
display:none;min-width:160px;
box-shadow:0 0 20px rgba(0,212,255,0.15);
}
#nmNodeInfo .ni-title{color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:4px}
#nmNodeInfo .ni-row{color:var(--text-dim);margin:2px 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}
</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 FEED
</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:14px">
<div style="font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);letter-spacing:1px">SAY "CLOSE MAP" TO DISMISS</div>
<button id="nmClose" onclick="closeNetMap()">✕ CLOSE</button>
</div>
</div>
<canvas id="nmCanvas"></canvas>
<div id="nmLegend">
<span><span class="nm-leg-dot" style="background:#00d4ff;box-shadow:0 0 5px #00d4ff"></span>AGENT ONLINE</span>
<span><span class="nm-leg-dot" style="background:#ff2244;box-shadow:0 0 5px #ff2244"></span>AGENT OFFLINE</span>
<span><span class="nm-leg-dot" style="background:#00ff88;box-shadow:0 0 5px #00ff88"></span>PROXMOX</span>
<span><span class="nm-leg-dot" style="background:#ffd700;box-shadow:0 0 5px #ffd700"></span>HA / AI</span>
<span><span class="nm-leg-dot" style="background:rgba(0,180,200,0.4)"></span>DEVICE</span>
<span style="margin-left:auto;opacity:0.5">CYAN FLOW = DATA IN &nbsp;·&nbsp; ORANGE FLOW = COMMANDS 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>
<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 = 100;
_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: 50 + Math.sin(angle) * ry * (0.7 + (i%2)*0.3),
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);
// 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 dx = b.x-a.x, dy = b.y-a.y, dist = Math.hypot(dx,dy);
if (dist > W * 0.55) continue;
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);
ctx.strokeStyle = 'rgba(0,180,220,0.13)'; ctx.lineWidth = 0.5; ctx.stroke();
// travelling pulse
const p = (_topoT * 0.4 + a.phase) % 1;
ctx.beginPath(); ctx.arc(a.x+dx*p, a.y+dy*p, 1.5, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,0.7)'; ctx.fill();
}
}
// Nodes
for (const n of _topoNodes) {
const pulse = 0.5 + Math.sin(_topoT*1.8 + n.phase) * 0.3;
const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80';
const a = n.on ? pulse * 0.7 : 0.2;
if (n.on) {
const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,9);
g.addColorStop(0,`rgba(${col},${(a*0.5).toFixed(2)})`); g.addColorStop(1,`rgba(${col},0)`);
ctx.beginPath(); ctx.arc(n.x,n.y,9,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
}
ctx.beginPath(); ctx.arc(n.x,n.y, n.agent ? 4 : 3, 0, Math.PI*2);
ctx.fillStyle = `rgba(${col},${a.toFixed(2)})`; ctx.fill();
ctx.font = '6.5px Share Tech Mono,monospace';
ctx.fillStyle = n.on ? 'rgba(180,230,255,0.65)' : 'rgba(255,100,100,0.45)';
ctx.fillText(n.label, n.x+5, n.y+3);
}
}
// ④ 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();
})();
// ── NETWORK MAP OVERLAY ───────────────────────────────────────────────
let _nmNodes = [], _nmEdges = [], _nmParticles = [], _nmRaf = null, _nmT = 0;
let _nmHoverNode = null;
const NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network\s*(map|topology|viz|visual|status|graph|diagram)|topology|node\s*map|connection\s*map)\b|\b(network\s*(map|topology|viz|visual|graph|diagram))\b/i;
const NM_CLOSE_RE = /\b(close|hide|dismiss|exit|shut|collapse)\b.*\b(network|map|topology|overlay|viz)\b|\b(close\s*map|hide\s*map|dismiss\s*map)\b/i;
async function openNetMap() {
const ov = document.getElementById('netMapOverlay');
if (!ov) return;
ov.classList.remove('nm-closing');
ov.classList.add('nm-open');
// Fetch network data
let devices = [];
try { const n = await api('network'); devices = n.devices || []; } catch(_) {}
_buildNetGraph(devices);
_startNetDraw();
}
function closeNetMap() {
const ov = document.getElementById('netMapOverlay');
if (!ov) return;
ov.classList.add('nm-closing');
setTimeout(() => { ov.classList.remove('nm-open','nm-closing'); }, 350);
if (_nmRaf) { cancelAnimationFrame(_nmRaf); _nmRaf = null; }
}
function _nodeColor(n) {
if (n.type === 'hub') return {r:0, g:212, b:255};
if (n.type === 'proxmox') return {r:0, g:255, b:136};
if (n.type === 'ha') return {r:255,g:215, b:0 };
if (n.type === 'ai') return {r:255,g:215, b:0 };
if (n.type === 'pbx') return {r:255,g:140, b:0 };
if (!n.online) return {r:255,g:34, b:68 };
if (n.agent) return {r:0, g:212, b:255};
return {r:0, g:160, b:200};
}
function _buildNetGraph(devices) {
const W = document.getElementById('nmCanvas')?.clientWidth || window.innerWidth;
const H = document.getElementById('nmCanvas')?.clientHeight || (window.innerHeight - 130);
const cx = W / 2, cy = H / 2;
_nmNodes = []; _nmEdges = []; _nmParticles = [];
// Hub node: JARVIS
_nmNodes.push({ id:'jarvis', label:'JARVIS', sub:'165.22.1.228 · DO', type:'hub',
online:true, agent:true, x:cx, y:cy, r:22, pulse:0, vx:0, vy:0 });
// Categorise devices
const agents = devices.filter(d => d.source === 'agent');
const scanned = devices.filter(d => d.source !== 'agent');
// Assign node types from hostname/agent_type
function classifyAgent(d) {
const h = (d.name||'').toLowerCase();
if (d.agent_type==='proxmox' || h.includes('pve') || h.includes('proxmox')) return 'proxmox';
if (d.agent_type==='homeassistant' || h.includes('homeassist') || h.includes('_ha')) return 'ha';
if (h.includes('ollama') || h.includes('ai')) return 'ai';
if (h.includes('fusion') || h.includes('pbx')) return 'pbx';
return 'agent';
}
// Place agents in inner ring, scanned in outer ring
const innerR = Math.min(cx, cy) * 0.40;
const outerR = Math.min(cx, cy) * 0.72;
agents.forEach((d, i) => {
const a = (i / Math.max(agents.length, 1)) * Math.PI * 2 - Math.PI / 2;
const jitter = (Math.random() - 0.5) * 30;
_nmNodes.push({
id: d.agent_id || d.ip || ('a'+i),
label: (d.name || d.ip || '?').replace(/_[a-f0-9]{6,}$/, '').substring(0, 14),
sub: d.ip || '',
type: classifyAgent(d),
online: !!(d.alive || d.status === 'online'),
agent: true,
x: cx + Math.cos(a) * (innerR + jitter),
y: cy + Math.sin(a) * (innerR + jitter),
r: 13, pulse: Math.random() * Math.PI * 2, vx: 0, vy: 0,
});
});
scanned.forEach((d, i) => {
const a = (i / Math.max(scanned.length, 1)) * Math.PI * 2 - Math.PI / 4;
const jitter = (Math.random() - 0.5) * 25;
_nmNodes.push({
id: d.ip || ('s'+i),
label: (d.name || d.ip || '?').substring(0, 12),
sub: d.ip || '',
type: 'device',
online: !!(d.alive || d.status === 'online'),
agent: false,
x: cx + Math.cos(a) * (outerR + jitter),
y: cy + Math.sin(a) * (outerR + jitter),
r: 7, pulse: Math.random() * Math.PI * 2, vx: 0, vy: 0,
});
});
// Build edges (all nodes → hub; proxmox nodes interconnect)
const hub = _nmNodes[0];
for (let i = 1; i < _nmNodes.length; i++) {
const n = _nmNodes[i];
if (!n.online && !hub.online) continue;
_nmEdges.push({ from: i, to: 0, strength: n.agent ? 1 : 0.4 });
}
// Cross-link proxmox nodes
const pveNodes = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.type==='proxmox');
for (let a = 0; a < pveNodes.length; a++)
for (let b = a+1; b < pveNodes.length; b++)
_nmEdges.push({ from: pveNodes[a].i, to: pveNodes[b].i, strength: 0.6 });
// Spawn particles for each edge
_nmEdges.forEach((e, ei) => {
const count = e.strength > 0.8 ? 5 : (e.strength > 0.5 ? 3 : 2);
for (let p = 0; p < count; p++) {
// Cyan particles: node → hub (data in)
_nmParticles.push({ edge: ei, t: Math.random(), dir: 'in',
speed: 0.003 + Math.random() * 0.003, r: 2.2 + Math.random() });
// Orange particles: hub → node (commands out) — fewer, slower
if (e.strength > 0.6 && Math.random() > 0.4) {
_nmParticles.push({ edge: ei, t: Math.random(), dir: 'out',
speed: 0.0018 + Math.random() * 0.002, r: 1.8 + Math.random() * 0.8 });
}
}
});
// Update stats bar
const online = _nmNodes.filter(n=>n.online).length;
const agentCount = _nmNodes.filter(n=>n.agent).length;
const el = id => document.getElementById(id);
if(el('nm-node-count')) el('nm-node-count').textContent = _nmNodes.length;
if(el('nm-online-count')) el('nm-online-count').textContent = online;
if(el('nm-agent-count')) el('nm-agent-count').textContent = agentCount;
}
function _bezierPt(ax, ay, bx, by, t) {
// Cubic bezier with perpendicular control points for curved lines
const mx = (ax + bx) / 2, my = (ay + by) / 2;
const dx = bx - ax, dy = by - ay;
const cpx = mx - dy * 0.25, cpy = my + dx * 0.25;
const inv = 1 - t;
return {
x: inv*inv*ax + 2*inv*t*cpx + t*t*bx,
y: inv*inv*ay + 2*inv*t*cpy + t*t*by,
};
}
function _startNetDraw() {
if (_nmRaf) cancelAnimationFrame(_nmRaf);
const canvas = document.getElementById('nmCanvas');
if (!canvas) return;
// Resize canvas to its display size
canvas.width = canvas.clientWidth || window.innerWidth;
canvas.height = canvas.clientHeight || (window.innerHeight - 130);
function draw() {
if (!document.getElementById('netMapOverlay')?.classList.contains('nm-open')) return;
_nmRaf = requestAnimationFrame(draw);
_nmT += 0.016;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background hex-grid tint
ctx.strokeStyle = 'rgba(0,180,255,0.04)';
ctx.lineWidth = 0.5;
const gs = 38;
for (let x = 0; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
// Draw edges
_nmEdges.forEach(e => {
const a = _nmNodes[e.from], b = _nmNodes[e.to];
if (!a || !b) return;
const steps = 60;
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const p = _bezierPt(a.x, a.y, b.x, b.y, i / steps);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
}
const alive = a.online && b.online;
ctx.strokeStyle = alive ? 'rgba(0,180,220,0.14)' : 'rgba(100,20,40,0.12)';
ctx.lineWidth = e.strength * 1.5;
ctx.stroke();
});
// Draw particles
_nmParticles.forEach(p => {
p.t += p.speed;
if (p.t > 1) p.t -= 1;
const e = _nmEdges[p.edge];
if (!e) return;
const a = _nmNodes[e.from], b = _nmNodes[e.to];
if (!a?.online || !b?.online) return;
const t = p.dir === 'in' ? p.t : 1 - p.t;
const pt = _bezierPt(a.x, a.y, b.x, b.y, t);
// Fade near endpoints
const fade = Math.min(t * 6, (1 - t) * 6, 1);
if (p.dir === 'in') {
// Cyan: data flowing to hub
ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0,212,255,${(0.6 + Math.sin(_nmT*3+p.t*10)*0.3) * fade})`;
ctx.shadowColor = 'rgba(0,212,255,0.6)'; ctx.shadowBlur = 6;
ctx.fill(); ctx.shadowBlur = 0;
} else {
// Orange: commands from hub
ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r * 0.85, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,140,0,${(0.55 + Math.sin(_nmT*4+p.t*8)*0.25) * fade})`;
ctx.shadowColor = 'rgba(255,120,0,0.5)'; ctx.shadowBlur = 5;
ctx.fill(); ctx.shadowBlur = 0;
}
});
// Draw nodes
_nmNodes.forEach((n, ni) => {
const col = _nodeColor(n);
const pulse = 0.55 + Math.sin(_nmT * 1.6 + n.pulse) * 0.3;
const isHover = _nmHoverNode === ni;
const baseR = n.r + (isHover ? 4 : 0);
if (n.online) {
// Outer glow ring
const glowR = baseR + 10 + Math.sin(_nmT + n.pulse) * 4;
const g = ctx.createRadialGradient(n.x, n.y, baseR * 0.5, n.x, n.y, glowR);
g.addColorStop(0, `rgba(${col.r},${col.g},${col.b},${pulse * 0.3})`);
g.addColorStop(1, `rgba(${col.r},${col.g},${col.b},0)`);
ctx.beginPath(); ctx.arc(n.x, n.y, glowR, 0, Math.PI*2);
ctx.fillStyle = g; ctx.fill();
// Rotating orbit ring for hub and agents
if (n.type === 'hub' || n.agent) {
ctx.beginPath();
ctx.arc(n.x, n.y, baseR + 6, _nmT * (n.type==='hub'?0.8:0.5), _nmT * (n.type==='hub'?0.8:0.5) + Math.PI * 1.4);
ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},${0.3 + pulse*0.2})`;
ctx.lineWidth = 1; ctx.stroke();
}
}
// Node fill
const filled = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, baseR);
const a = n.online ? pulse * 0.5 : 0.15;
filled.addColorStop(0, `rgba(${col.r},${col.g},${col.b},${a})`);
filled.addColorStop(1, `rgba(${col.r},${col.g},${col.b},${a*0.3})`);
ctx.beginPath(); ctx.arc(n.x, n.y, baseR, 0, Math.PI*2);
ctx.fillStyle = filled; ctx.fill();
// Node border
ctx.beginPath(); ctx.arc(n.x, n.y, baseR, 0, Math.PI*2);
ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},${n.online ? 0.7+pulse*0.3 : 0.25})`;
ctx.lineWidth = n.type === 'hub' ? 2 : 1.2; ctx.stroke();
// Hub cross-hairs
if (n.type === 'hub') {
ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},0.25)`;
ctx.lineWidth = 0.5;
[[n.x-40,n.y,n.x-baseR-4,n.y],[n.x+baseR+4,n.y,n.x+40,n.y],
[n.x,n.y-40,n.x,n.y-baseR-4],[n.x,n.y+baseR+4,n.x,n.y+40]].forEach(([x1,y1,x2,y2])=>{
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
});
}
// Label
ctx.font = `${n.type==='hub'?'700 ':''} ${n.type==='hub'?9:7.5}px Share Tech Mono,monospace`;
ctx.textAlign = 'center';
ctx.fillStyle = n.online ? `rgba(${col.r},${col.g},${col.b},0.9)` : 'rgba(200,80,80,0.6)';
ctx.fillText(n.label, n.x, n.y + baseR + 12);
if (n.sub && (n.type==='hub'||isHover)) {
ctx.font = '6.5px Share Tech Mono,monospace';
ctx.fillStyle = 'rgba(150,200,220,0.55)';
ctx.fillText(n.sub, n.x, n.y + baseR + 20);
}
ctx.textAlign = 'left';
});
}
draw();
// Mouse hover for node info
canvas.onmousemove = function(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
let found = -1;
_nmNodes.forEach((n, i) => {
if (Math.hypot(n.x - mx, n.y - my) < n.r + 8) found = i;
});
_nmHoverNode = found >= 0 ? found : null;
const info = document.getElementById('nmNodeInfo');
if (!info) return;
if (found >= 0) {
const n = _nmNodes[found];
const col = _nodeColor(n);
document.getElementById('ni-name').textContent = n.label;
document.getElementById('ni-name').style.color = `rgb(${col.r},${col.g},${col.b})`;
document.getElementById('ni-ip').textContent = 'IP: ' + (n.sub || '—');
document.getElementById('ni-status').textContent = 'STATUS: ' + (n.online ? 'ONLINE' : 'OFFLINE');
document.getElementById('ni-type').textContent = 'TYPE: ' + n.type.toUpperCase();
info.style.display = 'block';
info.style.left = (mx + 14) + 'px';
info.style.top = (my - 10) + 'px';
} else {
info.style.display = 'none';
}
};
canvas.onmouseleave = () => {
_nmHoverNode = null;
const info = document.getElementById('nmNodeInfo');
if (info) info.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 (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 — handled client-side without API round-trip
const t = text.toLowerCase();
// Network map open
if (NM_OPEN_RE.test(t)) {
input.value = '';
addMessage('user', text);
addMessage('jarvis', 'Launching network topology display. Stand by.');
speak('Launching network topology display.');
openNetMap();
return;
}
// Network map close
if (NM_CLOSE_RE.test(t)) {
input.value = '';
addMessage('user', text);
const isOpen = document.getElementById('netMapOverlay')?.classList.contains('nm-open');
if (isOpen) {
closeNetMap();
addMessage('jarvis', 'Network topology display closed.');
speak('Network topology display closed.');
} else {
addMessage('jarvis', 'Network map is not currently active.');
}
return;
}
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();
if (!voiceMode) {
if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode();
} else if (!voiceMuted) {
// Awake — any speech is a command; strip optional "jarvis" prefix
voiceLastCmd = Date.now();
voiceActive = Date.now();
const cmd = lc.startsWith(CMD_PREFIX)
? transcript.substring(CMD_PREFIX.length).trim()
: transcript;
if (cmd) {
_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() {
voiceMode = true;
voiceMuted = false;
voiceLastCmd = Date.now();
voiceActive = Date.now();
updateMicBtn();
speak('Yes, ' + (sessionUser || 'Sir') + '?');
// Bring window to front and maximize when JARVIS wakes
try {
window.focus();
if (!document.fullscreenElement && window.screen) {
window.moveTo(0, 0);
window.resizeTo(window.screen.availWidth, window.screen.availHeight);
}
} catch(e) {}
}
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>