mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
9f92e4d5e4
- Mobile UI: 3-button bottom nav with panel switcher - Chat history search: search modal with keyword query - News filtering: category filter with localStorage persistence - Proactive reminders: planner/appointment alerts at login and every 5 min - Proactive alerts: polls every 60s, speaks new critical/warning alerts - Agent sparklines: 2h CPU+MEM sparkline on each online agent card - Tier source badge: KB/GROQ/CLAUDE/OLLAMA pill shown after each reply - VM suggestions: 24h resource analysis via voice command - HA scene control: fuzzy-match scene activation via voice - Jellyfin control: pause/stop/next/previous via voice and KB - Pattern suggestions: usage_patterns table + proactive chips every 30 min - Multi-step commands: compound "X and Y" command parsing (Tier 0.5) - Arc Reactor health: warning=amber/1.2s, critical=red/0.6s pulse encoding - Cross-session history: last 6 turns loaded from prior session - Restart agent: voice command to restart any JARVIS agent - New endpoints: history.php, metrics.php, suggestions.php, jellyfin.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5393 lines
261 KiB
HTML
5393 lines
261 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||
<title>JARVIS — Integrated Defense and Logistics System</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Rajdhani:wght@300;400;500;600&family=Share+Tech+Mono&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#000810;
|
||
--bg2:#000d1a;
|
||
--cyan:#00d4ff;
|
||
--cyan2:#00a8cc;
|
||
--cyan3:rgba(0,212,255,0.15);
|
||
--orange:#ff6600;
|
||
--orange2:#ff4400;
|
||
--green:#00ff88;
|
||
--red:#ff2244;
|
||
--yellow:#ffd700;
|
||
--dim:rgba(0,212,255,0.4);
|
||
--dimmer:rgba(0,212,255,0.12);
|
||
--grid:rgba(0,180,255,0.07);
|
||
--text:#c8e6ff;
|
||
--text-dim:rgba(200,230,255,0.5);
|
||
--panel-bg:rgba(0,15,35,0.85);
|
||
--panel-border:rgba(0,212,255,0.2);
|
||
--font-display:'Orbitron',monospace;
|
||
--font-body:'Rajdhani',sans-serif;
|
||
--font-mono:'Share Tech Mono',monospace;
|
||
--r:8px;
|
||
}
|
||
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-body)}
|
||
|
||
/* ── GRID BACKGROUND — 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}
|
||
|
||
#leftPanel{grid-area:left}
|
||
#centerPanel{grid-area:center}
|
||
#rightPanel{grid-area:right}
|
||
#mainLayout{grid-template-areas:"left center right"}
|
||
#mainLayout.swapped{grid-template-areas:"right center left"}
|
||
#mainLayout.swapped.focus-mode #leftPanel{transform:translateX(20px)}
|
||
#mainLayout.swapped.focus-mode #rightPanel{transform:translateX(-20px)}
|
||
#btn-swap-panels{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 8px;cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;letter-spacing:1px;transition:all 0.2s}
|
||
#btn-swap-panels:hover,#btn-swap-panels.active{color:var(--cyan);border-color:var(--cyan)}
|
||
/* ── MOBILE RESPONSIVE ──────────────────────────────────────────────── */
|
||
@media(max-width:900px){
|
||
#mainLayout{grid-template-columns:1fr!important;grid-template-rows:1fr!important;padding:6px;gap:6px}
|
||
#leftPanel,#rightPanel{display:none}
|
||
#leftPanel.mob-active,#rightPanel.mob-active{display:flex!important;flex-direction:column}
|
||
#centerPanel{display:none}
|
||
#centerPanel.mob-active{display:flex!important;flex-direction:column}
|
||
#topBar{padding:0 10px;height:42px}
|
||
.tb-center{display:none}
|
||
.tb-logo{font-size:0.85rem;letter-spacing:2px}
|
||
#clock{font-size:0.85rem}
|
||
#date-display{display:none}
|
||
#btn-swap-panels,.btn-panels{display:none}
|
||
#inputArea{padding:8px 6px}
|
||
#textInput{font-size:0.85rem;padding:8px 10px;min-height:40px}
|
||
#sendBtn{min-height:40px;padding:0 14px;font-size:0.65rem}
|
||
#micBtn{min-height:40px;min-width:40px;font-size:1.1rem}
|
||
#app{padding-bottom:48px}
|
||
#mobileNav{display:flex!important}
|
||
}
|
||
@media(min-width:901px){#mobileNav{display:none!important}}
|
||
#mobileNav{
|
||
display:none;position:fixed;bottom:0;left:0;right:0;z-index:100;
|
||
background:rgba(0,8,22,0.96);border-top:1px solid var(--panel-border);height:48px;
|
||
}
|
||
.mob-nav-btn{
|
||
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
font-family:var(--font-display);font-size:0.5rem;letter-spacing:1.5px;
|
||
color:var(--text-dim);cursor:pointer;gap:2px;border:none;background:none;transition:color 0.2s;
|
||
}
|
||
.mob-nav-btn .mob-icon{font-size:1rem;line-height:1}
|
||
.mob-nav-btn.active{color:var(--cyan)}
|
||
.mob-nav-btn.active .mob-icon{filter:drop-shadow(0 0 4px var(--cyan))}
|
||
/* 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;
|
||
}
|
||
.panel-title{cursor:pointer;user-select:none}
|
||
.panel-collapse-btn{
|
||
background:none;border:none;color:rgba(0,212,255,0.35);cursor:pointer;
|
||
font-size:0.65rem;padding:0;margin-left:6px;flex-shrink:0;line-height:1;
|
||
transition:transform 0.25s ease,color 0.2s;
|
||
}
|
||
.panel-collapse-btn:hover{color:var(--cyan)!important}
|
||
.panel.collapsed .panel-collapse-btn{transform:rotate(-90deg);color:rgba(0,212,255,0.5)}
|
||
.panel.collapsed > *:not(.panel-title){
|
||
display:none!important;
|
||
}
|
||
.panel.collapsed{
|
||
flex:0 0 auto!important;min-height:0!important;overflow:visible!important;
|
||
}
|
||
|
||
/* ── LEFT PANEL — SYSTEM ─────────────────────────────────────────── */
|
||
#leftPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
|
||
|
||
.metric-row{margin-bottom:10px}
|
||
.metric-label{
|
||
font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim);
|
||
display:flex;justify-content:space-between;margin-bottom:4px;
|
||
}
|
||
.metric-label span{color:var(--cyan)}
|
||
.metric-bar{
|
||
height:4px;border-radius:2px;
|
||
background:rgba(0,212,255,0.1);
|
||
overflow:hidden;position:relative;
|
||
}
|
||
.metric-bar-fill{
|
||
height:100%;border-radius:2px;
|
||
background:linear-gradient(90deg,var(--cyan2),var(--cyan));
|
||
box-shadow:0 0 6px var(--cyan);
|
||
transition:width 1s ease;
|
||
}
|
||
.metric-bar-fill.warn{background:linear-gradient(90deg,#cc6600,var(--orange));box-shadow:0 0 6px var(--orange)}
|
||
.metric-bar-fill.danger{background:linear-gradient(90deg,#cc0022,var(--red));box-shadow:0 0 6px var(--red)}
|
||
|
||
.service-row{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:5px 0;border-bottom:1px solid rgba(0,212,255,0.06);
|
||
font-family:var(--font-mono);font-size:0.72rem;
|
||
}
|
||
.service-row:last-child{border-bottom:none}
|
||
.svc-name{color:var(--text-dim)}
|
||
.svc-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||
.svc-dot.on{background:var(--green);box-shadow:0 0 4px var(--green)}
|
||
.svc-dot.off{background:var(--red);box-shadow:0 0 4px var(--red)}
|
||
|
||
.do-section{margin-top:8px}
|
||
.val-row{
|
||
display:flex;justify-content:space-between;
|
||
font-family:var(--font-mono);font-size:0.72rem;padding:3px 0;
|
||
}
|
||
.val-row .lbl{color:var(--text-dim)}
|
||
.val-row .val{color:var(--cyan)}
|
||
.val-row .val.ok{color:var(--green)}
|
||
.val-row .val.warn{color:var(--orange)}
|
||
.val-row .val.danger{color:var(--red)}
|
||
|
||
/* ── CENTER PANEL ────────────────────────────────────────────────── */
|
||
#centerPanel{
|
||
display:flex;flex-direction:column;align-items:center;gap:10px;overflow:hidden;
|
||
}
|
||
|
||
/* ARC REACTOR ─────────────────────────────────────────────────────── */
|
||
#arcReactor{position:relative;width:220px;height:220px;flex-shrink:0}
|
||
.arc-ring{
|
||
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
border-radius:50%;border-style:solid;border-color:var(--cyan);
|
||
animation:spinRing var(--spd,6s) linear infinite var(--dir,normal);
|
||
}
|
||
.arc-ring.r1{width:220px;height:220px;border-width:1px;border-color:rgba(0,212,255,0.15);--spd:20s}
|
||
.arc-ring.r2{width:195px;height:195px;border-width:1px;border-color:rgba(0,212,255,0.25);--spd:15s;--dir:reverse}
|
||
.arc-ring.r3{
|
||
width:170px;height:170px;border-width:2px;--spd:10s;
|
||
border-top-color:transparent;border-right-color:transparent;
|
||
box-shadow:0 0 6px var(--cyan);
|
||
}
|
||
.arc-ring.r4{
|
||
width:145px;height:145px;border-width:1px;--spd:8s;--dir:reverse;
|
||
border-bottom-color:transparent;border-left-color:transparent;
|
||
}
|
||
.arc-ring.r5{
|
||
width:115px;height:115px;border-width:2px;--spd:5s;
|
||
border-color:var(--orange);border-right-color:transparent;
|
||
box-shadow:0 0 8px var(--orange);
|
||
}
|
||
.arc-ring.r6{width:88px;height:88px;border-width:1px;--spd:4s;--dir:reverse;border-color:rgba(0,212,255,0.6)}
|
||
.arc-ring.r7{
|
||
width:62px;height:62px;border-width:2px;--spd:3s;
|
||
border-left-color:transparent;border-bottom-color:transparent;
|
||
}
|
||
.arc-core{
|
||
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
width:36px;height:36px;border-radius:50%;
|
||
background:radial-gradient(circle,#fff 0%,var(--cyan) 35%,var(--cyan2) 65%,transparent 100%);
|
||
box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 60px rgba(0,212,255,0.4),0 0 100px rgba(0,212,255,0.15);
|
||
animation:corePulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
/* HUD TICKS ───────────────────────────────────────────────────────── */
|
||
.hud-ticks{
|
||
position:absolute;inset:0;border-radius:50%;
|
||
}
|
||
.hud-ticks::before,.hud-ticks::after{
|
||
content:'';position:absolute;
|
||
background:var(--cyan);opacity:0.5;
|
||
}
|
||
.hud-ticks::before{left:50%;top:0;width:1px;height:12px;transform:translateX(-50%)}
|
||
.hud-ticks::after{left:0;top:50%;height:1px;width:12px;transform:translateY(-50%)}
|
||
|
||
/* ── CHAT AREA ───────────────────────────────────────────────────── */
|
||
#chatArea{
|
||
flex:1;width:100%;display:flex;flex-direction:column;gap:8px;overflow:hidden;
|
||
}
|
||
#chatLog{
|
||
flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;
|
||
padding:4px 0;
|
||
scrollbar-width:thin;scrollbar-color:var(--dim) transparent;
|
||
}
|
||
.msg{
|
||
padding:10px 14px;border-radius:var(--r);max-width:100%;
|
||
font-family:var(--font-body);font-size:0.9rem;line-height:1.5;
|
||
animation:msgIn 0.3s ease;
|
||
}
|
||
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||
.msg.user{
|
||
background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.15);
|
||
border-left:3px solid var(--cyan);
|
||
font-family:var(--font-mono);font-size:0.8rem;color:var(--text);
|
||
}
|
||
.msg.user::before{content:'';display:none}
|
||
.msg.jarvis{
|
||
background:rgba(0,40,80,0.4);border:1px solid rgba(0,212,255,0.1);
|
||
border-left:3px solid var(--orange);
|
||
}
|
||
.msg.jarvis::before{content:'◆ JARVIS ';color:var(--orange);font-weight:700;font-size:0.7rem;letter-spacing:2px}
|
||
.msg.system{
|
||
border:1px solid rgba(255,215,0,0.2);background:rgba(255,215,0,0.03);
|
||
font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;
|
||
border-radius:4px;
|
||
}
|
||
|
||
/* ── CONTEXT CHIP ───────────────────────────────────────────────── */
|
||
#contextChip{
|
||
display:none;flex-shrink:0;
|
||
padding:5px 10px;margin-bottom:6px;
|
||
background:rgba(0,212,255,0.07);
|
||
border:1px solid rgba(0,212,255,0.35);
|
||
border-radius:var(--r);
|
||
display:flex;align-items:center;gap:8px;
|
||
font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;
|
||
}
|
||
#contextChip.visible{display:flex}
|
||
#contextLabel{color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#contextType{color:var(--text-dim)}
|
||
#contextClear{
|
||
background:none;border:none;color:var(--dim);cursor:pointer;
|
||
font-size:1rem;line-height:1;padding:0 2px;flex-shrink:0;
|
||
}
|
||
#contextClear:hover{color:var(--red)}
|
||
|
||
/* Planner mini panel */
|
||
/* Clickable panel items */
|
||
.vm-card{cursor:pointer;transition:background 0.15s,border-color 0.15s}
|
||
.vm-card:hover{background:rgba(0,212,255,0.07);border-color:rgba(0,212,255,0.4)}
|
||
.vm-card.ctx-active{border-color:var(--cyan);background:rgba(0,212,255,0.1)}
|
||
.device-item{cursor:pointer;transition:background 0.15s}
|
||
.device-item:hover{background:rgba(0,212,255,0.05)}
|
||
.device-item.ctx-active{background:rgba(0,212,255,0.1);border-color:rgba(0,212,255,0.4)}
|
||
.alert-item{cursor:pointer}
|
||
.alert-item.ctx-active{border-color:var(--cyan) !important;box-shadow:0 0 8px rgba(0,212,255,0.15)}
|
||
.tier-badge{
|
||
display:inline-block;font-family:var(--font-display);font-size:0.42rem;
|
||
letter-spacing:1.5px;padding:1px 5px;border-radius:2px;margin-top:4px;
|
||
opacity:0.7;border:1px solid;vertical-align:middle;
|
||
}
|
||
.tier-badge.kb {color:#00d4ff;border-color:rgba(0,212,255,0.4);background:rgba(0,212,255,0.06)}
|
||
.tier-badge.groq {color:#f5a623;border-color:rgba(245,166,35,0.4);background:rgba(245,166,35,0.06)}
|
||
.tier-badge.claude{color:#b57cf5;border-color:rgba(181,124,245,0.4);background:rgba(181,124,245,0.06)}
|
||
.tier-badge.ollama{color:#7ef55a;border-color:rgba(126,245,90,0.4);background:rgba(126,245,90,0.06)}
|
||
.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}}
|
||
|
||
/* ── ARC REACTOR HEALTH STATES ──────────────────────────────────── */
|
||
#arcReactor.health-warning .arc-ring.r3{border-color:rgba(245,166,35,0.8);box-shadow:0 0 8px #f5a623}
|
||
#arcReactor.health-warning .arc-ring.r5{border-color:rgba(245,166,35,0.6)}
|
||
#arcReactor.health-warning .arc-ring.r7{border-color:rgba(245,166,35,0.5)}
|
||
#arcReactor.health-warning .arc-core{
|
||
background:radial-gradient(circle,#fff 0%,#f5a623 35%,#c97b00 65%,transparent 100%);
|
||
box-shadow:0 0 15px #f5a623,0 0 30px #f5a623,0 0 60px rgba(245,166,35,0.4);
|
||
animation:corePulse 1.2s ease-in-out infinite;
|
||
}
|
||
#arcReactor.health-critical .arc-ring.r3{border-color:rgba(255,34,68,0.9);box-shadow:0 0 10px var(--red)}
|
||
#arcReactor.health-critical .arc-ring.r5{border-color:rgba(255,34,68,0.7)}
|
||
#arcReactor.health-critical .arc-ring.r7{border-color:rgba(255,34,68,0.6)}
|
||
#arcReactor.health-critical .arc-core{
|
||
background:radial-gradient(circle,#fff 0%,var(--red) 35%,#8b0000 65%,transparent 100%);
|
||
box-shadow:0 0 15px var(--red),0 0 30px var(--red),0 0 60px rgba(255,34,68,0.5);
|
||
animation:corePulse 0.6s ease-in-out infinite;
|
||
}
|
||
/* ── SPEAKING ANIMATION ──────────────────────────────────────────── */
|
||
@keyframes speakPulse{
|
||
0%,100%{opacity:0.85;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 50px rgba(0,212,255,0.3)}
|
||
50%{opacity:1;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 25px var(--cyan),0 0 55px var(--cyan),0 0 100px rgba(0,212,255,0.6),0 0 150px rgba(0,212,255,0.25)}
|
||
}
|
||
#arcReactor.speaking .arc-core{
|
||
animation:speakPulse 0.45s ease-in-out infinite;
|
||
}
|
||
#arcReactor.speaking .arc-ring.r3{border-color:rgba(0,212,255,0.7)}
|
||
#arcReactor.speaking .arc-ring.r5{border-color:rgba(255,165,0,0.6)}
|
||
|
||
/* ── SCROLLBAR ───────────────────────────────────────────────────── */
|
||
::-webkit-scrollbar{width:4px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:var(--dim);border-radius:2px}
|
||
|
||
/* ── TABS (for right panel) ────────────────────────────────────── */
|
||
.tab-bar{display:flex;gap:0;margin-bottom:10px;border-bottom:1px solid var(--panel-border);flex-wrap:wrap}
|
||
.forecast-card{background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:4px;padding:5px 3px;text-align:center;min-width:0}
|
||
.forecast-card .fc-day{font-family:var(--font-display);font-size:0.55rem;color:var(--text-dim);letter-spacing:1px;font-weight:700}
|
||
.forecast-card .fc-temps{font-size:0.6rem;color:var(--cyan);font-family:var(--font-display);margin-top:2px}
|
||
.forecast-card .fc-rain{font-size:0.5rem;color:#4fc3f7;min-height:0.7rem}
|
||
.news-item{padding:6px 0;border-bottom:1px solid rgba(0,212,255,0.08)}
|
||
.news-item:last-child{border-bottom:none}
|
||
.news-source{font-size:0.5rem;color:var(--cyan);font-family:var(--font-display);letter-spacing:1px}
|
||
.news-title{font-size:0.62rem;color:var(--text-primary);line-height:1.3;margin-top:2px}
|
||
.news-time{font-size:0.5rem;color:var(--text-dim);margin-top:2px}
|
||
.news-cat-header{font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--cyan);margin:8px 0 4px;border-bottom:1px solid rgba(0,212,255,0.2);padding-bottom:3px}
|
||
.tab{
|
||
font-family:var(--font-display);font-size:0.55rem;font-weight:700;letter-spacing:2px;
|
||
padding:6px 10px;cursor:pointer;color:var(--text-dim);
|
||
border-bottom:2px solid transparent;margin-bottom:-1px;transition:all 0.2s;
|
||
}
|
||
.tab.active{color:var(--cyan);border-bottom-color:var(--cyan)}
|
||
.tab-pane{display:none}
|
||
.tab-pane.active{display:block}
|
||
|
||
/* ── UTILITY ─────────────────────────────────────────────────────── */
|
||
.loading-shimmer{
|
||
background:linear-gradient(90deg,rgba(0,212,255,0.04) 0%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 100%);
|
||
background-size:200% 100%;
|
||
animation:shimmer 1.5s infinite;border-radius:4px;height:12px;
|
||
}
|
||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||
.text-cyan{color:var(--cyan)}
|
||
.text-green{color:var(--green)}
|
||
.text-orange{color:var(--orange)}
|
||
.text-red{color:var(--red)}
|
||
.text-dim{color:var(--text-dim)}
|
||
|
||
/* ① HUD CORNER RINGS */
|
||
#hudCornersCanvas{position:fixed;inset:0;z-index:3;pointer-events:none}
|
||
|
||
/* ② DATA STREAM COLUMNS */
|
||
#dataStreamCanvas{position:fixed;inset:0;z-index:0;pointer-events:none}
|
||
|
||
/* ③ NETWORK TOPOLOGY */
|
||
#topoCanvas{display:block;width:100%;flex-shrink:0;cursor:default;border-bottom:1px solid var(--panel-border);margin-bottom:6px}
|
||
|
||
/* ④ BOOT SEQUENCE */
|
||
@keyframes bootLeft{0%{opacity:0;transform:translateX(-70px)}100%{opacity:1;transform:none}}
|
||
@keyframes bootRight{0%{opacity:0;transform:translateX(70px)}100%{opacity:1;transform:none}}
|
||
@keyframes bootDown{0%{opacity:0;transform:translateY(-18px)}100%{opacity:1;transform:none}}
|
||
@keyframes bootCenter{0%{opacity:0;transform:scale(0.94) translateY(14px)}100%{opacity:1;transform:none}}
|
||
.boot-left{animation:bootLeft 0.55s cubic-bezier(0.4,0,0.2,1) both}
|
||
.boot-right{animation:bootRight 0.55s cubic-bezier(0.4,0,0.2,1) both}
|
||
.boot-top{animation:bootDown 0.4s ease both}
|
||
.boot-center{animation:bootCenter 0.65s cubic-bezier(0.4,0,0.2,1) both}
|
||
|
||
/* ⑤ BREATHING EDGE VIGNETTE */
|
||
#vignetteOverlay{position:fixed;inset:0;pointer-events:none;z-index:1;
|
||
background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(0,2,18,0.6) 100%);
|
||
animation:vignettePulse 5s ease-in-out infinite}
|
||
#vignetteOverlay.alert-vignette{background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(20,0,8,0.65) 100%)}
|
||
@keyframes vignettePulse{0%,100%{opacity:0.75}50%{opacity:1}}
|
||
|
||
/* ⑥ EKG HEARTBEAT */
|
||
#ekgWrap{flex:1;max-width:180px;display:flex;align-items:center;overflow:hidden}
|
||
#ekgCanvas{display:block;width:100%;height:22px;opacity:0.8}
|
||
|
||
/* ⑦ AUDIO RING */
|
||
.tb-reactor{position:relative}
|
||
#audioRingCanvas{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
width:60px;height:60px;pointer-events:none;z-index:4}
|
||
|
||
/* ⑧ TYPEWRITER CURSOR */
|
||
@keyframes cursorBlink{0%,100%{opacity:1}49%{opacity:1}50%,99%{opacity:0}}
|
||
.type-cursor{display:inline-block;width:6px;height:0.82em;background:var(--cyan);margin-left:1px;
|
||
vertical-align:text-bottom;animation:cursorBlink 0.7s step-end infinite}
|
||
|
||
/* ⑨ STATIC NOISE BURST */
|
||
@keyframes staticBurst{0%{opacity:0}10%{opacity:1}90%{opacity:1}100%{opacity:0}}
|
||
.panel-noise-layer{position:absolute;inset:0;pointer-events:none;z-index:20;border-radius:var(--r);
|
||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.1'/%3E%3C/svg%3E");
|
||
background-size:100% 100%;mix-blend-mode:screen;animation:staticBurst 0.28s ease forwards}
|
||
|
||
/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */
|
||
#netMapOverlay{position:fixed;top:0;left:0;width:100vw;height:100vh;
|
||
z-index:200;display:none;flex-direction:column;
|
||
background:rgba(0,4,18,0.96);border:1px solid rgba(0,212,255,0.28);
|
||
border-top:none;border-left:none;transform-origin:0 0;backdrop-filter:blur(14px);
|
||
overflow:hidden;box-shadow:6px 6px 40px rgba(0,0,0,0.75),0 0 50px rgba(0,212,255,0.05)}
|
||
#netMapOverlay::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:1;
|
||
background:
|
||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 18px 1px no-repeat,
|
||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 1px 18px no-repeat,
|
||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 18px 1px no-repeat,
|
||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 1px 18px no-repeat,
|
||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 18px 1px no-repeat,
|
||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 1px 18px no-repeat,
|
||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 18px 1px no-repeat,
|
||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 1px 18px no-repeat}
|
||
#netMapOverlay.nm-open{display:flex;animation:nmExplode 0.45s cubic-bezier(0.4,0,0.2,1) forwards}
|
||
#netMapOverlay.nm-closing{animation:nmCollapse 0.3s cubic-bezier(0.4,0,0.2,1) forwards}
|
||
@keyframes nmExplode{0%{transform:scale(0.04,0.06);opacity:0}60%{opacity:1}100%{transform:scale(1);opacity:1}}
|
||
@keyframes nmCollapse{0%{transform:scale(1);opacity:1}100%{transform:scale(0.04,0.06);opacity:0}}
|
||
#nmHeader{display:flex;align-items:center;justify-content:space-between;padding:7px 16px;
|
||
flex-shrink:0;border-bottom:1px solid rgba(0,212,255,0.16);background:rgba(0,8,28,0.6);z-index:2;position:relative}
|
||
#nmTitle{font-family:var(--font-display);font-size:0.62rem;letter-spacing:4px;color:var(--cyan);display:flex;align-items:center;gap:10px}
|
||
#nmTitle .nm-pulse{width:6px;height:6px;border-radius:50%;background:var(--cyan);box-shadow:0 0 7px var(--cyan);animation:corePulse 1.5s infinite}
|
||
#nmStats{font-family:var(--font-mono);font-size:0.58rem;color:var(--text-dim);display:flex;gap:16px}
|
||
#nmStats span{color:var(--cyan)}
|
||
#nmClose{background:none;border:1px solid rgba(0,212,255,0.3);color:var(--text-dim);font-family:var(--font-mono);font-size:0.56rem;padding:3px 10px;cursor:pointer;letter-spacing:2px;transition:all 0.2s}
|
||
#nmClose:hover{border-color:var(--red);color:var(--red)}
|
||
#nmCanvas{flex:1;display:block;z-index:2;position:relative}
|
||
#nmLegend{display:flex;gap:16px;align-items:center;padding:5px 16px;flex-shrink:0;
|
||
border-top:1px solid rgba(0,212,255,0.1);font-family:var(--font-mono);font-size:0.54rem;
|
||
color:var(--text-dim);background:rgba(0,8,28,0.6);z-index:2;position:relative}
|
||
.nm-leg-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:4px;flex-shrink:0}
|
||
#nmNodeInfo{position:absolute;pointer-events:none;z-index:10;background:rgba(0,8,30,0.95);
|
||
border:1px solid rgba(0,212,255,0.4);padding:7px 11px;font-family:var(--font-mono);
|
||
font-size:0.6rem;display:none;min-width:150px;box-shadow:0 0 18px rgba(0,212,255,0.12)}
|
||
#nmNodeInfo .ni-title{color:var(--cyan);font-size:0.62rem;letter-spacing:2px;margin-bottom:3px}
|
||
#nmNodeInfo .ni-row{color:var(--text-dim);margin:2px 0}
|
||
|
||
/* ── SLEEP MODE ──────────────────────────────────────────────────────── */
|
||
#sleepOverlay{
|
||
position:fixed;inset:0;z-index:500;display:none;
|
||
flex-direction:column;align-items:center;justify-content:center;
|
||
background:rgba(0,2,10,0.94);
|
||
backdrop-filter:blur(6px);
|
||
}
|
||
#sleepOverlay.active{display:flex}
|
||
@keyframes sleepPulse{0%,100%{opacity:0.25;transform:scale(1)}50%{opacity:0.6;transform:scale(1.06)}}
|
||
@keyframes sleepCoreGlow{0%,100%{box-shadow:0 0 30px rgba(0,212,255,0.15),0 0 60px rgba(0,212,255,0.05)}50%{box-shadow:0 0 50px rgba(0,212,255,0.3),0 0 100px rgba(0,212,255,0.1)}}
|
||
.sleep-reactor{position:relative;width:120px;height:120px;margin-bottom:40px}
|
||
.sleep-ring{position:absolute;border-radius:50%;border:1px solid rgba(0,212,255,0.2);top:50%;left:50%;transform:translate(-50%,-50%)}
|
||
.sleep-ring.sr1{width:120px;height:120px;animation:spinRing 18s linear infinite;border-color:rgba(0,212,255,0.12)}
|
||
.sleep-ring.sr2{width:85px;height:85px;animation:spinRing 12s linear infinite reverse;border-color:rgba(0,212,255,0.18)}
|
||
.sleep-ring.sr3{width:52px;height:52px;animation:spinRing 8s linear infinite;border-color:rgba(0,80,160,0.3)}
|
||
.sleep-core{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||
width:24px;height:24px;border-radius:50%;
|
||
background:radial-gradient(circle,rgba(0,150,220,0.6) 0%,rgba(0,80,160,0.3) 60%,transparent 100%);
|
||
animation:sleepCoreGlow 4s ease-in-out infinite,sleepPulse 4s ease-in-out infinite}
|
||
.sleep-label{font-family:var(--font-display);font-size:0.6rem;letter-spacing:6px;
|
||
color:rgba(0,212,255,0.35);animation:sleepPulse 4s ease-in-out infinite;margin-bottom:12px}
|
||
.sleep-sub{font-family:var(--font-mono);font-size:0.55rem;letter-spacing:3px;
|
||
color:rgba(0,212,255,0.2);animation:sleepPulse 4s ease-in-out infinite;animation-delay:0.5s}
|
||
/* App dims on sleep */
|
||
#app.sleeping #mainLayout,#app.sleeping #topBar,#app.sleeping #bottomBar{
|
||
pointer-events:none;
|
||
filter:brightness(0.08) saturate(0.3);
|
||
transition:filter 1.2s ease;
|
||
}
|
||
#app.sleeping #sleepOverlay{display:flex}
|
||
/* ── GUARDIAN MODE ────────────────────────────────────────────────── */
|
||
.guardian-event{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-bottom:1px solid var(--panel-border);cursor:pointer}
|
||
.guardian-event:hover{background:rgba(0,212,255,0.04)}
|
||
.guardian-event.critical{border-left:3px solid var(--red)}
|
||
.guardian-event.warning{border-left:3px solid #f5a623}
|
||
.guardian-event.info{border-left:3px solid rgba(0,212,255,0.3)}
|
||
.guardian-event.acked{opacity:0.45}
|
||
.guardian-sev{font-family:var(--font-mono);font-size:0.5rem;padding:2px 4px;border-radius:2px;flex-shrink:0;letter-spacing:1px;margin-top:1px}
|
||
.guardian-sev.critical{background:rgba(255,34,68,0.15);color:var(--red);border:1px solid rgba(255,34,68,0.3)}
|
||
.guardian-sev.warning{background:rgba(245,166,35,0.12);color:#f5a623;border:1px solid rgba(245,166,35,0.3)}
|
||
.guardian-sev.info{background:rgba(0,212,255,0.08);color:var(--cyan);border:1px solid rgba(0,212,255,0.2)}
|
||
.guardian-msg{flex:1;font-size:0.62rem;line-height:1.4;color:var(--text)}
|
||
.guardian-time{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0}
|
||
.guardian-ai{font-size:0.6rem;color:rgba(0,212,255,0.6);margin-top:3px;font-style:italic}
|
||
#bb-guardian-dot.all-clear{background:var(--green);box-shadow:0 0 5px var(--green)}
|
||
#bb-guardian-dot.warning{background:#f5a623;box-shadow:0 0 5px #f5a623}
|
||
#bb-guardian-dot.critical{background:var(--red);box-shadow:0 0 5px var(--red);animation:pulse 1.2s ease-in-out infinite}
|
||
.guardian-ack-btn{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:1px 5px;border-radius:2px;font-size:0.5rem;cursor:pointer;font-family:var(--font-mono);letter-spacing:1px;flex-shrink:0}
|
||
.guardian-ack-btn:hover{color:var(--cyan);border-color:var(--cyan)}
|
||
/* ── VISION PROTOCOL — screenshot lightbox ───────────────────────── */
|
||
#vision-lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:9999;flex-direction:column;align-items:center;justify-content:flex-start;padding:20px;overflow-y:auto}
|
||
#vision-lightbox.open{display:flex}
|
||
#vision-lb-header{width:100%;max-width:960px;display:flex;align-items:center;gap:10px;margin-bottom:12px}
|
||
#vision-lb-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);flex:1}
|
||
#vision-lb-close{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--font-display);font-size:0.6rem}
|
||
#vision-lb-img{max-width:960px;width:100%;border:1px solid var(--panel-border);border-radius:4px;margin-bottom:12px}
|
||
#vision-lb-analysis{max-width:960px;width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:14px 16px;font-size:0.65rem;line-height:1.7;color:var(--text);white-space:pre-wrap}
|
||
#vision-lb-spinner{color:var(--cyan);font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;animation:pulse 1.5s ease-in-out infinite;margin:30px auto}
|
||
/* ── COMMS PROTOCOL — email triage cards ─────────────────────────── */
|
||
.comms-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
|
||
.comms-card-head{display:flex;align-items:center;gap:7px;padding:7px 10px;cursor:pointer;user-select:none}
|
||
.comms-card-head:hover{background:rgba(0,212,255,0.06)}
|
||
.comms-card-subject{font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.comms-card-cat{font-family:var(--font-mono);font-size:0.52rem;padding:2px 5px;border-radius:2px;flex-shrink:0;text-transform:uppercase;letter-spacing:1px}
|
||
.comms-card-cat.urgent{color:#ff2244;border:1px solid rgba(255,34,68,0.4);animation:pulse 1.5s ease-in-out infinite}
|
||
.comms-card-cat.action{color:#ffd700;border:1px solid rgba(255,215,0,0.4)}
|
||
.comms-card-cat.reply{color:var(--cyan);border:1px solid rgba(0,212,255,0.3)}
|
||
.comms-card-cat.meeting{color:#a78bfa;border:1px solid rgba(167,139,250,0.4)}
|
||
.comms-card-cat.info{color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)}
|
||
.comms-card-cat.promo,.comms-card-cat.spam{color:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.08)}
|
||
.comms-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
|
||
.comms-card.open .comms-card-body{display:block}
|
||
.comms-card-from{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin:7px 0 3px}
|
||
.comms-card-summary{font-size:0.62rem;line-height:1.5;color:var(--text);margin:5px 0}
|
||
.comms-draft-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px}
|
||
.comms-draft{font-size:0.6rem;line-height:1.5;color:rgba(0,212,255,0.7);background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:3px;padding:7px 9px;white-space:pre-wrap;max-height:160px;overflow-y:auto}
|
||
.comms-empty{text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px}
|
||
.comms-header-bar{display:flex;gap:5px;margin-bottom:7px;flex-wrap:wrap}
|
||
.comms-filter-btn{flex:1;min-width:50px;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:4px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer;text-align:center}
|
||
.comms-filter-btn.active,.comms-filter-btn:hover{background:rgba(0,212,255,0.12);color:var(--cyan);border-color:var(--cyan)}
|
||
.comms-triage-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
|
||
.comms-triage-btn:hover{background:rgba(0,212,255,0.12)}
|
||
.comms-prio{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0}
|
||
.comms-section-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);border-bottom:1px solid var(--panel-border);padding:6px 0 4px;margin:8px 0 6px}
|
||
.comms-compose-btn{width:100%;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:5px}
|
||
.comms-compose-btn:hover{background:rgba(0,212,255,0.15)}
|
||
.comms-send-btn{flex:1;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.4);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer}
|
||
.comms-send-btn:hover{background:rgba(0,212,255,0.2)}
|
||
.comms-send-btn:disabled{opacity:0.4;cursor:not-allowed}
|
||
.comms-outbox-card{background:rgba(0,212,255,0.03);border:1px solid rgba(0,212,255,0.1);border-radius:3px;padding:6px 9px;margin-bottom:5px}
|
||
.comms-outbox-to{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
|
||
.comms-outbox-subj{font-family:var(--font-display);font-size:0.55rem;color:var(--cyan);margin:2px 0}
|
||
.comms-outbox-status{font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px}
|
||
.comms-outbox-status.sent{color:#00ff88;border:1px solid rgba(0,255,136,0.3)}
|
||
.comms-outbox-status.failed{color:#ff2244;border:1px solid rgba(255,34,68,0.3)}
|
||
.comms-outbox-status.queued{color:#ffd700;border:1px solid rgba(255,215,0,0.3)}
|
||
/* compose modal */
|
||
.comms-compose-modal{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9000}
|
||
.comms-compose-inner{background:#0a0e14;border:1px solid var(--cyan);border-radius:6px;padding:16px;width:min(90vw,480px);max-height:80vh;overflow-y:auto}
|
||
.comms-compose-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:12px}
|
||
.comms-compose-field{width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:3px;padding:6px 8px;color:var(--text);font-family:var(--font-mono);font-size:0.6rem;box-sizing:border-box;margin-bottom:7px}
|
||
.comms-compose-field:focus{outline:none;border-color:var(--cyan)}
|
||
.comms-compose-actions{display:flex;gap:6px;margin-top:8px}
|
||
/* ── DIRECTIVES HUD ──────────────────────────────────────────────── */
|
||
.dir-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
|
||
.dir-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
|
||
.dir-card-head:hover{background:rgba(0,212,255,0.06)}
|
||
.dir-card-title{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.dir-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
|
||
.dir-card.open .dir-card-body{display:block}
|
||
.dir-progress-bar{height:5px;background:rgba(255,255,255,0.08);border-radius:3px;margin:6px 0}
|
||
.dir-progress-fill{height:100%;border-radius:3px;transition:width 0.4s ease}
|
||
.dir-kr-row{display:flex;align-items:center;gap:6px;margin:4px 0;font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
|
||
.dir-kr-bar{flex:1;height:3px;background:rgba(255,255,255,0.06);border-radius:2px}
|
||
.dir-kr-fill{height:100%;border-radius:2px;background:rgba(0,212,255,0.5)}
|
||
.dir-admin-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
|
||
.dir-admin-btn:hover{background:rgba(0,212,255,0.12)}
|
||
/* ── MISSION OPS HUD ─────────────────────────────────────────────── */
|
||
.mission-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
|
||
.mission-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
|
||
.mission-card-head:hover{background:rgba(0,212,255,0.06)}
|
||
.mission-card-name{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.mission-card-trigger{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)}
|
||
.mission-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
|
||
.mission-card.open .mission-card-body{display:block}
|
||
.mission-run-bar{display:flex;gap:5px;margin-top:8px}
|
||
.mission-run-btn{flex:1;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer}
|
||
.mission-run-btn:hover{background:rgba(0,212,255,0.15)}
|
||
.mission-run-btn:disabled{opacity:0.4;cursor:not-allowed}
|
||
.mission-run-item{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem}
|
||
.mission-run-status.done{color:#00ff88}
|
||
.mission-run-status.failed{color:#ff2244}
|
||
.mission-run-status.running{color:#ffd700;animation:pulse 1.5s ease-in-out infinite}
|
||
.mission-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
|
||
.mission-new-btn:hover{background:rgba(0,212,255,0.12)}
|
||
/* ── CLEARANCE PROTOCOL ──────────────────────────────────────────── */
|
||
#clearance-banner{display:none;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:var(--r);padding:6px 10px;margin:0 0 8px;font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:#ff6680;animation:borderPulse 2s ease-in-out infinite}
|
||
@keyframes borderPulse{0%,100%{border-color:rgba(255,34,68,0.4)}50%{border-color:rgba(255,34,68,0.9)}}
|
||
#clearance-banner.active{display:flex;align-items:center;gap:8px}
|
||
#clearance-banner .clr-count{background:rgba(255,34,68,0.3);border-radius:3px;padding:1px 5px;font-size:0.6rem;color:#ff2244}
|
||
#clearance-banner .clr-view{margin-left:auto;cursor:pointer;color:#ff6680;text-decoration:underline}
|
||
.clr-card{background:rgba(255,34,68,0.04);border:1px solid rgba(255,34,68,0.3);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
|
||
.clr-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
|
||
.clr-card-head:hover{background:rgba(255,34,68,0.06)}
|
||
.clr-card-type{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;color:#ff8899}
|
||
.clr-card-risk{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;border:1px solid}
|
||
.clr-card-risk.critical{color:#ff2244;border-color:rgba(255,34,68,0.5)}
|
||
.clr-card-risk.high{color:#ffd700;border-color:rgba(255,215,0,0.4)}
|
||
.clr-card-risk.medium{color:#ff9900;border-color:rgba(255,153,0,0.4)}
|
||
.clr-card-body{display:none;padding:8px 10px 10px;border-top:1px solid rgba(255,34,68,0.2)}
|
||
.clr-card.open .clr-card-body{display:block}
|
||
.clr-card-desc{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin-bottom:8px;line-height:1.5;white-space:pre-wrap}
|
||
.clr-action-bar{display:flex;gap:6px;margin-top:8px}
|
||
.clr-approve-btn{flex:1;background:rgba(0,255,136,0.08);border:1px solid rgba(0,255,136,0.4);border-radius:3px;padding:4px 8px;color:#00ff88;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer}
|
||
.clr-approve-btn:hover{background:rgba(0,255,136,0.18)}
|
||
.clr-deny-btn{flex:1;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:3px;padding:4px 8px;color:#ff2244;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer}
|
||
.clr-deny-btn:hover{background:rgba(255,34,68,0.18)}
|
||
.clr-history-row{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
|
||
.clr-status-approved{color:#00ff88}.clr-status-denied{color:#ff2244}.clr-status-pending{color:#ffd700}.clr-status-expired{color:rgba(255,255,255,0.3)}
|
||
.clr-rule-row{display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem}
|
||
.clr-rule-type{flex:1;color:var(--cyan)}
|
||
.clr-rule-toggle{cursor:pointer;padding:2px 6px;border-radius:2px;font-size:0.48rem;border:1px solid}
|
||
.clr-rule-enabled{color:#00ff88;border-color:rgba(0,255,136,0.4)}
|
||
.clr-rule-disabled{color:rgba(255,255,255,0.3);border-color:rgba(255,255,255,0.15)}
|
||
.clr-admin-btn{width:100%;background:rgba(255,34,68,0.06);border:1px solid rgba(255,34,68,0.3);border-radius:4px;padding:5px;color:#ff6680;font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
|
||
.clr-admin-btn:hover{background:rgba(255,34,68,0.12)}
|
||
/* ── INTEL PROTOCOL — research result cards ──────────────────────── */
|
||
.intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
|
||
.intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
|
||
.intel-card-head:hover{background:rgba(0,212,255,0.06)}
|
||
.intel-card-query{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.intel-card-status{font-family:var(--font-mono);font-size:0.55rem;padding:2px 6px;border-radius:2px;flex-shrink:0}
|
||
.intel-card-status.running{color:#ffd700;border:1px solid rgba(255,215,0,0.4);animation:pulse 1.5s ease-in-out infinite}
|
||
.intel-card-status.done{color:var(--green);border:1px solid rgba(0,255,136,0.3)}
|
||
.intel-card-status.failed{color:var(--red);border:1px solid rgba(255,34,68,0.3)}
|
||
.intel-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
|
||
.intel-card.open .intel-card-body{display:block}
|
||
.intel-card-body .synthesis{font-size:0.65rem;line-height:1.6;color:var(--text);margin:8px 0;white-space:pre-wrap}
|
||
.intel-sources{margin-top:8px}
|
||
.intel-source{font-size:0.58rem;color:var(--text-dim);padding:2px 0;border-bottom:1px solid rgba(0,212,255,0.06)}
|
||
.intel-source a{color:var(--cyan2);text-decoration:none}
|
||
.intel-source a:hover{text-decoration:underline}
|
||
.intel-empty{text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px}
|
||
.intel-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;cursor:pointer;margin-bottom:8px}
|
||
.intel-new-btn:hover{background:rgba(0,212,255,0.12)}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
|
||
|
||
</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 <span id="tb-cpu">--</span>% CPU</div>
|
||
<div class="tb-stat">MEM <span id="tb-mem">--</span>%</div>
|
||
<div class="tb-stat">DO SERVER <span id="tb-do" class="text-dim">--</span></div>
|
||
<div class="tb-stat"><span id="tb-alerts" class="text-green">NO ALERTS</span></div>
|
||
<div 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 id="btn-swap-panels" onclick="swapPanels()" title="Swap side panels">⇄ SWAP</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>
|
||
<button id="searchBtn" onclick="openSearchModal()" title="Search chat history" style="background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);font-size:1rem;padding:0 10px;cursor:pointer;transition:all 0.2s;min-height:36px" onmouseover="this.style.color='var(--cyan)';this.style.borderColor='var(--cyan)'" onmouseout="this.style.color='var(--text-dim)';this.style.borderColor='var(--panel-border)'">🔍</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">
|
||
<!-- Clearance Alert Banner -->
|
||
<div id="clearance-banner">
|
||
<span>◈ CLEARANCE REQUIRED —</span>
|
||
<span class="clr-count" id="clr-banner-count">0</span>
|
||
<span>PENDING AUTHORIZATION</span>
|
||
<span class="clr-view" onclick="switchTab('clearance')">VIEW →</span>
|
||
</div>
|
||
<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 class="tab" id="tab-btn-intel" onclick="switchTab('intel')">INTEL</div>
|
||
<div class="tab" id="tab-btn-comms" onclick="switchTab('comms')">COMMS</div>
|
||
<div class="tab" id="tab-btn-guardian" onclick="switchTab('guardian')">GUARDIAN</div>
|
||
<div class="tab" id="tab-btn-missions" onclick="switchTab('missions')">MISSIONS</div>
|
||
<div class="tab" id="tab-btn-directives" onclick="switchTab('directives')">DIRECTIVES</div>
|
||
<div class="tab" id="tab-btn-clearance" onclick="switchTab('clearance')">CLEARANCE <span id="clr-tab-badge" style="display:none;background:#ff2244;color:#fff;border-radius:3px;padding:0 4px;font-size:0.5rem;margin-left:2px"></span></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 style="display:flex;align-items:center;justify-content:flex-end;margin-bottom:4px">
|
||
<button onclick="toggleNewsFilter()" title="Filter news sources" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:0.85rem;padding:2px 4px;transition:color 0.2s" onmouseover="this.style.color='var(--cyan)'" onmouseout="this.style.color='var(--text-dim)'">⚙</button>
|
||
</div>
|
||
<div id="news-filter-panel" style="display:none;margin-bottom:8px;padding:8px;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px">
|
||
<div style="font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;color:var(--cyan);margin-bottom:6px">SHOW CATEGORIES</div>
|
||
<div id="news-filter-checkboxes" style="display:flex;flex-direction:column;gap:4px"></div>
|
||
</div>
|
||
<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 id="tab-intel" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
|
||
<div id="intel-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-comms" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
|
||
<div id="comms-list"><div class="loading-shimmer"></div></div>
|
||
<div class="comms-section-label" style="margin:12px 4px 6px">◈ OUTBOX — SENT & QUEUED</div>
|
||
<div id="comms-outbox"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-guardian" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
|
||
<div id="guardian-list"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-missions" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
|
||
<div id="missions-hud"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-directives" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
|
||
<div id="directives-hud"><div class="loading-shimmer"></div></div>
|
||
</div>
|
||
<div id="tab-clearance" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
|
||
<div id="clearance-hud"><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 class="bb-item">
|
||
<div class="bb-dot" id="bb-arc-dot"></div>
|
||
<span>ARC REACTOR</span> <span id="bb-arc-status">OFFLINE</span>
|
||
</div>
|
||
|
||
<div class="bb-item" id="bb-guardian-item" style="cursor:pointer" onclick="switchGuardianTab()">
|
||
<div class="bb-dot" id="bb-guardian-dot" style="background:var(--text-dim)"></div>
|
||
<span>GUARDIAN</span> <span id="bb-guardian-status" style="color:var(--text-dim)">INIT</span>
|
||
<span id="bb-guardian-badge" style="display:none;background:var(--red);color:#fff;font-size:0.45rem;padding:1px 4px;border-radius:2px;font-family:var(--font-mono);letter-spacing:0">0</span>
|
||
</div>
|
||
|
||
<div class="bb-item" style="cursor:pointer" onclick="switchTab('clearance')" id="bb-memory-item">
|
||
<div class="bb-dot" id="bb-memory-dot" style="background:rgba(0,212,255,0.3)"></div>
|
||
<span>MEMORY</span> <span id="bb-memory-count" style="color:var(--text-dim)">--</span>
|
||
</div>
|
||
|
||
<div id="ekgWrap" style="margin-left:auto"><canvas id="ekgCanvas"></canvas></div>
|
||
<div style="font-size:0.65rem;flex-shrink:0">
|
||
JARVIS v2.0 · SECURITY LEVEL ALPHA · UPDATED <span id="last-refresh">--:--:--</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NETWORK MAP OVERLAY -->
|
||
<div id="netMapOverlay">
|
||
<div id="nmHeader">
|
||
<div id="nmTitle"><div class="nm-pulse"></div>◈ NETWORK TOPOLOGY — LIVE</div>
|
||
<div id="nmStats">NODES <span id="nm-node-count">—</span> · ONLINE <span id="nm-online-count">—</span> · AGENTS <span id="nm-agent-count">—</span></div>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<span style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);letter-spacing:1px">SAY "CLOSE MAP"</span>
|
||
<button id="nmClose" onclick="closeNetMap()">✕ CLOSE</button>
|
||
</div>
|
||
</div>
|
||
<canvas id="nmCanvas"></canvas>
|
||
<div id="nmLegend">
|
||
<span><span class="nm-leg-dot" style="background:#00ff88;box-shadow:0 0 4px #00ff88"></span>PROXMOX</span>
|
||
<span><span class="nm-leg-dot" style="background:#ffd700;box-shadow:0 0 4px #ffd700"></span>SERVICES</span>
|
||
<span><span class="nm-leg-dot" style="background:#00beff;box-shadow:0 0 4px #00beff"></span>AGENTS</span>
|
||
<span><span class="nm-leg-dot" style="background:rgba(0,160,200,0.9)"></span>DEVICES</span>
|
||
<span><span class="nm-leg-dot" style="background:rgba(0,110,170,0.9)"></span>NETWORK</span>
|
||
<span><span class="nm-leg-dot" style="background:#ff2244;box-shadow:0 0 4px #ff2244"></span>OFFLINE</span>
|
||
<span style="margin-left:auto;opacity:0.4;font-size:0.5rem">CYAN = DATA IN · ORANGE = CMD OUT</span>
|
||
</div>
|
||
<div id="nmNodeInfo"><div class="ni-title" id="ni-name">—</div><div class="ni-row" id="ni-ip"></div><div class="ni-row" id="ni-status"></div><div class="ni-row" id="ni-type"></div></div>
|
||
</div>
|
||
<!-- SLEEP OVERLAY -->
|
||
<div id="sleepOverlay">
|
||
<div class="sleep-reactor">
|
||
<div class="sleep-ring sr1"></div>
|
||
<div class="sleep-ring sr2"></div>
|
||
<div class="sleep-ring sr3"></div>
|
||
<div class="sleep-core"></div>
|
||
</div>
|
||
<div class="sleep-label">JARVIS — STANDBY</div>
|
||
<div class="sleep-sub">SAY "WAKE UP JARVIS" TO RESUME</div>
|
||
</div>
|
||
|
||
<div id="sitesModal" style="position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:none;align-items:flex-start;justify-content:center;padding:24px;overflow-y:auto">
|
||
<div style="background:var(--panel-bg);border:1px solid var(--panel-border);width:100%;max-width:960px;font-family:var(--font-mono)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid var(--panel-border)">
|
||
<div style="color:var(--cyan);font-size:0.75rem;letter-spacing:4px">◈ SITES MANAGER — EMAIL SETTINGS</div>
|
||
<button onclick="closeSitesModal()" style="background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;padding:4px 12px;letter-spacing:2px">✕ CLOSE</button>
|
||
</div>
|
||
<div style="padding:20px 24px">
|
||
<!-- Global API Key -->
|
||
<div style="background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.2);padding:16px;margin-bottom:20px">
|
||
<div style="color:var(--cyan);font-size:0.62rem;letter-spacing:3px;margin-bottom:10px">▸ CYBERMAIL API KEY — PUSH TO ALL SITES</div>
|
||
<div style="display:flex;gap:10px;align-items:center">
|
||
<input id="global-api-key" type="password"
|
||
style="flex:1;background:#0a0f1a;border:1px solid rgba(0,212,255,0.25);color:var(--text);font-family:var(--font-mono);font-size:0.7rem;padding:8px 12px;outline:none"
|
||
placeholder="sk_live_...">
|
||
<button onclick="pushApiKey()"
|
||
style="background:rgba(0,212,255,0.12);border:1px solid var(--cyan);color:var(--cyan);font-family:var(--font-mono);font-size:0.6rem;letter-spacing:2px;padding:8px 18px;cursor:pointer;white-space:nowrap">
|
||
PUSH TO ALL
|
||
</button>
|
||
</div>
|
||
<div id="push-status" style="font-size:0.6rem;color:var(--text-dim);margin-top:6px;min-height:16px"></div>
|
||
</div>
|
||
<!-- Site Cards Grid -->
|
||
<div id="sites-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||
<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem">LOADING...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="agentModal">
|
||
<div class="agent-modal-box">
|
||
<button class="agent-modal-close" onclick="document.getElementById('agentModal').classList.remove('open')">✕ CLOSE</button>
|
||
<h3 id="agentModalTitle">● JARVIS AGENT</h3>
|
||
<div id="agentModalContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden camera feed for face detection -->
|
||
<video id="faceVideo" autoplay muted playsinline
|
||
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px"></video>
|
||
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
|
||
|
||
<script>
|
||
// ── 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);
|
||
}
|
||
|
||
function setSystemHealth(level) {
|
||
// level: 'ok' | 'warning' | 'critical'
|
||
const reactor = document.getElementById('arcReactor');
|
||
if (!reactor) return;
|
||
reactor.classList.remove('health-warning', 'health-critical');
|
||
if (level === 'warning') reactor.classList.add('health-warning');
|
||
if (level === 'critical') reactor.classList.add('health-critical');
|
||
// Also update topbar logo dot
|
||
const dot = document.querySelector('.tb-logo-dot');
|
||
if (dot) {
|
||
dot.style.background = level === 'critical' ? 'var(--red)' : level === 'warning' ? '#f5a623' : 'var(--cyan)';
|
||
dot.style.boxShadow = level === 'critical' ? '0 0 8px var(--red)' : level === 'warning' ? '0 0 8px #f5a623' : '0 0 8px var(--cyan)';
|
||
}
|
||
}
|
||
|
||
// ── FACE TRACKING — reactor follows face position ─────────────────────
|
||
let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5
|
||
let _faceCurrX = 0, _faceCurrY = 0;
|
||
let _faceVisible = false;
|
||
let _faceTrackRaf = null;
|
||
const FACE_MAX_X = 48; // max px left/right travel
|
||
const FACE_MAX_Y = 10; // max px up/down travel
|
||
const FACE_LERP = 0.07; // smoothing (lower = slower/smoother)
|
||
|
||
function _faceTrackLoop() {
|
||
_faceTrackRaf = requestAnimationFrame(_faceTrackLoop);
|
||
|
||
// Lerp toward target (or back to 0 when no face)
|
||
const targetX = _faceVisible ? _faceTargetX : 0;
|
||
const targetY = _faceVisible ? _faceTargetY : 0;
|
||
_faceCurrX += (targetX - _faceCurrX) * FACE_LERP;
|
||
_faceCurrY += (targetY - _faceCurrY) * FACE_LERP;
|
||
|
||
const tx = _faceCurrX * FACE_MAX_X;
|
||
const ty = _faceCurrY * FACE_MAX_Y;
|
||
|
||
const logo = document.querySelector('.tb-logo');
|
||
if (logo) logo.style.transform = `translateX(${tx.toFixed(2)}px) translateY(${ty.toFixed(2)}px)`;
|
||
}
|
||
|
||
function updateFaceTarget(box, videoW, videoH) {
|
||
// Face center, normalized — flip X because front camera is mirrored
|
||
const cx = box.x + box.width / 2;
|
||
const cy = box.y + box.height / 2;
|
||
_faceTargetX = 0.5 - (cx / videoW); // flipped: move right when face is left
|
||
_faceTargetY = (cy / videoH) - 0.5; // positive = face low = logo drops slightly
|
||
_faceVisible = true;
|
||
|
||
// Position the scan overlay over the detected face in the viewport.
|
||
// The video feed is 320×240 but hidden; map to viewport coords.
|
||
const scaleX = window.innerWidth / videoW;
|
||
const scaleY = window.innerHeight / videoH;
|
||
const faceVx = box.x * scaleX;
|
||
const faceVy = box.y * scaleY;
|
||
const faceVw = box.width * scaleX;
|
||
const faceVh = box.height * scaleY;
|
||
const ov = document.getElementById('faceScanOverlay');
|
||
if (ov) {
|
||
ov.style.display = 'block';
|
||
// Center overlay on face, flipped X to match mirror
|
||
const ovX = window.innerWidth - faceVx - faceVw / 2 - 30;
|
||
const ovY = faceVy + faceVh / 2 - 30;
|
||
ov.style.left = Math.max(0, Math.min(window.innerWidth - 60, ovX)) + 'px';
|
||
ov.style.top = Math.max(0, Math.min(window.innerHeight - 80, ovY)) + 'px';
|
||
}
|
||
}
|
||
|
||
function clearFaceTarget() {
|
||
_faceVisible = false;
|
||
const ov = document.getElementById('faceScanOverlay');
|
||
if (ov) ov.style.display = 'none';
|
||
}
|
||
|
||
function startFaceTracking() {
|
||
const logo = document.querySelector('.tb-logo');
|
||
if (logo) logo.classList.add('face-tracking');
|
||
if (!_faceTrackRaf) _faceTrackLoop();
|
||
}
|
||
|
||
function stopFaceTracking() {
|
||
clearFaceTarget();
|
||
const logo = document.querySelector('.tb-logo');
|
||
if (logo) { logo.classList.remove('face-tracking'); logo.style.transform = ''; }
|
||
// Let the loop coast to zero naturally rather than snapping — cancel after settling
|
||
setTimeout(() => {
|
||
if (!_faceVisible && _faceTrackRaf) {
|
||
cancelAnimationFrame(_faceTrackRaf);
|
||
_faceTrackRaf = null;
|
||
}
|
||
}, 1500);
|
||
}
|
||
|
||
// ── GLITCH EFFECT ─────────────────────────────────────────────────────
|
||
(function initGlitch() {
|
||
function triggerGlitch() {
|
||
const el = document.querySelector('.tb-logo-text');
|
||
if (!el) return;
|
||
el.classList.add('glitching');
|
||
setTimeout(() => el.classList.remove('glitching'), 280);
|
||
setTimeout(triggerGlitch, 35000 + Math.random() * 25000);
|
||
}
|
||
setTimeout(triggerGlitch, 20000);
|
||
})();
|
||
|
||
// ① HUD CORNER RINGS ──────────────────────────────────────────────────
|
||
(function initHudCorners() {
|
||
const canvas = document.getElementById('hudCornersCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
let W, H, t = 0;
|
||
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
|
||
|
||
function drawCorner(cx, cy, a0, a1) {
|
||
const R = 62, R2 = R + 16;
|
||
// Edge lines
|
||
ctx.strokeStyle = 'rgba(0,212,255,0.35)'; ctx.lineWidth = 1;
|
||
const edgeLen = 28;
|
||
// two short lines along the screen edges from the corner point
|
||
const midA = (a0 + a1) / 2;
|
||
const ax = Math.cos(a0), ay = Math.sin(a0);
|
||
const bx = Math.cos(a1), by = Math.sin(a1);
|
||
ctx.beginPath(); ctx.moveTo(cx + ax*(R+6), cy + ay*(R+6)); ctx.lineTo(cx + ax*(R+6+edgeLen), cy + ay*(R+6+edgeLen)); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(cx + bx*(R+6), cy + by*(R+6)); ctx.lineTo(cx + bx*(R+6+edgeLen), cy + by*(R+6+edgeLen)); ctx.stroke();
|
||
|
||
// Primary arc
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, a0, a1);
|
||
ctx.strokeStyle = 'rgba(0,212,255,0.5)'; ctx.lineWidth = 1.2; ctx.stroke();
|
||
// Outer arc
|
||
ctx.beginPath(); ctx.arc(cx, cy, R2, a0 + 0.12, a1 - 0.12);
|
||
ctx.strokeStyle = 'rgba(0,212,255,0.18)'; ctx.lineWidth = 0.6; ctx.stroke();
|
||
|
||
// Tick marks
|
||
const ticks = 14;
|
||
for (let i = 0; i <= ticks; i++) {
|
||
const a = a0 + (a1 - a0) * (i / ticks);
|
||
const big = i % 7 === 0;
|
||
const len = big ? 9 : (i % 2 === 0 ? 5 : 3);
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + Math.cos(a) * (R - 2), cy + Math.sin(a) * (R - 2));
|
||
ctx.lineTo(cx + Math.cos(a) * (R - 2 - len), cy + Math.sin(a) * (R - 2 - len));
|
||
ctx.strokeStyle = big ? 'rgba(0,212,255,0.8)' : 'rgba(0,212,255,0.3)';
|
||
ctx.lineWidth = big ? 1 : 0.5; ctx.stroke();
|
||
}
|
||
|
||
// Animated scanning dot
|
||
const dotA = a0 + ((a1 - a0) * ((t * 0.35) % 1));
|
||
ctx.beginPath(); ctx.arc(cx + Math.cos(dotA) * R, cy + Math.sin(dotA) * R, 2.5, 0, Math.PI*2);
|
||
ctx.fillStyle = 'rgba(0,212,255,1)';
|
||
ctx.shadowColor = 'rgba(0,212,255,0.9)'; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowBlur = 0;
|
||
|
||
// Small numeric labels
|
||
ctx.font = '7px Share Tech Mono,monospace'; ctx.fillStyle = 'rgba(0,212,255,0.55)';
|
||
ctx.fillText(Math.round((Math.abs(a0) / (Math.PI*2)) * 360) + '°',
|
||
cx + Math.cos(a0) * (R + 20), cy + Math.sin(a0) * (R + 20));
|
||
}
|
||
|
||
function draw() {
|
||
ctx.clearRect(0, 0, W, H); t += 0.01;
|
||
drawCorner(0, 0, 0, Math.PI*0.5);
|
||
drawCorner(W, 0, Math.PI*0.5, Math.PI);
|
||
drawCorner(0, H, Math.PI*1.5, Math.PI*2);
|
||
drawCorner(W, H, Math.PI, Math.PI*1.5);
|
||
requestAnimationFrame(draw);
|
||
}
|
||
resize(); draw();
|
||
window.addEventListener('resize', resize);
|
||
})();
|
||
|
||
// ② DATA STREAM COLUMNS ───────────────────────────────────────────────
|
||
(function initDataStream() {
|
||
const canvas = document.getElementById('dataStreamCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
let W, H;
|
||
const CHARS = '0123456789ABCDEF◈◉▸▹⟩⟨⬡░▒';
|
||
const COL_COUNT = 22;
|
||
let cols = [];
|
||
|
||
function resize() {
|
||
W = canvas.width = window.innerWidth;
|
||
H = canvas.height = window.innerHeight;
|
||
cols = [];
|
||
for (let i = 0; i < COL_COUNT; i++) {
|
||
const x = (i / COL_COUNT) * W + Math.random() * (W / COL_COUNT) * 0.6;
|
||
cols.push({ x, y: Math.random() * H, speed: Math.random() * 0.7 + 0.25,
|
||
len: Math.floor(Math.random() * 14 + 5), chars: [],
|
||
alpha: Math.random() * 0.035 + 0.015, tick: 0 });
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
ctx.clearRect(0, 0, W, H);
|
||
ctx.font = '11px Share Tech Mono,monospace';
|
||
for (const c of cols) {
|
||
c.y += c.speed;
|
||
if (c.y - c.len * 14 > H) { c.y = -c.len * 14; c.alpha = Math.random() * 0.035 + 0.015; }
|
||
if (++c.tick > 7) { c.tick = 0; c.chars[0] = CHARS[Math.floor(Math.random() * CHARS.length)]; }
|
||
for (let i = 0; i < c.len; i++) {
|
||
if (!c.chars[i]) c.chars[i] = CHARS[Math.floor(Math.random() * CHARS.length)];
|
||
const cy = c.y - i * 14;
|
||
if (cy < -14 || cy > H + 14) continue;
|
||
const a = c.alpha * (1 - i / c.len);
|
||
ctx.fillStyle = i === 0 ? `rgba(180,240,255,${Math.min(a*4,0.15)})` : `rgba(0,185,225,${a})`;
|
||
ctx.fillText(c.chars[i], c.x, cy);
|
||
}
|
||
}
|
||
requestAnimationFrame(draw);
|
||
}
|
||
resize(); draw();
|
||
window.addEventListener('resize', resize);
|
||
})();
|
||
|
||
// ③ NETWORK TOPOLOGY ──────────────────────────────────────────────────
|
||
let _topoNodes = [], _topoT = 0, _topoRunning = false;
|
||
|
||
function renderTopology(devices) {
|
||
const canvas = document.getElementById('topoCanvas');
|
||
if (!canvas) return;
|
||
const W = canvas.parentElement?.clientWidth || 260;
|
||
canvas.width = W; canvas.height = 118;
|
||
|
||
_topoNodes = devices.slice(0, 18).map((d, i, arr) => {
|
||
const angle = (i / arr.length) * Math.PI * 2 - Math.PI / 2;
|
||
const rx = W * 0.36, ry = 36;
|
||
return {
|
||
x: W/2 + Math.cos(angle) * rx * (0.6 + (i%3)*0.18),
|
||
y: 52 + Math.sin(angle) * ry * (0.65 + (i%2)*0.25),
|
||
label: (d.name || d.ip || '?').split('.')[0].substring(0, 9),
|
||
on: !!(d.alive || d.status === 'online'),
|
||
agent: d.source === 'agent',
|
||
phase: Math.random() * Math.PI * 2,
|
||
};
|
||
});
|
||
|
||
if (!_topoRunning) { _topoRunning = true; _drawTopo(); }
|
||
}
|
||
|
||
function _drawTopo() {
|
||
requestAnimationFrame(_drawTopo);
|
||
const canvas = document.getElementById('topoCanvas');
|
||
if (!canvas || !_topoNodes.length) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const W = canvas.width, H = canvas.height;
|
||
_topoT += 0.018;
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
const floatY = n => Math.sin(_topoT * 0.9 + n.phase) * 2.5;
|
||
|
||
// Connections
|
||
for (let i = 0; i < _topoNodes.length; i++) {
|
||
for (let j = i+1; j < _topoNodes.length; j++) {
|
||
const a = _topoNodes[i], b = _topoNodes[j];
|
||
if (!a.on || !b.on) continue;
|
||
const ax = a.x, ay = a.y + floatY(a), bx = b.x, by = b.y + floatY(b);
|
||
const dist = Math.hypot(bx-ax, by-ay);
|
||
if (dist > W * 0.55) continue;
|
||
const lg = ctx.createLinearGradient(ax, ay, bx, by);
|
||
const col = a.agent ? '0,255,136' : '0,212,255';
|
||
lg.addColorStop(0, `rgba(${col},0.25)`); lg.addColorStop(0.5, `rgba(${col},0.1)`); lg.addColorStop(1, `rgba(${col},0.25)`);
|
||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
|
||
ctx.strokeStyle = lg; ctx.lineWidth = 0.8; ctx.stroke();
|
||
// travelling pulse
|
||
const p = (_topoT * 0.4 + a.phase) % 1;
|
||
ctx.beginPath(); ctx.arc(ax+(bx-ax)*p, ay+(by-ay)*p, 1.8, 0, Math.PI*2);
|
||
ctx.fillStyle = 'rgba(0,212,255,0.85)'; ctx.fill();
|
||
}
|
||
}
|
||
|
||
// Bubble nodes
|
||
for (const n of _topoNodes) {
|
||
const pulse = 0.5 + Math.sin(_topoT * 1.4 + n.phase) * 0.3;
|
||
const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80';
|
||
const r = n.agent ? 10 : 7;
|
||
const nx = n.x, ny = n.y + floatY(n);
|
||
|
||
if (n.on) {
|
||
// Ambient bloom
|
||
const bloom = ctx.createRadialGradient(nx, ny, r*0.4, nx, ny, r*3);
|
||
bloom.addColorStop(0, `rgba(${col},${(pulse*0.18).toFixed(3)})`);
|
||
bloom.addColorStop(1, `rgba(${col},0)`);
|
||
ctx.beginPath(); ctx.arc(nx, ny, r*3, 0, Math.PI*2); ctx.fillStyle = bloom; ctx.fill();
|
||
}
|
||
|
||
// Frosted glass fill
|
||
const fg = ctx.createRadialGradient(nx, ny - r*0.3, 0, nx, ny, r);
|
||
const fa = n.on ? 0.18 + pulse*0.1 : 0.07;
|
||
fg.addColorStop(0, `rgba(${col},${(fa*1.8).toFixed(3)})`);
|
||
fg.addColorStop(0.65, `rgba(${col},${fa.toFixed(3)})`);
|
||
fg.addColorStop(1, `rgba(${col},${(fa*0.2).toFixed(3)})`);
|
||
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.fillStyle = fg; ctx.fill();
|
||
|
||
// Border
|
||
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2);
|
||
ctx.strokeStyle = `rgba(${col},${n.on ? (0.5 + pulse*0.32).toFixed(3) : '0.2'})`;
|
||
ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
// Label below bubble
|
||
ctx.font = '6px Share Tech Mono,monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = n.on ? `rgba(${col},0.82)` : 'rgba(255,100,100,0.5)';
|
||
ctx.fillText(n.label, nx, ny + r + 7);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
|
||
// ④ EKG HEARTBEAT ────────────────────────────────────────────────────
|
||
(function initEKG() {
|
||
const canvas = document.getElementById('ekgCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
let W, H, data = [], phase = 0;
|
||
|
||
function resize() {
|
||
const wrap = document.getElementById('ekgWrap');
|
||
W = canvas.width = wrap ? Math.max(100, wrap.clientWidth) : 180;
|
||
H = canvas.height = 22;
|
||
data = new Array(W).fill(0);
|
||
}
|
||
|
||
function ekgVal(p) {
|
||
const c = p % 1;
|
||
if (c < 0.28) return 0;
|
||
if (c < 0.38) return Math.sin((c-0.28)/0.10*Math.PI) * 0.22; // P bump
|
||
if (c < 0.43) return 0; // PR flat
|
||
if (c < 0.45) return -(c-0.43)/0.02 * 0.18; // Q dip
|
||
if (c < 0.465){ const f=(c-0.45)/0.015; return f<0.5?f*2*0.95:(2-f*2)*0.95; } // R spike
|
||
if (c < 0.49) return -(c-0.465)/0.025 * 0.22; // S dip
|
||
if (c < 0.52) return -(0.22-(c-0.49)/0.03*0.22); // back to baseline
|
||
if (c < 0.70) return Math.sin((c-0.52)/0.18*Math.PI) * 0.28; // T wave
|
||
return 0;
|
||
}
|
||
|
||
function draw() {
|
||
phase += 0.0038;
|
||
data.shift(); data.push(ekgVal(phase));
|
||
ctx.clearRect(0, 0, W, H);
|
||
// Glow layer
|
||
ctx.beginPath();
|
||
data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); });
|
||
ctx.strokeStyle='rgba(0,220,100,0.2)'; ctx.lineWidth=4; ctx.stroke();
|
||
// Line
|
||
ctx.beginPath();
|
||
data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); });
|
||
ctx.strokeStyle='rgba(0,255,120,0.85)'; ctx.lineWidth=1.2; ctx.stroke();
|
||
requestAnimationFrame(draw);
|
||
}
|
||
resize(); draw();
|
||
window.addEventListener('resize', resize);
|
||
})();
|
||
|
||
// ⑤ AUDIO WAVEFORM RING ───────────────────────────────────────────────
|
||
(function initAudioRing() {
|
||
const canvas = document.getElementById('audioRingCanvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const CX = 30, CY = 30, BARS = 28, INNER = 20;
|
||
|
||
let t = 0;
|
||
function draw() {
|
||
t += 0.055;
|
||
ctx.clearRect(0, 0, 60, 60);
|
||
const listening = window.isListening;
|
||
const speaking = window.isSpeaking;
|
||
if (!listening && !speaking) { requestAnimationFrame(draw); return; }
|
||
|
||
for (let i = 0; i < BARS; i++) {
|
||
const angle = (i / BARS) * Math.PI * 2;
|
||
let amp;
|
||
if (speaking) {
|
||
amp = 0.35 + Math.abs(Math.sin(t*4.1+i*0.9))*0.5 + Math.abs(Math.sin(t*9+i*1.7))*0.15;
|
||
} else {
|
||
amp = 0.08 + Math.abs(Math.sin(t*1.1+i*0.6))*0.18;
|
||
}
|
||
const barLen = 3 + amp * 11;
|
||
ctx.beginPath();
|
||
ctx.moveTo(CX+Math.cos(angle)*INNER, CY+Math.sin(angle)*INNER);
|
||
ctx.lineTo(CX+Math.cos(angle)*(INNER+barLen), CY+Math.sin(angle)*(INNER+barLen));
|
||
const alpha = speaking ? 0.55+amp*0.45 : 0.25+amp*0.35;
|
||
ctx.strokeStyle = speaking ? `rgba(0,255,175,${alpha})` : `rgba(0,212,255,${alpha})`;
|
||
ctx.lineWidth = 1.8; ctx.stroke();
|
||
}
|
||
requestAnimationFrame(draw);
|
||
}
|
||
draw();
|
||
})();
|
||
|
||
// ⑥ STATIC NOISE BURSTS ───────────────────────────────────────────────
|
||
(function initStaticBursts() {
|
||
function burst() {
|
||
const panels = document.querySelectorAll('#app .panel');
|
||
if (panels.length) {
|
||
const p = panels[Math.floor(Math.random() * panels.length)];
|
||
const n = document.createElement('div');
|
||
n.className = 'panel-noise-layer';
|
||
p.appendChild(n);
|
||
setTimeout(() => n.remove(), 320);
|
||
}
|
||
setTimeout(burst, 75000 + Math.random() * 55000);
|
||
}
|
||
setTimeout(burst, 40000 + Math.random() * 30000);
|
||
})();
|
||
|
||
// ⑦ AMBIENT COLOR CYCLE ───────────────────────────────────────────────
|
||
(function initAmbientColor() {
|
||
let t = 0;
|
||
const root = document.documentElement;
|
||
function tick() {
|
||
t += 0.00025;
|
||
const g = Math.round(210 + Math.sin(t) * 22);
|
||
const b = Math.round(248 + Math.cos(t * 1.15) * 28);
|
||
root.style.setProperty('--cyan', `rgb(0,${g},${b})`);
|
||
root.style.setProperty('--cyan2', `rgb(0,${Math.round(g*.82)},${Math.round(b*.87)})`);
|
||
requestAnimationFrame(tick);
|
||
}
|
||
tick();
|
||
})();
|
||
|
||
|
||
// ── SLEEP MODE ────────────────────────────────────────────────────────────────
|
||
var isAsleep = false;
|
||
var _sleepRefreshTimer = null;
|
||
|
||
var SLEEP_CMDS = /\b(good\s*night(\s*jarvis)?|go\s*to\s*sleep|sleep\s*mode|shut\s*(down|off)\s*(jarvis|for\s*the\s*night)|go\s*offline|going\s*offline|jarvis\s*(go\s*)?(offline|sleep|shutdown)|stand\s*by\s*mode|power\s*down(\s*jarvis)?|signing\s*off)\b/i;
|
||
|
||
function enterSleepMode() {
|
||
if (isAsleep) return;
|
||
isAsleep = true;
|
||
|
||
// Pause voice mode
|
||
voiceMode = false;
|
||
voiceMuted = false;
|
||
updateMicBtn();
|
||
|
||
// Slow or pause the refresh loop — keep mic alive for wake word
|
||
clearInterval(refreshTimer);
|
||
refreshTimer = null;
|
||
// Light polling every 2 min just to stay alive
|
||
_sleepRefreshTimer = setInterval(function() {
|
||
// heartbeat only — keep session alive without hammering APIs
|
||
try { fetch('/api/auth', {method:'GET', headers:{'Authorization':'Bearer '+sessionToken}}); } catch(e) {}
|
||
}, 120000);
|
||
|
||
// Dim the UI
|
||
var app = document.getElementById('app');
|
||
if (app) app.classList.add('sleeping');
|
||
|
||
// Flash title to confirm
|
||
document.title = 'JARVIS — STANDBY';
|
||
|
||
addMessage('jarvis', 'Understood. Going offline. Say "wake up JARVIS" when you need me.');
|
||
}
|
||
|
||
function wakeFromSleep() {
|
||
if (!isAsleep) return;
|
||
isAsleep = false;
|
||
|
||
// Restore full polling
|
||
clearInterval(_sleepRefreshTimer);
|
||
_sleepRefreshTimer = null;
|
||
refreshAll();
|
||
refreshTimer = setInterval(refreshAll, 10000);
|
||
|
||
// Remove dim overlay
|
||
var app = document.getElementById('app');
|
||
if (app) app.classList.remove('sleeping');
|
||
document.title = 'JARVIS — Integrated Defense and Logistics System';
|
||
|
||
// Boot sequence
|
||
var topBar=document.getElementById('topBar'), lp=document.getElementById('leftPanel');
|
||
var rp=document.getElementById('rightPanel'), cp=document.getElementById('centerPanel');
|
||
[topBar,lp,rp,cp].forEach(function(el){if(el){el.style.opacity='0';}});
|
||
requestAnimationFrame(function(){
|
||
setTimeout(function(){if(topBar){topBar.style.opacity='';topBar.classList.add('boot-top');}},0);
|
||
setTimeout(function(){if(lp){lp.style.opacity='';lp.classList.add('boot-left');}},140);
|
||
setTimeout(function(){if(rp){rp.style.opacity='';rp.classList.add('boot-right');}},200);
|
||
setTimeout(function(){if(cp){cp.style.opacity='';cp.classList.add('boot-center');}},260);
|
||
setTimeout(function(){[topBar,lp,rp,cp].forEach(function(el){if(el)el.classList.remove('boot-top','boot-left','boot-right','boot-center');});},1400);
|
||
});
|
||
|
||
// Enter voice mode and greet
|
||
enterVoiceMode('wake');
|
||
}
|
||
|
||
function _focusWindow() {
|
||
// Attempt to bring browser window to front
|
||
try { window.focus(); } catch(e) {}
|
||
|
||
// Flash title to grab attention if tab is backgrounded
|
||
var _origTitle = 'JARVIS — Integrated Defense and Logistics System';
|
||
var _flashCount = 0;
|
||
var _titleFlash = setInterval(function() {
|
||
document.title = _flashCount % 2 === 0 ? '⚡ JARVIS — ONLINE' : _origTitle;
|
||
if (++_flashCount >= 8) { clearInterval(_titleFlash); document.title = _origTitle; }
|
||
}, 400);
|
||
|
||
// Notify if already have permission — never request during voice activity
|
||
if ('Notification' in window && Notification.permission === 'granted') {
|
||
try { new Notification('JARVIS', { body: 'Wake word detected.', tag: 'jarvis-wake', requireInteraction: false }); } catch(e) {}
|
||
}
|
||
}
|
||
|
||
// ── NETWORK MAP ──────────────────────────────────────────────────────────────
|
||
var _nmNodes=[], _nmEdges=[], _nmParticles=[], _nmRaf=null, _nmT=0, _nmHoverNode=null;
|
||
var _nmRot=[0,0,0,0,0,0];
|
||
var NM_RINGS=[
|
||
{name:'hub', label:'', rFrac:0, speed:0, rgb:'0,212,255', nodeR:30, cap:1 },
|
||
{name:'proxmox', label:'PROXMOX', rFrac:0.16, speed:0.006, rgb:'0,255,136', nodeR:22, cap:4 },
|
||
{name:'services',label:'SERVICES', rFrac:0.30, speed:-0.004, rgb:'255,215,0', nodeR:20, cap:7 },
|
||
{name:'agents', label:'AGENTS', rFrac:0.45, speed:0.0025, rgb:'0,190,255', nodeR:17, cap:12 },
|
||
{name:'devices', label:'DEVICES', rFrac:0.62, speed:-0.002, rgb:'0,160,200', nodeR:14, cap:14 },
|
||
{name:'network', label:'NETWORK', rFrac:0.82, speed:0.0015, rgb:'0,110,170', nodeR:11, cap:28 },
|
||
];
|
||
var NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network(\s*(map|topology|viz|visual|graph|panel|status|scan|view|overlay))?|topology|node\s*map)\b|\bnetwork\s*(map|topology|viz|visual|graph)\b|\b(show|open|display|launch|pull\s*up|bring\s*up)\s+(?:me\s+)?(?:the\s+)?network\b/i;
|
||
var NM_CLOSE_RE = /\b(close|hide|dismiss|exit|collapse)\b.*\b(network|map|topology|overlay)\b|\b(close|hide|dismiss)\s*map\b/i;
|
||
|
||
function _nmClassify(d){
|
||
var h=(d.name||'').toLowerCase();
|
||
if(d.source==='agent'){
|
||
if(d.agent_type==='proxmox'||h.indexOf('pve')>=0||h.indexOf('proxmox')>=0) return 'proxmox';
|
||
if(d.agent_type==='homeassistant'||h.indexOf('homeassist')>=0||h.indexOf('_ha')>=0||
|
||
h.indexOf('ollama')>=0||h.indexOf('ai')>=0||h.indexOf('fusion')>=0||h.indexOf('pbx')>=0||
|
||
h.indexOf('jellyfin')>=0||h.indexOf('homebridge')>=0) return 'services';
|
||
return 'agents';
|
||
}
|
||
// Named/pinned DB devices and static hosts get the inner device ring
|
||
if(d.source==='db'||d.source==='static') return 'devices';
|
||
// Netscan-discovered devices go to the outer network ring
|
||
return 'network';
|
||
}
|
||
function _nmRgb(n){
|
||
if(!n.online) return '255,50,80';
|
||
if(n.ringIdx===1) return '0,255,136';
|
||
if(n.ringIdx===2) return '255,215,0';
|
||
if(n.ringIdx===3) return '0,190,255';
|
||
if(n.ringIdx===4) return '0,160,200';
|
||
if(n.ringIdx===5) return '0,110,170';
|
||
return '0,212,255';
|
||
}
|
||
function _nmNodePos(n,W,H){
|
||
if(n.ringIdx===0) return {x:W/2, y:H/2};
|
||
var rd=NM_RINGS[n.ringIdx], rot=_nmRot[n.ringIdx]||0;
|
||
var r=Math.min(W/2,H/2)*rd.rFrac;
|
||
return {x:W/2+Math.cos(n.angle+rot)*r, y:H/2+Math.sin(n.angle+rot)*r};
|
||
}
|
||
|
||
async function openNetMap(){
|
||
var ov=document.getElementById('netMapOverlay'); if(!ov) return;
|
||
ov.classList.remove('nm-closing'); ov.classList.add('nm-open');
|
||
var devices=[];
|
||
try{ var n=await api('network'); devices=n.devices||[]; }catch(e){}
|
||
_nmBuild(devices); _nmDraw();
|
||
}
|
||
function closeNetMap(){
|
||
var ov=document.getElementById('netMapOverlay'); if(!ov) return;
|
||
ov.classList.add('nm-closing');
|
||
setTimeout(function(){ ov.classList.remove('nm-open','nm-closing'); }, 350);
|
||
if(_nmRaf){ cancelAnimationFrame(_nmRaf); _nmRaf=null; }
|
||
}
|
||
function _nmBuild(devices){
|
||
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
|
||
// Hub
|
||
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'165.22.1.228',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
|
||
// Bucket
|
||
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
|
||
for(var i=0;i<devices.length;i++) buckets[_nmClassify(devices[i])].push(devices[i]);
|
||
// Sort netscan devices: online first, then those with meaningful hostnames
|
||
buckets.network.sort(function(a,b){
|
||
var sa=a.alive?1:0, sb=b.alive?1:0;
|
||
if(sb!==sa) return sb-sa;
|
||
var ha=(a.name&&a.name!==a.ip&&a.name.indexOf('10.48')!==0)?1:0;
|
||
var hb=(b.name&&b.name!==b.ip&&b.name.indexOf('10.48')!==0)?1:0;
|
||
return hb-ha;
|
||
});
|
||
var rings=['proxmox','services','agents','devices','network'];
|
||
for(var ri=0;ri<rings.length;ri++){
|
||
var rname=rings[ri], rd=NM_RINGS[ri+1], list=buckets[rname].slice(0,rd.cap);
|
||
for(var j=0;j<list.length;j++){
|
||
var d=list[j];
|
||
var baseA=(ri%2===0?-Math.PI/2:-Math.PI/3);
|
||
var angle=baseA+(j/Math.max(list.length,1))*Math.PI*2;
|
||
_nmNodes.push({
|
||
id:d.agent_id||d.ip||(rname+j),
|
||
label:(d.name||d.ip||'?').replace(/_[a-f0-9]{6,}$/,'').substring(0,11),
|
||
sub:d.ip||'', online:!!(d.alive||d.status==='online'),
|
||
agent:d.source==='agent', ringIdx:ri+1, angle:angle,
|
||
r:rd.nodeR, pulse:Math.random()*Math.PI*2,
|
||
});
|
||
}
|
||
}
|
||
// Edges + particles
|
||
for(var i=1;i<_nmNodes.length;i++){
|
||
var n=_nmNodes[i], str=n.agent?0.9:0.45;
|
||
_nmEdges.push({from:i,to:0,strength:str});
|
||
if(n.online){
|
||
var cnt=n.agent?3:2;
|
||
for(var p=0;p<cnt;p++) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'in',speed:0.002+Math.random()*0.0025,r:1.8+Math.random()*0.9});
|
||
if(n.agent&&Math.random()>0.45) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'out',speed:0.0013+Math.random()*0.0017,r:1.5+Math.random()*0.7});
|
||
}
|
||
}
|
||
// Stats
|
||
var online=_nmNodes.filter(function(n){return n.online;}).length;
|
||
var agt=_nmNodes.filter(function(n){return n.agent;}).length;
|
||
function sg(id){return document.getElementById(id);}
|
||
if(sg('nm-node-count')) sg('nm-node-count').textContent=_nmNodes.length;
|
||
if(sg('nm-online-count')) sg('nm-online-count').textContent=online;
|
||
if(sg('nm-agent-count')) sg('nm-agent-count').textContent=agt;
|
||
}
|
||
function _nmDraw(){
|
||
if(_nmRaf) cancelAnimationFrame(_nmRaf);
|
||
var canvas=document.getElementById('nmCanvas'); if(!canvas) return;
|
||
var ov=document.getElementById('netMapOverlay');
|
||
canvas.width = ov ? ov.clientWidth : 820;
|
||
canvas.height= ov ? ov.clientHeight-48 : 490;
|
||
var W=canvas.width, H=canvas.height, cx=W/2, cy=H/2, minR=Math.min(cx,cy);
|
||
var ctx=canvas.getContext('2d');
|
||
|
||
function frame(){
|
||
if(!document.getElementById('netMapOverlay').classList.contains('nm-open')) return;
|
||
_nmRaf=requestAnimationFrame(frame);
|
||
_nmT+=0.016;
|
||
for(var i=0;i<NM_RINGS.length;i++) _nmRot[i]=(_nmRot[i]||0)+NM_RINGS[i].speed;
|
||
ctx.clearRect(0,0,W,H);
|
||
|
||
// Dot grid
|
||
ctx.fillStyle='rgba(0,180,255,0.05)';
|
||
for(var gx=26;gx<W;gx+=38) for(var gy=26;gy<H;gy+=38){ctx.beginPath();ctx.arc(gx,gy,0.7,0,Math.PI*2);ctx.fill();}
|
||
|
||
// Ring tracks
|
||
for(var ri=1;ri<NM_RINGS.length;ri++){
|
||
var rd=NM_RINGS[ri], r=minR*rd.rFrac;
|
||
var hasOn=false;
|
||
for(var i=0;i<_nmNodes.length;i++) if(_nmNodes[i].ringIdx===ri&&_nmNodes[i].online){hasOn=true;break;}
|
||
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
|
||
ctx.strokeStyle=hasOn?'rgba('+rd.rgb+',0.12)':'rgba(60,60,80,0.08)';
|
||
ctx.lineWidth=0.8; ctx.setLineDash([3,9]); ctx.stroke(); ctx.setLineDash([]);
|
||
// Ticks
|
||
for(var a=0;a<Math.PI*2;a+=Math.PI/6){
|
||
ctx.beginPath();ctx.moveTo(cx+Math.cos(a)*(r-3),cy+Math.sin(a)*(r-3));ctx.lineTo(cx+Math.cos(a)*(r+3),cy+Math.sin(a)*(r+3));
|
||
ctx.strokeStyle='rgba('+rd.rgb+',0.18)';ctx.lineWidth=0.6;ctx.stroke();
|
||
}
|
||
// Label at 3-o'clock
|
||
var zOn=0,zTot=0;
|
||
for(var i=0;i<_nmNodes.length;i++){if(_nmNodes[i].ringIdx===ri){zTot++;if(_nmNodes[i].online)zOn++;}}
|
||
ctx.font='700 8px Share Tech Mono,monospace'; ctx.textAlign='left';
|
||
ctx.fillStyle='rgba('+rd.rgb+',0.4)'; ctx.fillText(rd.label,cx+r+5,cy+2);
|
||
if(zTot>0){ctx.font='6.5px Share Tech Mono,monospace';ctx.fillStyle='rgba('+rd.rgb+',0.28)';ctx.fillText(zOn+'/'+zTot,cx+r+5,cy+11);}
|
||
ctx.textAlign='left';
|
||
}
|
||
|
||
// Compute node positions
|
||
var pos=[];
|
||
for(var i=0;i<_nmNodes.length;i++) pos.push(_nmNodePos(_nmNodes[i],W,H));
|
||
|
||
// Spokes
|
||
for(var ei=0;ei<_nmEdges.length;ei++){
|
||
var e=_nmEdges[ei], pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) continue;
|
||
var n=_nmNodes[e.from], rgb=_nmRgb(n);
|
||
// Apply float offset to spoke endpoints
|
||
var fya=Math.sin(_nmT*0.85+_nmNodes[e.from].pulse)*4;
|
||
var fyb=Math.sin(_nmT*0.85+_nmNodes[e.to].pulse)*4;
|
||
var lg=ctx.createLinearGradient(pa.x,pa.y+fya,pb.x,pb.y+fyb);
|
||
if(n.online){lg.addColorStop(0,'rgba('+rgb+',0.22)');lg.addColorStop(0.5,'rgba('+rgb+',0.08)');lg.addColorStop(1,'rgba(0,212,255,0.15)');}
|
||
else{lg.addColorStop(0,'rgba(80,20,30,0.07)');lg.addColorStop(1,'rgba(80,20,30,0.07)');}
|
||
ctx.beginPath();ctx.moveTo(pa.x,pa.y+fya);ctx.lineTo(pb.x,pb.y+fyb);
|
||
ctx.strokeStyle=lg;ctx.lineWidth=e.strength*1.1;ctx.stroke();
|
||
}
|
||
|
||
// Particles
|
||
for(var pi=0;pi<_nmParticles.length;pi++){
|
||
var p=_nmParticles[pi]; p.t=(p.t+p.speed)%1;
|
||
var e=_nmEdges[p.edge]; if(!e) continue;
|
||
var pa=pos[e.from],pb=pos[e.to]; if(!pa||!pb) continue;
|
||
if(!_nmNodes[e.from].online) continue;
|
||
var t=p.dir==='in'?p.t:1-p.t;
|
||
var px=pa.x+(pb.x-pa.x)*t, py=pa.y+(pb.y-pa.y)*t;
|
||
var fade=Math.min(t*8,(1-t)*8,1);
|
||
ctx.beginPath();ctx.arc(px,py,p.r,0,Math.PI*2);
|
||
if(p.dir==='in'){ctx.fillStyle='rgba(0,210,255,'+(((0.6+Math.sin(_nmT*3+p.t*10)*0.3)*fade).toFixed(3))+')';ctx.shadowColor='rgba(0,200,255,0.7)';}
|
||
else{ctx.fillStyle='rgba(255,130,0,'+(((0.5+Math.sin(_nmT*4+p.t*8)*0.25)*fade).toFixed(3))+')';ctx.shadowColor='rgba(255,110,0,0.6)';}
|
||
ctx.shadowBlur=6;ctx.fill();ctx.shadowBlur=0;
|
||
}
|
||
|
||
// Bubble nodes
|
||
for(var ni=0;ni<_nmNodes.length;ni++){
|
||
var n=_nmNodes[ni], p=pos[ni], rgb=_nmRgb(n);
|
||
var pulse=0.5+Math.sin(_nmT*1.4+n.pulse)*0.3;
|
||
var isHub=n.ringIdx===0, isHov=_nmHoverNode===ni;
|
||
// Float offset — each node drifts on Y with unique phase
|
||
var fy=Math.sin(_nmT*0.85+n.pulse)*4;
|
||
var px=p.x, py=p.y+fy;
|
||
var baseR=n.r*(isHov?1.28:1.0);
|
||
|
||
// Ambient glow bloom
|
||
if(n.online){
|
||
var bloomR=baseR*2.6+Math.sin(_nmT*1.1+n.pulse)*3;
|
||
var bloom=ctx.createRadialGradient(px,py,baseR*0.4,px,py,bloomR);
|
||
bloom.addColorStop(0,'rgba('+rgb+','+(pulse*0.2).toFixed(3)+')');
|
||
bloom.addColorStop(1,'rgba('+rgb+',0)');
|
||
ctx.beginPath();ctx.arc(px,py,bloomR,0,Math.PI*2);ctx.fillStyle=bloom;ctx.fill();
|
||
|
||
// Sonar ping — expanding ring that fades out
|
||
var pingR=baseR+(((_nmT*0.6+n.pulse)%1)*baseR*2.5);
|
||
var pingA=(1-(pingR-baseR)/(baseR*2.5))*0.3;
|
||
ctx.beginPath();ctx.arc(px,py,pingR,0,Math.PI*2);
|
||
ctx.strokeStyle='rgba('+rgb+','+pingA.toFixed(3)+')';
|
||
ctx.lineWidth=0.7;ctx.stroke();
|
||
}
|
||
|
||
// Frosted glass fill
|
||
var fg=ctx.createRadialGradient(px,py-baseR*0.28,0,px,py,baseR);
|
||
var fa=n.online?0.17+pulse*0.1:0.06;
|
||
fg.addColorStop(0,'rgba('+rgb+','+(fa*2.0).toFixed(3)+')');
|
||
fg.addColorStop(0.55,'rgba('+rgb+','+fa.toFixed(3)+')');
|
||
fg.addColorStop(1,'rgba('+rgb+','+(fa*0.15).toFixed(3)+')');
|
||
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=fg;ctx.fill();
|
||
|
||
// Glassy highlight sheen (top-left arc)
|
||
if(n.online){
|
||
var sh=ctx.createRadialGradient(px-baseR*0.3,py-baseR*0.35,0,px,py,baseR);
|
||
sh.addColorStop(0,'rgba(255,255,255,0.12)');sh.addColorStop(0.45,'rgba(255,255,255,0.03)');sh.addColorStop(1,'rgba(255,255,255,0)');
|
||
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=sh;ctx.fill();
|
||
}
|
||
|
||
// Border
|
||
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);
|
||
ctx.strokeStyle='rgba('+rgb+','+(n.online?(0.45+pulse*0.35).toFixed(3):'0.18')+')';
|
||
ctx.lineWidth=isHub?1.8:1.1;ctx.stroke();
|
||
|
||
// Hub crosshairs (softer)
|
||
if(isHub){
|
||
ctx.strokeStyle='rgba('+rgb+',0.15)';ctx.lineWidth=0.6;
|
||
var ext=50;
|
||
var hlines=[[px-ext,py,px-baseR-3,py],[px+baseR+3,py,px+ext,py],[px,py-ext,px,py-baseR-3],[px,py+baseR+3,px,py+ext]];
|
||
for(var li=0;li<hlines.length;li++){ctx.beginPath();ctx.moveTo(hlines[li][0],hlines[li][1]);ctx.lineTo(hlines[li][2],hlines[li][3]);ctx.stroke();}
|
||
}
|
||
|
||
// Status dot
|
||
if(!isHub){
|
||
ctx.beginPath();ctx.arc(px+baseR*0.62,py-baseR*0.62,2.5,0,Math.PI*2);
|
||
ctx.fillStyle=n.online?'rgba(0,255,120,0.95)':'rgba(255,50,80,0.95)';ctx.fill();
|
||
}
|
||
|
||
// Label — centered below bubble
|
||
var lblY=py+baseR+11;
|
||
ctx.font=(isHub?'700 11':'8')+'px Share Tech Mono,monospace';
|
||
ctx.textAlign='center';
|
||
ctx.fillStyle=n.online?'rgba('+rgb+',0.95)':'rgba(220,90,90,0.7)';
|
||
ctx.fillText(n.label,px,lblY);
|
||
if(n.sub&&(isHub||isHov)){
|
||
ctx.font='6.5px Share Tech Mono,monospace';
|
||
ctx.fillStyle='rgba(140,195,215,0.55)';
|
||
ctx.fillText(n.sub,px,lblY+10);
|
||
}
|
||
ctx.textAlign='left';
|
||
}
|
||
}
|
||
frame();
|
||
|
||
canvas.onmousemove=function(e){
|
||
var rect=canvas.getBoundingClientRect(), mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
||
var found=-1;
|
||
for(var i=0;i<_nmNodes.length;i++){var p=_nmNodePos(_nmNodes[i],W,H);if(Math.sqrt((p.x-mx)*(p.x-mx)+(p.y-my)*(p.y-my))<_nmNodes[i].r+10){found=i;break;}}
|
||
_nmHoverNode=found>=0?found:null;
|
||
var info=document.getElementById('nmNodeInfo'); if(!info) return;
|
||
if(found>=0){
|
||
var n=_nmNodes[found],rgb=_nmRgb(n);
|
||
document.getElementById('ni-name').textContent=n.label; document.getElementById('ni-name').style.color='rgb('+rgb+')';
|
||
document.getElementById('ni-ip').textContent='IP: '+(n.sub||'—');
|
||
document.getElementById('ni-status').textContent='STATUS: '+(n.online?'ONLINE':'OFFLINE');
|
||
document.getElementById('ni-type').textContent='RING: '+(NM_RINGS[n.ringIdx]?NM_RINGS[n.ringIdx].name.toUpperCase():'HUB');
|
||
info.style.display='block'; info.style.left=(mx+14)+'px'; info.style.top=(my-6)+'px';
|
||
} else { info.style.display='none'; }
|
||
};
|
||
canvas.onmouseleave=function(){_nmHoverNode=null;var i=document.getElementById('nmNodeInfo');if(i)i.style.display='none';};
|
||
}
|
||
|
||
// ── GLOBALS ──────────────────────────────────────────────────────────
|
||
let sessionToken = '';
|
||
let sessionUser = '';
|
||
let sessionId = 'session_' + Date.now();
|
||
let isListening = false;
|
||
let recognition = null;
|
||
let synth = window.speechSynthesis;
|
||
let selectedVoice = null;
|
||
let refreshTimer = null;
|
||
let isSpeaking = false;
|
||
let panelsVisible = true;
|
||
let cameraActive = false;
|
||
let faceLoopId = null;
|
||
let lastFaceSeen = 0;
|
||
let autoMicCooldown = 0;
|
||
let faceApiReady = false;
|
||
let lastActivity = Date.now();
|
||
const IDLE_RELOAD_MS = 5 * 60 * 1000; // 5 min inactivity → full reload
|
||
let voiceMode = false; // true = JARVIS awake (listening for commands)
|
||
let voiceMuted = false; // true = awake but mic muted
|
||
let voiceLastCmd = 0;
|
||
const VOICE_SLEEP_MS = 30 * 60 * 1000; // 30 min voice inactivity → sleep
|
||
const VOICE_ACTIVE_MS = 17000; // 17s active window after each command
|
||
let voiceActive = 0; // timestamp of last issued command
|
||
// Phase 1: full phrase required to wake from sleep
|
||
const WAKE_PHRASES = ["wake up jarvis", "daddy's home", "wake up, jarvis", "daddys home"];
|
||
// Phase 2: command prefix — "jarvis <command>"; then 17s free-listen window
|
||
const CMD_PREFIX = 'jarvis';
|
||
const FACE_MODEL_URL = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights';
|
||
|
||
// ── INIT ─────────────────────────────────────────────────────────────
|
||
function initCollapsiblePanels() {
|
||
document.querySelectorAll('.panel').forEach(panel => {
|
||
const title = panel.querySelector('.panel-title');
|
||
if (!title) return;
|
||
const btn = document.createElement('button');
|
||
btn.className = 'panel-collapse-btn';
|
||
btn.textContent = '▾';
|
||
btn.title = 'Collapse / expand';
|
||
title.appendChild(btn);
|
||
const key = 'pnl_' + (title.textContent||'').trim().substring(0,24).replace(/\s+/g,'_').toLowerCase().replace(/[^a-z0-9_]/g,'');
|
||
if (localStorage.getItem(key) === '1') panel.classList.add('collapsed');
|
||
title.addEventListener('click', e => {
|
||
if (e.target.closest('button:not(.panel-collapse-btn),a,input,select')) return;
|
||
const col = panel.classList.toggle('collapsed');
|
||
localStorage.setItem(key, col ? '1' : '0');
|
||
});
|
||
});
|
||
}
|
||
|
||
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) {}
|
||
if (localStorage.getItem('jarvis_panels_swapped') === '1') swapPanels();
|
||
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
|
||
initCollapsiblePanels();
|
||
refreshAll();
|
||
refreshTimer = setInterval(refreshAll, 10000); // every 10s
|
||
setInterval(() => {
|
||
if (!isAsleep && Date.now() - lastActivity > IDLE_RELOAD_MS) {
|
||
sessionStorage.setItem('jarvis_autoreload', '1');
|
||
location.reload();
|
||
}
|
||
}, 30000);
|
||
setInterval(() => {
|
||
if (voiceMode && voiceLastCmd > 0 && Date.now() - voiceLastCmd > VOICE_SLEEP_MS) {
|
||
exitVoiceMode();
|
||
}
|
||
}, 60000);
|
||
// Watchdog: reset isSpeaking if stuck; heartbeat keeps mic alive
|
||
setInterval(() => {
|
||
if (isSpeaking && !_ttsAudio && !window.speechSynthesis?.speaking) {
|
||
isSpeaking = false;
|
||
if (isListening) _scheduleRecStart(200);
|
||
}
|
||
}, 4000);
|
||
// Heartbeat: if mic should be on but recognition has gone quiet, nudge it
|
||
setInterval(() => {
|
||
if (isListening && !isSpeaking) {
|
||
try {
|
||
recognition.start(); // throws if already running — that's fine
|
||
} catch(_) {}
|
||
}
|
||
}, 12000);
|
||
startListening();
|
||
loadNetwork();
|
||
loadHA();
|
||
checkAgentStatus();
|
||
checkArcStatus().catch(() => {});
|
||
loadAgents();
|
||
loadAlerts();
|
||
loadWeather();
|
||
loadNews();
|
||
initMobile();
|
||
setTimeout(checkPlannerReminder, 3000);
|
||
setInterval(checkUpcomingAppts, 300000);
|
||
setTimeout(pollAlertsProactive, 8000);
|
||
setTimeout(checkSuggestions, 15000);
|
||
setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load
|
||
setInterval(pollAlertsProactive, 60000); // poll every 60s
|
||
// Guardian Mode — badge refresh + proactive chat
|
||
setTimeout(() => {
|
||
_refreshGuardianBadge();
|
||
_pollProactiveChat();
|
||
startGuardianPolling();
|
||
setInterval(_pollProactiveChat, 30000);
|
||
}, 5000);
|
||
// Clearance banner — poll every 30s
|
||
setTimeout(() => {
|
||
updateClearanceBanner();
|
||
setInterval(updateClearanceBanner, 30000);
|
||
}, 6000);
|
||
// Memory Core — poll count every 60s
|
||
setTimeout(() => {
|
||
updateMemoryCount();
|
||
setInterval(updateMemoryCount, 60000);
|
||
}, 8000);
|
||
}
|
||
|
||
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 swapPanels() {
|
||
const layout = document.getElementById('mainLayout');
|
||
const btn = document.getElementById('btn-swap-panels');
|
||
const isSwapped = layout.classList.toggle('swapped');
|
||
btn.classList.toggle('active', isSwapped);
|
||
localStorage.setItem('jarvis_panels_swapped', isSwapped ? '1' : '0');
|
||
}
|
||
|
||
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 Arc Reactor status every 6th tick (~60s)
|
||
if (_refreshTick % 6 === 0) {
|
||
checkArcStatus().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) {}
|
||
}
|
||
|
||
// ── PROACTIVE REMINDERS ──────────────────────────────────────────────────────
|
||
let _reminderShown = false;
|
||
async function checkPlannerReminder() {
|
||
if (_reminderShown || sessionStorage.getItem('reminderShown')) return;
|
||
_reminderShown = true;
|
||
sessionStorage.setItem('reminderShown', '1');
|
||
const d = await api('planner/today').catch(() => null);
|
||
if (!d) return;
|
||
const tasks = [...(d.tasks_overdue||[]), ...(d.tasks_today||[])];
|
||
const appts = d.appts_today || [];
|
||
const overdue = d.tasks_overdue?.length || 0;
|
||
if (!tasks.length && !appts.length) return;
|
||
|
||
const parts = [];
|
||
if (overdue) parts.push(overdue + ' overdue task' + (overdue > 1 ? 's' : ''));
|
||
if (tasks.length - overdue > 0) parts.push((tasks.length - overdue) + ' task' + (tasks.length - overdue > 1 ? 's' : '') + ' due today');
|
||
if (appts.length) {
|
||
const nextAppt = appts[0];
|
||
const t = nextAppt.start_at ? new Date(nextAppt.start_at).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}) : '';
|
||
parts.push((t ? 'appointment at ' + t : appts.length + ' appointment' + (appts.length > 1 ? 's' : '') + ' today'));
|
||
}
|
||
|
||
const msg = 'Heads up, ' + (sessionUser||'Sir') + '. You have ' + parts.join(' and ') + '.';
|
||
addMessage('jarvis', msg);
|
||
if (typeof speak === 'function' && isVoiceActive) speak(msg);
|
||
}
|
||
|
||
// Check for upcoming appointments (fires every 5 min after load)
|
||
let _apptAlerted = new Set();
|
||
async function checkUpcomingAppts() {
|
||
const d = await api('planner/today').catch(() => null);
|
||
if (!d) return;
|
||
const now = Date.now();
|
||
for (const a of (d.appts_today||[])) {
|
||
if (!a.start_at || _apptAlerted.has(a.id)) continue;
|
||
const start = new Date(a.start_at).getTime();
|
||
const minsUntil = (start - now) / 60000;
|
||
if (minsUntil > 0 && minsUntil <= 15) {
|
||
_apptAlerted.add(a.id);
|
||
const msg = 'Reminder: ' + a.title + ' starts in ' + Math.round(minsUntil) + ' minutes' + (a.location ? ' at ' + a.location : '') + '.';
|
||
addMessage('jarvis', msg);
|
||
if (typeof speak === 'function' && isVoiceActive) speak(msg);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
setSystemHealth('ok');
|
||
return;
|
||
}
|
||
|
||
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
|
||
tb.className='text-red';
|
||
setAlertState(true);
|
||
const hasCritical = alerts.some(a => a.severity === 'critical');
|
||
setSystemHealth(hasCritical ? 'critical' : 'warning');
|
||
|
||
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();
|
||
}
|
||
|
||
// ── PROACTIVE ALERT POLLING ───────────────────────────────────────────────────
|
||
let _knownAlertIds = null;
|
||
let _spokenAlertIds = new Set();
|
||
|
||
async function pollAlertsProactive() {
|
||
const data = await api('alerts').catch(() => null);
|
||
if (!data) return;
|
||
const alerts = (data.alerts || []);
|
||
|
||
if (_knownAlertIds === null) {
|
||
// First run: baseline existing alerts — do not speak them
|
||
_knownAlertIds = new Set(alerts.map(a => a.id));
|
||
return;
|
||
}
|
||
|
||
for (const a of alerts) {
|
||
if (_knownAlertIds.has(a.id) || _spokenAlertIds.has(a.id)) continue;
|
||
_knownAlertIds.add(a.id);
|
||
_spokenAlertIds.add(a.id);
|
||
|
||
if (a.severity === 'critical' || a.severity === 'warning') {
|
||
const prefix = a.severity === 'critical' ? '🚨' : '⚠';
|
||
addMessage('jarvis', `${prefix} ${a.title}: ${a.message}`);
|
||
const tts = (a.severity === 'critical' ? 'Critical alert. ' : 'Warning. ') + a.title + '. ' + a.message;
|
||
if (typeof speak === 'function' && isVoiceActive) speak(tts);
|
||
}
|
||
}
|
||
|
||
// Remove resolved alerts from known set so they can re-trigger if they come back
|
||
const liveIds = new Set(alerts.map(a => a.id));
|
||
for (const id of _knownAlertIds) {
|
||
if (!liveIds.has(id)) _knownAlertIds.delete(id);
|
||
}
|
||
}
|
||
|
||
// ── WEATHER ───────────────────────────────────────────────────────────
|
||
async function loadWeather() {
|
||
const d = await api('weather');
|
||
if (!d || !d.current) return;
|
||
const c = d.current;
|
||
document.getElementById('weather-temp').textContent = c.temp;
|
||
document.getElementById('weather-desc').textContent = (c.desc || '').toUpperCase();
|
||
document.getElementById('weather-feels').textContent = c.feels + '°F';
|
||
document.getElementById('weather-humidity').textContent = c.humidity + '%';
|
||
document.getElementById('weather-details').textContent =
|
||
'Wind ' + c.wind + ' mph · Cloud ' + c.cloud + '% · Vis ' + c.vis + ' mi';
|
||
|
||
const fc = d.forecast || [];
|
||
document.getElementById('weather-forecast').innerHTML = fc.slice(0, 4).map(day => `
|
||
<div class="forecast-card">
|
||
<div class="fc-day">${day.day}</div>
|
||
<div class="fc-icon" style="font-size:0.55rem;color:var(--cyan);padding:3px 0;line-height:1.3">${day.icon}</div>
|
||
<div class="fc-temps">${day.high}°<span style="color:var(--text-dim)">${day.low}°</span></div>
|
||
<div class="fc-rain">${day.rain_pct > 0 ? day.rain_pct+'%' : ''}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// ── NEWS ──────────────────────────────────────────────────────────────
|
||
function getNewsHidden() {
|
||
try { return JSON.parse(localStorage.getItem('news_hidden_cats') || '[]'); } catch(e) { return []; }
|
||
}
|
||
function setNewsHidden(arr) { localStorage.setItem('news_hidden_cats', JSON.stringify(arr)); }
|
||
|
||
function toggleNewsFilter() {
|
||
const panel = document.getElementById('news-filter-panel');
|
||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
let _newsCats = [];
|
||
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', pinned: '📌 JARVIS PINNED' };
|
||
const hidden = getNewsHidden();
|
||
_newsCats = Object.keys(d.categories);
|
||
|
||
// Build filter checkboxes
|
||
const cbContainer = document.getElementById('news-filter-checkboxes');
|
||
if (cbContainer) {
|
||
cbContainer.innerHTML = _newsCats.map(cat => `
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.6rem;color:var(--text-primary)">
|
||
<input type="checkbox" ${hidden.includes(cat) ? '' : 'checked'} onchange="toggleNewsCat('${cat}',this.checked)"
|
||
style="accent-color:var(--cyan)"/>
|
||
${catLabels[cat] || cat.toUpperCase()}
|
||
</label>`).join('');
|
||
}
|
||
|
||
let html = '';
|
||
for (const [cat, articles] of Object.entries(d.categories)) {
|
||
if (!articles.length || hidden.includes(cat)) 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>`;
|
||
}
|
||
}
|
||
if (!html) html = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">All categories hidden — use ⚙ to show sources</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;
|
||
}
|
||
|
||
function toggleNewsCat(cat, show) {
|
||
const hidden = getNewsHidden();
|
||
if (show) {
|
||
const idx = hidden.indexOf(cat);
|
||
if (idx > -1) hidden.splice(idx, 1);
|
||
} else {
|
||
if (!hidden.includes(cat)) hidden.push(cat);
|
||
}
|
||
setNewsHidden(hidden);
|
||
loadNews();
|
||
}
|
||
|
||
// ── 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 === 'intel') loadIntel();
|
||
if (name === 'comms') { loadComms(); loadCommsOutbox(); }
|
||
if (name === 'guardian') loadGuardian();
|
||
if (name === 'missions') loadMissionsHud();
|
||
if (name === 'directives') loadDirectivesHud();
|
||
if (name === 'clearance') loadClearanceHud();
|
||
if (name === 'alerts') loadAlerts();
|
||
}
|
||
|
||
// ── CHAT ──────────────────────────────────────────────────────────────
|
||
function sourceBadge(source) {
|
||
if (!source) return '';
|
||
let cls, label;
|
||
if (/^intent:|^planner:|^kb:/.test(source)) { cls = 'kb'; label = 'KB'; }
|
||
else if (/^groq:/.test(source)) { cls = 'groq'; label = 'GROQ'; }
|
||
else if (source === 'claude' || /^claude/.test(source)) { cls = 'claude'; label = 'CLAUDE'; }
|
||
else if (/^ollama/.test(source)) { cls = 'ollama'; label = 'LOCAL AI'; }
|
||
else return '';
|
||
const s = document.createElement('div');
|
||
s.style.cssText = 'margin-top:4px;text-align:right';
|
||
s.innerHTML = `<span class="tier-badge ${cls}">${label}</span>`;
|
||
return s;
|
||
}
|
||
|
||
function addMessage(role, text, source=null) {
|
||
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();
|
||
const badge = sourceBadge(source);
|
||
if (badge) div.appendChild(badge);
|
||
}
|
||
};
|
||
setTimeout(type, 0);
|
||
} else {
|
||
div.textContent = text;
|
||
}
|
||
|
||
log.scrollTop = log.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
function showThinking() {
|
||
const log = document.getElementById('chatLog');
|
||
const div = document.createElement('div');
|
||
div.className = 'msg jarvis';
|
||
div.innerHTML = '<div class="thinking"><div class="thinking-dot"></div><div class="thinking-dot"></div><div class="thinking-dot"></div></div>';
|
||
div.id = 'thinking-bubble';
|
||
log.appendChild(div);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
// ── PANEL CONTEXT SELECTION ───────────────────────────────────────────
|
||
function selectContext(key) {
|
||
const ctx = _panelCtx[key];
|
||
if (!ctx) return;
|
||
|
||
// Clear previous active highlight
|
||
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
|
||
|
||
selectedContext = ctx;
|
||
|
||
// Highlight clicked element
|
||
const el = document.querySelector('[data-ctx-key="' + key + '"]');
|
||
if (el) el.classList.add('ctx-active');
|
||
|
||
// Show chip
|
||
const chip = document.getElementById('contextChip');
|
||
const typeLabels = {vm:'VM', network:'DEVICE', alert:'ALERT', news:'NEWS', ha:'HOME'};
|
||
document.getElementById('contextType').textContent = typeLabels[ctx.type] || ctx.type.toUpperCase();
|
||
document.getElementById('contextLabel').textContent = ctx.label;
|
||
chip.classList.add('visible');
|
||
|
||
// Focus input for immediate question
|
||
document.getElementById('textInput').focus();
|
||
}
|
||
|
||
function clearContext() {
|
||
selectedContext = null;
|
||
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
|
||
const chip = document.getElementById('contextChip');
|
||
chip.classList.remove('visible');
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const input = document.getElementById('textInput');
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
|
||
// Local commands — no API round-trip
|
||
var t2 = text.toLowerCase();
|
||
|
||
// Sleep command
|
||
if (SLEEP_CMDS.test(t2)) {
|
||
input.value = '';
|
||
addMessage('user', text);
|
||
enterSleepMode();
|
||
return;
|
||
}
|
||
|
||
if (NM_OPEN_RE.test(t2)) {
|
||
input.value=''; addMessage('user',text);
|
||
addMessage('jarvis','Launching network topology display.');
|
||
speak('Launching network topology display.');
|
||
openNetMap(); return;
|
||
}
|
||
if (NM_CLOSE_RE.test(t2)) {
|
||
input.value=''; addMessage('user',text);
|
||
var isOpen=document.getElementById('netMapOverlay')&&document.getElementById('netMapOverlay').classList.contains('nm-open');
|
||
if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');}
|
||
else addMessage('jarvis','Network map is not currently active.');
|
||
return;
|
||
}
|
||
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, data.source || null);
|
||
speak(data.reply);
|
||
}
|
||
if (data.open_network_map) { openNetMap(); }
|
||
if (data.ui_action === 'focus_mode') { if (panelsVisible) togglePanels(true); }
|
||
if (data.ui_action === 'show_panels') { if (!panelsVisible) togglePanels(true); }
|
||
if (data.arc_job) { onArcJobStarted(data.arc_job, data.source || ''); }
|
||
} catch(e) {
|
||
const bubble = document.getElementById('thinking-bubble');
|
||
if (bubble) bubble.remove();
|
||
addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.');
|
||
}
|
||
}
|
||
|
||
// ── VOICE RECOGNITION ─────────────────────────────────────────────────
|
||
function initVoice() {
|
||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (!SR) {
|
||
if (window.isSecureContext === false) {
|
||
console.warn('Speech Recognition blocked: not a secure context');
|
||
} else {
|
||
console.warn('Speech Recognition not supported in this browser');
|
||
}
|
||
return;
|
||
}
|
||
recognition = new SR();
|
||
recognition.continuous = false; // restart-per-utterance — most reliable in Chrome
|
||
recognition.interimResults = false;
|
||
recognition.lang = 'en-US';
|
||
recognition.maxAlternatives = 1;
|
||
|
||
recognition.onresult = (e) => {
|
||
if (isSpeaking) return;
|
||
const transcript = (e.results[0][0].transcript || '').trim();
|
||
if (!transcript) return;
|
||
const lc = transcript.toLowerCase();
|
||
|
||
// Sleeping: ONLY respond to master wake phrases
|
||
if (isAsleep) {
|
||
if (WAKE_PHRASES.some(p => lc.includes(p))) wakeFromSleep();
|
||
return;
|
||
}
|
||
|
||
if (!voiceMode) {
|
||
if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode();
|
||
} else if (!voiceMuted) {
|
||
voiceLastCmd = Date.now();
|
||
voiceActive = Date.now();
|
||
const cmd = lc.startsWith(CMD_PREFIX)
|
||
? transcript.substring(CMD_PREFIX.length).trim()
|
||
: transcript;
|
||
if (cmd) {
|
||
// Check for sleep command by voice
|
||
if (SLEEP_CMDS.test(cmd)) {
|
||
addMessage('user', transcript);
|
||
enterSleepMode();
|
||
return;
|
||
}
|
||
_showTranscript(cmd);
|
||
document.getElementById('textInput').value = cmd;
|
||
sendMessage();
|
||
}
|
||
}
|
||
};
|
||
|
||
recognition.onend = () => {
|
||
// Restart immediately unless TTS is playing or mic is off
|
||
if (isListening && !isSpeaking) {
|
||
_scheduleRecStart(100);
|
||
}
|
||
};
|
||
|
||
recognition.onerror = (e) => {
|
||
if (e.error === 'not-allowed') {
|
||
isListening = false;
|
||
updateMicBtn();
|
||
addMessage('system', 'Microphone access denied. Please allow microphone permission in your browser, then reload.');
|
||
} else if (e.error === 'audio-capture') {
|
||
isListening = false;
|
||
updateMicBtn();
|
||
addMessage('system', 'No microphone detected. Please connect a microphone and try again.');
|
||
}
|
||
// no-speech / aborted / network: onend will fire and restart
|
||
};
|
||
}
|
||
|
||
function _showTranscript(text) {
|
||
const el = document.getElementById('textInput');
|
||
if (el) { el.placeholder = '▶ ' + text.substring(0, 60); setTimeout(() => { el.placeholder = 'Enter command or speak to JARVIS...'; }, 3000); }
|
||
}
|
||
|
||
function enterVoiceMode(source) {
|
||
voiceMode = true;
|
||
voiceMuted = false;
|
||
voiceLastCmd = Date.now();
|
||
voiceActive = Date.now();
|
||
updateMicBtn();
|
||
// Focus/notify when woken from minimized or sleep
|
||
_focusWindow();
|
||
if (source === 'wake') {
|
||
const g = 'All systems back online, ' + (sessionUser || 'Sir') + '. Good to have you back.';
|
||
addMessage('jarvis', g);
|
||
speak(g);
|
||
} else {
|
||
speak('Yes, ' + (sessionUser || 'Sir') + '?');
|
||
}
|
||
}
|
||
|
||
function exitVoiceMode() {
|
||
voiceMode = false;
|
||
voiceMuted = false;
|
||
updateMicBtn();
|
||
}
|
||
|
||
function updateMicBtn() {
|
||
const btn = document.getElementById('micBtn');
|
||
const icon = document.getElementById('micIcon');
|
||
const wave = document.getElementById('waveform');
|
||
if (!btn) return;
|
||
if (!voiceMode) {
|
||
btn.classList.remove('listening', 'muted');
|
||
btn.title = 'Click to activate, or say: wake up JARVIS / daddy\'s home';
|
||
icon.textContent = '🎤';
|
||
wave.classList.remove('active');
|
||
} else if (voiceMuted) {
|
||
btn.classList.remove('listening');
|
||
btn.classList.add('muted');
|
||
btn.title = 'Muted — click to unmute';
|
||
icon.textContent = '🔇';
|
||
wave.classList.remove('active');
|
||
} else {
|
||
btn.classList.add('listening');
|
||
btn.classList.remove('muted');
|
||
btn.title = 'Listening — click to mute';
|
||
icon.textContent = '🟢';
|
||
wave.classList.add('active');
|
||
}
|
||
}
|
||
|
||
function toggleVoice() {
|
||
if (!voiceMode) {
|
||
enterVoiceMode();
|
||
} else {
|
||
voiceMuted = !voiceMuted;
|
||
if (!voiceMuted) voiceLastCmd = Date.now();
|
||
updateMicBtn();
|
||
}
|
||
}
|
||
|
||
let _recTimer = null;
|
||
function _scheduleRecStart(ms = 100) {
|
||
clearTimeout(_recTimer);
|
||
_recTimer = setTimeout(() => {
|
||
if (isListening && !isSpeaking) {
|
||
try { recognition.start(); } catch(_) {}
|
||
}
|
||
}, ms);
|
||
}
|
||
|
||
function startListening() {
|
||
if (!recognition) {
|
||
if (!window.isSecureContext) {
|
||
addMessage('system', 'Voice recognition requires a trusted HTTPS connection. Please access JARVIS via https://jarvis.orbishosting.com for voice support.');
|
||
} else {
|
||
addMessage('system', 'Voice recognition requires Chrome or Edge browser.');
|
||
}
|
||
return;
|
||
}
|
||
isListening = true;
|
||
_scheduleRecStart(50);
|
||
}
|
||
|
||
function stopListening() {
|
||
isListening = false;
|
||
voiceMode = false;
|
||
voiceMuted = false;
|
||
updateMicBtn();
|
||
clearTimeout(_recTimer);
|
||
try { recognition.abort(); } catch(_) {}
|
||
}
|
||
|
||
// ── SPEECH SYNTHESIS ──────────────────────────────────────────────────
|
||
function loadVoices() {
|
||
const set = () => {
|
||
const voices = synth.getVoices();
|
||
// Priority: Australian male → Australian → British male → British → any English
|
||
selectedVoice =
|
||
voices.find(v => v.name === 'Nathan') // macOS Australian male
|
||
|| voices.find(v => v.name === 'Google Australian English') // Chrome Australian
|
||
|| voices.find(v => v.name === 'Karen') // macOS Australian female
|
||
|| voices.find(v => v.lang === 'en-AU') // any Australian
|
||
|| voices.find(v => v.name === 'Daniel') // macOS British male
|
||
|| voices.find(v => v.name === 'Google UK English Male') // Chrome British male
|
||
|| voices.find(v => v.lang === 'en-GB') // any British
|
||
|| voices.find(v => v.lang.startsWith('en')) // any English
|
||
|| voices[0]
|
||
|| null;
|
||
};
|
||
set();
|
||
synth.onvoiceschanged = set;
|
||
}
|
||
|
||
let _ttsAudio = null;
|
||
|
||
async function speak(text) {
|
||
if (!text) return;
|
||
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
|
||
synth?.cancel();
|
||
isSpeaking = true;
|
||
// Pause recognition while JARVIS speaks to avoid mic feedback
|
||
try { recognition?.abort(); } catch(_) {}
|
||
const reactor = document.getElementById('arcReactor');
|
||
reactor?.classList.add('speaking');
|
||
const _resumeMic = () => {
|
||
isSpeaking = false;
|
||
reactor?.classList.remove('speaking');
|
||
// onend will fire from the abort we did before TTS, and restart cleanly
|
||
if (isListening) _scheduleRecStart(900);
|
||
};
|
||
try {
|
||
const res = await fetch('/api/tts', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json','X-Session-Token': sessionToken},
|
||
body: JSON.stringify({text: text.substring(0, 400)}),
|
||
});
|
||
if (!res.ok) throw new Error('tts');
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
_ttsAudio = new Audio(url);
|
||
_ttsAudio.onended = () => { URL.revokeObjectURL(url); _ttsAudio = null; _resumeMic(); };
|
||
_ttsAudio.onerror = () => { _ttsAudio = null; _resumeMic(); };
|
||
await _ttsAudio.play();
|
||
} catch(e) {
|
||
_resumeMic();
|
||
_speakFallback(text);
|
||
}
|
||
}
|
||
|
||
function _speakFallback(text) {
|
||
if (!synth || !text) return;
|
||
synth.cancel();
|
||
isSpeaking = true;
|
||
const utter = new SpeechSynthesisUtterance(text);
|
||
if (selectedVoice) utter.voice = selectedVoice;
|
||
utter.rate = 0.92; utter.pitch = 0.85; utter.volume = 1;
|
||
const reactor = document.getElementById('arcReactor');
|
||
utter.onstart = () => reactor?.classList.add('speaking');
|
||
utter.onend = () => {
|
||
reactor?.classList.remove('speaking');
|
||
isSpeaking = false;
|
||
if (isListening) _scheduleRecStart(900);
|
||
};
|
||
synth.speak(utter);
|
||
}
|
||
|
||
// ── AGENT DETECTION & BROWSER INSTALL ─────────────────────────────────
|
||
let _agentOnline = false;
|
||
let _myAgent = null;
|
||
|
||
function detectOS() {
|
||
const ua = navigator.userAgent;
|
||
const p = (navigator.platform || '').toLowerCase();
|
||
// Tablets — check before desktop OS (iPads spoof MacIntel)
|
||
if (/iPad|Android/.test(ua) || (p.includes('mac') && navigator.maxTouchPoints > 1)) return 'tablet';
|
||
if (/iPhone/.test(ua)) return 'tablet';
|
||
if (p.includes('win') || ua.includes('Windows')) return 'windows';
|
||
if (p.includes('mac') || ua.includes('Macintosh')) return 'mac';
|
||
if (p.includes('linux') || ua.includes('Linux')) return 'linux';
|
||
return 'unknown';
|
||
}
|
||
|
||
async function checkAgentStatus() {
|
||
const dot = document.getElementById('bb-agent-dot');
|
||
const sta = document.getElementById('bb-agent-status');
|
||
const btn = document.getElementById('agentBtn');
|
||
if (!dot || !sta) return;
|
||
try {
|
||
const data = await api('agent/list');
|
||
const agents = data.agents || [];
|
||
const online = agents.filter(a => a.status === 'online');
|
||
dot.className = 'bb-dot ' + (online.length > 0 ? 'online' : 'offline');
|
||
sta.textContent = online.length > 0 ? online.length + ' ONLINE' : 'NONE';
|
||
const cnt = document.getElementById('net-agent-count');
|
||
if (cnt) cnt.textContent = online.length + ' AGENT' + (online.length !== 1 ? 'S' : '') + ' ONLINE';
|
||
const myIp = data.my_ip || '';
|
||
// Match by exact IP first, then by same /24 subnet (handles NAT behind same router)
|
||
const mySubnet = myIp.split('.').slice(0,3).join('.');
|
||
_myAgent = online.find(a => a.ip_address === myIp)
|
||
|| online.find(a => a.ip_address && a.ip_address.startsWith(mySubnet + '.'));
|
||
_agentOnline = !!_myAgent;
|
||
if (btn) {
|
||
const isTablet = detectOS() === 'tablet';
|
||
if (isTablet) {
|
||
btn.title = 'JARVIS Agent — not available for tablets';
|
||
btn.style.opacity = '0.5';
|
||
} else if (_agentOnline) {
|
||
btn.classList.add('agent-online');
|
||
btn.title = 'Agent active: ' + _myAgent.hostname;
|
||
} else {
|
||
btn.classList.remove('agent-online');
|
||
btn.title = 'Click to install JARVIS Agent on this machine';
|
||
}
|
||
}
|
||
// Also refresh the AGENTS tab if it's visible
|
||
if (document.getElementById('tab-agents').classList.contains('active')) {
|
||
renderAgentsTab(agents, data.metrics || {});
|
||
}
|
||
} catch(e) {
|
||
if (dot) dot.className = 'bb-dot offline';
|
||
if (sta) sta.textContent = 'ERROR';
|
||
}
|
||
}
|
||
|
||
// ── ARC REACTOR STATUS ────────────────────────────────────────────────
|
||
let _arcOnline = false;
|
||
let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
|
||
|
||
async function checkArcStatus() {
|
||
const dot = document.getElementById('bb-arc-dot');
|
||
const sta = document.getElementById('bb-arc-status');
|
||
if (!dot || !sta) return;
|
||
try {
|
||
const d = await api('arc?action=status');
|
||
if (d && d.online) {
|
||
_arcOnline = true;
|
||
dot.className = 'bb-dot online';
|
||
const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
|
||
sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
|
||
_arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
|
||
done: d.jobs_done||0, failed: d.jobs_failed||0 };
|
||
} else {
|
||
_arcOnline = false;
|
||
dot.className = 'bb-dot offline';
|
||
sta.textContent = 'OFFLINE';
|
||
}
|
||
} catch(e) {
|
||
_arcOnline = false;
|
||
dot.className = 'bb-dot offline';
|
||
sta.textContent = 'OFFLINE';
|
||
}
|
||
}
|
||
|
||
// Submit a job to the Arc Reactor and return job_id
|
||
async function arcSubmitJob(type, payload, priority) {
|
||
payload = payload || {};
|
||
priority = priority || 5;
|
||
const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
|
||
return d.job_id || null;
|
||
}
|
||
|
||
// Poll a job until done or failed (max 120s), calling onProgress each tick
|
||
async function arcWaitJob(jobId, onProgress) {
|
||
var start = Date.now();
|
||
while (Date.now() - start < 120000) {
|
||
const d = await api('arc?action=job_get&id=' + jobId);
|
||
if (onProgress) onProgress(d);
|
||
if (d.status === 'done') return d;
|
||
if (d.status === 'failed') throw new Error(d.error || 'Job failed');
|
||
await new Promise(function(r){ setTimeout(r, 1500); });
|
||
}
|
||
throw new Error('Arc Reactor job timed out');
|
||
}
|
||
|
||
|
||
// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
|
||
let _intelPollTimer = null;
|
||
let _intelActiveJobs = new Set();
|
||
let _intelLastLoad = 0;
|
||
|
||
async function loadIntel() {
|
||
const el = document.getElementById('intel-list');
|
||
if (!el) return;
|
||
_intelLastLoad = Date.now();
|
||
|
||
try {
|
||
// Fetch recent research + tool_loop jobs
|
||
const [resJobs, toolJobs] = await Promise.all([
|
||
api('arc?action=jobs&status=&limit=20').catch(() => []),
|
||
Promise.resolve([]),
|
||
]);
|
||
const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
|
||
|
||
if (!jobs.length) {
|
||
el.innerHTML = '<div class="intel-empty">◈ NO INTEL JOBS<br><span style="opacity:0.5">Say "research [topic]" to activate</span></div>';
|
||
stopIntelPolling();
|
||
return;
|
||
}
|
||
|
||
// Check for active jobs
|
||
const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running');
|
||
if (hasActive) startIntelPolling(); else stopIntelPolling();
|
||
|
||
let html = '<button class="intel-new-btn" onclick="intelPrompt()">⚡ NEW RESEARCH</button>';
|
||
for (const job of jobs) {
|
||
const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running';
|
||
const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running';
|
||
const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase();
|
||
const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM';
|
||
|
||
// Get result details if done
|
||
let bodyHtml = '';
|
||
if (job.status === 'done' && job.result) {
|
||
let r = job.result;
|
||
if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} }
|
||
if (typeof r === 'object') {
|
||
const synthesis = (r.synthesis || r.result || r.response || '').trim();
|
||
const sources = r.sources || [];
|
||
const query = r.query || r.task || '';
|
||
const provider = r.provider || '';
|
||
|
||
bodyHtml = `<div class="intel-card-body">`;
|
||
if (provider) bodyHtml += `<div style="font-size:0.55rem;color:var(--text-dim);margin:6px 0 2px;font-family:var(--font-mono)">PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}</div>`;
|
||
if (synthesis) bodyHtml += `<div class="synthesis">${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}</div>`;
|
||
if (sources.length) {
|
||
bodyHtml += '<div class="intel-sources"><div style="font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin-bottom:4px;font-family:var(--font-display)">SOURCES</div>';
|
||
sources.slice(0,5).forEach((s,i) => {
|
||
const title = escHtml((s.title||s.url||'').substring(0,60));
|
||
const url = escHtml(s.url||'');
|
||
bodyHtml += `<div class="intel-source">${i+1}. <a href="${url}" target="_blank" rel="noopener">${title||url}</a></div>`;
|
||
});
|
||
bodyHtml += '</div>';
|
||
}
|
||
bodyHtml += '</div>';
|
||
}
|
||
} else if (job.status === 'running' || job.status === 'queued') {
|
||
const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...';
|
||
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--text-dim);padding:8px 0;font-family:var(--font-mono)">${typeMsg}</div></div>`;
|
||
} else if (job.status === 'failed' && job.error) {
|
||
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--red);padding:8px 0;font-family:var(--font-mono)">${escHtml(job.error.substring(0,200))}</div></div>`;
|
||
}
|
||
|
||
const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : '';
|
||
const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : '';
|
||
|
||
html += `<div class="intel-card${(isOpen && bodyHtml) ? ' open':''}" id="intel-card-${job.id}">
|
||
<div class="intel-card-head" onclick="toggleIntelCard(${job.id})">
|
||
<span style="font-size:0.55rem;color:var(--text-dim);font-family:var(--font-mono);flex-shrink:0">${typeLabel}</span>
|
||
<span class="intel-card-query">#${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))}</span>
|
||
<span style="font-size:0.55rem;color:var(--text-dim);flex-shrink:0;font-family:var(--font-mono)">${ts}</span>
|
||
<span class="intel-card-status ${statusClass}">${statusLabel}</span>
|
||
</div>
|
||
${bodyHtml}
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="intel-empty">INTEL OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleIntelCard(id) {
|
||
const card = document.getElementById('intel-card-' + id);
|
||
if (!card) return;
|
||
if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id);
|
||
else _intelActiveJobs.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
function startIntelPolling() {
|
||
if (_intelPollTimer) return;
|
||
_intelPollTimer = setInterval(() => {
|
||
if (document.getElementById('tab-intel')?.classList.contains('active')) {
|
||
loadIntel();
|
||
}
|
||
}, 4000);
|
||
}
|
||
|
||
function stopIntelPolling() {
|
||
if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; }
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function intelPrompt() {
|
||
const input = document.getElementById('textInput');
|
||
if (input) { input.value = 'research '; input.focus(); }
|
||
}
|
||
|
||
// Called when arc_job is returned from chat response
|
||
function onArcJobStarted(jobId, jobType) {
|
||
const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
|
||
if (commsTypes.includes(jobType)) {
|
||
const commsBtn = document.getElementById('tab-btn-comms');
|
||
if (commsBtn) commsBtn.click();
|
||
startCommsPolling();
|
||
} else {
|
||
_intelActiveJobs.add(jobId);
|
||
const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]');
|
||
if (intelTab) intelTab.click();
|
||
startIntelPolling();
|
||
}
|
||
}
|
||
|
||
// ── COMMS PROTOCOL — email triage HUD ────────────────────────────────────
|
||
let _commsPollTimer = null;
|
||
let _commsFilter = 'priority';
|
||
let _commsOpenCards = new Set();
|
||
|
||
async function loadComms() {
|
||
const el = document.getElementById('comms-list');
|
||
if (!el) return;
|
||
|
||
try {
|
||
const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter);
|
||
const items = Array.isArray(res) ? res : (res.items || []);
|
||
|
||
if (!items.length) {
|
||
el.innerHTML = '<button class="comms-triage-btn" onclick="commsTriageNow()">◈ TRIAGE INBOX NOW</button>'
|
||
+ '<div class="comms-empty">◈ NO TRIAGE DATA<br><span style="opacity:0.5">Say "check my email" to activate</span></div>';
|
||
stopCommsPolling();
|
||
return;
|
||
}
|
||
|
||
const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
|
||
const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'ℹ', promo:'📢', spam:'🗑'};
|
||
|
||
let html = '<div style="display:flex;gap:5px;margin-bottom:5px">';
|
||
html += '<button class="comms-triage-btn" style="flex:3;margin-bottom:0" onclick="commsTriageNow()">◈ TRIAGE INBOX</button>';
|
||
html += '<button class="comms-compose-btn" style="flex:2;margin-bottom:0" onclick="commsShowCompose()">+ COMPOSE</button>';
|
||
html += '</div>';
|
||
html += '<div class="comms-header-bar">';
|
||
for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) {
|
||
html += `<div class="comms-filter-btn${_commsFilter===f?' active':''}" onclick="commsSetFilter('${f}')">${label}</div>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
for (const item of items) {
|
||
const cat = item.category || 'info';
|
||
const icon = catIcons[cat] || '◈';
|
||
const prio = item.priority || 0;
|
||
const isOpen = _commsOpenCards.has(item.id);
|
||
const hasReply = item.draft_reply && item.draft_reply.trim().length > 5;
|
||
|
||
html += `<div class="comms-card${isOpen?' open':''}" id="comms-card-${item.id}">
|
||
<div class="comms-card-head" onclick="toggleCommsCard(${item.id})">
|
||
<span class="comms-card-cat ${cat}">${icon} ${cat.toUpperCase()}</span>
|
||
<span class="comms-card-subject">${escHtml((item.subject||'(no subject)').substring(0,60))}</span>
|
||
<span class="comms-prio">${prio}/10</span>
|
||
</div>
|
||
<div class="comms-card-body">
|
||
<div class="comms-card-from">FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}</div>
|
||
<div class="comms-card-summary">${escHtml(item.summary||'')}</div>
|
||
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft" id="comms-draft-${item.id}">${escHtml(item.draft_reply)}</div>` : ''}
|
||
<div style="display:flex;gap:5px;margin-top:8px">
|
||
${hasReply ? `<button class="comms-send-btn" id="comms-send-${item.id}" onclick="commsSendReply(${item.id})">◈ SEND REPLY</button>` : ''}
|
||
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY</button>` : ''}
|
||
<button onclick="commsDismiss(${item.id})" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">DISMISS</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="comms-empty">COMMS OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleCommsCard(id) {
|
||
const card = document.getElementById('comms-card-' + id);
|
||
if (!card) return;
|
||
if (_commsOpenCards.has(id)) _commsOpenCards.delete(id);
|
||
else _commsOpenCards.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
function commsSetFilter(f) {
|
||
_commsFilter = f;
|
||
loadComms();
|
||
}
|
||
|
||
async function commsDismiss(id) {
|
||
await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {});
|
||
loadComms();
|
||
}
|
||
|
||
async function commsCopyReply(id) {
|
||
const draft = document.querySelector(`#comms-draft-${id}`);
|
||
if (draft) {
|
||
navigator.clipboard.writeText(draft.innerText).catch(() => {});
|
||
const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
|
||
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
|
||
}
|
||
}
|
||
|
||
async function commsSendReply(id) {
|
||
const btn = document.getElementById('comms-send-' + id);
|
||
const draft = document.getElementById('comms-draft-' + id);
|
||
if (!btn || !draft) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '◈ SENDING…';
|
||
try {
|
||
const res = await api('arc', 'POST', {
|
||
action: 'job_create',
|
||
type: 'send_email',
|
||
payload: { triage_id: id, content: draft.innerText },
|
||
priority: 8,
|
||
});
|
||
if (res.job_id) {
|
||
btn.textContent = '◈ SENT ✓';
|
||
btn.style.color = '#00ff88';
|
||
setTimeout(() => loadComms(), 3000);
|
||
loadCommsOutbox();
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.textContent = '◈ SEND REPLY';
|
||
alert('Send failed: ' + (res.error || 'unknown error'));
|
||
}
|
||
} catch(e) {
|
||
btn.disabled = false;
|
||
btn.textContent = '◈ SEND REPLY';
|
||
}
|
||
}
|
||
|
||
function commsShowCompose() {
|
||
const existing = document.getElementById('comms-compose-modal');
|
||
if (existing) existing.remove();
|
||
const modal = document.createElement('div');
|
||
modal.className = 'comms-compose-modal';
|
||
modal.id = 'comms-compose-modal';
|
||
modal.innerHTML = `
|
||
<div class="comms-compose-inner">
|
||
<div class="comms-compose-title">◈ COMPOSE MESSAGE</div>
|
||
<select id="cc-account" class="comms-compose-field" style="cursor:pointer">
|
||
<option value="gmail">Gmail</option>
|
||
<option value="icloud">iCloud</option>
|
||
</select>
|
||
<input id="cc-to" class="comms-compose-field" placeholder="To: email address" type="email">
|
||
<input id="cc-subject" class="comms-compose-field" placeholder="Subject">
|
||
<textarea id="cc-instructions" class="comms-compose-field" rows="4" placeholder="Describe what to say (AI will draft it)"></textarea>
|
||
<div id="cc-preview" style="display:none">
|
||
<div class="comms-draft-label">DRAFTED MESSAGE</div>
|
||
<div class="comms-draft" id="cc-preview-body" style="max-height:200px"></div>
|
||
</div>
|
||
<div class="comms-compose-actions">
|
||
<button class="comms-send-btn" style="flex:1" onclick="commsComposeDraft()">◈ DRAFT</button>
|
||
<button class="comms-send-btn" style="flex:1;display:none" id="cc-send-btn" onclick="commsComposeAndSend()">◈ SEND NOW</button>
|
||
<button onclick="document.getElementById('comms-compose-modal').remove()" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">CANCEL</button>
|
||
</div>
|
||
<div id="cc-status" style="font-family:var(--font-mono);font-size:0.55rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
|
||
}
|
||
|
||
let _ccDraftedBody = '';
|
||
|
||
async function commsComposeDraft() {
|
||
const to = document.getElementById('cc-to')?.value.trim();
|
||
const subject = document.getElementById('cc-subject')?.value.trim();
|
||
const instructions = document.getElementById('cc-instructions')?.value.trim();
|
||
const account = document.getElementById('cc-account')?.value;
|
||
const status = document.getElementById('cc-status');
|
||
if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
|
||
if (status) status.textContent = '◈ DRAFTING…';
|
||
try {
|
||
const res = await api('arc', 'POST', {
|
||
action: 'job_create', type: 'compose_email',
|
||
payload: { recipient: to, subject, instructions, account, auto_send: false },
|
||
priority: 7,
|
||
});
|
||
if (!res.job_id) throw new Error(res.error || 'No job');
|
||
// poll for result
|
||
let attempts = 0;
|
||
const poll = async () => {
|
||
const job = await api('arc?action=job_get&id=' + res.job_id);
|
||
if (job.status === 'done' && job.result?.drafted_body) {
|
||
_ccDraftedBody = job.result.drafted_body;
|
||
document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
|
||
document.getElementById('cc-preview').style.display = 'block';
|
||
document.getElementById('cc-send-btn').style.display = '';
|
||
if (status) status.textContent = '◈ DRAFT READY — Review and send';
|
||
} else if (job.status === 'failed') {
|
||
if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
|
||
} else if (attempts++ < 20) {
|
||
setTimeout(poll, 1500);
|
||
} else {
|
||
if (status) status.textContent = '◈ Job still running — check INTEL tab';
|
||
}
|
||
};
|
||
setTimeout(poll, 1500);
|
||
} catch(e) {
|
||
if (status) status.textContent = '✗ Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function commsComposeAndSend() {
|
||
const to = document.getElementById('cc-to')?.value.trim();
|
||
const subject = document.getElementById('cc-subject')?.value.trim();
|
||
const account = document.getElementById('cc-account')?.value;
|
||
const status = document.getElementById('cc-status');
|
||
const btn = document.getElementById('cc-send-btn');
|
||
if (!to || !_ccDraftedBody) return;
|
||
if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
|
||
if (status) status.textContent = '◈ TRANSMITTING…';
|
||
try {
|
||
const res = await api('arc', 'POST', {
|
||
action: 'job_create', type: 'send_email',
|
||
payload: { to_email: to, subject, body: _ccDraftedBody, account },
|
||
priority: 9,
|
||
});
|
||
if (res.job_id) {
|
||
if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
|
||
setTimeout(() => {
|
||
document.getElementById('comms-compose-modal')?.remove();
|
||
loadCommsOutbox();
|
||
}, 1500);
|
||
} else {
|
||
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||
if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
|
||
}
|
||
} catch(e) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||
if (status) status.textContent = '✗ Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function loadCommsOutbox() {
|
||
const el = document.getElementById('comms-outbox');
|
||
if (!el) return;
|
||
try {
|
||
const data = await api('arc?action=comms_sent&limit=20');
|
||
const sent = Array.isArray(data) ? data : (data.sent || []);
|
||
if (!sent.length) {
|
||
el.innerHTML = '<div class="comms-empty" style="padding:10px">No sent messages yet</div>';
|
||
return;
|
||
}
|
||
const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
|
||
let html = '';
|
||
for (const m of sent) {
|
||
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
|
||
const sc = m.status || 'sent';
|
||
html += `<div class="comms-outbox-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<div class="comms-outbox-to">TO: ${escHtml((m.to_email||'').substring(0,40))}</div>
|
||
<span class="comms-outbox-status ${sc}">${sc.toUpperCase()}</span>
|
||
</div>
|
||
<div class="comms-outbox-subj">${escHtml((m.subject||'(no subject)').substring(0,60))}</div>
|
||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${ts} · ${m.account||'gmail'}</div>
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
} catch(e) {
|
||
el.innerHTML = '<div class="comms-empty" style="padding:10px">OUTBOX OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function commsTriageNow() {
|
||
const input = document.getElementById('textInput');
|
||
if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||
}
|
||
|
||
function startCommsPolling() {
|
||
if (_commsPollTimer) return;
|
||
_commsPollTimer = setInterval(() => {
|
||
if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
|
||
}, 8000);
|
||
}
|
||
|
||
function stopCommsPolling() {
|
||
if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; }
|
||
}
|
||
|
||
// ── GUARDIAN MODE ─────────────────────────────────────────────────────────────
|
||
let _guardianPollTimer = null;
|
||
let _guardianChatTimer = null;
|
||
let _guardianLastChat = '';
|
||
let _guardianUnread = 0;
|
||
|
||
async function loadGuardian() {
|
||
const el = document.getElementById('guardian-list');
|
||
if (!el) return;
|
||
|
||
try {
|
||
const [statusData, eventsData] = await Promise.all([
|
||
api('arc?action=guardian_status').catch(() => ({})),
|
||
api('arc?action=guardian_events&limit=40').catch(() => []),
|
||
]);
|
||
|
||
const events = Array.isArray(eventsData) ? eventsData : [];
|
||
const status = statusData || {};
|
||
const counts = status.counts || {};
|
||
const unread = parseInt(counts.unread || 0);
|
||
const critU = parseInt(counts.critical_unread || 0);
|
||
|
||
_guardianUnread = unread;
|
||
_updateGuardianBadge(unread, critU);
|
||
|
||
const lastScan = status.last_scan
|
||
? new Date(status.last_scan + 'Z').toLocaleTimeString()
|
||
: '—';
|
||
|
||
let html = `<div style="padding:6px 10px 4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--cyan)">◈ GUARDIAN MODE</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.5rem;color:${status.enabled?'var(--green)':'var(--red)'}">
|
||
${status.enabled ? '● ACTIVE' : '○ INACTIVE'}
|
||
</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim)">SCAN: ${lastScan}</span>
|
||
${unread ? `<button onclick="guardianAckAll()" class="guardian-ack-btn" style="margin-left:auto">ACK ALL (${unread})</button>` : '<span style="margin-left:auto"></span>'}
|
||
<button onclick="guardianSitrep()" style="background:rgba(0,212,255,0.08);border:1px solid var(--panel-border);color:var(--cyan);padding:3px 7px;border-radius:3px;font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SITREP</button>
|
||
</div>`;
|
||
|
||
if (!events.length) {
|
||
html += '<div style="text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px">◈ ALL CLEAR<br><span style="opacity:0.5">Guardian is watching...</span></div>';
|
||
} else {
|
||
for (const ev of events) {
|
||
const sev = ev.severity || 'info';
|
||
const acked = ev.acknowledged;
|
||
const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : '';
|
||
const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',
|
||
mem_high:'⚡',disk_high:'💾',service_down:'✗',
|
||
service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈';
|
||
html += `<div class="guardian-event ${sev}${acked?' acked':''}" id="gev-${ev.id}">
|
||
<span class="guardian-sev ${sev}">${sev.toUpperCase()}</span>
|
||
<div style="flex:1">
|
||
<div class="guardian-msg">${typeIco} ${escHtml(ev.message||'')}</div>
|
||
${ev.ai_analysis ? `<div class="guardian-ai">${escHtml(ev.ai_analysis.substring(0,200))}</div>` : ''}
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
|
||
<span class="guardian-time">${ts}</span>
|
||
${!acked ? `<button class="guardian-ack-btn" onclick="guardianAck(${ev.id})">ACK</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
el.innerHTML = html;
|
||
startGuardianPolling();
|
||
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div style="text-align:center;padding:20px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim)">GUARDIAN OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function _updateGuardianBadge(unread, critical) {
|
||
const dot = document.getElementById('bb-guardian-dot');
|
||
const badge = document.getElementById('bb-guardian-badge');
|
||
const status = document.getElementById('bb-guardian-status');
|
||
if (!dot) return;
|
||
dot.className = 'bb-dot';
|
||
if (critical > 0) {
|
||
dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)';
|
||
} else if (unread > 0) {
|
||
dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623';
|
||
} else {
|
||
dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)';
|
||
}
|
||
if (unread > 0) {
|
||
badge.textContent = unread; badge.style.display = 'inline';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function guardianAck(id) {
|
||
await api('arc?action=guardian_ack&id=' + id).catch(() => {});
|
||
const ev = document.getElementById('gev-' + id);
|
||
if (ev) ev.classList.add('acked');
|
||
_guardianUnread = Math.max(0, _guardianUnread - 1);
|
||
_updateGuardianBadge(_guardianUnread, 0);
|
||
}
|
||
|
||
async function guardianAckAll() {
|
||
await api('arc?action=guardian_ack').catch(() => {});
|
||
loadGuardian();
|
||
}
|
||
|
||
function guardianSitrep() {
|
||
const input = document.getElementById('textInput');
|
||
if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||
}
|
||
|
||
function switchGuardianTab() {
|
||
const btn = document.getElementById('tab-btn-guardian');
|
||
if (btn) btn.click();
|
||
}
|
||
|
||
function startGuardianPolling() {
|
||
if (_guardianPollTimer) return;
|
||
_guardianPollTimer = setInterval(() => {
|
||
if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian();
|
||
else _refreshGuardianBadge();
|
||
}, 30000);
|
||
}
|
||
|
||
async function _refreshGuardianBadge() {
|
||
const s = await api('arc?action=guardian_status').catch(() => null);
|
||
if (!s) return;
|
||
const counts = s.counts || {};
|
||
_updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0));
|
||
}
|
||
|
||
// Proactive chat polling — checks for guardian-injected messages every 30s
|
||
let _proactiveChatLastId = 0;
|
||
async function _pollProactiveChat() {
|
||
try {
|
||
const rows = await api('arc?action=guardian_chat').catch(() => []);
|
||
if (!Array.isArray(rows)) return;
|
||
for (const row of rows) {
|
||
if (row.id > _proactiveChatLastId) {
|
||
_proactiveChatLastId = row.id;
|
||
// Don't spam on first load — only show messages from last 5 min
|
||
const age = Date.now() - new Date(row.created_at + 'Z').getTime();
|
||
if (age < 300000) {
|
||
addMessage('jarvis', row.message);
|
||
speak(row.message);
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
|
||
let _missionsOpenCards = new Set();
|
||
|
||
async function loadMissionsHud() {
|
||
const el = document.getElementById('missions-hud');
|
||
if (!el) return;
|
||
try {
|
||
const missions = await api('arc?action=missions');
|
||
const list = Array.isArray(missions) ? missions : [];
|
||
|
||
let html = '<button class="mission-new-btn" onclick="window.open(\'/admin#missions\',\'_blank\')">◈ MANAGE MISSIONS IN ADMIN</button>';
|
||
|
||
if (!list.length) {
|
||
html += '<div class="comms-empty">◈ NO MISSIONS<br><span style="opacity:0.5">Create workflows in Admin → Mission Ops</span></div>';
|
||
el.innerHTML = html;
|
||
return;
|
||
}
|
||
|
||
const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
|
||
for (const m of list) {
|
||
const isOpen = _missionsOpenCards.has(m.id);
|
||
const icon = trigIcons[m.trigger_type] || '◈';
|
||
const enabled = m.enabled;
|
||
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
|
||
html += `<div class="mission-card${isOpen?' open':''}" id="mission-card-${m.id}">
|
||
<div class="mission-card-head" onclick="toggleMissionCard(${m.id})">
|
||
<span style="opacity:${enabled?1:0.35}">${icon}</span>
|
||
<span class="mission-card-name" style="opacity:${enabled?1:0.45}">${escHtml(m.name)}</span>
|
||
<span class="mission-card-trigger">${m.trigger_type.replace('_',' ').toUpperCase()}</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${m.run_count||0} runs</span>
|
||
</div>
|
||
<div class="mission-card-body">
|
||
${m.description ? `<div style="font-size:0.58rem;color:var(--text-dim);margin:6px 0">${escHtml(m.description)}</div>` : ''}
|
||
<div style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);margin:4px 0">Last run: ${lastRun} · ${m.run_count||0} total runs</div>
|
||
<div class="mission-run-bar">
|
||
<button class="mission-run-btn" id="mission-run-btn-${m.id}" onclick="hudRunMission(${m.id})"${!enabled?' disabled title="Mission disabled"':''}>▶ RUN NOW</button>
|
||
</div>
|
||
<div id="mission-run-result-${m.id}" style="font-family:var(--font-mono);font-size:0.52rem;margin-top:6px;min-height:12px"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="comms-empty">MISSIONS OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleMissionCard(id) {
|
||
const card = document.getElementById('mission-card-' + id);
|
||
if (!card) return;
|
||
if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
|
||
else _missionsOpenCards.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
async function hudRunMission(id) {
|
||
const btn = document.getElementById('mission-run-btn-' + id);
|
||
const res = document.getElementById('mission-run-result-' + id);
|
||
if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
|
||
if (res) res.textContent = '';
|
||
try {
|
||
const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
|
||
const s = data.status || 'done';
|
||
const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
|
||
if (res) res.style.color = color;
|
||
if (res) res.textContent = `◈ ${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
|
||
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
|
||
setTimeout(loadMissionsHud, 2000);
|
||
} catch(e) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
|
||
if (res) res.textContent = '✗ Run failed';
|
||
}
|
||
}
|
||
|
||
// ── DIRECTIVES HUD ────────────────────────────────────────────────────────────
|
||
let _dirOpenCards = new Set();
|
||
|
||
async function loadDirectivesHud() {
|
||
const el = document.getElementById('directives-hud');
|
||
if (!el) return;
|
||
try {
|
||
const d = await api('directives/list?status=active');
|
||
const list = (d.directives || []);
|
||
|
||
let html = '<button class="dir-admin-btn" onclick="window.open(\'/admin#directives\',\'_blank\')">◈ MANAGE IN ADMIN</button>';
|
||
|
||
if (!list.length) {
|
||
html += '<div class="comms-empty">◈ NO ACTIVE DIRECTIVES<br><span style="opacity:0.5">Create objectives in Admin → Directives</span></div>';
|
||
el.innerHTML = html;
|
||
return;
|
||
}
|
||
|
||
const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'};
|
||
for (const dir of list) {
|
||
const pct = Math.min(100, Math.round(dir.progress || 0));
|
||
const isOpen = _dirOpenCards.has(dir.id);
|
||
const color = catColors[dir.category] || 'var(--cyan)';
|
||
const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644';
|
||
const daysLeft = dir.target_date
|
||
? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null;
|
||
const dueTxt = daysLeft !== null
|
||
? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`)
|
||
: '';
|
||
const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)';
|
||
|
||
html += `<div class="dir-card${isOpen?' open':''}" id="dir-card-${dir.id}">
|
||
<div class="dir-card-head" onclick="toggleDirCard(${dir.id})">
|
||
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${color};flex-shrink:0">${dir.category.toUpperCase()}</span>
|
||
<span class="dir-card-title" style="color:${color}">${escHtml(dir.title)}</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${fillColor};flex-shrink:0">${pct}%</span>
|
||
${dueTxt ? `<span style="font-family:var(--font-mono);font-size:0.48rem;color:${dueColor};flex-shrink:0">${dueTxt}</span>` : ''}
|
||
</div>
|
||
<div class="dir-card-body">
|
||
<div class="dir-progress-bar"><div class="dir-progress-fill" style="width:${pct}%;background:${fillColor}"></div></div>
|
||
<div style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim);margin-bottom:6px">${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS</div>
|
||
<button onclick="hudDirectiveReview(${dir.id})" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.2);border-radius:3px;padding:3px 8px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ AI REVIEW</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="comms-empty">DIRECTIVES OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleDirCard(id) {
|
||
const card = document.getElementById('dir-card-' + id);
|
||
if (!card) return;
|
||
if (_dirOpenCards.has(id)) _dirOpenCards.delete(id);
|
||
else _dirOpenCards.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
async function hudDirectiveReview(id) {
|
||
const res = await api('arc?action=job_create', 'POST', {
|
||
type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6,
|
||
});
|
||
if (res.job_id) {
|
||
addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`);
|
||
speak(`Directive review underway. I'll brief you on your progress in a moment.`);
|
||
}
|
||
}
|
||
|
||
// ── MEMORY CORE — bottom bar count ────────────────────────────────────────────
|
||
async function updateMemoryCount() {
|
||
try {
|
||
const stats = await api('memory?action=stats');
|
||
const el = document.getElementById('bb-memory-count');
|
||
const dot = document.getElementById('bb-memory-dot');
|
||
if (el && stats) {
|
||
const total = stats.total || 0;
|
||
el.textContent = total + ' FACTS';
|
||
if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
|
||
const _clrOpenCards = new Set();
|
||
|
||
async function updateClearanceBanner() {
|
||
try {
|
||
const pending = await api('arc?action=clearance_pending');
|
||
const list = Array.isArray(pending) ? pending : [];
|
||
const count = list.length;
|
||
const banner = document.getElementById('clearance-banner');
|
||
const badge = document.getElementById('clr-tab-badge');
|
||
const bcount = document.getElementById('clr-banner-count');
|
||
if (banner) {
|
||
if (count > 0) {
|
||
banner.classList.add('active');
|
||
if (bcount) bcount.textContent = count;
|
||
} else {
|
||
banner.classList.remove('active');
|
||
}
|
||
}
|
||
if (badge) {
|
||
if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
|
||
else badge.style.display = 'none';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function loadClearanceHud() {
|
||
const el = document.getElementById('clearance-hud');
|
||
if (!el) return;
|
||
try {
|
||
const [pendingRes, rulesRes, historyRes] = await Promise.all([
|
||
api('arc?action=clearance_pending'),
|
||
api('arc?action=clearance_rules'),
|
||
api('arc?action=clearance_history&limit=20')
|
||
]);
|
||
const pending = Array.isArray(pendingRes) ? pendingRes : [];
|
||
const rules = Array.isArray(rulesRes) ? rulesRes : [];
|
||
const history = Array.isArray(historyRes) ? historyRes : [];
|
||
|
||
let html = '<button class="clr-admin-btn" onclick="window.open(\'/admin#clearance\',\'_blank\')">◈ MANAGE CLEARANCE RULES IN ADMIN</button>';
|
||
|
||
// Pending requests
|
||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:#ff6680;margin:8px 0 4px">PENDING AUTHORIZATION (${pending.length})</div>`;
|
||
if (!pending.length) {
|
||
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">◈ NO PENDING CLEARANCE REQUESTS</div>';
|
||
} else {
|
||
for (const cr of pending) {
|
||
const isOpen = _clrOpenCards.has(cr.id);
|
||
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
|
||
const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
|
||
const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
|
||
html += `<div class="clr-card${isOpen?' open':''}" id="clr-card-${cr.id}">
|
||
<div class="clr-card-head" onclick="toggleClrCard(${cr.id})">
|
||
<span class="clr-card-type">${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
|
||
<span class="clr-card-risk ${cr.risk_level}">${cr.risk_level.toUpperCase()}</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">#${cr.id}</span>
|
||
</div>
|
||
<div class="clr-card-body">
|
||
<div class="clr-card-desc">${escHtml(cr.description || 'No description')}</div>
|
||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:4px">
|
||
Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
|
||
</div>
|
||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:6px;word-break:break-all">
|
||
Payload: ${escHtml(JSON.stringify(pl))}
|
||
</div>
|
||
<div class="clr-action-bar">
|
||
<button class="clr-approve-btn" onclick="hudClearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
|
||
<button class="clr-deny-btn" onclick="hudClearanceDecide(${cr.id},'deny')">✕ DENY</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// Rules
|
||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:12px 0 4px">CLEARANCE RULES</div>`;
|
||
if (!rules.length) {
|
||
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">No rules configured</div>';
|
||
} else {
|
||
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px;margin-bottom:8px">';
|
||
for (const r of rules) {
|
||
const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
|
||
const enLabel = r.enabled ? 'ON' : 'OFF';
|
||
const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
|
||
const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
|
||
html += `<div class="clr-rule-row">
|
||
<span class="clr-rule-type">${r.job_type.replace(/_/g,' ').toUpperCase()}</span>
|
||
<span class="clr-card-risk ${r.risk_level}" style="font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px;border:1px solid">${r.risk_level.toUpperCase()}</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${reqLabel}${autoTxt}</span>
|
||
<button class="clr-rule-toggle ${enClass}" onclick="hudClearanceRuleToggle(${r.id},${r.enabled?0:1})">${enLabel}</button>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
// Recent history
|
||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">RECENT HISTORY</div>`;
|
||
const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
|
||
if (!recentDecided.length) {
|
||
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3)">No history yet</div>';
|
||
} else {
|
||
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px">';
|
||
for (const h of recentDecided) {
|
||
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
|
||
html += `<div class="clr-history-row">
|
||
<span class="clr-status-${h.status}">◈</span>
|
||
<span style="flex:1">${h.job_type.replace(/_/g,' ').toUpperCase()}</span>
|
||
<span class="clr-status-${h.status}">${h.status.toUpperCase()}</span>
|
||
<span style="color:rgba(255,255,255,0.3)">${ts}</span>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
await updateClearanceBanner();
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="comms-empty">CLEARANCE SYSTEM OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleClrCard(id) {
|
||
const card = document.getElementById('clr-card-' + id);
|
||
if (!card) return;
|
||
if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
|
||
else _clrOpenCards.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
async function hudClearanceDecide(id, action) {
|
||
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
|
||
if (!confirm(`${label} clearance request #${id}?`)) return;
|
||
const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
|
||
try {
|
||
const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
|
||
const msg = action === 'approve'
|
||
? `◈ Clearance #${id} authorized. Job dispatched.`
|
||
: `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
|
||
addMessage('jarvis', msg);
|
||
speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
|
||
await loadClearanceHud();
|
||
} catch(e) {
|
||
addMessage('system', 'Clearance action failed.');
|
||
}
|
||
}
|
||
|
||
async function hudClearanceRuleToggle(id, newEnabled) {
|
||
try {
|
||
await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
|
||
await loadClearanceHud();
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function loadAgents() {
|
||
const [listData, metricsData] = await Promise.all([
|
||
api('agent/list'),
|
||
api('agent/status')
|
||
]);
|
||
const agents = listData.agents || [];
|
||
const metrics = metricsData.metrics || {};
|
||
// Fetch sparkline data (non-blocking)
|
||
api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
|
||
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();
|
||
}
|
||
|
||
let _agentSparkData = {};
|
||
function sparkline(points, width=80, height=20, color='var(--cyan)') {
|
||
if (!points || points.length < 2) return '';
|
||
const max = Math.max(...points, 1);
|
||
const min = Math.min(...points);
|
||
const range = max - min || 1;
|
||
const step = width / (points.length - 1);
|
||
const pts = points.map((v, i) => {
|
||
const x = i * step;
|
||
const y = height - ((v - min) / range) * (height - 2) - 1;
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
return `<svg width="${width}" height="${height}" style="overflow:visible;display:block">
|
||
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>
|
||
<circle cx="${((points.length-1)*step).toFixed(1)}" cy="${(height - ((points[points.length-1]-min)/range)*(height-2)-1).toFixed(1)}" r="2" fill="${color}"/>
|
||
</svg>`;
|
||
}
|
||
|
||
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:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px">
|
||
<div>
|
||
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">CPU 2H</div>
|
||
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
|
||
</div>
|
||
<div>
|
||
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">MEM 2H</div>
|
||
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
|
||
</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>
|
||
${alive ? `<div style="display:flex;gap:5px;margin-top:6px">
|
||
<button onclick="event.stopPropagation();agentScreenshot('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SCREENSHOT</button>
|
||
<button onclick="event.stopPropagation();agentSysinfo('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">⚡ SYSINFO</button>
|
||
</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');
|
||
}
|
||
}
|
||
|
||
// ── VISION PROTOCOL — screenshot lightbox ────────────────────────────────────
|
||
function openVisionLightbox(title) {
|
||
const lb = document.getElementById('vision-lightbox');
|
||
document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL';
|
||
document.getElementById('vision-lb-img').style.display = 'none';
|
||
document.getElementById('vision-lb-img').src = '';
|
||
document.getElementById('vision-lb-analysis').textContent = '';
|
||
document.getElementById('vision-lb-spinner').style.display = 'block';
|
||
lb.classList.add('open');
|
||
}
|
||
|
||
function closeVisionLightbox() {
|
||
document.getElementById('vision-lightbox').classList.remove('open');
|
||
}
|
||
|
||
async function agentScreenshot(hostname) {
|
||
openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase());
|
||
const arcRes = await api('arc?action=job_create', 'POST', {
|
||
type: 'screenshot',
|
||
payload: {agent: hostname, analyze: true},
|
||
priority: 8,
|
||
}).catch(() => null);
|
||
|
||
if (!arcRes || !arcRes.job_id) {
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.';
|
||
return;
|
||
}
|
||
|
||
// Poll for result
|
||
const jobId = arcRes.job_id;
|
||
let tries = 0;
|
||
const poll = async () => {
|
||
tries++;
|
||
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
|
||
if (job && job.status === 'done') {
|
||
const r = job.result || {};
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
if (r.has_image && r.screenshot_id) {
|
||
// Fetch full screenshot with image
|
||
const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null);
|
||
if (full && full.image_b64) {
|
||
const img = document.getElementById('vision-lb-img');
|
||
img.src = 'data:image/png;base64,' + full.image_b64;
|
||
img.style.display = 'block';
|
||
}
|
||
}
|
||
document.getElementById('vision-lb-analysis').textContent =
|
||
r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2));
|
||
} else if (job && job.status === 'failed') {
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error');
|
||
} else if (tries < 30) {
|
||
setTimeout(poll, 2000);
|
||
} else {
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.';
|
||
}
|
||
};
|
||
setTimeout(poll, 2000);
|
||
}
|
||
|
||
async function agentSysinfo(hostname) {
|
||
openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase());
|
||
const arcRes = await api('arc?action=job_create', 'POST', {
|
||
type: 'sysinfo',
|
||
payload: {agent: hostname, analyze: true},
|
||
priority: 7,
|
||
}).catch(() => null);
|
||
|
||
if (!arcRes || !arcRes.job_id) {
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.';
|
||
return;
|
||
}
|
||
|
||
const jobId = arcRes.job_id;
|
||
let tries = 0;
|
||
const poll = async () => {
|
||
tries++;
|
||
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
|
||
if (job && job.status === 'done') {
|
||
const r = job.result || {};
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
const snap = r.snapshot || {};
|
||
const snapText = Object.entries(snap)
|
||
.filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k))
|
||
.map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`)
|
||
.join('\n');
|
||
document.getElementById('vision-lb-analysis').textContent =
|
||
(r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2));
|
||
} else if (job && job.status === 'failed') {
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error');
|
||
} else if (tries < 20) {
|
||
setTimeout(poll, 2000);
|
||
} else {
|
||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||
document.getElementById('vision-lb-analysis').textContent = 'Timed out.';
|
||
}
|
||
};
|
||
setTimeout(poll, 2000);
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeVisionLightbox();
|
||
});
|
||
|
||
// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
|
||
function openSearchModal() {
|
||
document.getElementById('searchModal').style.display = 'flex';
|
||
document.getElementById('searchInput').focus();
|
||
}
|
||
function closeSearchModal() {
|
||
document.getElementById('searchModal').style.display = 'none';
|
||
document.getElementById('searchResults').innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>';
|
||
document.getElementById('searchInput').value = '';
|
||
}
|
||
async function runSearch() {
|
||
const q = document.getElementById('searchInput').value.trim();
|
||
if (!q) return;
|
||
const el = document.getElementById('searchResults');
|
||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Searching...</div>';
|
||
try {
|
||
const d = await api('history?q=' + encodeURIComponent(q));
|
||
if (!d.results || !d.results.length) {
|
||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">No results for "' + q + '"</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = d.results.map(r => {
|
||
const role = r.role === 'user' ? '👤' : '🤖';
|
||
const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
|
||
const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
|
||
return `<div style="background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:10px 12px">
|
||
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||
<span style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:var(--cyan)">${role} ${r.role.toUpperCase()}</span>
|
||
<span style="font-size:0.52rem;color:var(--text-dim)">${ts}</span>
|
||
</div>
|
||
<div style="font-size:0.68rem;color:var(--text-primary);line-height:1.4">${snippet.replace(/</g,'<')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
el.innerHTML = '<div style="color:var(--red);font-size:0.65rem;text-align:center;padding:20px">Search failed</div>';
|
||
}
|
||
}
|
||
document.getElementById('searchModal')?.addEventListener('click', e => {
|
||
if (e.target === document.getElementById('searchModal')) closeSearchModal();
|
||
});
|
||
|
||
// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
|
||
const _shownSuggestions = new Set();
|
||
async function checkSuggestions() {
|
||
const d = await api('suggestions').catch(() => null);
|
||
if (!d || !d.suggestions || !d.suggestions.length) return;
|
||
for (const s of d.suggestions) {
|
||
const key = s.intent + ':' + d.hour + ':' + d.dow;
|
||
if (_shownSuggestions.has(key)) continue;
|
||
_shownSuggestions.add(key);
|
||
// Show as a soft suggestion chip in chat
|
||
const log = document.getElementById('chatLog');
|
||
const chip = document.createElement('div');
|
||
chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
|
||
chip.innerHTML = `<button onclick="sendSuggestion('${s.intent}',this)" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.25);border-radius:12px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:1px;padding:4px 12px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.background='rgba(0,212,255,0.12)'" onmouseout="this.style.background='rgba(0,212,255,0.06)'">◈ ${s.prompt}</button>`;
|
||
log.appendChild(chip);
|
||
log.scrollTop = log.scrollHeight;
|
||
break; // show max one suggestion at a time
|
||
}
|
||
}
|
||
|
||
function sendSuggestion(intent, btn) {
|
||
btn.closest('div').remove();
|
||
const prompts = {
|
||
'network_scan': 'run a network scan',
|
||
'jellyfin_now_playing': 'what is playing on Jellyfin',
|
||
'ha_scene': 'what scenes are available',
|
||
'planner:briefing': 'daily briefing',
|
||
'vm_suggestions': 'VM resource suggestions',
|
||
'focus_mode': 'focus mode',
|
||
};
|
||
const msg = prompts[intent] || intent.replace(/_/g,' ');
|
||
document.getElementById('textInput').value = msg;
|
||
sendMessage();
|
||
}
|
||
|
||
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
|
||
function mobSwitch(which) {
|
||
if (window.innerWidth > 900) return;
|
||
const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
|
||
Object.entries(panels).forEach(([k, id]) => {
|
||
document.getElementById(id)?.classList.toggle('mob-active', k === which);
|
||
});
|
||
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('mob-btn-' + which)?.classList.add('active');
|
||
if (which === 'right') loadNews();
|
||
}
|
||
function initMobile() {
|
||
if (window.innerWidth > 900) return;
|
||
['leftPanel','centerPanel','rightPanel'].forEach(id =>
|
||
document.getElementById(id)?.classList.remove('mob-active'));
|
||
document.getElementById('leftPanel')?.classList.add('mob-active');
|
||
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('mob-btn-left')?.classList.add('active');
|
||
}
|
||
window.addEventListener('resize', initMobile);
|
||
|
||
</script>
|
||
|
||
<!-- VISION LIGHTBOX -->
|
||
<div id="vision-lightbox">
|
||
<div id="vision-lb-header">
|
||
<span id="vision-lb-title">◈ VISION PROTOCOL</span>
|
||
<button id="vision-lb-close" onclick="closeVisionLightbox()">✕ CLOSE</button>
|
||
</div>
|
||
<div id="vision-lb-spinner">● SCANNING...</div>
|
||
<img id="vision-lb-img" alt="Agent Screenshot" style="display:none">
|
||
<pre id="vision-lb-analysis"></pre>
|
||
</div>
|
||
|
||
<!-- CHAT HISTORY SEARCH MODAL -->
|
||
<div id="searchModal" style="display:none;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);align-items:center;justify-content:center">
|
||
<div style="background:#000d1f;border:1px solid var(--panel-border);border-radius:6px;padding:20px;width:min(620px,94vw);max-height:80vh;display:flex;flex-direction:column;gap:12px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||
<div style="font-family:var(--font-display);font-size:0.75rem;letter-spacing:3px;color:var(--cyan)">◈ CHAT HISTORY SEARCH</div>
|
||
<button onclick="closeSearchModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1rem">✕</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<input id="searchInput" type="text" placeholder="Search conversations…"
|
||
style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);color:var(--text-primary);font-family:var(--font-mono);font-size:0.75rem;padding:8px 12px;outline:none"
|
||
onkeydown="if(event.key==='Enter')runSearch()" autocomplete="off"/>
|
||
<button onclick="runSearch()" style="background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-display);font-size:0.6rem;letter-spacing:2px;padding:8px 16px;cursor:pointer">SEARCH</button>
|
||
</div>
|
||
<div id="searchResults" style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:6px;min-height:60px">
|
||
<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav id="mobileNav">
|
||
<button class="mob-nav-btn active" id="mob-btn-left" onclick="mobSwitch('left')">
|
||
<span class="mob-icon">📊</span>STATS
|
||
</button>
|
||
<button class="mob-nav-btn" id="mob-btn-center" onclick="mobSwitch('center')">
|
||
<span class="mob-icon">💬</span>CHAT
|
||
</button>
|
||
<button class="mob-nav-btn" id="mob-btn-right" onclick="mobSwitch('right')">
|
||
<span class="mob-icon">🛰</span>INFO
|
||
</button>
|
||
</nav>
|
||
</body>
|
||
</html>
|