mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e4ded7c9 | |||
| c1275d47a6 | |||
| 08fbfaa3e4 | |||
| 1f25b5d04d | |||
| 84cd2ded50 | |||
| 89a82a1573 | |||
| e68bb7d165 | |||
| 2f4b4ef5c3 | |||
| 21e0b81a98 | |||
| 95d49f15cb | |||
| 51b598dd5d | |||
| 6f0459be85 | |||
| a6d4365f16 | |||
| 383de0146c | |||
| aaf9f9d56a | |||
| aa88a2f73b | |||
| f1d73e7b6a | |||
| 572f1b1816 | |||
| 1838e02d56 | |||
| 178040c18b | |||
| 45845a1f61 | |||
| 52ddee3e78 | |||
| ab1aa16ac8 | |||
| 1979c5f667 | |||
| 1b071f4f67 | |||
| 5cbaeda730 | |||
| 5140573be0 | |||
| b7aea1371c | |||
| 49694e76e1 | |||
| 04510ac39f | |||
| 38ab8d2977 | |||
| ca66152f45 | |||
| 0b7f2d013b | |||
| 8085a113d5 | |||
| b85e8dd16f | |||
| 188f6f8f10 | |||
| 7f6397b514 | |||
| dd2f48193b | |||
| 1e57a7c90c |
@@ -0,0 +1,151 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
JARVIS Agent installer for Windows.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs JARVIS Agent as a Windows Service that auto-starts at boot.
|
||||
Requires: PowerShell 5.1+, internet access, and Administrator rights.
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive install (prompts for registration key):
|
||||
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
|
||||
# Silent install with key:
|
||||
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$JARVIS_URL = 'https://jarvis.orbishosting.com'
|
||||
$INSTALL_DIR = 'C:\ProgramData\jarvis-agent'
|
||||
$SERVICE_NAME = 'JARVISAgent'
|
||||
$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py"
|
||||
$CONFIG_FILE = "$INSTALL_DIR\config.json"
|
||||
|
||||
function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan }
|
||||
function Write-OK { param($msg) Write-Host " OK: $msg" -ForegroundColor Green }
|
||||
function Write-Fail { param($msg) Write-Host " ERROR: $msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Yellow
|
||||
Write-Host " JARVIS Agent Installer for Windows" -ForegroundColor Yellow
|
||||
Write-Host "========================================`n" -ForegroundColor Yellow
|
||||
|
||||
# ── Stop existing service if running ─────────────────────────────────────────
|
||||
$existing = Get-Service -Name $SERVICE_NAME -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Step "Stopping existing JARVIS Agent service..."
|
||||
if ($existing.Status -eq 'Running') {
|
||||
Stop-Service -Name $SERVICE_NAME -Force
|
||||
Start-Sleep 2
|
||||
}
|
||||
try {
|
||||
& python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null
|
||||
} catch {}
|
||||
Write-OK "Existing service removed."
|
||||
}
|
||||
|
||||
# ── Check / install Python ────────────────────────────────────────────────────
|
||||
Write-Step "Checking Python..."
|
||||
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $py) {
|
||||
Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run."
|
||||
}
|
||||
winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User")
|
||||
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" }
|
||||
}
|
||||
$pyVersion = & python --version 2>&1
|
||||
Write-OK $pyVersion
|
||||
|
||||
# ── Install pywin32 ───────────────────────────────────────────────────────────
|
||||
Write-Step "Checking pywin32..."
|
||||
$checkWin32 = & python -c "import win32service; print('ok')" 2>&1
|
||||
if ($checkWin32 -ne 'ok') {
|
||||
Write-Host " Installing pywin32..." -ForegroundColor Yellow
|
||||
& python -m pip install --quiet pywin32
|
||||
& python -m pywin32_postinstall -install 2>$null
|
||||
Write-OK "pywin32 installed."
|
||||
} else {
|
||||
Write-OK "pywin32 already installed."
|
||||
}
|
||||
|
||||
# ── Create install dir ────────────────────────────────────────────────────────
|
||||
Write-Step "Creating install directory..."
|
||||
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
|
||||
Write-OK $INSTALL_DIR
|
||||
|
||||
# ── Download agent script ─────────────────────────────────────────────────────
|
||||
Write-Step "Downloading JARVIS agent..."
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing
|
||||
Write-OK "Agent downloaded to $AGENT_SCRIPT"
|
||||
} catch {
|
||||
Write-Fail "Failed to download agent: $_"
|
||||
}
|
||||
|
||||
# ── Get registration key ──────────────────────────────────────────────────────
|
||||
$regKey = $env:JARVIS_REG_KEY
|
||||
if (-not $regKey -and (Test-Path $CONFIG_FILE)) {
|
||||
$existingCfg = Get-Content $CONFIG_FILE | ConvertFrom-Json
|
||||
$regKey = $existingCfg.registration_key
|
||||
if ($regKey) { Write-OK "Using existing registration key from config." }
|
||||
}
|
||||
if (-not $regKey) {
|
||||
$regKey = Read-Host "`n Enter JARVIS registration key"
|
||||
if (-not $regKey) { Write-Fail "Registration key required." }
|
||||
}
|
||||
|
||||
# ── Get hostname ──────────────────────────────────────────────────────────────
|
||||
$hostname = $env:COMPUTERNAME
|
||||
$customHostname = $env:JARVIS_HOSTNAME
|
||||
if ($customHostname) { $hostname = $customHostname }
|
||||
|
||||
# ── Write config ──────────────────────────────────────────────────────────────
|
||||
Write-Step "Writing config..."
|
||||
$cfg = @{
|
||||
jarvis_url = $JARVIS_URL
|
||||
registration_key = $regKey
|
||||
hostname = $hostname
|
||||
agent_type = 'windows'
|
||||
ssl_verify = $true
|
||||
poll_interval = 30
|
||||
heartbeat_every = 10
|
||||
update_check_hours = 24
|
||||
watch_services = @('WinDefend', 'Spooler', 'wuauserv')
|
||||
} | ConvertTo-Json -Depth 5
|
||||
$cfg | Out-File -FilePath $CONFIG_FILE -Encoding utf8
|
||||
Write-OK "Config written to $CONFIG_FILE"
|
||||
|
||||
# ── Install Windows Service ───────────────────────────────────────────────────
|
||||
Write-Step "Installing Windows service..."
|
||||
$pyPath = (Get-Command python).Source
|
||||
& $pyPath "$AGENT_SCRIPT" --startup auto install
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." }
|
||||
Write-OK "Service '$SERVICE_NAME' installed."
|
||||
|
||||
# ── Start service ─────────────────────────────────────────────────────────────
|
||||
Write-Step "Starting service..."
|
||||
Start-Service -Name $SERVICE_NAME
|
||||
Start-Sleep 3
|
||||
$svc = Get-Service -Name $SERVICE_NAME
|
||||
if ($svc.Status -ne 'Running') { Write-Fail "Service failed to start. Check C:\ProgramData\jarvis-agent\jarvis-agent.log" }
|
||||
Write-OK "Service is running."
|
||||
|
||||
# ── Test connectivity ─────────────────────────────────────────────────────────
|
||||
Write-Step "Testing JARVIS connection..."
|
||||
try {
|
||||
$ping = Invoke-RestMethod -Uri "$JARVIS_URL/api/ping" -TimeoutSec 10
|
||||
Write-OK "JARVIS is online: $($ping.codename)"
|
||||
} catch {
|
||||
Write-Host " WARNING: Could not reach JARVIS at $JARVIS_URL - check connectivity." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host " JARVIS Agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host " Hostname: $hostname" -ForegroundColor Green
|
||||
Write-Host " Service: $SERVICE_NAME (auto-start at boot)" -ForegroundColor Green
|
||||
Write-Host " Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Green
|
||||
Write-Host "========================================`n" -ForegroundColor Green
|
||||
@@ -434,7 +434,7 @@ def main():
|
||||
|
||||
try:
|
||||
# Heartbeat + get commands
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify)
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {"version": AGENT_VERSION}, headers, ssl_verify=ssl_verify)
|
||||
if "error" in hb:
|
||||
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS HA Poller — pulls entity states from Home Assistant REST API
|
||||
and pushes them to JARVIS as a homeassistant-type agent.
|
||||
Runs on VM211 as a systemd service (jarvis-ha-poller).
|
||||
|
||||
Config: /etc/jarvis-agent/ha-poller.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json"
|
||||
AGENT_VERSION = "1.0"
|
||||
AGENT_ID = "homeassistant_ha"
|
||||
HOSTNAME = "homeassistant"
|
||||
|
||||
# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean)
|
||||
SKIP_DOMAINS = {
|
||||
'sensor', 'binary_sensor', 'button', 'update', 'select', 'number',
|
||||
'device_tracker', 'event', 'image', 'person', 'zone', 'tts',
|
||||
'conversation', 'assist_satellite', 'input_button', 'media_player',
|
||||
'scene', 'water_heater', 'alarm_control_panel', 'automation',
|
||||
'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification',
|
||||
'tag', 'system_health', 'timer', 'counter',
|
||||
'camera', 'siren', 'remote', 'todo', 'lawn_mower',
|
||||
}
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _ssl_ctx(verify: bool):
|
||||
if not verify:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log(f"HA API error: {e}")
|
||||
return None
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
reg_key = cfg["registration_key"]
|
||||
|
||||
log(f"Registering HA poller with JARVIS at {jarvis_url}...")
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/register",
|
||||
{
|
||||
"hostname": HOSTNAME,
|
||||
"version": AGENT_VERSION,
|
||||
"agent_type": "homeassistant",
|
||||
"ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0],
|
||||
"capabilities": ["ha_entities", "ha_state"],
|
||||
"agent_id": AGENT_ID,
|
||||
},
|
||||
{"X-Registration-Key": reg_key},
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Registration failed: {result['error']}")
|
||||
return ""
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = AGENT_ID
|
||||
save_state(state)
|
||||
log(f"Registered. agent_id={AGENT_ID}")
|
||||
return api_key
|
||||
|
||||
def push_entities(cfg: dict, api_key: str, entities: list) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
|
||||
# Send in batches of 200
|
||||
batch_size = 200
|
||||
total = len(entities)
|
||||
ok = True
|
||||
for i in range(0, total, batch_size):
|
||||
batch = entities[i:i+batch_size]
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/ha_state",
|
||||
{"entities": batch},
|
||||
headers,
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Push batch {i//batch_size+1} failed: {result['error']}")
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def heartbeat(cfg: dict, api_key: str) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/heartbeat",
|
||||
{"version": AGENT_VERSION},
|
||||
{"X-Agent-Key": api_key},
|
||||
ssl_verify,
|
||||
timeout=10,
|
||||
)
|
||||
return "error" not in result
|
||||
|
||||
def fetch_ha_states(cfg: dict) -> list:
|
||||
ha_url = cfg["ha_url"].rstrip("/")
|
||||
token = cfg["ha_token"]
|
||||
states = ha_get(f"{ha_url}/api/states", token)
|
||||
if not states or not isinstance(states, list):
|
||||
return []
|
||||
|
||||
entities = []
|
||||
for s in states:
|
||||
entity_id = s.get("entity_id", "")
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if domain in SKIP_DOMAINS:
|
||||
continue
|
||||
attrs = s.get("attributes", {})
|
||||
# Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime
|
||||
lc = s.get("last_changed", "")
|
||||
try:
|
||||
dt = datetime.fromisoformat(lc.replace("Z", "+00:00"))
|
||||
lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
lc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
entities.append({
|
||||
"entity_id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": s.get("state", ""),
|
||||
"attributes": attrs,
|
||||
"last_changed": lc,
|
||||
})
|
||||
return entities
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
api_key = state.get("api_key", "")
|
||||
while not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
log("Could not register. Retrying in 60s...")
|
||||
time.sleep(60)
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_push = 0
|
||||
log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Heartbeat
|
||||
if not heartbeat(cfg, api_key):
|
||||
log("Heartbeat failed (401?) — re-registering...")
|
||||
state.clear()
|
||||
save_state(state)
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Push entity states every poll_interval
|
||||
if now - last_push >= poll_interval:
|
||||
entities = fetch_ha_states(cfg)
|
||||
if entities:
|
||||
ok = push_entities(cfg, api_key, entities)
|
||||
if ok:
|
||||
log(f"Pushed {len(entities)} HA entities to JARVIS.")
|
||||
last_push = now
|
||||
else:
|
||||
log("No HA entities fetched (HA down or token invalid?)")
|
||||
last_push = now
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# JARVIS Network Scanner — runs on PVE1, pushes nmap results to JARVIS
|
||||
# Cron: */3 * * * * /usr/local/bin/jarvis-netscan.sh >/dev/null 2>&1
|
||||
|
||||
JARVIS_URL="http://10.48.200.211"
|
||||
JARVIS_HOST="jarvis.orbishosting.com"
|
||||
REG_KEY=$(cat /etc/jarvis-agent/reg-key 2>/dev/null)
|
||||
if [ -z "$REG_KEY" ]; then
|
||||
echo "$(date): ERROR: /etc/jarvis-agent/reg-key not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
SUBNET="10.48.200.0/24"
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
nmap -sn --send-ip "$SUBNET" 2>/dev/null > "$TMPFILE"
|
||||
|
||||
if [ ! -s "$TMPFILE" ]; then
|
||||
echo "$(date): nmap produced no output" >&2
|
||||
rm -f "$TMPFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JSON=$(python3 - "$TMPFILE" <<'PYEOF'
|
||||
import sys, re, json
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
data = f.read()
|
||||
|
||||
devices = []
|
||||
cur = None
|
||||
|
||||
for line in data.splitlines():
|
||||
line = line.strip()
|
||||
m = re.match(r'Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)\)?', line)
|
||||
if m:
|
||||
if cur:
|
||||
devices.append(cur)
|
||||
hn = m.group(1) if m.group(1) and m.group(1) != m.group(2) else ''
|
||||
cur = {'ip': m.group(2), 'hostname': hn, 'mac': '', 'vendor': ''}
|
||||
elif cur:
|
||||
m2 = re.match(r'MAC Address: ([0-9A-Fa-f:]{17}) \(([^)]+)\)', line)
|
||||
if m2:
|
||||
cur['mac'] = m2.group(1).lower()
|
||||
cur['vendor'] = '' if m2.group(2) == 'Unknown' else m2.group(2)
|
||||
|
||||
if cur:
|
||||
devices.append(cur)
|
||||
|
||||
print(json.dumps({'devices': devices}))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
rm -f "$TMPFILE"
|
||||
|
||||
if [ -z "$JSON" ]; then
|
||||
echo "$(date): JSON parse failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -sk --max-time 15 \
|
||||
-X POST "$JARVIS_URL/api/netscan" \
|
||||
-H "Host: $JARVIS_HOST" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Registration-Key: $REG_KEY" \
|
||||
-d "$JSON" 2>/dev/null)
|
||||
|
||||
echo "$(date): $RESPONSE"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# JARVIS VoIP Phone Probe — runs every minute on PVE1
|
||||
# Pings all Yealink phones + checks FusionPBX SIP registration (read-only)
|
||||
# 200.3 is on an external FusionPBX — ping only, no SIP check
|
||||
|
||||
JARVIS_URL="http://10.48.200.211"
|
||||
JARVIS_HOST="jarvis.orbishosting.com"
|
||||
REG_KEY=$(cat /etc/jarvis-agent/reg-key 2>/dev/null)
|
||||
if [ -z "$REG_KEY" ]; then
|
||||
echo "$(date): ERROR: /etc/jarvis-agent/reg-key not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
FUSION_HOST="134.209.72.226"
|
||||
|
||||
# IP|alias|extension(none=skip SIP check)|mac
|
||||
PHONES=(
|
||||
"10.48.200.2|Yealink — Myron Main (Ext 1000)|1000|80:5e:c0:35:04:77"
|
||||
"10.48.200.3|Yealink — United Mirror & Glass (External SIP)|none|c4:fc:22:28:63:71"
|
||||
"10.48.200.43|Yealink T48S — Tommy Main (Ext 1001)|1001|80:5e:0c:15:0c:4f"
|
||||
"10.48.200.86|Yealink — Myron Vanguard WiFi (Offline During Work Hrs)|none|"
|
||||
"10.48.200.65|Yealink — Myron Vanguard Work (Ext 1003)|1003|c4:fc:22:13:e1:89"
|
||||
)
|
||||
|
||||
# Get SIP registrations from FusionPBX (read-only)
|
||||
REG_OUTPUT=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \
|
||||
root@$FUSION_HOST "fs_cli -x 'show registrations'" 2>/dev/null || echo "")
|
||||
|
||||
# Collect results as TSV, delegate JSON building to python3 to avoid injection
|
||||
RESULTS=""
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
IFS='|' read -r IP ALIAS EXT MAC <<< "$PHONE"
|
||||
|
||||
if ping -c 1 -W 2 "$IP" > /dev/null 2>&1; then
|
||||
STATUS="online"
|
||||
else
|
||||
STATUS="offline"
|
||||
fi
|
||||
|
||||
if [ "$EXT" = "none" ]; then
|
||||
SIP="external"
|
||||
elif [ -n "$REG_OUTPUT" ] && echo "$REG_OUTPUT" | grep -q "^${EXT},"; then
|
||||
SIP="registered"
|
||||
else
|
||||
SIP="unregistered"
|
||||
fi
|
||||
|
||||
RESULTS="${RESULTS}${IP}\t${ALIAS}\t${MAC}\t${STATUS}\t${SIP}\t${EXT}\n"
|
||||
done
|
||||
|
||||
JSON=$(printf "%b" "$RESULTS" | python3 -c "
|
||||
import sys, json
|
||||
devices = []
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip('\n')
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
ip, alias, mac, status, sip, ext = parts[:6]
|
||||
devices.append({
|
||||
'ip': ip, 'alias': alias, 'mac': mac,
|
||||
'vendor': 'Yealink', 'status': status,
|
||||
'sip_status': sip, 'extension': ext,
|
||||
})
|
||||
print(json.dumps({'devices': devices}))
|
||||
")
|
||||
|
||||
curl -sk --max-time 10 \
|
||||
-X POST "$JARVIS_URL/api/netscan" \
|
||||
-H "Host: $JARVIS_HOST" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Registration-Key: $REG_KEY" \
|
||||
-d "$JSON" > /dev/null 2>&1
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Ping Probe — runs on PVE1 (10.48.200.90), which is on the LAN.
|
||||
Pings devices that can't run the full agent, then calls JARVIS heartbeat
|
||||
on their behalf so the dashboard shows live status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
|
||||
JARVIS_URL = "http://10.48.200.211"
|
||||
HOST_HEADER = "jarvis.orbishosting.com"
|
||||
|
||||
# Devices to probe: agent_id → api_key
|
||||
DEVICES = {
|
||||
"fortigate_gw": "00103aea6fcbf837bc55e11b445a3620",
|
||||
"yealink_t48s": "2bf8bd7ca8dd31c28fd16aa956e15f88",
|
||||
"homeassistant_ha": "6f8077dee7a7b4af202bc80886f1223d",
|
||||
}
|
||||
|
||||
# Map agent_id → IP (for ping)
|
||||
IPS = {
|
||||
"fortigate_gw": "10.48.200.1",
|
||||
"yealink_t48s": "10.48.200.43",
|
||||
"homeassistant_ha": "10.48.200.97",
|
||||
}
|
||||
|
||||
def ping(ip: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "2", ip],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
def heartbeat(agent_id: str, api_key: str, alive: bool):
|
||||
# If device is down we still send heartbeat so JARVIS updates last_seen
|
||||
# and sets status based on the alive flag via the metric payload
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
payload = json.dumps({}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{JARVIS_URL}/api/agent/heartbeat",
|
||||
data=payload, method="POST"
|
||||
)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("X-Agent-Key", api_key)
|
||||
req.add_header("Host", HOST_HEADER)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_status(agent_id: str, api_key: str, status: str):
|
||||
"""Push a minimal metric so JARVIS knows if device is up or down."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
payload = json.dumps({
|
||||
"type": "system",
|
||||
"data": {
|
||||
"hostname": agent_id,
|
||||
"cpu_percent": 0,
|
||||
"ping_only": True,
|
||||
"ping_status": status,
|
||||
}
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{JARVIS_URL}/api/agent/metrics",
|
||||
data=payload, method="POST"
|
||||
)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("X-Agent-Key", api_key)
|
||||
req.add_header("Host", HOST_HEADER)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def main():
|
||||
for agent_id, api_key in DEVICES.items():
|
||||
ip = IPS.get(agent_id, "")
|
||||
alive = ping(ip) if ip else False
|
||||
status = "online" if alive else "offline"
|
||||
print(f"{agent_id} ({ip}): {status}", flush=True)
|
||||
heartbeat(agent_id, api_key, alive)
|
||||
update_status(agent_id, api_key, status)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+14
-6
@@ -37,11 +37,18 @@ function get_agent_by_key(string $key): ?array {
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
function update_agent_seen(string $agentId, string $status = 'online'): void {
|
||||
JarvisDB::query(
|
||||
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
|
||||
[$status, $agentId]
|
||||
);
|
||||
function update_agent_seen(string $agentId, string $status = 'online', ?string $version = null): void {
|
||||
if ($version !== null) {
|
||||
JarvisDB::query(
|
||||
'UPDATE registered_agents SET last_seen = NOW(), status = ?, version = ? WHERE agent_id = ?',
|
||||
[$status, $version, $agentId]
|
||||
);
|
||||
} else {
|
||||
JarvisDB::query(
|
||||
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
|
||||
[$status, $agentId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth (all actions except register) ───────────────────────────────────────
|
||||
@@ -104,7 +111,8 @@ switch ($agentAction) {
|
||||
|
||||
// ── HEARTBEAT ────────────────────────────────────────────────────────────
|
||||
case 'heartbeat':
|
||||
update_agent_seen($agent['agent_id']);
|
||||
$hbStatus = in_array($data['status'] ?? '', ['online','offline']) ? $data['status'] : 'online';
|
||||
update_agent_seen($agent['agent_id'], $hbStatus, trim($data['version'] ?? '') ?: null);
|
||||
|
||||
// Return any pending commands for this agent
|
||||
$commands = JarvisDB::query(
|
||||
|
||||
@@ -2302,7 +2302,7 @@ if (!$reply) {
|
||||
$sec = (int) file_get_contents('/proc/uptime');
|
||||
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
|
||||
$load = explode(' ', file_get_contents('/proc/loadavg'));
|
||||
$systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
|
||||
$systemContext .= "Jarvis server (10.48.200.211 PVE1): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$alerts = JarvisDB::query(
|
||||
@@ -2317,12 +2317,12 @@ if (!$reply) {
|
||||
$systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision.
|
||||
|
||||
Infrastructure:
|
||||
- Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04)
|
||||
- Jarvis Server: 10.48.200.211 (PVE1, nginx/PHP-FPM, Ubuntu 24.04)
|
||||
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
|
||||
- Proxmox Host: 10.48.200.90 (manages all VMs)
|
||||
- Home Assistant: 10.48.200.97:8123
|
||||
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
|
||||
- Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
|
||||
- Digital Ocean: 165.22.1.228 (website hosting — tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
|
||||
- Network: 10.48.200.0/24, FortiGate firewall
|
||||
|
||||
Live data:
|
||||
|
||||
@@ -30,7 +30,7 @@ $dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
|
||||
$diskPct = trim($dfOut);
|
||||
|
||||
// Services
|
||||
$svcNames = ["lshttpd", "mysql", "redis"];
|
||||
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
|
||||
$svcMap = [];
|
||||
foreach ($svcNames as $s) {
|
||||
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
|
||||
@@ -39,7 +39,7 @@ foreach ($svcNames as $s) {
|
||||
|
||||
// Site health from kb_facts
|
||||
$siteLabels = [
|
||||
"jarvis" => "jarvis.orbishosting.com",
|
||||
"jarvis" => "jarvis.orbishosting.com:1972",
|
||||
"tomsjavajive" => "tomsjavajive.com",
|
||||
"epictravelexp"=> "epictravelexpeditions.com",
|
||||
"parkersling" => "parkerslingshotrentals.com",
|
||||
@@ -59,8 +59,24 @@ foreach ($rows as $r) {
|
||||
$uptimeDays = intdiv($uptime, 86400);
|
||||
$uptimeHrs = intdiv($uptime % 86400, 3600);
|
||||
|
||||
|
||||
// DO server agent metrics (jarvis-do agent reporting via Tailscale)
|
||||
$doAgent = JarvisDB::query(
|
||||
"SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
|
||||
);
|
||||
$doMet = [];
|
||||
if (!empty($doAgent[0]['metric_data'])) {
|
||||
$dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
|
||||
$doMet = [
|
||||
"cpu" => $dm['cpu_percent'] ?? 0,
|
||||
"mem" => $dm['memory']['percent'] ?? 0,
|
||||
"disk" => (int)($dm['disk'][0]['percent'] ?? 0),
|
||||
"uptime" => $dm['uptime']['human'] ?? "--",
|
||||
"online" => true,
|
||||
];
|
||||
}
|
||||
echo json_encode([
|
||||
"ip" => DO_SERVER_IP,
|
||||
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
|
||||
"reachable" => true,
|
||||
"cpu_pct" => getCpuPct(),
|
||||
"memory" => [
|
||||
@@ -73,5 +89,6 @@ echo json_encode([
|
||||
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
|
||||
"services" => $svcMap,
|
||||
"sites" => $sites,
|
||||
"do_server" => $doMet,
|
||||
"timestamp" => date("c"),
|
||||
]);
|
||||
|
||||
@@ -80,26 +80,15 @@ function collect_all(): array {
|
||||
$results['system'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// ── Network ───────────────────────────────────────────────────────────
|
||||
// ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ──
|
||||
try {
|
||||
$watchlist = [
|
||||
'gateway' => '10.48.200.1',
|
||||
'proxmox' => '10.48.200.90',
|
||||
'ollama' => '10.48.200.95',
|
||||
'fusionpbx' => '10.48.200.96',
|
||||
'ha' => '10.48.200.97',
|
||||
'do_server' => '165.22.1.228',
|
||||
];
|
||||
$online = 0;
|
||||
$total = count($watchlist);
|
||||
foreach ($watchlist as $name => $ip) {
|
||||
exec('ping -c1 -W1 ' . escapeshellarg($ip) . ' > /dev/null 2>&1', $o, $code);
|
||||
$up = ($code === 0);
|
||||
if ($up) $online++;
|
||||
KBEngine::storeFact('network', "host_{$name}", $up ? 'online' : 'offline', $ip, $ttl);
|
||||
}
|
||||
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
|
||||
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
|
||||
$rows = JarvisDB::query(
|
||||
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
|
||||
);
|
||||
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
|
||||
$total = count($rows);
|
||||
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
|
||||
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
|
||||
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
|
||||
$results['network'] = "ok ({$online}/{$total} online)";
|
||||
} catch (Exception $e) {
|
||||
@@ -111,7 +100,7 @@ function collect_all(): array {
|
||||
$results['proxmox'] = 'skipped (fresh)';
|
||||
} else try {
|
||||
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
|
||||
$base = 'https://orbisne.fortiddns.com:' . PROXMOX_PORT . '/api2/json';
|
||||
$base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
|
||||
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
|
||||
|
||||
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
|
||||
@@ -146,7 +135,7 @@ function collect_all(): array {
|
||||
|
||||
// ── Digital Ocean ─────────────────────────────────────────────────────
|
||||
try {
|
||||
exec('ping -c1 -W2 165.22.1.228 > /dev/null 2>&1', $o2, $doCode);
|
||||
exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);;
|
||||
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
|
||||
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
|
||||
$results['do_server'] = "ok ({$doStatus})";
|
||||
@@ -160,7 +149,7 @@ function collect_all(): array {
|
||||
} else try {
|
||||
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
|
||||
$ch = curl_init($ollamaHost . '/api/tags');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
@@ -192,7 +181,7 @@ function collect_all(): array {
|
||||
$results['sites'] = 'skipped (fresh)';
|
||||
} else try {
|
||||
$sites = [
|
||||
'jarvis' => 'https://jarvis.orbishosting.com',
|
||||
"jarvis" => "http://127.0.0.1",
|
||||
'tomsjavajive' => 'https://tomsjavajive.com',
|
||||
'epictravelexp'=> 'https://epictravelexpeditions.com',
|
||||
'parkersling' => 'https://parkerslingshotrentals.com',
|
||||
@@ -202,9 +191,15 @@ function collect_all(): array {
|
||||
];
|
||||
$down = [];
|
||||
foreach ($sites as $key => $url) {
|
||||
$ch = curl_init($url);
|
||||
$parsed = parse_url($url);
|
||||
$host = $parsed['host'] ?? $url;
|
||||
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
|
||||
// All JARVIS-hosted sites are served from this same OLS instance.
|
||||
$localUrl = $url; // external check
|
||||
$ch = curl_init($localUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
@@ -234,9 +229,10 @@ function pve_api_get(string $url, string $authHeader): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_HTTPHEADER => [$authHeader],
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
+22
-9
@@ -83,15 +83,28 @@ if ($method === 'POST' && $action === 'service') {
|
||||
// Serve entities from ha_entities table (real-time agent push data)
|
||||
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
||||
'device_tracker','event','image','person','zone','tts','conversation',
|
||||
'assist_satellite','input_button'];
|
||||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
||||
'_siren_on','_email_on','_manual_record','_infrared_',
|
||||
'do_not_disturb','matter_server','zerotier','mariadb',
|
||||
'spotify_connect','file_editor','ssh_web','uptime_kuma',
|
||||
'adguard_','folding_home','music_assistant','get_hacs','mealie',
|
||||
'mosquitto','social_to','motion_detection',
|
||||
'front_yard_record','down_hill_record','camera1_record',
|
||||
'back_yard_record','nvr_','assist_microphone'];
|
||||
'assist_satellite','input_button','media_player','scene','water_heater',
|
||||
'alarm_control_panel','automation','script','calendar','notify','weather','camera','siren','remote','todo','lawn_mower'];
|
||||
$skipKeywords = [
|
||||
// HACS / system toggles
|
||||
'pre_release','get_hacs','matter_server','zerotier','mariadb',
|
||||
'spotify_connect','file_editor','ssh_web','uptime_kuma','adguard_',
|
||||
'folding_home','music_assistant','mealie','mosquitto','social_to',
|
||||
'assist_microphone','cec_scanner','esphome_device_builder',
|
||||
// Camera controls
|
||||
'_record','_ftp_','_push_','_hub_ringtone','_siren_on',
|
||||
'_email_on','_manual_record','_infrared_','motion_detection',
|
||||
'front_yard_record','down_hill_record','camera1_record',
|
||||
'back_yard_record','nvr_',
|
||||
// Echo / smart display noise
|
||||
'do_not_disturb',
|
||||
// Konnected security panel switches
|
||||
'floodlight',
|
||||
'konnected',
|
||||
// Energy / power monitoring (sensors, not controls)
|
||||
'_energy','_power','_voltage','_current','_consumption',
|
||||
'electricity_maps',
|
||||
];
|
||||
|
||||
$rows = JarvisDB::query(
|
||||
"SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts
|
||||
|
||||
@@ -36,7 +36,7 @@ function cacheStore(string $key, $data): void {
|
||||
|
||||
// ── Proxmox ──────────────────────────────────────────────────────────────
|
||||
if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') {
|
||||
$pveBase = 'https://orbisne.fortiddns.com:' . PROXMOX_PORT . '/api2/json';
|
||||
$pveBase = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
|
||||
$pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL];
|
||||
|
||||
// Cluster resources API — returns all VMs/CTs from ALL nodes (pve + pve2)
|
||||
@@ -134,8 +134,8 @@ if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false
|
||||
$config = $configRaw ? json_decode($configRaw, true) : [];
|
||||
|
||||
// Controllable domains only — skip read-only sensors to keep list manageable
|
||||
$interesting = ['light','switch','scene','media_player','alarm_control_panel',
|
||||
'lawn_mower','water_heater','fan','lock','cover','climate','input_boolean'];
|
||||
$interesting = ['light','switch','alarm_control_panel',
|
||||
'lawn_mower','fan','lock','cover','climate','input_boolean'];
|
||||
// Switches that are HA internals / camera settings, not physical devices
|
||||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
||||
'_siren_on','_email_on','_manual_record','_infrared_',
|
||||
|
||||
@@ -89,7 +89,7 @@ function getNetworkIO(): array {
|
||||
}
|
||||
|
||||
function getServices(): array {
|
||||
$services = ["lshttpd","mysql","redis","memcached","postfix","dovecot","jarvis-agent"];
|
||||
$services = ["nginx","php8.3-fpm","mariadb","redis-server","jarvis-arc","jarvis-agent"];
|
||||
$result = [];
|
||||
foreach ($services as $svc) {
|
||||
$out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null');
|
||||
|
||||
+207
-13
@@ -1,4 +1,9 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: jarvis_db
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 10.11.14-MariaDB-0ubuntu0.24.04.1
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
@@ -10,6 +15,11 @@
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `agent_commands`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `agent_commands`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -28,6 +38,11 @@ CREATE TABLE `agent_commands` (
|
||||
KEY `idx_created` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `agent_metrics`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `agent_metrics`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -40,8 +55,13 @@ CREATE TABLE `agent_metrics` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_agent_time` (`agent_id`,`recorded_at`),
|
||||
KEY `idx_recorded` (`recorded_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=28329 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=31422 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `alerts`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `alerts`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -58,8 +78,13 @@ CREATE TABLE `alerts` (
|
||||
`auto_resolve` tinyint(1) DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_source_key` (`source_key`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `api_cache`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `api_cache`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -70,6 +95,80 @@ CREATE TABLE `api_cache` (
|
||||
PRIMARY KEY (`cache_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `appointments`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `appointments`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `appointments` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`category` varchar(64) DEFAULT 'personal',
|
||||
`start_at` datetime NOT NULL,
|
||||
`end_at` datetime DEFAULT NULL,
|
||||
`location` varchar(255) DEFAULT NULL,
|
||||
`all_day` tinyint(1) DEFAULT 0,
|
||||
`reminder_min` int(11) DEFAULT 30,
|
||||
`alerted` tinyint(1) DEFAULT 0,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_start` (`start_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `arc_jobs`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `arc_jobs`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `arc_jobs` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`job_type` varchar(64) NOT NULL,
|
||||
`payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||
`priority` int(11) DEFAULT 0,
|
||||
`status` enum('queued','running','done','failed','cancelled') DEFAULT 'queued',
|
||||
`result` longtext DEFAULT NULL,
|
||||
`error` varchar(2000) DEFAULT NULL,
|
||||
`created_by` varchar(128) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`started_at` datetime DEFAULT NULL,
|
||||
`completed_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `arc_status`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `arc_status`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `arc_status` (
|
||||
`id` int(11) NOT NULL DEFAULT 1,
|
||||
`version` varchar(20) DEFAULT NULL,
|
||||
`started_at` datetime DEFAULT NULL,
|
||||
`last_heartbeat` datetime DEFAULT NULL,
|
||||
`active_jobs` int(11) DEFAULT 0,
|
||||
`jobs_done` int(11) DEFAULT 0,
|
||||
`jobs_failed` int(11) DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `conversations`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `conversations`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -83,8 +182,13 @@ CREATE TABLE `conversations` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_session` (`session_id`),
|
||||
KEY `idx_created` (`created_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=325 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=335 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `ha_entities`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `ha_entities`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -102,8 +206,13 @@ CREATE TABLE `ha_entities` (
|
||||
UNIQUE KEY `uk_agent_entity` (`agent_id`,`entity_id`),
|
||||
KEY `idx_domain` (`domain`),
|
||||
KEY `idx_updated` (`updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=77909 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_facts`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_facts`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -117,8 +226,13 @@ CREATE TABLE `kb_facts` (
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_fact` (`category`,`fact_key`,`host`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=26088 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=41478 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_intents`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_intents`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -133,8 +247,13 @@ CREATE TABLE `kb_intents` (
|
||||
`active` tinyint(1) DEFAULT 1,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_ollama_models`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_ollama_models`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -147,8 +266,13 @@ CREATE TABLE `kb_ollama_models` (
|
||||
`pulled_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `model_name` (`model_name`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_preferences`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_preferences`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -159,8 +283,13 @@ CREATE TABLE `kb_preferences` (
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `pref_key` (`pref_key`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `known_commands`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `known_commands`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -173,6 +302,11 @@ CREATE TABLE `known_commands` (
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `metrics_history`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `metrics_history`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -184,8 +318,13 @@ CREATE TABLE `metrics_history` (
|
||||
`recorded_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_metric_time` (`metric_name`,`recorded_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=33415 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=34771 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `network_devices`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `network_devices`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -201,8 +340,13 @@ CREATE TABLE `network_devices` (
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ip` (`ip`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=409 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=5556 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `registered_agents`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `registered_agents`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -210,10 +354,11 @@ CREATE TABLE `registered_agents` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`agent_id` varchar(128) NOT NULL,
|
||||
`hostname` varchar(255) NOT NULL,
|
||||
`agent_type` enum('linux','homeassistant','proxmox') NOT NULL DEFAULT 'linux',
|
||||
`agent_type` enum('linux','homeassistant','proxmox','windows','macos') NOT NULL DEFAULT 'linux',
|
||||
`ip_address` varchar(45) DEFAULT NULL,
|
||||
`api_key` varchar(64) NOT NULL,
|
||||
`capabilities` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capabilities`)),
|
||||
`version` varchar(32) DEFAULT NULL,
|
||||
`last_seen` datetime DEFAULT NULL,
|
||||
`status` enum('online','offline','unknown') NOT NULL DEFAULT 'unknown',
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
@@ -221,8 +366,56 @@ CREATE TABLE `registered_agents` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_agent_id` (`agent_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tasks`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tasks`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tasks` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`notes` text DEFAULT NULL,
|
||||
`category` varchar(64) DEFAULT 'personal',
|
||||
`priority` enum('urgent','high','normal','low') DEFAULT 'normal',
|
||||
`status` enum('pending','in_progress','done','cancelled') DEFAULT 'pending',
|
||||
`due_date` date DEFAULT NULL,
|
||||
`due_time` time DEFAULT NULL,
|
||||
`completed_at` datetime DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status_due` (`status`,`due_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `usage_patterns`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `usage_patterns`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `usage_patterns` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`intent_name` varchar(64) NOT NULL,
|
||||
`hour` tinyint(2) NOT NULL,
|
||||
`dow` tinyint(1) NOT NULL,
|
||||
`hit_count` int(11) DEFAULT 1,
|
||||
`last_seen` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_intent_time` (`intent_name`,`hour`,`dow`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -236,7 +429,7 @@ CREATE TABLE `users` (
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
@@ -248,3 +441,4 @@ CREATE TABLE `users` (
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2026-06-29 23:15:44
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- JARVIS KB Seed Data
|
||||
-- Preferences
|
||||
INSERT INTO kb_preferences (pref_key, pref_value) VALUES
|
||||
('user_name', 'Myron'),
|
||||
('user_title', 'Mr. Blair'),
|
||||
('ai_model', 'llama3.1:8b'),
|
||||
('timezone', 'America/Chicago')
|
||||
ON DUPLICATE KEY UPDATE pref_value = VALUES(pref_value);
|
||||
|
||||
-- Intents: greeting, time, system, network, proxmox, ollama, tasks, HA
|
||||
INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active) VALUES
|
||||
|
||||
-- Greetings
|
||||
('greeting', '(?i)^(hello|hi|hey|good (morning|afternoon|evening)|what.?s up|howdy)\\b', 'Good {current_time}, {user_title}. All systems are online. How can I assist you?', 'system', 'response', 10, 1),
|
||||
|
||||
-- Time / date
|
||||
('current_time', '(?i)\\b(what.?s the (time|current time)|what time is it|tell me the time)\\b', 'It is currently {current_time}, {user_title}.', NULL, 'response', 9, 1),
|
||||
('current_date', '(?i)\\b(what.?s (today.?s date|the date)|what day is it|today.?s date)\\b', 'Today is {current_date}, {user_title}.', NULL, 'response', 9, 1),
|
||||
|
||||
-- System status
|
||||
('system_status', '(?i)\\b(system (status|health)|how.?s (the system|everything)|jarvis status|all systems)\\b', 'JARVIS is fully operational, {user_title}. CPU: {cpu_usage}%, Memory: {mem_percent}% used ({mem_used_gb}GB / {mem_total_gb}GB). Disk: {disk_used} used of {disk_total}. Uptime: {uptime}. Network agents: {online_count}/{total_count} online.', 'system', 'response', 8, 1),
|
||||
('cpu_status', '(?i)\\b(cpu|processor) (usage|load|status|percent|utilization)\\b', 'Current CPU usage is {cpu_usage}%, {user_title}. Load averages: {load_1m} (1m), {load_5m} (5m), {load_15m} (15m).', 'system', 'response', 8, 1),
|
||||
('memory_status', '(?i)\\b(memory|ram|mem) (usage|status|free|used|available)\\b', 'Memory: {mem_used_gb}GB used of {mem_total_gb}GB ({mem_percent}% utilized), {user_title}. Free: {mem_free_gb}GB.', 'system', 'response', 8, 1),
|
||||
('disk_status', '(?i)\\b(disk|storage|drive) (usage|space|status|free|used|available)\\b', 'Disk status: {disk_used} used of {disk_total} total, {disk_free} free, {user_title}.', 'system', 'response', 8, 1),
|
||||
('uptime', '(?i)\\b(uptime|how long.*running|how long.*up|server uptime)\\b', 'JARVIS has been running for {uptime}, {user_title}.', 'system', 'response', 7, 1),
|
||||
|
||||
-- Network status
|
||||
('network_status', '(?i)\\b(network (status|health|agents)|agents (online|status)|how many (agents|devices) (online|running))\\b', 'Network status: {online_count} of {total_count} agents are online, {user_title}.', 'network', 'response', 8, 1),
|
||||
('network_scan', '(?i)\\b(run (a )?network scan|scan (the )?network|nmap scan|network devices)\\b', 'Initiating network scan, {user_title}.', NULL, 'action', 7, 1),
|
||||
|
||||
-- Proxmox
|
||||
('proxmox_status', '(?i)\\b(proxmox (status|health)|vm (status|count|summary)|virtual machines|how many vms)\\b', 'Proxmox: {vm_running} of {vm_total} VMs/containers running, {user_title}. Host CPU: {pve_cpu_percent}%, Memory: {pve_mem_used_gb}GB / {pve_mem_total_gb}GB ({pve_mem_percent}%).', 'proxmox', 'response', 8, 1),
|
||||
('vm_suggestions', '(?i)\\b(vm (resources|performance|usage)|check vms|resource usage)\\b', 'Checking VM resource usage, {user_title}.', 'proxmox', 'action', 7, 1),
|
||||
|
||||
-- Ollama / AI
|
||||
('ollama_status', '(?i)\\b(ollama (status|health|models)|ai models|llm status|local (ai|models))\\b', 'Ollama is {status} with {model_count} model(s) available: {available_models}, {user_title}.', 'ollama', 'response', 7, 1),
|
||||
|
||||
-- Site health
|
||||
('site_status', '(?i)\\b(site(s)? (status|health|up|down)|website status|are (the )?sites (up|down))\\b', 'Site health — jarvis: {jarvis}, orbishosting: {orbishosting}, tomtomgames: {tomtomgames}, tomsjavajive: {tomsjavajive}, parkerslingshotrentals: {parkersling}, epictravelexpeditions: {epictravelexp}, {user_title}.', 'sites', 'response', 7, 1),
|
||||
|
||||
-- Tasks / planner
|
||||
('task_count', '(?i)\\b(how many tasks|pending tasks|task (count|summary)|my tasks)\\b', 'You have {pending_count} pending tasks and {overdue_count} overdue, {user_title}.', NULL, 'response', 7, 1),
|
||||
('planner_briefing', '(?i)\\b((daily )?briefing|what.?s (on|happening) today|today.?s schedule|morning briefing)\\b', 'Fetching your daily briefing, {user_title}.', NULL, 'action', 8, 1),
|
||||
|
||||
-- Home Assistant
|
||||
('ha_lights_on', '(?i)\\b(turn (on|off) (the |all )?lights?|lights? (on|off)|switch (on|off) (the )?lights?)\\b', 'Sending light command, {user_title}.', NULL, 'action', 8, 1),
|
||||
('ha_scene', '(?i)\\b(activate (a |the )?scene|set (a |the )?scene|home scene)\\b', 'Activating home scene, {user_title}.', NULL, 'action', 7, 1),
|
||||
|
||||
-- Jellyfin
|
||||
('jellyfin_now_playing', '(?i)\\b(what.?s (playing|on)|now playing|jellyfin.*playing|playing.*jellyfin)\\b', 'Checking Jellyfin now playing, {user_title}.', NULL, 'action', 7, 1),
|
||||
('jellyfin_library', '(?i)\\b(jellyfin (library|media|shows?|movies?)|media library|show.*library)\\b', 'Fetching Jellyfin library, {user_title}.', NULL, 'action', 6, 1),
|
||||
('jellyfin_pause', '(?i)\\b(pause (jellyfin|playback|media)|stop (playing|jellyfin))\\b', 'Pausing Jellyfin, {user_title}.', NULL, 'action', 7, 1),
|
||||
|
||||
-- DO server
|
||||
('do_status', '(?i)\\b(do (server|status)|digital ocean (status|server)|vps status)\\b', 'Digital Ocean server is {do_status}, {user_title}.', 'do_server', 'response', 7, 1),
|
||||
|
||||
-- Focus / panels
|
||||
('focus_mode', '(?i)\\b(focus (mode|on)|enable focus|concentration mode)\\b', 'Enabling focus mode, {user_title}.', NULL, 'action', 6, 1),
|
||||
('show_panels', '(?i)\\b(show (all )?panels|expand (all|everything)|full view)\\b', 'Expanding all panels, {user_title}.', NULL, 'action', 6, 1),
|
||||
|
||||
-- Help
|
||||
('help', '(?i)^(help|what can you do|commands|capabilities|what do you know)\\s*\\??$', 'I can help you with: system status, network status, VM/Proxmox status, Ollama AI models, site health, tasks and planner briefings, Jellyfin media, Home Assistant lights and devices, and general questions via Ollama. What would you like to know, {user_title}?', NULL, 'response', 5, 1)
|
||||
|
||||
ON DUPLICATE KEY UPDATE
|
||||
pattern = VALUES(pattern),
|
||||
response_template = VALUES(response_template),
|
||||
active = 1;
|
||||
|
||||
SELECT COUNT(*) AS intents_seeded FROM kb_intents;
|
||||
SELECT COUNT(*) AS prefs_seeded FROM kb_preferences;
|
||||
+5
-5
@@ -688,8 +688,8 @@ async def handle_screenshot(payload: dict) -> dict:
|
||||
try:
|
||||
snap_text = json.dumps(result, indent=2)[:3000]
|
||||
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
|
||||
analysis = await llm_call([{"role": "user", "content": prompt}], "claude")
|
||||
provider_used = "claude"
|
||||
analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
|
||||
provider_used = "groq"
|
||||
except Exception as e:
|
||||
analysis = f"Analysis unavailable: {e}"
|
||||
|
||||
@@ -1022,7 +1022,7 @@ async def guardian_loop() -> None:
|
||||
"for Myron. Be direct about severity and what action to take. "
|
||||
"No markdown, no headers."
|
||||
)
|
||||
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "claude")
|
||||
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
|
||||
# Update the most recent guardian event with AI analysis
|
||||
await db_execute(
|
||||
"""UPDATE guardian_events SET ai_analysis=%s
|
||||
@@ -1062,10 +1062,10 @@ async def _guardian_inject_chat(message: str) -> None:
|
||||
async def handle_sitrep(payload: dict) -> dict:
|
||||
"""
|
||||
Situation Report — comprehensive health briefing across all field stations.
|
||||
payload: { detail: brief|full, provider: claude }
|
||||
payload: { detail: brief|full, provider: groq }
|
||||
"""
|
||||
detail = payload.get("detail", "full")
|
||||
provider = payload.get("provider", "claude")
|
||||
provider = payload.get("provider", "groq")
|
||||
|
||||
log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
|
||||
**Last Updated:** 2026-06-14
|
||||
**Last Updated:** 2026-06-18
|
||||
**Owner:** Myron Blair — myronblair@outlook.com
|
||||
|
||||
---
|
||||
@@ -29,7 +29,7 @@ INTERNET
|
||||
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
|
||||
│ (proxied DNS for public sites)
|
||||
│
|
||||
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites + JARVIS
|
||||
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (6 sites)
|
||||
│
|
||||
└─► [FusionPBX 134.209.72.226] — FreeSWITCH PBX (SSH via DO relay)
|
||||
|
||||
@@ -42,7 +42,7 @@ HOME NETWORK (FortiGate router at 10.48.200.1)
|
||||
│ ├── VM 113 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
|
||||
│ ├── VM 118 10.48.200.18 Homebridge
|
||||
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
|
||||
│ ├── VM 210 10.48.200.95 Ollama (local LLM)
|
||||
│ ├── VM 210 10.48.200.210 Ollama (local LLM) (local LLM)
|
||||
│ └── CT110 10.48.200.19 WireGuard exit container
|
||||
│
|
||||
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
|
||||
@@ -73,13 +73,13 @@ FortiGate Port Forwards:
|
||||
| **OS** | Ubuntu 22.04 LTS |
|
||||
| **Panel** | CyberPanel (OpenLiteSpeed) |
|
||||
| **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` |
|
||||
| **Purpose** | All public websites + JARVIS AI + webhook deploy system |
|
||||
| **Purpose** | All public websites (6 sites) — webhook deploy for websites |
|
||||
|
||||
**Key Paths:**
|
||||
- All sites: `/home/<domain>/public_html/`
|
||||
- JARVIS: `/home/jarvis.orbishosting.com/`
|
||||
- Deploy log: `/home/jarvis.orbishosting.com/logs/deploy.log`
|
||||
- Watchdog log: `/home/jarvis.orbishosting.com/logs/watchdog.log`
|
||||
|
||||
- Deploy log: per-site (website deploys only)
|
||||
- Watchdog log: `/usr/local/lsws/logs/watchdog.log`
|
||||
- Infra repo: `/opt/infra`
|
||||
|
||||
**Services running:**
|
||||
@@ -87,7 +87,7 @@ FortiGate Port Forwards:
|
||||
- MySQL 8 — all site databases on localhost
|
||||
- Redis — session/cache
|
||||
- PHP 8.5 (`lsphp85`) — runtime for all sites
|
||||
- Cron jobs: JARVIS deploy runner (every 1 min), facts collector (every 3 min), stats cache (every 5 min), watchdog (every 5 min)
|
||||
- Cron jobs: website deploy runner (every 1 min), watchdog (every 5 min)
|
||||
|
||||
**CyberPanel Web UI:** `https://165.22.1.228:8090`
|
||||
Login: `myron / Joker1974!!!`
|
||||
@@ -102,7 +102,7 @@ Login: `myron / Joker1974!!!`
|
||||
|-------|-------|
|
||||
| **IP** | 134.209.72.226 |
|
||||
| **OS** | Debian (DigitalOcean droplet) |
|
||||
| **SSH** | Must relay via DO: `ssh root@165.22.1.228` → `ssh root@134.209.72.226` — password: `Joker1974!@#` |
|
||||
| **SSH** | Direct via Tailscale: `ssh root@100.74.46.120` — password: `Joker1974!@#` |
|
||||
| **Direct SSH** | Only from: 107.178.2.130 / 97.154.109.245 |
|
||||
| **Purpose** | VoIP phone system — handles all inbound/outbound calls |
|
||||
|
||||
@@ -308,11 +308,11 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
|
||||
### VM 210 — Ollama Local LLM (PVE1)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **IP** | 10.48.200.95 |
|
||||
| **IP** | 10.48.200.210 |
|
||||
| **OS** | Ubuntu (cloud image) |
|
||||
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
|
||||
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat |
|
||||
| **API** | `http://10.48.200.95:11434` (Ollama REST API) |
|
||||
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
|
||||
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
|
||||
|
||||
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
|
||||
@@ -372,11 +372,11 @@ All sites are at `/home/<domain>/public_html/` on DO (165.22.1.228).
|
||||
|
||||
---
|
||||
|
||||
### jarvis.orbishosting.com — JARVIS AI Dashboard
|
||||
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **URL** | https://jarvis.orbishosting.com |
|
||||
| **Path** | `/home/jarvis.orbishosting.com/` |
|
||||
| **URL** | http://jarvis.orbishosting.com:1972 |
|
||||
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
|
||||
| **GitHub** | `myronblair/jarvis` |
|
||||
| **Login** | `myron / Joker1974!!!` |
|
||||
| **Purpose** | Iron Man-style AI home dashboard with voice control, smart home, media, planner |
|
||||
@@ -468,11 +468,11 @@ See Section 7 for full JARVIS details.
|
||||
|
||||
## 7. JARVIS AI SYSTEM
|
||||
|
||||
**URL:** https://jarvis.orbishosting.com
|
||||
**Files:** `/home/jarvis.orbishosting.com/` on DO
|
||||
**URL:** http://jarvis.orbishosting.com:1972
|
||||
**Files:** `/var/www/jarvis/` on JARVIS VM (PVE1 VM 211 — 10.48.200.211, 8 cores, 16GB RAM)
|
||||
**DB:** `jarvis_db` — `jarvis_user / J4rv1s_Pr0t0c0l_2026!`
|
||||
**Login:** `myron / Joker1974!!!`
|
||||
**Admin portal:** https://jarvis.orbishosting.com/admin
|
||||
**Admin portal:** http://jarvis.orbishosting.com:1972/admin
|
||||
|
||||
### Architecture (end-to-end)
|
||||
|
||||
@@ -484,7 +484,7 @@ Voice (browser mic)
|
||||
→ /api/chat.php (4-tier AI)
|
||||
Tier 0.7: KB intents / planner (tasks, appointments)
|
||||
Tier 1: Knowledge Base (MySQL)
|
||||
Tier 1.5: Ollama (10.48.200.95:11434, llama3.2) — local LLM
|
||||
Tier 1.5: Ollama (10.48.200.210:11434, llama3.2) — local LLM
|
||||
Tier 2: Groq (cloud, model: compound-beta-mini)
|
||||
Tier 3: Claude API (Anthropic, fallback)
|
||||
→ ElevenLabs TTS → browser speaker
|
||||
@@ -501,7 +501,7 @@ Webhook secret: `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1`
|
||||
### Agent System
|
||||
Agents installed on all servers — phone home every 10s (heartbeat) / 30s (metrics).
|
||||
Registration key: `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518`
|
||||
Install command: `curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s <hostname> <linux|proxmox>`
|
||||
Install command: `curl -sk http://10.48.200.211/install-agent.sh | bash -s <hostname> <linux|proxmox>`
|
||||
|
||||
### Self-Healing Watchdog
|
||||
`/usr/local/bin/jarvis-watchdog.sh` — runs every 5 min (root cron on DO)
|
||||
@@ -680,8 +680,8 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
|
||||
| phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` |
|
||||
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
|
||||
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
|
||||
| JARVIS | https://jarvis.orbishosting.com | myron | `Joker1974!!!` |
|
||||
| JARVIS Admin | https://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
|
||||
| JARVIS | http://jarvis.orbishosting.com:1972 | myron | `Joker1974!!!` |
|
||||
| JARVIS Admin | http://jarvis.orbishosting.com:1972/admin | myron | `Joker1974!!!` |
|
||||
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
|
||||
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
|
||||
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
|
||||
|
||||
@@ -242,7 +242,9 @@ if ($action) {
|
||||
$search = strtolower(trim($_GET['search'] ?? ''));
|
||||
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
||||
'device_tracker','event','image','person','zone','tts','conversation',
|
||||
'assist_satellite','input_button'];
|
||||
'assist_satellite','input_button','media_player','scene','water_heater',
|
||||
'alarm_control_panel','automation','script','calendar','notify',
|
||||
'weather','camera','siren','remote','todo','lawn_mower'];
|
||||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
||||
'_siren_on','_email_on','_manual_record','_infrared_',
|
||||
'do_not_disturb','matter_server','zerotier','mariadb',
|
||||
|
||||
@@ -1,278 +1,151 @@
|
||||
# JARVIS Agent Installer — Windows (PowerShell)
|
||||
# Registers the agent as a proper Windows Service (Win 8.1+, no open window required).
|
||||
# Requires pywin32. Runs the service as LocalSystem.
|
||||
#
|
||||
# Run as Administrator:
|
||||
# Set-ExecutionPolicy Bypass -Scope Process
|
||||
# .\install-windows.ps1 -JarvisUrl https://jarvis.orbishosting.com -Key YOUR_KEY
|
||||
#
|
||||
# One-liner (PowerShell as Admin):
|
||||
# irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
JARVIS Agent installer for Windows.
|
||||
|
||||
param(
|
||||
[string]$JarvisUrl = "",
|
||||
[string]$Key = "",
|
||||
[string]$AgentName = ""
|
||||
)
|
||||
.DESCRIPTION
|
||||
Installs JARVIS Agent as a Windows Service that auto-starts at boot.
|
||||
Requires: PowerShell 5.1+, internet access, and Administrator rights.
|
||||
|
||||
# param() defaults don't apply when piped through iex — set here as fallback
|
||||
if (-not $JarvisUrl) { $JarvisUrl = "https://jarvis.orbishosting.com" }
|
||||
if (-not $Key) { $Key = "f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" }
|
||||
if (-not $AgentName) { $AgentName = $env:COMPUTERNAME.ToLower() }
|
||||
.EXAMPLE
|
||||
# Interactive install (prompts for registration key):
|
||||
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$InstallDir = "C:\ProgramData\jarvis-agent"
|
||||
$AgentScript = "$InstallDir\jarvis-agent-windows.py"
|
||||
$ConfigFile = "$InstallDir\config.json"
|
||||
$ServiceName = "JARVISAgent"
|
||||
$OldTaskName = "JARVIS-Agent" # legacy scheduled-task name
|
||||
# Silent install with key:
|
||||
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
#>
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ====================================" -ForegroundColor Cyan
|
||||
Write-Host " JARVIS Agent Installer v3.1 " -ForegroundColor Cyan
|
||||
Write-Host " Windows Service Edition " -ForegroundColor Cyan
|
||||
Write-Host " ====================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$JARVIS_URL = 'https://jarvis.orbishosting.com'
|
||||
$INSTALL_DIR = 'C:\ProgramData\jarvis-agent'
|
||||
$SERVICE_NAME = 'JARVISAgent'
|
||||
$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py"
|
||||
$CONFIG_FILE = "$INSTALL_DIR\config.json"
|
||||
|
||||
# ── Require admin ──────────────────────────────────────────────────────────────
|
||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "Run PowerShell as Administrator and try again."
|
||||
}
|
||||
function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan }
|
||||
function Write-OK { param($msg) Write-Host " OK: $msg" -ForegroundColor Green }
|
||||
function Write-Fail { param($msg) Write-Host " ERROR: $msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# ── Prompt if not provided ─────────────────────────────────────────────────────
|
||||
$JarvisUrl = $JarvisUrl.TrimEnd("/")
|
||||
Write-Host "`n========================================" -ForegroundColor Yellow
|
||||
Write-Host " JARVIS Agent Installer for Windows" -ForegroundColor Yellow
|
||||
Write-Host "========================================`n" -ForegroundColor Yellow
|
||||
|
||||
# ── Find or install Python 3 (system-wide so LocalSystem service can reach it) ─
|
||||
Write-Host "[1/6] Checking for Python 3..." -ForegroundColor Cyan
|
||||
|
||||
$pythonPath = $null
|
||||
|
||||
# System-wide paths — accessible by LocalSystem service account
|
||||
$systemPaths = @(
|
||||
"C:\Program Files\Python313\python.exe",
|
||||
"C:\Program Files\Python312\python.exe",
|
||||
"C:\Program Files\Python311\python.exe",
|
||||
"C:\Program Files\Python310\python.exe",
|
||||
"C:\Program Files\Python39\python.exe",
|
||||
"C:\Python313\python.exe",
|
||||
"C:\Python312\python.exe",
|
||||
"C:\Python311\python.exe",
|
||||
"C:\Python310\python.exe"
|
||||
)
|
||||
|
||||
function Install-PythonSystemWide {
|
||||
# Try winget first (Win 10 1709+ / Win 11)
|
||||
$wingetOk = $false
|
||||
try {
|
||||
$null = Get-Command winget -ErrorAction Stop
|
||||
Write-Host " Using winget (system-wide)..." -NoNewline
|
||||
winget install Python.Python.3.12 --silent --scope machine `
|
||||
--accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { $wingetOk = $true; Write-Host " done." -ForegroundColor Green }
|
||||
} catch {}
|
||||
|
||||
if (-not $wingetOk) {
|
||||
# Direct download — works on Win 8.1 without winget
|
||||
# Python 3.11 explicitly supports Win 8.1+
|
||||
Write-Host " Downloading Python 3.11 (Win 8.1+ compatible)..." -NoNewline
|
||||
$pyInstaller = "$env:TEMP\python-installer.exe"
|
||||
$pyUrl = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe"
|
||||
try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
$wc = New-Object System.Net.WebClient
|
||||
$wc.DownloadFile($pyUrl, $pyInstaller)
|
||||
Write-Host " downloaded." -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Error "Could not download Python. Install from https://python.org choosing 'Install for all users', then re-run."
|
||||
}
|
||||
Write-Host " Installing system-wide (silent)..." -NoNewline
|
||||
$proc = Start-Process -FilePath $pyInstaller `
|
||||
-ArgumentList "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0" `
|
||||
-Wait -PassThru
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Write-Error "Python installer exited $($proc.ExitCode). Install manually from https://python.org then re-run."
|
||||
}
|
||||
Write-Host " done." -ForegroundColor Green
|
||||
Remove-Item $pyInstaller -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Refresh PATH after install
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" +
|
||||
[System.Environment]::GetEnvironmentVariable("PATH","User")
|
||||
}
|
||||
|
||||
# ── Search for system-wide Python first ───────────────────────────────────────
|
||||
foreach ($p in $systemPaths) {
|
||||
if (Test-Path $p) {
|
||||
try {
|
||||
$ver = & $p --version 2>&1
|
||||
if ("$ver" -match "Python 3") { $pythonPath = $p; break }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Fall back to PATH — but flag if it's per-user ─────────────────────────────
|
||||
if (-not $pythonPath) {
|
||||
foreach ($cmd in @("python", "python3", "py")) {
|
||||
try {
|
||||
$ver = & $cmd --version 2>&1
|
||||
if ("$ver" -match "Python 3") {
|
||||
$resolved = (Get-Command $cmd -ErrorAction SilentlyContinue)
|
||||
if ($resolved) { $pythonPath = $resolved.Source; break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
# ── If Python is per-user (AppData), install system-wide so LocalSystem can use it ──
|
||||
$needsSystemPython = $false
|
||||
if ($pythonPath -and ($pythonPath -match "AppData")) {
|
||||
Write-Host " Found per-user Python: $pythonPath" -ForegroundColor Yellow
|
||||
Write-Host " LocalSystem service needs system-wide Python. Installing..." -ForegroundColor Yellow
|
||||
$needsSystemPython = $true
|
||||
} elseif (-not $pythonPath) {
|
||||
Write-Host " Python 3 not found. Installing system-wide..." -ForegroundColor Yellow
|
||||
$needsSystemPython = $true
|
||||
}
|
||||
|
||||
if ($needsSystemPython) {
|
||||
Install-PythonSystemWide
|
||||
# Locate the newly installed system-wide Python
|
||||
$pythonPath = $null
|
||||
foreach ($p in $systemPaths) {
|
||||
if (Test-Path $p) {
|
||||
try {
|
||||
$ver = & $p --version 2>&1
|
||||
if ("$ver" -match "Python 3") { $pythonPath = $p; break }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $pythonPath) {
|
||||
Write-Error "System-wide Python not found after install. Open a new Admin PowerShell and re-run."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host " Python: $pythonPath" -ForegroundColor Green
|
||||
|
||||
# ── Install pywin32 (required for Windows service support) ────────────────────
|
||||
Write-Host "[2/6] Installing pywin32..." -ForegroundColor Cyan
|
||||
|
||||
# pip install
|
||||
$pipResult = & $pythonPath -m pip install --upgrade pywin32 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "pip install pywin32 failed (exit $LASTEXITCODE).`n$pipResult`nTry manually: $pythonPath -m pip install pywin32"
|
||||
}
|
||||
|
||||
# postinstall registers service runner DLLs — non-fatal if it fails
|
||||
try {
|
||||
$postResult = & $pythonPath -c "import pywin32_postinstall; pywin32_postinstall.install()" 2>&1
|
||||
Write-Host " pywin32 installed." -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " pywin32 installed (postinstall skipped — service should still work)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ── Create install directory and download agent ────────────────────────────────
|
||||
Write-Host "[3/6] Downloading agent..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
$wc = New-Object System.Net.WebClient
|
||||
$wc.Headers.Add("User-Agent", "JARVIS-Installer/3.1")
|
||||
$wc.DownloadFile("$JarvisUrl/agent/jarvis-agent-windows.py", $AgentScript)
|
||||
Write-Host " Downloaded to $AgentScript" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Error "Download failed: $_"
|
||||
}
|
||||
|
||||
# ── Write config ───────────────────────────────────────────────────────────────
|
||||
Write-Host "[4/6] Writing config..." -ForegroundColor Cyan
|
||||
$agentId = "${AgentName}_windows"
|
||||
$config = [ordered]@{
|
||||
jarvis_url = $JarvisUrl
|
||||
host_header = ""
|
||||
ssl_verify = $true
|
||||
registration_key = $Key
|
||||
agent_type = "windows"
|
||||
hostname = $AgentName
|
||||
agent_id = $agentId
|
||||
poll_interval = 30
|
||||
heartbeat_every = 10
|
||||
update_check_hours = 24
|
||||
watch_services = @("WinDefend", "Spooler")
|
||||
} | ConvertTo-Json -Depth 3
|
||||
|
||||
[System.IO.File]::WriteAllText($ConfigFile, $config, [System.Text.UTF8Encoding]::new($false))
|
||||
Write-Host " Config: $ConfigFile" -ForegroundColor Green
|
||||
|
||||
# ── Remove legacy scheduled task if present ────────────────────────────────────
|
||||
try {
|
||||
$oldTask = Get-ScheduledTask -TaskName $OldTaskName -ErrorAction SilentlyContinue
|
||||
if ($oldTask) {
|
||||
Stop-ScheduledTask -TaskName $OldTaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $OldTaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Write-Host " Removed legacy scheduled task '$OldTaskName'." -ForegroundColor Yellow
|
||||
}
|
||||
} catch {}
|
||||
|
||||
# ── Register Windows service ───────────────────────────────────────────────────
|
||||
Write-Host "[5/6] Registering Windows service '$ServiceName'..." -ForegroundColor Cyan
|
||||
|
||||
# Stop + remove any existing service first
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
# ── Stop existing service if running ─────────────────────────────────────────
|
||||
$existing = Get-Service -Name $SERVICE_NAME -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
if ($existing.Status -eq "Running") {
|
||||
Write-Host " Stopping existing service..." -NoNewline
|
||||
& $pythonPath $AgentScript stop 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " stopped." -ForegroundColor Yellow
|
||||
Write-Step "Stopping existing JARVIS Agent service..."
|
||||
if ($existing.Status -eq 'Running') {
|
||||
Stop-Service -Name $SERVICE_NAME -Force
|
||||
Start-Sleep 2
|
||||
}
|
||||
Write-Host " Removing existing service..." -NoNewline
|
||||
& $pythonPath $AgentScript remove 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host " removed." -ForegroundColor Yellow
|
||||
try {
|
||||
& python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null
|
||||
} catch {}
|
||||
Write-OK "Existing service removed."
|
||||
}
|
||||
|
||||
# Install the service (--startup auto = start at boot)
|
||||
& $pythonPath $AgentScript --startup auto install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Service registration failed (exit $LASTEXITCODE). Check that pywin32 postinstall completed."
|
||||
# ── Check / install Python ────────────────────────────────────────────────────
|
||||
Write-Step "Checking Python..."
|
||||
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $py) {
|
||||
Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run."
|
||||
}
|
||||
winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User")
|
||||
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" }
|
||||
}
|
||||
$pyVersion = & python --version 2>&1
|
||||
Write-OK $pyVersion
|
||||
|
||||
# ── Install pywin32 ───────────────────────────────────────────────────────────
|
||||
Write-Step "Checking pywin32..."
|
||||
$checkWin32 = & python -c "import win32service; print('ok')" 2>&1
|
||||
if ($checkWin32 -ne 'ok') {
|
||||
Write-Host " Installing pywin32..." -ForegroundColor Yellow
|
||||
& python -m pip install --quiet pywin32
|
||||
& python -m pywin32_postinstall -install 2>$null
|
||||
Write-OK "pywin32 installed."
|
||||
} else {
|
||||
Write-OK "pywin32 already installed."
|
||||
}
|
||||
|
||||
# Configure failure recovery: restart after 5s, 10s, 30s
|
||||
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null
|
||||
Write-Host " Service registered with auto-restart on failure." -ForegroundColor Green
|
||||
# ── Create install dir ────────────────────────────────────────────────────────
|
||||
Write-Step "Creating install directory..."
|
||||
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
|
||||
Write-OK $INSTALL_DIR
|
||||
|
||||
# ── Start the service ──────────────────────────────────────────────────────────
|
||||
Write-Host "[6/6] Starting service..." -ForegroundColor Cyan
|
||||
& $pythonPath $AgentScript start
|
||||
Start-Sleep -Seconds 4
|
||||
# ── Download agent script ─────────────────────────────────────────────────────
|
||||
Write-Step "Downloading JARVIS agent..."
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing
|
||||
Write-OK "Agent downloaded to $AGENT_SCRIPT"
|
||||
} catch {
|
||||
Write-Fail "Failed to download agent: $_"
|
||||
}
|
||||
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
$status = if ($svc) { $svc.Status } else { "NotFound" }
|
||||
$color = if ($status -eq "Running") { "Green" } else { "Yellow" }
|
||||
Write-Host " Service status: $status" -ForegroundColor $color
|
||||
# ── Get registration key ──────────────────────────────────────────────────────
|
||||
$regKey = $env:JARVIS_REG_KEY
|
||||
if (-not $regKey -and (Test-Path $CONFIG_FILE)) {
|
||||
$existingCfg = Get-Content $CONFIG_FILE | ConvertFrom-Json
|
||||
$regKey = $existingCfg.registration_key
|
||||
if ($regKey) { Write-OK "Using existing registration key from config." }
|
||||
}
|
||||
if (-not $regKey) {
|
||||
$regKey = Read-Host "`n Enter JARVIS registration key"
|
||||
if (-not $regKey) { Write-Fail "Registration key required." }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ====================================" -ForegroundColor Green
|
||||
Write-Host " Installation complete! " -ForegroundColor Green
|
||||
Write-Host " ====================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " Machine : $AgentName ($agentId)" -ForegroundColor White
|
||||
Write-Host " JARVIS : $JarvisUrl" -ForegroundColor White
|
||||
Write-Host " Python : $pythonPath" -ForegroundColor White
|
||||
Write-Host " Logs : $InstallDir\jarvis-agent.log" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " Manage the service:" -ForegroundColor Gray
|
||||
Write-Host " Get-Service JARVISAgent" -ForegroundColor Gray
|
||||
Write-Host " Start-Service JARVISAgent" -ForegroundColor Gray
|
||||
Write-Host " Stop-Service JARVISAgent" -ForegroundColor Gray
|
||||
Write-Host " Restart-Service JARVISAgent" -ForegroundColor Gray
|
||||
Write-Host " Get-Content '$InstallDir\jarvis-agent.log' -Tail 30 -Wait" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " To uninstall:" -ForegroundColor Gray
|
||||
Write-Host " Stop-Service JARVISAgent" -ForegroundColor Gray
|
||||
Write-Host " & '$pythonPath' '$AgentScript' remove" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
# ── Get hostname ──────────────────────────────────────────────────────────────
|
||||
$hostname = $env:COMPUTERNAME
|
||||
$customHostname = $env:JARVIS_HOSTNAME
|
||||
if ($customHostname) { $hostname = $customHostname }
|
||||
|
||||
# ── Write config ──────────────────────────────────────────────────────────────
|
||||
Write-Step "Writing config..."
|
||||
$cfg = @{
|
||||
jarvis_url = $JARVIS_URL
|
||||
registration_key = $regKey
|
||||
hostname = $hostname
|
||||
agent_type = 'windows'
|
||||
ssl_verify = $true
|
||||
poll_interval = 30
|
||||
heartbeat_every = 10
|
||||
update_check_hours = 24
|
||||
watch_services = @('WinDefend', 'Spooler', 'wuauserv')
|
||||
} | ConvertTo-Json -Depth 5
|
||||
$cfg | Out-File -FilePath $CONFIG_FILE -Encoding utf8
|
||||
Write-OK "Config written to $CONFIG_FILE"
|
||||
|
||||
# ── Install Windows Service ───────────────────────────────────────────────────
|
||||
Write-Step "Installing Windows service..."
|
||||
$pyPath = (Get-Command python).Source
|
||||
& $pyPath "$AGENT_SCRIPT" --startup auto install
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." }
|
||||
Write-OK "Service '$SERVICE_NAME' installed."
|
||||
|
||||
# ── Start service ─────────────────────────────────────────────────────────────
|
||||
Write-Step "Starting service..."
|
||||
Start-Service -Name $SERVICE_NAME
|
||||
Start-Sleep 3
|
||||
$svc = Get-Service -Name $SERVICE_NAME
|
||||
if ($svc.Status -ne 'Running') { Write-Fail "Service failed to start. Check C:\ProgramData\jarvis-agent\jarvis-agent.log" }
|
||||
Write-OK "Service is running."
|
||||
|
||||
# ── Test connectivity ─────────────────────────────────────────────────────────
|
||||
Write-Step "Testing JARVIS connection..."
|
||||
try {
|
||||
$ping = Invoke-RestMethod -Uri "$JARVIS_URL/api/ping" -TimeoutSec 10
|
||||
Write-OK "JARVIS is online: $($ping.codename)"
|
||||
} catch {
|
||||
Write-Host " WARNING: Could not reach JARVIS at $JARVIS_URL - check connectivity." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host " JARVIS Agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host " Hostname: $hostname" -ForegroundColor Green
|
||||
Write-Host " Service: $SERVICE_NAME (auto-start at boot)" -ForegroundColor Green
|
||||
Write-Host " Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Green
|
||||
Write-Host "========================================`n" -ForegroundColor Green
|
||||
|
||||
@@ -9,8 +9,8 @@ set -e
|
||||
|
||||
HOSTNAME_ARG="${1:-$(hostname -s)}"
|
||||
AGENT_TYPE="${2:-linux}"
|
||||
JARVIS_URL="https://165.22.1.228"
|
||||
JARVIS_HOST="jarvis.orbishosting.com"
|
||||
JARVIS_URL="${JARVIS_URL:-https://jarvis.orbishosting.com}"
|
||||
JARVIS_HOST=""
|
||||
INSTALL_DIR="/opt/jarvis-agent"
|
||||
CONFIG_DIR="/etc/jarvis-agent"
|
||||
STATE_DIR="/var/lib/jarvis-agent"
|
||||
@@ -38,7 +38,7 @@ mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR"
|
||||
|
||||
# ── Download agent ─────────────────────────────────────────────────────────────
|
||||
echo "Downloading agent..."
|
||||
curl -sk -H "Host: $JARVIS_HOST" "$JARVIS_URL/agent/jarvis-agent.py" -o "$INSTALL_DIR/jarvis-agent.py"
|
||||
curl -sk "$JARVIS_URL/agent/jarvis-agent.py" -o "$INSTALL_DIR/jarvis-agent.py"
|
||||
cp "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py
|
||||
chmod +x "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py
|
||||
|
||||
@@ -50,7 +50,7 @@ else
|
||||
{
|
||||
"jarvis_url": "$JARVIS_URL",
|
||||
"host_header": "$JARVIS_HOST",
|
||||
"ssl_verify": false,
|
||||
"ssl_verify": true,
|
||||
"registration_key": "$REG_KEY",
|
||||
"hostname": "$HOSTNAME_ARG",
|
||||
"agent_type": "$AGENT_TYPE",
|
||||
|
||||
@@ -28,11 +28,45 @@ AGENT_VERSION = "3.1"
|
||||
# ── Config helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict:
|
||||
legacy_path = "/opt/jarvis-agent/config.json"
|
||||
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
if os.path.exists(legacy_path):
|
||||
print(f"[JARVIS] Config found at legacy path {legacy_path} - migrating...", flush=True)
|
||||
Path(CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(legacy_path) as f:
|
||||
cfg = json.load(f)
|
||||
else:
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
with open(CONFIG_PATH) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
# Migrate old key names so the agent self-heals instead of crash-looping
|
||||
import re as _re
|
||||
changed = False
|
||||
if "server_url" in cfg and "jarvis_url" not in cfg:
|
||||
cfg["jarvis_url"] = cfg.pop("server_url")
|
||||
print("[JARVIS] Config migrated: server_url -> jarvis_url", flush=True)
|
||||
changed = True
|
||||
if "api_key" in cfg and "registration_key" not in cfg:
|
||||
cfg["registration_key"] = cfg.pop("api_key")
|
||||
print("[JARVIS] Config migrated: api_key -> registration_key", flush=True)
|
||||
changed = True
|
||||
if "hostname" not in cfg:
|
||||
cfg["hostname"] = socket.gethostname()
|
||||
changed = True
|
||||
if "ssl_verify" not in cfg:
|
||||
cfg["ssl_verify"] = not bool(_re.match(r"https?://\d+\.\d+\.\d+\.\d+", cfg.get("jarvis_url", "")))
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
print("[JARVIS] Config saved after migration.", flush=True)
|
||||
|
||||
return cfg
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
@@ -265,11 +299,23 @@ def get_load() -> list:
|
||||
except Exception:
|
||||
return [0, 0, 0]
|
||||
|
||||
def get_nordvpn_status() -> dict | None:
|
||||
"""Check nordlynx WireGuard interface. Returns None if nordlynx not present on this host."""
|
||||
try:
|
||||
r = subprocess.run(["ip", "link", "show", "nordlynx"],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
active = "UP,LOWER_UP" in r.stdout or "state UP" in r.stdout
|
||||
return {"active": active, "interface": "nordlynx"}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def collect_metrics(cfg: dict) -> dict:
|
||||
# First reading for CPU delta
|
||||
get_cpu_percent()
|
||||
time.sleep(1)
|
||||
return {
|
||||
metrics = {
|
||||
"hostname": cfg.get("hostname", socket.gethostname()),
|
||||
"cpu_percent": get_cpu_percent(),
|
||||
"memory": get_memory(),
|
||||
@@ -280,6 +326,10 @@ def collect_metrics(cfg: dict) -> dict:
|
||||
"platform": platform.system(),
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
nordvpn = get_nordvpn_status()
|
||||
if nordvpn is not None:
|
||||
metrics["nordvpn"] = nordvpn
|
||||
return metrics
|
||||
|
||||
# ── Proxmox metrics ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -384,7 +434,7 @@ def main():
|
||||
|
||||
try:
|
||||
# Heartbeat + get commands
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify)
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {"version": AGENT_VERSION}, headers, ssl_verify=ssl_verify)
|
||||
if "error" in hb:
|
||||
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
||||
else:
|
||||
|
||||
@@ -1 +1 @@
|
||||
1a9e8e24e5aee8f27a5900b6340373023ff2171e844e71e451eecdbf3b2b0f03 jarvis-agent.py
|
||||
6ba92a1ad4f91a218cbc4ce6834c55e8f56a0e22fca04278d77260958e429d5b
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS HA Poller — pulls entity states from Home Assistant REST API
|
||||
and pushes them to JARVIS as a homeassistant-type agent.
|
||||
Runs on VM211 as a systemd service (jarvis-ha-poller).
|
||||
|
||||
Config: /etc/jarvis-agent/ha-poller.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json"
|
||||
AGENT_VERSION = "1.0"
|
||||
AGENT_ID = "homeassistant_ha"
|
||||
HOSTNAME = "homeassistant"
|
||||
|
||||
# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean)
|
||||
SKIP_DOMAINS = {
|
||||
'sensor', 'binary_sensor', 'button', 'update', 'select', 'number',
|
||||
'device_tracker', 'event', 'image', 'person', 'zone', 'tts',
|
||||
'conversation', 'assist_satellite', 'input_button', 'media_player',
|
||||
'scene', 'water_heater', 'alarm_control_panel', 'automation',
|
||||
'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification',
|
||||
'tag', 'system_health', 'timer', 'counter',
|
||||
'camera', 'siren', 'remote', 'todo', 'lawn_mower',
|
||||
}
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _ssl_ctx(verify: bool):
|
||||
if not verify:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log(f"HA API error: {e}")
|
||||
return None
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
reg_key = cfg["registration_key"]
|
||||
|
||||
log(f"Registering HA poller with JARVIS at {jarvis_url}...")
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/register",
|
||||
{
|
||||
"hostname": HOSTNAME,
|
||||
"version": AGENT_VERSION,
|
||||
"agent_type": "homeassistant",
|
||||
"ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0],
|
||||
"capabilities": ["ha_entities", "ha_state"],
|
||||
"agent_id": AGENT_ID,
|
||||
},
|
||||
{"X-Registration-Key": reg_key},
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Registration failed: {result['error']}")
|
||||
return ""
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = AGENT_ID
|
||||
save_state(state)
|
||||
log(f"Registered. agent_id={AGENT_ID}")
|
||||
return api_key
|
||||
|
||||
def push_entities(cfg: dict, api_key: str, entities: list) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
|
||||
# Send in batches of 200
|
||||
batch_size = 200
|
||||
total = len(entities)
|
||||
ok = True
|
||||
for i in range(0, total, batch_size):
|
||||
batch = entities[i:i+batch_size]
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/ha_state",
|
||||
{"entities": batch},
|
||||
headers,
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Push batch {i//batch_size+1} failed: {result['error']}")
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def heartbeat(cfg: dict, api_key: str) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/heartbeat",
|
||||
{"version": AGENT_VERSION},
|
||||
{"X-Agent-Key": api_key},
|
||||
ssl_verify,
|
||||
timeout=10,
|
||||
)
|
||||
return "error" not in result
|
||||
|
||||
def fetch_ha_states(cfg: dict) -> list:
|
||||
ha_url = cfg["ha_url"].rstrip("/")
|
||||
token = cfg["ha_token"]
|
||||
states = ha_get(f"{ha_url}/api/states", token)
|
||||
if not states or not isinstance(states, list):
|
||||
return []
|
||||
|
||||
entities = []
|
||||
for s in states:
|
||||
entity_id = s.get("entity_id", "")
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if domain in SKIP_DOMAINS:
|
||||
continue
|
||||
attrs = s.get("attributes", {})
|
||||
# Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime
|
||||
lc = s.get("last_changed", "")
|
||||
try:
|
||||
dt = datetime.fromisoformat(lc.replace("Z", "+00:00"))
|
||||
lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
lc = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
entities.append({
|
||||
"entity_id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": s.get("state", ""),
|
||||
"attributes": attrs,
|
||||
"last_changed": lc,
|
||||
})
|
||||
return entities
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
api_key = state.get("api_key", "")
|
||||
if not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
log("Could not register. Retrying in 60s...")
|
||||
time.sleep(60)
|
||||
main()
|
||||
return
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_push = 0
|
||||
log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Heartbeat
|
||||
if not heartbeat(cfg, api_key):
|
||||
log("Heartbeat failed (401?) — re-registering...")
|
||||
state.clear()
|
||||
save_state(state)
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Push entity states every poll_interval
|
||||
if now - last_push >= poll_interval:
|
||||
entities = fetch_ha_states(cfg)
|
||||
if entities:
|
||||
ok = push_entities(cfg, api_key, entities)
|
||||
if ok:
|
||||
log(f"Pushed {len(entities)} HA entities to JARVIS.")
|
||||
last_push = now
|
||||
else:
|
||||
log("No HA entities fetched (HA down or token invalid?)")
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1208,3 +1208,174 @@ body::after{
|
||||
/* ── AGENT TOPOLOGY ────────────────────────────────────────────────── */
|
||||
#agentTopoCanvas{background:transparent;border-top:1px solid rgba(0,212,255,0.08);display:block}
|
||||
#agent-topo-btn.active{background:rgba(0,212,255,0.15);border-color:rgba(0,212,255,0.5)}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
FIRE HD 8 (12th Gen) TABLET MODE
|
||||
Applied via body.tablet-mode — set automatically on Silk UA detection
|
||||
Target: 1280×800 landscape, 189 PPI, touch-only input
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Prevent accidental text selection on touch; restore for inputs */
|
||||
body.tablet-mode { -webkit-user-select:none; user-select:none; }
|
||||
body.tablet-mode input,
|
||||
body.tablet-mode textarea { -webkit-user-select:auto; user-select:auto; }
|
||||
|
||||
/* ── TOPBAR — taller row, bigger tap zones ─────────────────────── */
|
||||
body.tablet-mode #topBar {
|
||||
height:54px;
|
||||
padding:0 12px;
|
||||
}
|
||||
body.tablet-mode #clock { font-size:1.1rem; letter-spacing:3px; }
|
||||
body.tablet-mode .tb-logo { font-size:0.95rem; }
|
||||
|
||||
/* Toolbar buttons — min 40px touch target */
|
||||
body.tablet-mode .btn-panels,
|
||||
body.tablet-mode .btn-camera {
|
||||
font-size:0.62rem;
|
||||
letter-spacing:1.5px;
|
||||
padding:9px 13px;
|
||||
min-height:40px;
|
||||
margin-right:4px;
|
||||
}
|
||||
|
||||
/* Theme color dots — bigger tap area */
|
||||
body.tablet-mode #themeBar { gap:6px; }
|
||||
body.tablet-mode .theme-btn {
|
||||
width:20px; height:20px;
|
||||
font-size:0.75rem;
|
||||
}
|
||||
|
||||
/* Swap + logout */
|
||||
body.tablet-mode #btn-swap-panels {
|
||||
font-size:0.65rem;
|
||||
padding:7px 11px;
|
||||
}
|
||||
body.tablet-mode .btn-logout {
|
||||
font-size:0.72rem;
|
||||
padding:7px 12px;
|
||||
}
|
||||
|
||||
/* ── MAIN LAYOUT — narrower side panels → wider center ──────────── */
|
||||
/* 220+220 side cols → center gets ~808px instead of ~660px */
|
||||
body.tablet-mode #mainLayout {
|
||||
grid-template-columns:220px 1fr 220px;
|
||||
padding:8px;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
/* ── PANELS — tighter padding, larger text ──────────────────────── */
|
||||
body.tablet-mode .panel { padding:11px; }
|
||||
body.tablet-mode .panel-title {
|
||||
font-size:0.67rem;
|
||||
letter-spacing:2.5px;
|
||||
margin-bottom:9px;
|
||||
}
|
||||
|
||||
/* Metric rows */
|
||||
body.tablet-mode .metric-label { font-size:0.75rem; }
|
||||
body.tablet-mode .service-row { font-size:0.75rem; padding:6px 0; }
|
||||
body.tablet-mode .val-row { font-size:0.75rem; padding:4px 0; }
|
||||
body.tablet-mode .device-item { font-size:0.75rem; padding:7px 0; }
|
||||
body.tablet-mode .device-name { font-size:0.75rem; }
|
||||
body.tablet-mode .device-ip { font-size:0.68rem; }
|
||||
body.tablet-mode .vm-card { font-size:0.75rem; padding:9px 10px; }
|
||||
|
||||
/* Scrollable side panels — smooth touch inertia */
|
||||
body.tablet-mode #leftPanel,
|
||||
body.tablet-mode #rightPanel {
|
||||
-webkit-overflow-scrolling:touch;
|
||||
overscroll-behavior:contain;
|
||||
}
|
||||
|
||||
/* ── CENTER — arc reactor + chat ────────────────────────────────── */
|
||||
/* Scale reactor down so chat gets more vertical room */
|
||||
body.tablet-mode #arcReactor { width:180px; height:180px; }
|
||||
body.tablet-mode .arc-ring.r1 { width:180px; height:180px; }
|
||||
body.tablet-mode .arc-ring.r2 { width:159px; height:159px; }
|
||||
body.tablet-mode .arc-ring.r3 { width:139px; height:139px; }
|
||||
body.tablet-mode .arc-ring.r4 { width:118px; height:118px; }
|
||||
body.tablet-mode .arc-ring.r5 { width:94px; height:94px; }
|
||||
body.tablet-mode .arc-ring.r6 { width:72px; height:72px; }
|
||||
body.tablet-mode .arc-ring.r7 { width:51px; height:51px; }
|
||||
body.tablet-mode .arc-core { width:30px; height:30px; }
|
||||
|
||||
/* Chat messages — comfortable reading size */
|
||||
body.tablet-mode .msg {
|
||||
font-size:0.95rem;
|
||||
line-height:1.55;
|
||||
padding:11px 14px;
|
||||
}
|
||||
body.tablet-mode .msg.user { font-size:0.88rem; }
|
||||
body.tablet-mode .msg.system { font-size:0.78rem; }
|
||||
|
||||
/* Touch-scroll chat log */
|
||||
body.tablet-mode #chatLog {
|
||||
-webkit-overflow-scrolling:touch;
|
||||
overscroll-behavior:contain;
|
||||
}
|
||||
|
||||
/* Input row — 16px prevents Silk from zooming on focus */
|
||||
body.tablet-mode #textInput {
|
||||
font-size:1rem;
|
||||
min-height:46px;
|
||||
padding:12px 14px;
|
||||
}
|
||||
body.tablet-mode #sendBtn {
|
||||
font-size:0.68rem;
|
||||
min-height:46px;
|
||||
padding:0 18px;
|
||||
}
|
||||
body.tablet-mode #micBtn {
|
||||
width:52px; height:52px;
|
||||
flex-shrink:0;
|
||||
}
|
||||
body.tablet-mode #searchBtn {
|
||||
min-height:46px !important;
|
||||
padding:0 13px !important;
|
||||
font-size:1.1rem !important;
|
||||
}
|
||||
|
||||
/* ── TABS — bigger tap targets ──────────────────────────────────── */
|
||||
body.tablet-mode .tab {
|
||||
font-size:0.58rem;
|
||||
letter-spacing:1.5px;
|
||||
padding:9px 12px;
|
||||
}
|
||||
|
||||
/* ── HA TABLE — more readable on 8" ────────────────────────────── */
|
||||
body.tablet-mode .ha-thead th { font-size:0.55rem; padding:6px 3px 8px; }
|
||||
body.tablet-mode .ha-row td { font-size:0.74rem; padding:6px 3px; }
|
||||
|
||||
/* Toggle slider — bigger for fat fingers */
|
||||
body.tablet-mode .ha-toggle { width:36px; height:18px; }
|
||||
body.tablet-mode .ha-slider::before { width:12px; height:12px; left:2px; top:2px; }
|
||||
body.tablet-mode .ha-toggle input:checked + .ha-slider::before { transform:translateX(18px); }
|
||||
|
||||
/* ── DISABLE HOVER-RISE — not meaningful on touch ───────────────── */
|
||||
body.tablet-mode .panel:hover {
|
||||
transform:translateY(var(--pty,0px)) !important;
|
||||
border-color:var(--panel-border) !important;
|
||||
box-shadow:none !important;
|
||||
transition:none !important;
|
||||
}
|
||||
|
||||
/* ── ALERTS ──────────────────────────────────────────────────────── */
|
||||
body.tablet-mode .alert-item { font-size:0.75rem; padding:9px 11px; }
|
||||
|
||||
/* ── BOTTOM BAR ─────────────────────────────────────────────────── */
|
||||
body.tablet-mode #bottomBar { font-size:0.7rem; height:34px; }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
KIOSK MODE — hide noisy panels, keep it clean on Fire tablet
|
||||
Only active when body.kiosk-mode (fullscreen)
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
body.kiosk-mode #server-panel { display:none !important; }
|
||||
body.kiosk-mode #network-status-panel { display:none !important; }
|
||||
body.kiosk-mode #tab-btn-agents { display:none !important; }
|
||||
body.kiosk-mode #tab-btn-guardian { display:none !important; }
|
||||
body.kiosk-mode #tab-agents { display:none !important; }
|
||||
body.kiosk-mode #tab-guardian { display:none !important; }
|
||||
body.kiosk-mode #bb-ha-item { display:none !important; }
|
||||
body.kiosk-mode #bb-agents-item { display:none !important; }
|
||||
body.kiosk-mode #bb-memory-item { display:none !important; }
|
||||
body.kiosk-mode #bb-pve-item { display:none !important; }
|
||||
|
||||
@@ -397,7 +397,7 @@ 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'};
|
||||
const _svcLabels = {nginx:'WEB','php8.3-fpm':'PHP',mariadb:'DB','redis-server':'REDIS','jarvis-arc':'ARC','jarvis-agent':'AGENT'};
|
||||
|
||||
async function refreshAll() {
|
||||
_refreshTick++;
|
||||
@@ -550,6 +550,21 @@ function renderDO(d) {
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// WEB HOST (DO server agent metrics)
|
||||
const ds = d.do_server || {};
|
||||
const doStatus = document.getElementById('do-host-status');
|
||||
const doCpu = document.getElementById('do-cpu');
|
||||
const doMem = document.getElementById('do-mem');
|
||||
const doDisk = document.getElementById('do-disk');
|
||||
if (ds.online) {
|
||||
if (doStatus) { doStatus.textContent = '●'; doStatus.style.color = 'var(--green)'; }
|
||||
if (doCpu) doCpu.textContent = (ds.cpu || 0) + '%';
|
||||
if (doMem) doMem.textContent = (ds.mem || 0) + '%';
|
||||
if (doDisk) doDisk.textContent = (ds.disk || 0) + '%';
|
||||
} else {
|
||||
if (doStatus) { doStatus.textContent = '○'; doStatus.style.color = 'var(--red)'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNetwork() {
|
||||
@@ -1390,6 +1405,7 @@ function enterVoiceMode(source) {
|
||||
}
|
||||
|
||||
function exitVoiceMode() {
|
||||
if (document.body.classList.contains('kiosk-mode')) return;
|
||||
voiceMode = false;
|
||||
voiceMuted = false;
|
||||
updateMicBtn();
|
||||
@@ -1739,3 +1755,67 @@ document.addEventListener('keydown', function(e) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ── FIRE HD 8 TABLET DETECTION ────────────────────────────────────────────────────────
|
||||
const IS_SILK = /Silk\//i.test(navigator.userAgent);
|
||||
const IS_FIRE = /KFTT|KFOT|KFJWI|KFSOWI|KFTHWI|KFTHWA|KFAPWI|KFAPWA|KFARWI|KFASWI|KFMEWI|KFFOWI|KFSAWA|KFMAWI|KFGIWI|KFDOWI|KFTBWI|KFTRWI|KFKAWI/i.test(navigator.userAgent);
|
||||
function isTablet() { return IS_SILK || IS_FIRE; }
|
||||
|
||||
function applyTabletMode() {
|
||||
document.body.classList.add("tablet-mode");
|
||||
const kb = document.getElementById("kioskBtn");
|
||||
if (kb) kb.title = "Full-screen kiosk (Fire HD 8 layout active)";
|
||||
}
|
||||
if (isTablet()) applyTabletMode();
|
||||
|
||||
// ── KIOSK MODE ────────────────────────────────────────────────────────────────────────
|
||||
let _wakeLock = null;
|
||||
|
||||
async function toggleKiosk() {
|
||||
const btn = document.getElementById("kioskBtn");
|
||||
const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
|
||||
if (!isFs) {
|
||||
applyTabletMode();
|
||||
const el = document.documentElement;
|
||||
const req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
|
||||
if (req) req.call(el).catch(() => {});
|
||||
if ("wakeLock" in navigator) {
|
||||
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
|
||||
}
|
||||
document.body.classList.add("kiosk-mode");
|
||||
// Kiosk: silently activate mic + voice mode (no TTS greeting)
|
||||
if (typeof wakeFromSleep === "function" && isAsleep) wakeFromSleep();
|
||||
voiceMode = true; voiceMuted = false; voiceLastCmd = Date.now(); updateMicBtn();
|
||||
if (typeof startListening === "function" && !isListening) startListening();
|
||||
if (btn) { btn.textContent = "⧞ EXIT"; btn.style.color = "var(--cyan)"; }
|
||||
} else {
|
||||
const ex = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen;
|
||||
if (ex) ex.call(document).catch(() => {});
|
||||
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
|
||||
document.body.classList.remove("kiosk-mode");
|
||||
if (typeof stopListening === "function") stopListening();
|
||||
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
|
||||
if (!isTablet()) document.body.classList.remove("tablet-mode");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (_wakeLock && document.visibilityState === "visible") {
|
||||
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
|
||||
}
|
||||
});
|
||||
|
||||
function _onFsChange() {
|
||||
const btn = document.getElementById("kioskBtn");
|
||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
|
||||
document.body.classList.remove("kiosk-mode");
|
||||
if (typeof stopListening === "function") stopListening();
|
||||
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
|
||||
if (!isTablet()) document.body.classList.remove("tablet-mode");
|
||||
}
|
||||
}
|
||||
document.addEventListener("fullscreenchange", _onFsChange);
|
||||
document.addEventListener("webkitfullscreenchange", _onFsChange);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 (document.body.classList.contains("kiosk-mode")) return;
|
||||
if (isAsleep) return;
|
||||
isAsleep = true;
|
||||
|
||||
@@ -141,7 +142,7 @@ function closeNetMap(){
|
||||
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});
|
||||
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'10.48.200.211',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
|
||||
// Bucket
|
||||
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
|
||||
// Deduplicate agent devices by hostname (same logical host registered twice)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
// ── 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 = window.location.origin;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
// ── 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);
|
||||
if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('JARVIS ALERT', {
|
||||
body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.',
|
||||
icon: '/favicon.ico',
|
||||
});
|
||||
}
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// ── 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);
|
||||
|
||||
// ── COMMAND PALETTE (Ctrl+K) ──────────────────────────────────────────────
|
||||
const _PALETTE_COMMANDS = [
|
||||
{ label: 'Run a network scan', q: 'run a network scan', group: 'Network' },
|
||||
{ label: 'Show online devices', q: 'who is online on the network', group: 'Network' },
|
||||
{ label: 'Proxmox status', q: 'proxmox status', group: 'Network' },
|
||||
{ label: 'Check agent status', q: 'check all agents', group: 'Agents' },
|
||||
{ label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' },
|
||||
{ label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' },
|
||||
{ label: 'Daily briefing', q: 'daily briefing', group: 'Planner' },
|
||||
{ label: 'My tasks today', q: 'my tasks today', group: 'Planner' },
|
||||
{ label: 'My calendar', q: 'my calendar', group: 'Planner' },
|
||||
{ label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' },
|
||||
{ label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' },
|
||||
{ label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' },
|
||||
{ label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' },
|
||||
{ label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'},
|
||||
{ label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'},
|
||||
{ label: 'Focus mode', q: 'focus mode', group: 'UI' },
|
||||
{ label: 'Show all panels', q: 'show all panels', group: 'UI' },
|
||||
{ label: 'Check alerts', q: 'check alerts', group: 'System' },
|
||||
{ label: 'Site health', q: 'site health', group: 'System' },
|
||||
{ label: 'System status', q: 'system status', group: 'System' },
|
||||
{ label: 'Check inbox', q: 'check inbox', group: 'Comms' },
|
||||
{ label: 'Search history…', q: '', group: 'Chat', search: true },
|
||||
];
|
||||
|
||||
let _paletteOpen = false;
|
||||
|
||||
function openPalette() {
|
||||
if (_paletteOpen) return;
|
||||
_paletteOpen = true;
|
||||
const ov = document.getElementById('cmdPalette');
|
||||
if (!ov) return;
|
||||
ov.style.display = 'flex';
|
||||
const inp = document.getElementById('cmdPaletteInput');
|
||||
inp.value = '';
|
||||
renderPaletteItems('');
|
||||
requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); });
|
||||
}
|
||||
|
||||
function closePalette() {
|
||||
if (!_paletteOpen) return;
|
||||
_paletteOpen = false;
|
||||
const ov = document.getElementById('cmdPalette');
|
||||
if (!ov) return;
|
||||
ov.classList.remove('open');
|
||||
setTimeout(() => { ov.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
function renderPaletteItems(q) {
|
||||
const list = document.getElementById('cmdPaletteList');
|
||||
if (!list) return;
|
||||
const low = q.toLowerCase().trim();
|
||||
const filtered = low
|
||||
? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low))
|
||||
: _PALETTE_COMMANDS;
|
||||
|
||||
let currentGroup = null;
|
||||
list.innerHTML = '';
|
||||
filtered.forEach((cmd, i) => {
|
||||
if (cmd.group !== currentGroup) {
|
||||
currentGroup = cmd.group;
|
||||
const g = document.createElement('div');
|
||||
g.className = 'cp-group';
|
||||
g.textContent = cmd.group;
|
||||
list.appendChild(g);
|
||||
}
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cp-item' + (i === 0 ? ' cp-active' : '');
|
||||
row.dataset.q = cmd.q;
|
||||
row.dataset.search = cmd.search ? '1' : '';
|
||||
const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'),
|
||||
m => `<mark>${m}</mark>`);
|
||||
row.innerHTML = `<span class="cp-icon">◈</span><span class="cp-label">${lbl}</span><kbd class="cp-kbd">↵</kbd>`;
|
||||
row.addEventListener('click', () => firePaletteItem(row));
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function movePaletteSelection(dir) {
|
||||
const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item'));
|
||||
if (!items.length) return;
|
||||
const cur = items.findIndex(el => el.classList.contains('cp-active'));
|
||||
const next = (cur + dir + items.length) % items.length;
|
||||
items.forEach(el => el.classList.remove('cp-active'));
|
||||
items[next].classList.add('cp-active');
|
||||
items[next].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function firePaletteItem(el) {
|
||||
if (!el) {
|
||||
const active = document.querySelector('#cmdPaletteList .cp-active');
|
||||
if (!active) return;
|
||||
el = active;
|
||||
}
|
||||
const q = el.dataset.q;
|
||||
const isSearch = el.dataset.search === '1';
|
||||
closePalette();
|
||||
if (isSearch) {
|
||||
if (typeof openSearchModal === 'function') openSearchModal();
|
||||
return;
|
||||
}
|
||||
if (q) {
|
||||
document.getElementById('textInput').value = q;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard events
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
_paletteOpen ? closePalette() : openPalette();
|
||||
return;
|
||||
}
|
||||
if (!_paletteOpen) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); }
|
||||
});
|
||||
|
||||
// Filter on type
|
||||
document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
|
||||
renderPaletteItems(e.target.value);
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
document.getElementById('cmdPalette')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'cmdPalette') closePalette();
|
||||
});
|
||||
|
||||
// ── AGENT TOPOLOGY MAP ─────────────────────────────────────────────────────────────
|
||||
let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = [];
|
||||
|
||||
function toggleAgentTopo() {
|
||||
_agentTopoMode = !_agentTopoMode;
|
||||
const btn = document.getElementById('agent-topo-btn');
|
||||
const list = document.getElementById('agents-list');
|
||||
const cvs = document.getElementById('agentTopoCanvas');
|
||||
if (!btn || !list || !cvs) return;
|
||||
btn.classList.toggle('active', _agentTopoMode);
|
||||
if (_agentTopoMode) {
|
||||
list.style.display = 'none'; cvs.style.display = 'block';
|
||||
_buildAgentTopoData(); _drawAgentTopo();
|
||||
} else {
|
||||
list.style.display = 'block'; cvs.style.display = 'none';
|
||||
if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function _buildAgentTopoData() {
|
||||
// Build node list from rendered agent cards
|
||||
_agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}];
|
||||
document.querySelectorAll('.agent-card').forEach(el => {
|
||||
const nameEl = el.querySelector('.agent-name, [class*="name"]');
|
||||
if (!nameEl) return;
|
||||
const name = nameEl.textContent.trim();
|
||||
const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online');
|
||||
const lname = name.toLowerCase();
|
||||
let type = 'linux';
|
||||
if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox';
|
||||
else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant';
|
||||
else if (lname.includes('windows') || lname.includes('mini')) type = 'windows';
|
||||
_agentTopoData.push({id:name, label:name.substring(0,12), online, type});
|
||||
});
|
||||
// Fallback: use last known registered agent list if cards not rendered
|
||||
if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') {
|
||||
(_lastAgents || []).forEach(a => {
|
||||
_agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _drawAgentTopo() {
|
||||
const cvs = document.getElementById('agentTopoCanvas');
|
||||
if (!cvs || !_agentTopoMode) return;
|
||||
const ctx = cvs.getContext('2d');
|
||||
const rect = cvs.getBoundingClientRect();
|
||||
const W = rect.width || 280, H = rect.height || 260;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
cvs.width = W * dpr; cvs.height = H * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68};
|
||||
const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'};
|
||||
// Assign positions
|
||||
const byType = {};
|
||||
_agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); });
|
||||
_agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2;
|
||||
Object.entries(byType).forEach(([tp, nodes]) => {
|
||||
const rf = typeRing[tp] || 0.68;
|
||||
const r = Math.min(W, H) / 2 * rf;
|
||||
nodes.forEach((n, i) => {
|
||||
const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2;
|
||||
n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r;
|
||||
});
|
||||
});
|
||||
let t = 0;
|
||||
function frame() {
|
||||
if (!_agentTopoMode) return;
|
||||
t += 0.007; ctx.clearRect(0, 0, W, H);
|
||||
// Orbit rings
|
||||
[0.28, 0.48, 0.68].forEach(rf => {
|
||||
ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2);
|
||||
ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke();
|
||||
});
|
||||
// Edges
|
||||
_agentTopoData.slice(1).forEach(n => {
|
||||
if (!n.x) return;
|
||||
const col = typeColor[n.type] || '0,190,255';
|
||||
ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y);
|
||||
ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)';
|
||||
ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke();
|
||||
});
|
||||
// Particles
|
||||
_agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => {
|
||||
const p = ((t*0.35+i*0.41)%1);
|
||||
const col = typeColor[n.type]||'0,190,255';
|
||||
const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p;
|
||||
ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2);
|
||||
ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill();
|
||||
});
|
||||
// Nodes
|
||||
_agentTopoData.forEach((n,i) => {
|
||||
if (!n.x) return;
|
||||
const col = typeColor[n.type]||'0,190,255';
|
||||
const nr = n.type==='hub' ? 13 : 7;
|
||||
const pulse = Math.sin(t+i*0.9)*0.25+0.75;
|
||||
if (n.online||n.type==='hub') {
|
||||
const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5);
|
||||
g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')');
|
||||
g.addColorStop(1,'transparent');
|
||||
ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2);
|
||||
ctx.fillStyle=g; ctx.fill();
|
||||
}
|
||||
ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2);
|
||||
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke();
|
||||
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)';
|
||||
ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace';
|
||||
ctx.textAlign='center';
|
||||
ctx.fillText(n.label, n.x, n.y+nr+9);
|
||||
});
|
||||
_agentTopoRaf = requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
}
|
||||
+24
-14
@@ -53,7 +53,7 @@
|
||||
<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">JARVIS VM <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>
|
||||
@@ -65,6 +65,7 @@
|
||||
<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="kioskBtn" class="btn-panels" onclick="toggleKiosk()" title="Full-screen kiosk mode">⛶ KIOSK</button>
|
||||
<button id="agentBtn" class="btn-agent" onclick="openAgentModal()" title="Install JARVIS Agent on this machine"><div class="agent-dot"></div>AGENT</button>
|
||||
|
||||
<div id="themeBar" style="display:flex;gap:3px;align-items:center;margin-right:2px">
|
||||
@@ -102,8 +103,8 @@
|
||||
</div>
|
||||
<div id="weather-forecast" style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px"></div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="panel" id="server-panel">
|
||||
<div class="panel-title">JARVIS SERVER <span style="font-size:0.5rem;color:var(--text-dim)">10.48.200.211</span><div class="indicator"></div></div>
|
||||
|
||||
<!-- Metric bars + sparklines -->
|
||||
<div class="metric-row">
|
||||
@@ -143,6 +144,13 @@
|
||||
<div class="loading-shimmer" style="margin-bottom:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Web Host (DO Server) -->
|
||||
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">WEB HOST <span id="do-host-status" style="color:var(--green)">●</span></div>
|
||||
<div id="do-host-stats" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;font-family:var(--font-mono);font-size:0.62rem">
|
||||
<div><div style="color:var(--text-dim);font-size:0.52rem">CPU</div><div id="do-cpu">--%</div></div>
|
||||
<div><div style="color:var(--text-dim);font-size:0.52rem">RAM</div><div id="do-mem">--%</div></div>
|
||||
<div><div style="color:var(--text-dim);font-size:0.52rem">DISK</div><div id="do-disk">--%</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Arc Reactor + Chat -->
|
||||
@@ -195,7 +203,7 @@
|
||||
<div id="rightPanel">
|
||||
|
||||
<!-- Network Status -->
|
||||
<div class="panel" style="flex:0 1 auto;max-height:35%;display:flex;flex-direction:column;min-height:100px">
|
||||
<div class="panel" id="network-status-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">
|
||||
@@ -220,7 +228,7 @@
|
||||
<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" id="tab-btn-agents" 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>
|
||||
@@ -292,17 +300,17 @@
|
||||
</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>
|
||||
<span>JARVIS VM</span> <span id="bb-do-status">CHECKING</span>
|
||||
</div>
|
||||
<div class="bb-item">
|
||||
<div class="bb-item" id="bb-pve-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-item" id="bb-ha-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-item" id="bb-agents-item">
|
||||
<div class="bb-dot" id="bb-agent-dot"></div>
|
||||
<span>AGENTS</span> <span id="bb-agent-status">--</span>
|
||||
</div>
|
||||
@@ -416,12 +424,14 @@
|
||||
<!-- 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 data-cfasync="false" src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
|
||||
|
||||
<script data-cfasync="false" src="assets/js/jarvis-effects.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-protocols.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-effects.js?v=20260621k"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260621k"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260621k"></script>
|
||||
<script data-cfasync="false" src="assets/js/panels/jarvis-arc.js?v=20260621k"></script>
|
||||
<script data-cfasync="false" src="assets/js/panels/jarvis-agents.js?v=20260621k"></script>
|
||||
<script data-cfasync="false" src="assets/js/panels/jarvis-assistant.js?v=20260621k"></script>
|
||||
|
||||
<!-- VISION LIGHTBOX -->
|
||||
<div id="vision-lightbox">
|
||||
|
||||
+2
-11
@@ -14,7 +14,7 @@ if (!defined('WEBHOOK_SECRET')) {
|
||||
exit;
|
||||
}
|
||||
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
|
||||
define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');
|
||||
define('DEPLOY_LOG', '/var/www/jarvis/logs/deploy.log');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -40,16 +40,7 @@ if ($ref !== 'refs/heads/main') {
|
||||
}
|
||||
|
||||
$repoMap = [
|
||||
'jarvis' => '/home/jarvis.orbishosting.com',
|
||||
'tomsjavajive' => '/home/tomsjavajive.com/public_html',
|
||||
'epictravelexpeditions' => '/home/epictravelexpeditions.com/public_html',
|
||||
'parkerslingshot' => '/home/epictravelexpeditions.com/parkerslingshot',
|
||||
'parkerslingshotrentals' => '/home/parkerslingshotrentals.com/public_html',
|
||||
'orbishosting' => '/home/orbishosting.com/public_html',
|
||||
'orbis-hosting-portal' => '/home/orbis.orbishosting.com/public_html',
|
||||
'tomtomgames' => '/home/tomtomgames.com/public_html',
|
||||
'infra' => '/opt/infra',
|
||||
'novacpx' => '__NOVACPX_VM__',
|
||||
'jarvis' => '/var/www/jarvis',
|
||||
];
|
||||
|
||||
if (!isset($repoMap[$repo])) {
|
||||
|
||||
Reference in New Issue
Block a user