mirror of
https://github.com/myronblair/jarvis
synced 2026-07-29 01:03:14 -05:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10350cbcd8 | |||
| 24bc876c1d | |||
| cfb5b2a3f9 | |||
| 0b1a19d9de | |||
| 66a5443f22 | |||
| e5536b077c | |||
| a292411d52 | |||
| 26b501b600 | |||
| 7327104612 | |||
| a13f750846 | |||
| 3c8dd8e206 | |||
| 554488aefa | |||
| e99c3aa171 | |||
| 2528b37ea1 | |||
| 46dabe3c31 | |||
| d6a1fc9456 | |||
| 2f74b98bbc | |||
| ed15ff12dd | |||
| 3f18cec739 | |||
| 8911645c20 | |||
| af03a2f2d8 | |||
| dfc92a6791 | |||
| 05522edb1d | |||
| 435af8ccc9 | |||
| a9ea75db98 | |||
| 651455cb47 | |||
| 90e4ded7c9 | |||
| c1275d47a6 | |||
| 08fbfaa3e4 | |||
| 1f25b5d04d | |||
| 84cd2ded50 | |||
| 89a82a1573 | |||
| 874f6e8c5c | |||
| 42a82c40cb | |||
| 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
|
||||
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
|
||||
return {}
|
||||
|
||||
def get_services(cfg: dict) -> list:
|
||||
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
|
||||
watch = cfg.get("watch_services", [])
|
||||
statuses = []
|
||||
for svc in watch:
|
||||
try:
|
||||
@@ -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()
|
||||
@@ -18,13 +18,13 @@ define(chr(39)+'CLAUDE_MODEL'.chr(39), chr(39)+'claude-sonnet-4-6'.chr(39))
|
||||
define(chr(39)+'CLAUDE_MAX_TOKENS'.chr(39), 1024);
|
||||
|
||||
define(chr(39)+'GROQ_API_KEY'.chr(39), chr(39)+'gsk_...'.chr(39));
|
||||
define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'groq/compound-mini'.chr(39));
|
||||
define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'compound-beta-mini'.chr(39));
|
||||
define(chr(39)+'GROQ_MODEL_GENERAL'.chr(39), chr(39)+'llama-3.3-70b-versatile'.chr(39));
|
||||
define(chr(39)+'GROQ_TIMEOUT'.chr(39), 30);
|
||||
|
||||
define(chr(39)+'OLLAMA_HOST'.chr(39), chr(39)+'http://10.48.200.95:11434'.chr(39));
|
||||
define(chr(39)+'OLLAMA_MODEL_PRIMARY'.chr(39), chr(39)+'llama3.2:1b'.chr(39));
|
||||
define(chr(39)+'OLLAMA_MODEL_HEAVY'.chr(39), chr(39)+'llama3.1:70b'.chr(39));
|
||||
define(chr(39)+'OLLAMA_HOST'.chr(39), chr(39)+'http://10.48.200.210:11434'.chr(39));
|
||||
define(chr(39)+'OLLAMA_MODEL_PRIMARY'.chr(39), chr(39)+'llama3.1:8b'.chr(39));
|
||||
define(chr(39)+'OLLAMA_MODEL_HEAVY'.chr(39), chr(39)+'llama3.1:8b'.chr(39));
|
||||
define(chr(39)+'OLLAMA_TIMEOUT'.chr(39), 90);
|
||||
|
||||
define(chr(39)+'LOCAL_SUBNET'.chr(39), chr(39)+'10.48.200'.chr(39));
|
||||
|
||||
+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,19 +181,25 @@ 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',
|
||||
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
|
||||
'orbishosting' => 'https://orbishosting.com',
|
||||
'orbisportal' => 'https://orbis.orbishosting.com',
|
||||
'tomtomgames' => 'https://tomtomgames.com',
|
||||
];
|
||||
$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
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS KB Intent Generator
|
||||
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
|
||||
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
|
||||
*
|
||||
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
|
||||
* Schedule: Daily – 3 am
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
|
||||
/* ── helpers ── */
|
||||
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
|
||||
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
|
||||
|
||||
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
|
||||
for ($attempt = 0; $attempt <= $retries; $attempt++) {
|
||||
if ($attempt > 0) {
|
||||
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
|
||||
sleep(25);
|
||||
}
|
||||
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . GROQ_API_KEY,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => 'llama-3.3-70b-versatile',
|
||||
'max_tokens' => $max,
|
||||
'temperature' => 0.7,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$info = curl_getinfo($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err || !$raw) continue;
|
||||
|
||||
// Check for rate limit response (429)
|
||||
if (($info['http_code'] ?? 0) === 429) continue;
|
||||
|
||||
$d = json_decode($raw, true);
|
||||
$content = $d['choices'][0]['message']['content'] ?? null;
|
||||
if ($content !== null) return $content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── normalize a pattern to valid PHP PCRE ── */
|
||||
function normalize_pattern(string $pat): string {
|
||||
$pat = trim($pat);
|
||||
// If pattern already has PCRE delimiters, leave it alone
|
||||
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
|
||||
// Strip any (?i) inline flag — we'll add /i at the delimiter level
|
||||
$pat = preg_replace('/^\(\?i\)/', '', $pat);
|
||||
// Escape forward slashes inside the pattern
|
||||
$pat = str_replace('/', '\\/', $pat);
|
||||
return '/' . $pat . '/i';
|
||||
}
|
||||
|
||||
/* ── run guard: skip if ran within last 4 hours ── */
|
||||
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
|
||||
$lastRun = JarvisDB::single(
|
||||
"SELECT updated_at FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
|
||||
);
|
||||
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
|
||||
if (!$forceRun && $lastRun && (time() - strtotime($lastRun['updated_at'])) < 14400) {
|
||||
log_line('Skipping – ran within last 4 hours. Use --force to override.');
|
||||
exit(0);
|
||||
}
|
||||
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
|
||||
|
||||
log_line('Starting daily KB intent generation run.');
|
||||
|
||||
/* ── load active topics from database ── */
|
||||
$BATCHES = JarvisDB::query(
|
||||
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
|
||||
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
|
||||
);
|
||||
if (empty($BATCHES)) {
|
||||
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
|
||||
exit(1);
|
||||
}
|
||||
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
|
||||
|
||||
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
|
||||
define('BATCH_SIZE', 25);
|
||||
$totalTopics = count($BATCHES);
|
||||
$offsetRow = JarvisDB::single(
|
||||
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
|
||||
);
|
||||
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
|
||||
if ($batchOffset >= $totalTopics) $batchOffset = 0;
|
||||
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
|
||||
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
|
||||
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
|
||||
|
||||
$runBatches = [];
|
||||
for ($i = 0; $i < BATCH_SIZE; $i++) {
|
||||
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
|
||||
}
|
||||
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
|
||||
log_line("Rotation: topics " . ($batchOffset + 1) . "–" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
|
||||
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
|
||||
|
||||
/* ── main generation loop ── */
|
||||
$totalBatches = count($runBatches);
|
||||
foreach ($runBatches as $idx => $batch) {
|
||||
$num = $idx + 1;
|
||||
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
|
||||
|
||||
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
|
||||
. "Subtopics to cover: {$batch['desc']}.\n"
|
||||
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
|
||||
. "Category string to use: \"{$batch['category']}\".";
|
||||
|
||||
$raw = groq($SYSTEM, $user, 5000);
|
||||
if ($raw === null) {
|
||||
log_line(" ✗ API call failed after retries – skipping batch.");
|
||||
$errors += 20;
|
||||
sleep(8);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip markdown code fences if model added them
|
||||
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
|
||||
$raw = preg_replace('/^```\s*/m', '', $raw);
|
||||
$raw = trim($raw);
|
||||
|
||||
// Extract JSON array; fall back to partial recovery for truncated responses
|
||||
$items = null;
|
||||
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
|
||||
$items = json_decode($m[0], true);
|
||||
if (!is_array($items)) {
|
||||
log_line(" ✗ JSON parse failed – skipping batch.");
|
||||
$errors += 20; sleep(8); continue;
|
||||
}
|
||||
} else {
|
||||
$start = strpos($raw, '[');
|
||||
if ($start !== false) {
|
||||
$partial = substr($raw, $start);
|
||||
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
|
||||
$recovered = '[' . implode(',', $objs[0]) . ']';
|
||||
$items = json_decode($recovered, true);
|
||||
if (is_array($items) && count($items) > 0)
|
||||
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
|
||||
}
|
||||
}
|
||||
if (!is_array($items) || count($items) === 0) {
|
||||
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
|
||||
$errors += 20; sleep(8); continue;
|
||||
}
|
||||
}
|
||||
|
||||
$batchInserted = 0;
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) continue;
|
||||
safe_insert($item, $batch['category']);
|
||||
$batchInserted++;
|
||||
}
|
||||
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
|
||||
|
||||
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
|
||||
if ($num < $totalBatches) sleep(8);
|
||||
}
|
||||
|
||||
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
|
||||
|
||||
/* ── cleanup phase ── */
|
||||
log_line('Starting cleanup phase...');
|
||||
|
||||
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
|
||||
$dups = JarvisDB::query(
|
||||
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
|
||||
);
|
||||
$dupsPruned = 0;
|
||||
foreach ($dups as $dup) {
|
||||
$rows = JarvisDB::query(
|
||||
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
|
||||
[$dup['intent_name']]
|
||||
);
|
||||
array_shift($rows);
|
||||
foreach ($rows as $row) {
|
||||
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
|
||||
$dupsPruned++;
|
||||
}
|
||||
}
|
||||
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
|
||||
|
||||
// 2. Remove intents with very short responses (< 40 chars)
|
||||
$shortPruned = JarvisDB::execute(
|
||||
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
|
||||
);
|
||||
log_line(" Short-response rows pruned: {$shortPruned}");
|
||||
|
||||
// 3. Trim whitespace on all generated intents
|
||||
JarvisDB::execute(
|
||||
"UPDATE kb_intents SET
|
||||
intent_name = TRIM(intent_name),
|
||||
pattern = TRIM(pattern),
|
||||
response_template = TRIM(response_template),
|
||||
fact_category = TRIM(fact_category)
|
||||
WHERE priority = 5"
|
||||
);
|
||||
log_line(' Whitespace trimmed on all generated intents.');
|
||||
|
||||
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
|
||||
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
|
||||
$badPattern = 0;
|
||||
$fixedPattern = 0;
|
||||
foreach ($all as $row) {
|
||||
$pat = normalize_pattern($row['pattern']);
|
||||
if ($pat !== $row['pattern']) {
|
||||
// Update to normalized form
|
||||
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
|
||||
$fixedPattern++;
|
||||
} elseif (@preg_match($pat, '') === false) {
|
||||
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
|
||||
$badPattern++;
|
||||
} else {
|
||||
// Valid pattern — make sure it's active
|
||||
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
|
||||
}
|
||||
}
|
||||
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
|
||||
|
||||
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
|
||||
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
|
||||
log_line(" Re-activated previously inactive intents: {$reactivated}");
|
||||
|
||||
// 6. Final stats
|
||||
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
|
||||
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
|
||||
|
||||
/* ── record last-run timestamp ── */
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'last_run', NOW(), 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
|
||||
[]
|
||||
);
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'batch_offset', ?, 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||
[$nextOffset]
|
||||
);
|
||||
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'last_inserted', ?, 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||
[$inserted]
|
||||
);
|
||||
|
||||
log_line('Done.');
|
||||
@@ -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');
|
||||
|
||||
+299
-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,96 @@ CREATE TABLE `users` (
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
--
|
||||
-- Table structure for table `guardian_config`
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `guardian_config` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`key_name` varchar(64) NOT NULL,
|
||||
`value` varchar(255) NOT NULL DEFAULT '',
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_key` (`key_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
--
|
||||
-- Table structure for table `guardian_events`
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `guardian_events` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`event_type` varchar(64) NOT NULL,
|
||||
`severity` enum('info','warning','critical') NOT NULL DEFAULT 'info',
|
||||
`agent_id` varchar(64) NOT NULL DEFAULT '',
|
||||
`hostname` varchar(128) NOT NULL DEFAULT '',
|
||||
`metric` varchar(64) NOT NULL DEFAULT '',
|
||||
`value` float NOT NULL DEFAULT 0,
|
||||
`threshold` float NOT NULL DEFAULT 0,
|
||||
`message` text NOT NULL,
|
||||
`ai_analysis` text NOT NULL DEFAULT '',
|
||||
`acknowledged` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_severity` (`severity`),
|
||||
KEY `idx_ack` (`acknowledged`),
|
||||
KEY `idx_created` (`created_at`),
|
||||
KEY `idx_agent` (`agent_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Dump completed on 2026-06-29 23:15:44
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `email_triage` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`msg_id` varchar(255) NOT NULL,
|
||||
`account` varchar(64) NOT NULL DEFAULT 'gmail',
|
||||
`from_name` varchar(255) DEFAULT NULL,
|
||||
`from_email` varchar(255) DEFAULT NULL,
|
||||
`subject` varchar(500) DEFAULT NULL,
|
||||
`date_received` datetime DEFAULT NULL,
|
||||
`category` varchar(32) NOT NULL DEFAULT 'info',
|
||||
`priority` int(11) NOT NULL DEFAULT 3,
|
||||
`summary` text DEFAULT NULL,
|
||||
`draft_reply` text DEFAULT NULL,
|
||||
`action_taken` varchar(32) NOT NULL DEFAULT 'none',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_msg` (`msg_id`),
|
||||
KEY `idx_category` (`category`),
|
||||
KEY `idx_action` (`action_taken`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `email_actions` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`msg_id` varchar(255) DEFAULT NULL,
|
||||
`from_name` varchar(255) DEFAULT NULL,
|
||||
`from_email` varchar(255) DEFAULT NULL,
|
||||
`subject` varchar(500) DEFAULT NULL,
|
||||
`received_at` datetime DEFAULT NULL,
|
||||
`suggested_title` varchar(255) DEFAULT NULL,
|
||||
`suggested_date` date DEFAULT NULL,
|
||||
`task_id` int(11) DEFAULT NULL,
|
||||
`appointment_id` int(11) DEFAULT NULL,
|
||||
`dismissed` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_dismissed` (`dismissed`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `email_sent` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`account` varchar(64) NOT NULL DEFAULT 'gmail',
|
||||
`to_email` varchar(255) NOT NULL,
|
||||
`to_name` varchar(255) DEFAULT NULL,
|
||||
`subject` varchar(500) DEFAULT NULL,
|
||||
`body` text DEFAULT NULL,
|
||||
`triage_id` int(11) DEFAULT NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'sent',
|
||||
`sent_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`error` text DEFAULT NULL,
|
||||
`message_id` varchar(255) DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_account` (`account`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=JARVIS Arc Reactor
|
||||
After=network-online.target mysql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/jarvis-arc
|
||||
ExecStart=/opt/jarvis-arc/venv/bin/python3 /opt/jarvis-arc/reactor.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=append:/home/jarvis.orbishosting.com/logs/arc_reactor.log
|
||||
StandardError=append:/home/jarvis.orbishosting.com/logs/arc_reactor.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# JARVIS backup — DB dump as tar.gz, admin-panel compatible
|
||||
BACKUP_DIR="/var/backups/jarvis"
|
||||
LOG="$BACKUP_DIR/backup.log"
|
||||
LOCK="$BACKUP_DIR/backup.lock"
|
||||
DB_NAME="jarvis_db"
|
||||
DB_USER="jarvis_user"
|
||||
DB_PASS="J4rv1s_Pr0t0c0l_2026!"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
OUTFILE="$BACKUP_DIR/jarvis_backup_${TIMESTAMP}.tar.gz"
|
||||
TMPDIR=$(mktemp -d)
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
touch "$LOCK"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup..." >> "$LOG"
|
||||
|
||||
cleanup() { rm -rf "$TMPDIR"; rm -f "$LOCK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
if mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$TMPDIR/jarvis_db.sql" 2>>"$LOG"; then
|
||||
tar -czf "$OUTFILE" -C "$TMPDIR" jarvis_db.sql
|
||||
SIZE=$(du -sh "$OUTFILE" | cut -f1)
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup OK: $(basename "$OUTFILE") ($SIYE)" >> "$LOG"
|
||||
else
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: mysqldump failed" >> "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
find "$BACKUP_DIR" -name "jarvis_backup_*.tar.gz" -mtime +7 -delete
|
||||
COUNT=$(ls "$BACKUP_DIR"/jarvis_backup_*.tar.gz 2>/dev/null | wc -l)
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done. Files retained: $COUNT" >> "$LOG"
|
||||
+35
-4
@@ -80,11 +80,42 @@ while IFS= read -r path; do
|
||||
systemctl reload lsws 2>/dev/null || systemctl restart lsws 2>/dev/null
|
||||
log "OLS reloaded for JARVIS deploy"
|
||||
|
||||
# Sync reactor.py to runtime location if it changed
|
||||
if echo "$CHANGED" | grep -q 'deploy/reactor.py'; then
|
||||
# Patch live config.php with correct Ollama host/model and Groq search model
|
||||
CONFIG="$path/api/config.php"
|
||||
if [ -f "$CONFIG" ]; then
|
||||
sed -i "s|http://10\.48\.200\.95:11434|http://10.48.200.210:11434|g" "$CONFIG"
|
||||
sed -i "s|'llama3\.2:1b'|'llama3.1:8b'|g" "$CONFIG"
|
||||
sed -i "s|\"llama3\.2:1b\"|\"llama3.1:8b\"|g" "$CONFIG"
|
||||
sed -i "s|'llama3\.1:70b'|'llama3.1:8b'|g" "$CONFIG"
|
||||
sed -i "s|\"llama3\.1:70b\"|\"llama3.1:8b\"|g" "$CONFIG"
|
||||
sed -i "s|'groq/compound-mini'|'compound-beta-mini'|g;s|\"groq/compound-mini\"|\"compound-beta-mini\"|g" "$CONFIG"
|
||||
log "Patched config.php: Ollama IP→.210, model→llama3.1:8b, Groq search→compound-beta-mini"
|
||||
fi
|
||||
|
||||
# Self-install the deploy script on every run
|
||||
cp "$path/deploy/jarvis-deploy.sh" /usr/local/bin/jarvis-deploy.sh 2>/dev/null && chmod +x /usr/local/bin/jarvis-deploy.sh
|
||||
|
||||
# Sync reactor.py + service file to runtime location if they changed
|
||||
if echo "$CHANGED" | grep -q 'deploy/reactor.py\|deploy/jarvis-arc.service\|deploy/requirements.txt'; then
|
||||
mkdir -p /opt/jarvis-arc /home/jarvis.orbishosting.com/logs
|
||||
cp "$path/deploy/reactor.py" /opt/jarvis-arc/reactor.py
|
||||
systemctl restart jarvis-arc
|
||||
log "Arc Reactor updated and restarted (reactor.py changed)"
|
||||
cp "$path/deploy/requirements.txt" /opt/jarvis-arc/requirements.txt 2>/dev/null
|
||||
# Bootstrap venv if it doesn't exist
|
||||
if [ ! -f /opt/jarvis-arc/venv/bin/activate ]; then
|
||||
log "Arc Reactor venv missing — creating and installing packages"
|
||||
python3 -m venv /opt/jarvis-arc/venv
|
||||
/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt
|
||||
log "Arc Reactor venv ready"
|
||||
fi
|
||||
if echo "$CHANGED" | grep -q 'deploy/jarvis-arc.service'; then
|
||||
cp "$path/deploy/jarvis-arc.service" /etc/systemd/system/jarvis-arc.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable jarvis-arc
|
||||
log "Arc Reactor service file installed and enabled"
|
||||
fi
|
||||
systemctl restart jarvis-arc 2>/dev/null || \
|
||||
bash -c "pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &"
|
||||
log "Arc Reactor updated and restarted"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+66
-50
@@ -41,16 +41,17 @@ DB_PORT = 3306
|
||||
DB_USER = "jarvis_user"
|
||||
DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
|
||||
DB_NAME = "jarvis_db"
|
||||
LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log"
|
||||
LOG_FILE = "/var/log/jarvis/arc_reactor.log"
|
||||
POLL_INTERVAL = 3
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
|
||||
CLAUDE_API_KEY = "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"
|
||||
CLAUDE_MODEL = "claude-sonnet-4-6"
|
||||
GROQ_API_KEY = "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"
|
||||
GROQ_API_KEY = "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"
|
||||
GROQ_MODEL = "llama-3.3-70b-versatile"
|
||||
OLLAMA_HOST = "http://10.48.200.95:11434"
|
||||
OLLAMA_MODEL = "llama3.2:1b"
|
||||
OLLAMA_HOST = "http://10.48.200.210:11434"
|
||||
OLLAMA_MODEL = "llama3.1:8b"
|
||||
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "") # e.g. "llava" or "moondream" -- empty = disabled
|
||||
|
||||
GMAIL_USER = "myronblair@gmail.com"
|
||||
GMAIL_PASS = "demsvdylwweacbcx"
|
||||
@@ -175,6 +176,57 @@ async def _ollama_call(messages: list, system: str = "") -> str:
|
||||
data = await resp.json()
|
||||
return data.get("response", "")
|
||||
|
||||
|
||||
# -- VISION CALL ----------------------------------------------------------
|
||||
async def _vision_call(image_b64: str, prompt: str) -> tuple:
|
||||
"""
|
||||
Vision provider cascade: Claude -> Ollama vision model -> graceful fallback.
|
||||
Returns (analysis: str, provider: str).
|
||||
To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava").
|
||||
"""
|
||||
# 1. Claude (primary)
|
||||
if CLAUDE_API_KEY:
|
||||
try:
|
||||
import anthropic as _anthropic
|
||||
_client = _anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
|
||||
_msg = await _client.messages.create(
|
||||
model="claude-opus-4-8-20251101",
|
||||
max_tokens=2048,
|
||||
messages=[{"role": "user", "content": [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_b64}},
|
||||
{"type": "text", "text": prompt},
|
||||
]}],
|
||||
)
|
||||
text = _msg.content[0].text if _msg.content else ""
|
||||
log.info("[VISION] Claude vision OK")
|
||||
return text, "claude"
|
||||
except Exception as e:
|
||||
log.warning(f"[VISION] Claude failed ({e}), trying next provider")
|
||||
|
||||
# 2. Ollama vision model (if configured via OLLAMA_VISION_MODEL env var)
|
||||
if OLLAMA_VISION_MODEL:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as _sess:
|
||||
_payload = {"model": OLLAMA_VISION_MODEL, "prompt": prompt,
|
||||
"images": [image_b64], "stream": False}
|
||||
async with _sess.post(f"{OLLAMA_HOST}/api/generate", json=_payload,
|
||||
timeout=aiohttp.ClientTimeout(total=120)) as _resp:
|
||||
if _resp.status == 200:
|
||||
_data = await _resp.json()
|
||||
text = _data.get("response", "")
|
||||
log.info(f"[VISION] Ollama vision OK ({OLLAMA_VISION_MODEL})")
|
||||
return text, f"ollama/{OLLAMA_VISION_MODEL}"
|
||||
raise RuntimeError(f"Ollama HTTP {_resp.status}")
|
||||
except Exception as e:
|
||||
log.warning(f"[VISION] Ollama vision failed ({e})")
|
||||
|
||||
# 3. No vision provider available
|
||||
msg = ("[Vision unavailable] Claude credits depleted and no local vision model configured. "
|
||||
"To enable: pull a vision model on Ollama (e.g. 'ollama pull llava') then set "
|
||||
"OLLAMA_VISION_MODEL=llava in the jarvis-arc systemd environment.")
|
||||
log.warning("[VISION] All vision providers unavailable")
|
||||
return msg, "none"
|
||||
|
||||
async def handle_llm(payload: dict) -> dict:
|
||||
message = payload.get("message", "")
|
||||
system = payload.get("system", "You are JARVIS, an Iron Man-style AI assistant.")
|
||||
@@ -662,34 +714,15 @@ async def handle_screenshot(payload: dict) -> dict:
|
||||
analysis = ""
|
||||
provider_used = ""
|
||||
if do_analyze and image_b64:
|
||||
try:
|
||||
import anthropic
|
||||
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
|
||||
msg = await client.messages.create(
|
||||
model="claude-opus-4-8-20251101",
|
||||
max_tokens=1024,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image", "source": {"type": "base64",
|
||||
"media_type": "image/png", "data": image_b64}},
|
||||
{"type": "text", "text": analyze_prompt},
|
||||
],
|
||||
}],
|
||||
)
|
||||
analysis = msg.content[0].text if msg.content else ""
|
||||
provider_used = "claude"
|
||||
log.info(f"[VISION] Claude analysis complete ({len(analysis)} chars)")
|
||||
except Exception as e:
|
||||
log.warning(f"[VISION] Claude vision failed: {e}")
|
||||
analysis = f"Vision analysis unavailable: {e}"
|
||||
analysis, provider_used = await _vision_call(image_b64, analyze_prompt)
|
||||
log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)")
|
||||
elif do_analyze and not image_b64 and result.get("snapshot_type") == "text":
|
||||
# Text-only sysinfo snapshot — summarize with LLM
|
||||
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}"
|
||||
|
||||
@@ -744,30 +777,13 @@ async def handle_vision(payload: dict) -> dict:
|
||||
|
||||
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}")
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
|
||||
msg = await client.messages.create(
|
||||
model="claude-opus-4-8-20251101",
|
||||
max_tokens=2048,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image", "source": {"type": "base64",
|
||||
"media_type": "image/png", "data": image_b64}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}],
|
||||
)
|
||||
analysis = msg.content[0].text if msg.content else ""
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Vision analysis failed: {e}")
|
||||
analysis, provider_used = await _vision_call(image_b64, prompt)
|
||||
|
||||
# Update stored screenshot if we have an ID
|
||||
if screenshot_id:
|
||||
await db_execute(
|
||||
"UPDATE agent_screenshots SET vision_analysis=%s, vision_provider=%s WHERE id=%s",
|
||||
(analysis, "claude", int(screenshot_id))
|
||||
(analysis, provider_used, int(screenshot_id))
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -775,7 +791,7 @@ async def handle_vision(payload: dict) -> dict:
|
||||
"screenshot_id": screenshot_id,
|
||||
"prompt": prompt,
|
||||
"analysis": analysis,
|
||||
"provider": "claude",
|
||||
"provider": provider_used,
|
||||
}
|
||||
|
||||
|
||||
@@ -1022,7 +1038,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 +1078,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})")
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiomysql
|
||||
aiohttp
|
||||
anthropic
|
||||
trafilatura
|
||||
@@ -1,5 +1,5 @@
|
||||
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
|
||||
**Last Updated:** 2026-06-14
|
||||
**Last Updated:** 2026-07-06
|
||||
**Owner:** Myron Blair — myronblair@outlook.com
|
||||
|
||||
---
|
||||
@@ -17,6 +17,7 @@
|
||||
10. [Backup Systems](#10-backup-systems)
|
||||
11. [SSH Quick Reference](#11-ssh-quick-reference)
|
||||
12. [Critical Credentials Master List](#12-critical-credentials-master-list)
|
||||
13. [Git & Repository Management](#13-git--repository-management)
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +30,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 (7 sites)
|
||||
│
|
||||
└─► [FusionPBX 134.209.72.226] — FreeSWITCH PBX (SSH via DO relay)
|
||||
|
||||
@@ -42,7 +43,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 + vision) — llama3.1:8b, llava:7b
|
||||
│ └── CT110 10.48.200.19 WireGuard exit container
|
||||
│
|
||||
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
|
||||
@@ -73,13 +74,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 (7 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 +88,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 +103,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 |
|
||||
|
||||
@@ -305,17 +306,20 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
|
||||
|
||||
---
|
||||
|
||||
### VM 210 — Ollama Local LLM (PVE1)
|
||||
### VM 210 — Ollama Local LLM + Vision (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) |
|
||||
| **SSH** | `ssh root@10.48.200.210` via PVE1 hop — password: `Joker1974!!!` |
|
||||
| **Purpose** | Local AI inference — chat (llama3.1:8b) + vision (llava:7b) |
|
||||
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
|
||||
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
|
||||
| **Models** | `llama3.1:8b` (chat/Tier 1), `llava:7b` (vision cascade) |
|
||||
|
||||
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
|
||||
**Vision cascade:** Arc Reactor calls Claude first; if Claude credits depleted, falls back to llava:7b via Ollama.
|
||||
Vision is enabled via: `/etc/systemd/system/jarvis-arc.service.d/vision.conf` → `OLLAMA_VISION_MODEL=llava:7b`
|
||||
|
||||
---
|
||||
|
||||
@@ -368,15 +372,15 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
|
||||
|
||||
All sites are at `/home/<domain>/public_html/` on DO (165.22.1.228).
|
||||
**Auto-deploy:** Push to `main` on GitHub → webhook → server pulls in ~1 min.
|
||||
**GitHub PAT:** `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` (expires ~2026-08-20)
|
||||
**GitHub PAT:** `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` (rotated 2026-07-05 — old PAT `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` was found exposed in `.git/config` on all 6 original sites and must be treated as compromised/revoked)
|
||||
|
||||
---
|
||||
|
||||
### 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 (port 80 — old `:1972` reference was wrong, corrected 2026-07-04) |
|
||||
| **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 |
|
||||
@@ -385,6 +389,24 @@ See Section 7 for full JARVIS details.
|
||||
|
||||
---
|
||||
|
||||
### worktracking.orbishosting.com — ChuckCo Time Keeper
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **URL** | https://worktracking.orbishosting.com |
|
||||
| **Path** | `/home/worktracking.orbishosting.com/public_html/` |
|
||||
| **GitHub** | `myronblair/chucko` (private) |
|
||||
| **Gitea** | `myron/chucko` (pull-mirror of GitHub) |
|
||||
| **Local clone** | `C:\Users\myron\repos\chucko` on admin Windows machine |
|
||||
| **Purpose** | Work-tracking app for a flat-rate 5-day (Fri–Thu) work week — self-reported hours via personal secret-URL tokens (no login), single shared admin password, phone-friendly screenshot pages for texting workers/payer |
|
||||
| **Admin URL** | `https://worktracking.orbishosting.com/admin/login.php` — password `Joker1974!!!` |
|
||||
| **DB** | `workt_track_db` / `workt_track_user` / `ZWCNMRP2N5NVPsghmve5aRS9` |
|
||||
| **Linked from Blair HQ** | `web.orbishosting.com` dashboard's "Websites" card has direct links to the admin login and the all-workers overview page (site-wide token `972f82cbf7832fdb2cffcdcc84129a4af69e30bd`) |
|
||||
| **Note** | Built 2026-07-05. `includes/config.php` (DB creds, admin password hash, site token) lives outside `public_html`/webroot and is intentionally NOT in the git repo. |
|
||||
| **Admin login rate limiting** | Added 2026-07-06 — `login_attempts` table (`ip_address`, `attempts`, `last_attempt`) in `workt_track_db`; 5 failed attempts locks that IP out for 15 minutes. |
|
||||
| **Code review (2026-07-06)** | 5 findings fixed and deployed: `w.php` mark_paid now rejects any `week_start` that isn't the true current week (was trusting client input); `admin/worker.php` no longer double-HTML-escapes the page title; `all.php` now shows a Paid/Unpaid badge per worker for the displayed week; `s.php`/`p.php`/`all.php` validate the `week` param before building dates (malformed input used to throw an uncaught DateTime exception); admin login rate-limited (see above). |
|
||||
|
||||
---
|
||||
|
||||
### tomsjavajive.com — Tom's Java Jive
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
@@ -468,11 +490,24 @@ 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
|
||||
**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/admin
|
||||
|
||||
### Security hardening (2026-07-06 code review)
|
||||
Full front-end + admin panel review found and fixed 8 issues, 2 of them live critical exposures:
|
||||
- **INFRASTRUCTURE-REFERENCE.md was publicly downloadable with zero auth** (static nginx path bypassed the admin session check entirely). Fixed: file moved to `/var/www/jarvis-private/INFRASTRUCTURE-REFERENCE.md` (owned `www-data:www-data`, mode 640, NOT under `public_html` so nginx never serves it directly), and the DOCS tab now downloads it via a new authenticated `docs_download` action in `admin/index.php` that gates on the existing `loggedIn()` check and streams the file with `readfile()`.
|
||||
- **Two full backup copies of the 5000-line admin panel (`index.php.bak2`, `index.php.bak.<date>`) were sitting in `public_html/admin/` and downloadable with no auth**, exposing the entire admin source/attack surface. Relocated to `/root/jarvis-old-backups/` (no secrets were found in them, so no credential rotation was needed).
|
||||
- `esc()` (the admin panel's JS HTML-escaper) doesn't escape `'`, so it doesn't protect values embedded inside a single-quoted JS string within an `onclick` attribute — HTML-decoding happens before the JS parser sees it. Added a proper `escJs()` helper (backslash + quote + newline escaping, then HTML-escape) and applied it to the Network/Alerts/Intents/Custom-News/Calendar-Feeds edit-modal `onclick` handlers, which were reachable by e.g. any device on the LAN setting a malicious DHCP/mDNS hostname.
|
||||
- Same class of bug on the front-end dashboard (`assets/js/jarvis-app.js`, `assets/js/panels/jarvis-agents.js`): device names and news article titles/sources were inserted into `innerHTML` completely unescaped — a rogue LAN device or a malicious/compromised news feed could inject script that runs with the logged-in session. Added `escHtml()`/`escJs()` helpers directly in `jarvis-app.js` and applied them to device names, VM names, agent hostnames, and news content.
|
||||
- `session.cookie_httponly` was **Off** server-wide (PHP default), meaning the actual session cookie — not just the app's own bearer token — was readable via `document.cookie` from any of the above XSS bugs. Fixed at the PHP-FPM level (`/etc/php/8.3/fpm/php.ini`): `session.cookie_httponly = 1`, `session.cookie_samesite = Lax`, `php8.3-fpm` restarted. Verified live: `Set-Cookie` now includes `HttpOnly; SameSite=Lax`.
|
||||
- `api.php` had `Access-Control-Allow-Origin: *` — tightened to an explicit allow-list of the real JARVIS origin only, with `Access-Control-Allow-Credentials: true` only sent when the origin matches.
|
||||
- Two admin actions (Arc Reactor restart/setup) called an undefined function `k()` instead of the real JSON responder `j()`, causing a PHP fatal error even though the underlying `systemctl` command still fired. Fixed (verified via direct API test — clean `{"ok":true,...}` response now).
|
||||
- Calendar feed passwords were stored and returned in plaintext via `cal_feeds_list`'s `SELECT *`. Changed to return a `has_password` boolean instead of the raw password (the edit UI never actually displayed the password back anyway — write-only field, "leave blank to keep").
|
||||
|
||||
All fixes verified via direct API testing (SSH + curl through the login/action flow) since this doesn't have a staging environment. Pushed to `myronblair/jarvis` master, commit `24bc876`.
|
||||
|
||||
### Architecture (end-to-end)
|
||||
|
||||
@@ -484,12 +519,49 @@ 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.1:8b) — local LLM
|
||||
Vision: Ollama llava:7b (via Arc Reactor _vision_call cascade)
|
||||
Tier 2: Groq (cloud, model: compound-beta-mini)
|
||||
Tier 3: Claude API (Anthropic, fallback)
|
||||
→ ElevenLabs TTS → browser speaker
|
||||
```
|
||||
|
||||
### Arc Reactor (AI Job Processor)
|
||||
**Service:** `jarvis-arc` (systemd) — port 7474
|
||||
**Runtime:** `/opt/jarvis-arc/` (Python venv, `reactor.py`)
|
||||
**Log:** `/var/log/jarvis/arc.log`
|
||||
**Admin button:** Workers → Daemons → `SETUP` (live popup) / `RESTART`
|
||||
**Vision:** Claude → Ollama llava:7b → graceful fallback
|
||||
**Vision config:** `/etc/systemd/system/jarvis-arc.service.d/vision.conf`
|
||||
|
||||
```bash
|
||||
systemctl status jarvis-arc
|
||||
systemctl restart jarvis-arc
|
||||
journalctl -u jarvis-arc -f
|
||||
```
|
||||
|
||||
To re-deploy Arc Reactor from source:
|
||||
Use **Workers → Daemons → SETUP** in JARVIS admin (live log popup shows progress).
|
||||
|
||||
### Planner: Tasks / Directives / Missions
|
||||
Admin UI sections backed by real schema + live API — verified working 2026-07-02 (created/read/deleted a test row in each, end-to-end).
|
||||
|
||||
**Tasks** — simple to-do list, stored directly in `jarvis_db`.
|
||||
- Table: `tasks` (title, notes, category, priority [urgent/high/normal/low], status [pending/in_progress/done/cancelled], due_date, due_time)
|
||||
- API: `task_list` (GET), `task_save` (POST, **form-encoded**), `task_done` (POST), `task_delete` (POST)
|
||||
|
||||
**Directives** — OKR-style goals with key results, stored directly in `jarvis_db`.
|
||||
- Tables: `directives` (title, description, category, status, priority, target_date) + `directive_key_results` (directive_id, title, current_value, target_value, unit) + `directive_links` (directive_id, link_type, link_id — links a directive to a task/etc.)
|
||||
- API: `directive_list`, `directive_get`, `directive_save` (POST, **JSON body** via `php://input`, id passed as `?id=` query param on update), `directive_delete`
|
||||
|
||||
**Missions** — automation workflows, NOT stored in `jarvis_db` — proxied through **Arc Reactor** (port 7474) which owns the mission state.
|
||||
- Arc Reactor endpoints used by admin: `GET/POST /missions`, `GET /missions/{id}`, `GET /missions/{id}/runs`, `PUT/POST /missions/{id}`, `DELETE /missions/{id}`, `POST /missions/{id}/run`
|
||||
- JARVIS-side mirror tables exist (`missions`, `mission_runs`, `mission_steps`) but the admin panel reads/writes live via Arc Reactor's HTTP API, not directly against these tables
|
||||
- API: `mission_list`, `mission_get`, `mission_runs`, `mission_save` (POST, **JSON body**, id as `?id=` on update), `mission_delete`, `mission_run`, `mission_toggle`
|
||||
- If Arc Reactor is down, these calls return `{"error":"Arc Reactor unreachable"}` — check `systemctl status jarvis-arc` first
|
||||
|
||||
As of 2026-07-02: all three tables are empty (0 rows) — features are fully functional, just unused so far.
|
||||
|
||||
### Deploy Pipeline
|
||||
```
|
||||
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
|
||||
@@ -501,7 +573,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)
|
||||
@@ -571,6 +643,7 @@ fs_cli -x "reloadacl" # reload ACL (safe)
|
||||
- WAN IP: 97.154.109.245 (dynamic)
|
||||
- DDNS: `orbisne.fortiddns.com` (FortiGate auto-updates on IP change)
|
||||
- Blocks: outbound port 53 (DNS) — MediaStack uses PVE1 dnsmasq (10.48.200.90) as resolver → 100.100.100.100
|
||||
- **Upstream DNS (changed 2026-07-05):** Network → DNS set to "Specify" mode — Primary `1.1.1.1` (Cloudflare), Secondary `8.8.4.4` (Google). Previously defaulted to the router itself (`10.48.200.1`)/ISP-provided servers. Admin: `https://10.48.200.1:9443` — `admin / Joker1974!!!`. Note: `8.8.8.8` specifically showed as "Unreachable" during setup (transient — ISP's own DNS servers were also showing high latency at that moment); `1.1.1.1`/`8.8.4.4` tested healthy and are what's live now.
|
||||
|
||||
**Port Forwards:**
|
||||
| External Port | Internal Destination | Purpose |
|
||||
@@ -594,6 +667,14 @@ fs_cli -x "reloadacl" # reload ACL (safe)
|
||||
|
||||
## 10. BACKUP SYSTEMS
|
||||
|
||||
### JARVIS Database Backup
|
||||
- **Script:** `/usr/local/bin/jarvis-backup.sh` (also at `/var/www/jarvis/deploy/`)
|
||||
- **Output:** `/var/backups/jarvis/jarvis_backup_TIMESTAMP.tar.gz`
|
||||
- **Log:** `/var/backups/jarvis/backup.log`
|
||||
- **Retention:** 7 days (auto-purge)
|
||||
- **Trigger:** JARVIS admin → Backups → RUN BACKUP NOW, or run script directly
|
||||
- **DB:** `jarvis_db` — `jarvis_user / J4rv1s_Pr0t0c0l_2026!`
|
||||
|
||||
### DO Server Backup
|
||||
- **Repo:** `myronblair/do-server-config`
|
||||
- **Schedule:** Weekly, Sunday 4am
|
||||
@@ -613,8 +694,22 @@ fs_cli -x "reloadacl" # reload ACL (safe)
|
||||
- **Repo:** `myronblair/fusionpbx-config`
|
||||
- **Schedule:** Weekly, Sunday 5am
|
||||
- **Launcher:** `/usr/local/bin/fusionpbx-backup`
|
||||
- **Covers:** PostgreSQL dump (gzip, ~29MB) + FreeSWITCH configs
|
||||
- **Covers:** PostgreSQL dump (gzip, ~29-60MB) + FreeSWITCH configs
|
||||
- **Restore:** 10-phase wizard in `restore.sh`
|
||||
- **Known issue (found 2026-07-05):** this repo has grown to ~166MB on GitHub / ~196MB on Gitea because the DB dump gets committed directly into git history on every backup run (3 copies in history as of this writing, each ~60MB) rather than being excluded/rotated. Options not yet decided: gitignore the dump going forward, or purge it from history with `git filter-repo` + force-push (destructive, needs explicit sign-off).
|
||||
|
||||
### MSP360 Backup Status (Dashboard Integration)
|
||||
- **Client software:** MSP360 (CloudBerry) Backup CLI installed on all 6 hosts — PVE1, JARVIS (211), NovaCPX (110), Jellyfin (33), MediaStack (35), Homebridge (18)
|
||||
- **Storage target:** `NAS-MSPBackups` destination → Synology NAS CIFS share, mounted at `/mnt/nas-backups/MSPBackups`
|
||||
- **Mount reliability:** `/usr/local/bin/msp360-mount-ensure.sh` (cron `*/15 * * * *` on hosts using the NAS mount) — bind-mounts the MSPBackups subdir onto itself since MSP360's pre-flight `mountpoint` check fails on a subdirectory of a CIFS mount otherwise
|
||||
- **Collector:** `/usr/local/bin/backup-status-collect.sh` on PVE1 (runs via key-trusted root SSH — PVE1 is the only host with passwordless SSH to all 6 targets; other hosts use password auth via `sshpass`)
|
||||
- Queries each host's plan via `cbb plan -l` (legacy v1 CLI — outputs `State:` / `Last result:` fields directly, unlike `cbbV2`/`cbbCommandLineV2` which needs `plan list -b` and different parsing)
|
||||
- Writes `/tmp/backup-status.json`, then `scp`s it to `root@10.48.200.110:/home/webacct/public_html/downloads/backup-status.json`
|
||||
- **Schedule:** daily `0 6 * * *` on PVE1 (`>> /var/log/backup-status-collect.log`)
|
||||
- **Dashboard card:** `web.orbishosting.com` "BACKUP STATUS" card (`index.html`) fetches `/downloads/backup-status.json` client-side (`loadBackupStatus()`), color-codes dots by `result` (green=Success, yellow=Warning, red=Fail, cyan=Running, gray=unknown)
|
||||
- **JSON schema:** `{"updated": "<ISO8601 UTC>", "hosts": [{"name","ip","state","result"}, ...]}`
|
||||
- **Homebridge (2026-07-04): dropped MSP360 entirely.** After extensive troubleshooting (RAM starvation, a bug where its account scanned every other host's shared backup data, missing bind-mount depths, CIFS tuning, a full plan recreation) Homebridge's MSP360 agent kept failing with a false "storage drive not mounted" error at a consistent ~60-75s mark, root cause never conclusively identified (survived every environmental fix, looked like an app-level bug tied to any custom/non-default account path). Since Homebridge (VM 118) was already being backed up successfully every night by the cluster-wide Proxmox vzdump job (`backup-aa6b1890-23c0`, all VMs, 21:00 daily, keep-last=3, to `SynologyProx` storage), MSP360 was stopped/disabled on Homebridge (`systemctl disable msp360-backup.service msp360-backupWA.service`) and removed from the dashboard collector's per-host MSP360 check. The collector now reads Homebridge's status directly from `/mnt/pve/SynologyProx/dump/vzdump-qemu-118-*.vma.zst` on PVE1 instead of querying an in-guest agent.
|
||||
- **Known state (2026-07-04):** 4/5 remaining MSP360 hosts report `Warning`, NovaCPX reports `Fail` — plan-level result, not investigated further; worth checking each host's MSP360 GUI/log for root cause if backups need to be trusted for restore. Homebridge reports `Success` via Proxmox.
|
||||
|
||||
---
|
||||
|
||||
@@ -680,8 +775,9 @@ 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 | myron | `Joker1974!!!` |
|
||||
| JARVIS Admin | http://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
|
||||
| ChuckCo Time Keeper Admin | https://worktracking.orbishosting.com/admin/login.php | — | `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!` |
|
||||
@@ -703,13 +799,14 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
|
||||
| Epic Travel | `epic_travel_db` | (see config.php) | (see config.php) |
|
||||
| Epic/Parker Slingshot | `epic_parkersling` | `epic_parkersling` | `Joker1974!!!` |
|
||||
| NovaCPX | SQLite: `/var/lib/novacpx/panel.db` | — | — |
|
||||
| ChuckCo Time Keeper | `workt_track_db` | `workt_track_user` | `ZWCNMRP2N5NVPsghmve5aRS9` |
|
||||
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
|
||||
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
|
||||
|
||||
### API Keys
|
||||
| Service | Key |
|
||||
|---------|-----|
|
||||
| GitHub PAT | `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` (exp ~2026-08-20) |
|
||||
| GitHub PAT | `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` (rotated 2026-07-05, scopes `repo`+`workflow`) |
|
||||
| JARVIS Agent Registration | `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518` |
|
||||
| Proxmox API Token | `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b` |
|
||||
| HA Long-lived Token | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE` |
|
||||
@@ -735,4 +832,16 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
|
||||
|
||||
---
|
||||
|
||||
## 13. GIT & REPOSITORY MANAGEMENT
|
||||
|
||||
**GitHub** (`myronblair`, 26 private repos as of 2026-07-05) is the permanent/source-of-truth storage. **Gitea** (`gitea.orbishosting.com`, hosted on the Synology NAS, login `myron / Joker1974!!!`) mirrors it one-way — GitHub → Gitea only, via pull-mirrors triggered by `POST /api/v1/repos/<owner>/<repo>/mirror-sync`. Never push directly to a Gitea remote.
|
||||
|
||||
**5 repos are Gitea-only by design, never pushed to GitHub** (deliberately kept off a third-party cloud service since they hold real credentials): `fortigate-config`, `infra-private`, `jarvis-secrets`, `msp360-config`, `proxmox-secrets`.
|
||||
|
||||
**Local clones** live on the admin Windows machine at `C:\Users\myron\repos\` (currently `chucko`, `web-dashboard` — more cloned there as needed). Periodic maintenance: `git gc --aggressive --prune=now` to keep loose objects packed.
|
||||
|
||||
**Per-site deploy pattern (the 7 DO-hosted sites + jarvis + web-dashboard):** `.git` metadata is relocated outside the public webroot (e.g. `/home/<site>/git-data` with a `gitdir:` pointer file left in `public_html/.git`) so `.git` itself is never web-accessible, while `git` commands run normally from inside `public_html`. Most sites deploy via a GitHub webhook → queue file → cron puller (~1 min); `chucko` (ChuckCo Time Keeper) currently has no auto-deploy hook — pushes are manual (SSH in, `git pull`/`push` directly).
|
||||
|
||||
**Known repo-hygiene issue:** `fusionpbx-config` bloated to ~166-196MB from repeatedly committing a large DB dump straight into history — see Section 10.
|
||||
|
||||
*This document contains sensitive credentials. Store securely and do not share.*
|
||||
|
||||
+591
-29
@@ -430,7 +430,7 @@ if ($action) {
|
||||
|
||||
|
||||
case 'cal_feeds_list':
|
||||
j(JarvisDB::query("SELECT * FROM calendar_feeds ORDER BY source,name") ?? []);
|
||||
j(JarvisDB::query("SELECT id,name,source,ics_url,username,(password != '' AND password IS NOT NULL) AS has_password,active,created_at FROM calendar_feeds ORDER BY source,name") ?? []);
|
||||
|
||||
case 'cal_feed_save':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
@@ -487,25 +487,26 @@ if ($action) {
|
||||
$arcCounts = [];
|
||||
foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt'];
|
||||
$cronLast = [];
|
||||
$cronLog = '/home/jarvis.orbishosting.com/logs/cron.log';
|
||||
$cronLog = '/var/log/jarvis/cron.log';
|
||||
if (file_exists($cronLog)) {
|
||||
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar' " . escapeshellarg($cronLog) . " | tail -60")));
|
||||
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar\\|intent' " . escapeshellarg($cronLog) . " | tail -60")));
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
|
||||
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
|
||||
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
|
||||
if (preg_match('/^\\[(\\d{2}{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
|
||||
if (preg_match('/^\\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\\].*intent/i', $line, $m)) $cronLast['kb_intent_generator'] = $m[1];
|
||||
}
|
||||
}
|
||||
if (empty($cronLast['stats_cache'])) {
|
||||
$row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")');
|
||||
if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t'];
|
||||
}
|
||||
$deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log';
|
||||
$deployLog = '/var/log/jarvis/deploy.log';
|
||||
if (file_exists($deployLog)) {
|
||||
$last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1");
|
||||
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1];
|
||||
}
|
||||
$wdLog = '/home/jarvis.orbishosting.com/logs/watchdog.log';
|
||||
$wdLog = '/var/log/jarvis/watchdog.log';
|
||||
if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog));
|
||||
$bkLog = '/var/backups/jarvis/backup.log';
|
||||
if (file_exists($bkLog)) {
|
||||
@@ -518,9 +519,9 @@ if ($action) {
|
||||
break;
|
||||
|
||||
case 'worker_action':
|
||||
$wType = $data['worker_type'] ?? '';
|
||||
$wId = $data['worker_id'] ?? '';
|
||||
$wAction = $data['action'] ?? '';
|
||||
$wType = $_REQUEST['worker_type'] ?? '';
|
||||
$wId = $_REQUEST['worker_id'] ?? '';
|
||||
$wAction = $_REQUEST['waction'] ?? $_REQUEST['action'] ?? '';
|
||||
if ($wType === 'agent' && $wAction === 'update') {
|
||||
JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)',
|
||||
[$wId,'update','{}','pending']);
|
||||
@@ -533,25 +534,153 @@ if ($action) {
|
||||
j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']);
|
||||
} elseif ($wType === 'cron' && $wAction === 'run') {
|
||||
$scripts = [
|
||||
'facts_collector'=>[true, '/home/jarvis.orbishosting.com/api/endpoints/facts_collector.php'],
|
||||
'stats_cache' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/stats_cache.php'],
|
||||
'calendar_sync' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/calendar_sync.php'],
|
||||
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
|
||||
'jarvis_watchdog'=>[false,'/usr/local/bin/jarvis-watchdog.sh'],
|
||||
'facts_collector' =>[true, '/var/www/jarvis/api/endpoints/facts_collector.php'],
|
||||
'stats_cache' =>[true, '/var/www/jarvis/api/endpoints/stats_cache.php'],
|
||||
'calendar_sync' =>[true, '/var/www/jarvis/api/endpoints/calendar_sync.php'],
|
||||
'kb_intent_generator' =>[true, '/var/www/jarvis/api/endpoints/kb_intent_generator.php'],
|
||||
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
|
||||
'jarvis_watchdog' =>[false,'/usr/local/bin/jarvis-watchdog.sh'],
|
||||
];
|
||||
if (isset($scripts[$wId])) {
|
||||
[$isPhp,$path] = $scripts[$wId];
|
||||
$env = ($wId === 'kb_intent_generator') ? 'JARVIS_FORCE_RUN=1 ' : '';
|
||||
$cmd = $isPhp
|
||||
? '/usr/local/lsws/lsphp85/bin/lsphp '.escapeshellarg($path).' >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 &'
|
||||
: escapeshellcmd($path).' >> /home/jarvis.orbishosting.com/logs/deploy.log 2>&1 &';
|
||||
? $env.'/usr/bin/php8.3 '.escapeshellarg($path).' >> /var/log/jarvis/cron.log 2>&1 &'
|
||||
: escapeshellcmd($path).' >> /var/log/jarvis/deploy.log 2>&1 &';
|
||||
shell_exec($cmd);
|
||||
j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']);
|
||||
} else { bad('Unknown cron worker'); }
|
||||
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') {
|
||||
shell_exec('pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &');
|
||||
j(['ok'=>true,'msg'=>'Arc Reactor restarting']);
|
||||
shell_exec('systemctl restart jarvis-arc 2>&1');
|
||||
j(['ok'=>true,'msg'=>'Arc Reactor restarting via systemd']);
|
||||
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') {
|
||||
$log = '/var/log/jarvis/arc-setup.log';
|
||||
$cmd = implode(' && ', [
|
||||
'mkdir -p /opt/jarvis-arc /var/log/jarvis',
|
||||
'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py',
|
||||
'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt',
|
||||
'[ ! -f /opt/jarvis-arc/venv/bin/activate ] && python3 -m venv /opt/jarvis-arc/venv || true',
|
||||
'/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt',
|
||||
'cp /var/www/jarvis/deploy/jarvis-arc.service /etc/systemd/system/jarvis-arc.service',
|
||||
'systemctl daemon-reload',
|
||||
'systemctl enable jarvis-arc',
|
||||
'systemctl restart jarvis-arc',
|
||||
]);
|
||||
shell_exec("($cmd) >> " . escapeshellarg($log) . " 2>&1 &");
|
||||
j(['ok'=>true,'msg'=>'Arc Reactor setup started — check ' . $log]);
|
||||
} elseif ($wType === 'agent' && $wAction === 'update_status') {
|
||||
$ag = JarvisDB::single('SELECT version, status FROM registered_agents WHERE agent_id=?', [$wId]);
|
||||
j(['ok'=>true,'version'=>$ag['version']??null,'status'=>$ag['status']??'unknown']);
|
||||
} else { bad('Invalid worker action'); }
|
||||
break;
|
||||
case 'intent_gen_history':
|
||||
$logFile = '/var/log/jarvis/cron.log';
|
||||
$result = ['last_success'=>null,'last_success_count'=>null,'failures'=>[]];
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$cutoff = time() - 7 * 86400;
|
||||
foreach (array_reverse($lines) as $line) {
|
||||
if (stripos($line, 'KB Intent Generator') === false) continue;
|
||||
// parse timestamp
|
||||
preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/', $line, $tm);
|
||||
$ts = isset($tm[1]) ? strtotime($tm[1]) : 0;
|
||||
// last success (done/complete)
|
||||
if (!$result['last_success'] && preg_match('/done|complete/i', $line)) {
|
||||
$result['last_success'] = $tm[1] ?? null;
|
||||
preg_match('/inserted[:\s]+(\d+)/i', $line, $cnt);
|
||||
$result['last_success_count'] = isset($cnt[1]) ? (int)$cnt[1] : null;
|
||||
}
|
||||
// failures in last 7 days
|
||||
if ($ts >= $cutoff && preg_match('/error|fail/i', $line)) {
|
||||
$result['failures'][] = $line;
|
||||
if (count($result['failures']) >= 20) break;
|
||||
}
|
||||
}
|
||||
$result['failures'] = array_reverse($result['failures']);
|
||||
}
|
||||
j($result);
|
||||
case 'intent_gen_log':
|
||||
$logFile = '/var/log/jarvis/cron.log';
|
||||
$since = max(0, (int)($_GET['since'] ?? 0));
|
||||
if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); }
|
||||
$allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$total = count($allLines);
|
||||
// snapshot=1 just returns the current line count (call BEFORE triggering run)
|
||||
if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); }
|
||||
// Return only KB Intent Generator lines from position $since onward
|
||||
$out = [];
|
||||
for ($i = $since; $i < $total; $i++) {
|
||||
if (stripos($allLines[$i], 'KB Intent Generator') !== false) {
|
||||
$out[] = $allLines[$i];
|
||||
}
|
||||
}
|
||||
j(['lines'=>$out, 'next_line'=>$total]);
|
||||
|
||||
// ── KB GENERATOR TOPICS ─────────────────────────────────────────────
|
||||
case 'kb_topics':
|
||||
$where = ['1=1']; $params = [];
|
||||
if (!empty($_GET['q'])) {
|
||||
$q2 = '%'.$_GET['q'].'%';
|
||||
$where[] = '(topic_name LIKE ? OR topic_id LIKE ? OR category LIKE ? OR description LIKE ?)';
|
||||
$params = array_merge($params, [$q2,$q2,$q2,$q2]);
|
||||
}
|
||||
if (isset($_GET['cat']) && $_GET['cat'] !== '') { $where[] = 'category=?'; $params[] = $_GET['cat']; }
|
||||
if (isset($_GET['active']) && $_GET['active'] !== '') { $where[] = 'active=?'; $params[] = (int)$_GET['active']; }
|
||||
$topics = JarvisDB::query(
|
||||
'SELECT id,topic_id,category,topic_name,description,active,run_count,last_run_at
|
||||
FROM kb_generator_topics WHERE '.implode(' AND ',$where).' ORDER BY category,topic_name',
|
||||
$params
|
||||
);
|
||||
$catRows = JarvisDB::query('SELECT DISTINCT category FROM kb_generator_topics ORDER BY category');
|
||||
j(['topics'=>$topics, 'categories'=>array_column($catRows,'category')]);
|
||||
|
||||
case 'kb_topic_save':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$tid = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($_POST['topic_id'] ?? '')));
|
||||
$cat = trim($_POST['category'] ?? '');
|
||||
$name = trim($_POST['topic_name'] ?? '');
|
||||
$desc = trim($_POST['description'] ?? '');
|
||||
$act = (int)!empty($_POST['active']);
|
||||
if (!$tid || !preg_match('/[a-z0-9]/', $tid) || !$cat || !$name || !$desc)
|
||||
bad('All fields required — topic_id must contain at least one letter or digit');
|
||||
try {
|
||||
if ($id) {
|
||||
JarvisDB::execute(
|
||||
'UPDATE kb_generator_topics SET topic_id=?,category=?,topic_name=?,description=?,active=?,updated_at=NOW() WHERE id=?',
|
||||
[$tid,$cat,$name,$desc,$act,$id]
|
||||
);
|
||||
} else {
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_generator_topics (topic_id,category,topic_name,description,active) VALUES (?,?,?,?,?)',
|
||||
[$tid,$cat,$name,$desc,$act]
|
||||
);
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
bad(strpos($e->getMessage(), 'Duplicate entry') !== false
|
||||
? 'topic_id already in use — choose a different slug'
|
||||
: 'Database error');
|
||||
}
|
||||
j(['ok'=>true,'msg'=> $id ? 'Topic updated' : 'Topic created']);
|
||||
|
||||
case 'kb_topic_delete':
|
||||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
JarvisDB::execute('DELETE FROM kb_generator_topics WHERE id=?', [$id]);
|
||||
j(['ok'=>true]);
|
||||
|
||||
case 'kb_topic_toggle':
|
||||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
JarvisDB::execute('UPDATE kb_generator_topics SET active=NOT active, updated_at=NOW() WHERE id=?', [$id]);
|
||||
$row = JarvisDB::single('SELECT active FROM kb_generator_topics WHERE id=?', [$id]);
|
||||
j(['ok'=>true,'active'=>(bool)$row['active']]);
|
||||
|
||||
case 'arc_setup_log':
|
||||
$logFile = '/var/log/jarvis/arc-setup.log';
|
||||
$since = max(0, (int)($_GET['since'] ?? 0));
|
||||
if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); }
|
||||
$allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$total = count($allLines);
|
||||
if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); }
|
||||
j(['lines'=>array_values(array_slice($allLines, $since)), 'next_line'=>$total]);
|
||||
case 'arc_status':
|
||||
$ch = curl_init('http://127.0.0.1:7474/status');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]);
|
||||
@@ -1126,6 +1255,17 @@ if ($action) {
|
||||
readfile($path);
|
||||
exit;
|
||||
|
||||
case 'docs_download':
|
||||
$path = '/var/www/jarvis-private/INFRASTRUCTURE-REFERENCE.md';
|
||||
if (!file_exists($path)) bad('File not found', 404);
|
||||
header('Content-Type: text/markdown');
|
||||
header('Content-Disposition: attachment; filename="INFRASTRUCTURE-REFERENCE.md"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('X-Accel-Buffering: no');
|
||||
ob_end_clean();
|
||||
readfile($path);
|
||||
exit;
|
||||
|
||||
default: bad('Unknown action');
|
||||
}
|
||||
}
|
||||
@@ -1426,6 +1566,8 @@ select.filter-sel:focus{border-color:var(--cyan)}
|
||||
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
|
||||
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
|
||||
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
|
||||
<button class="btn btn-sm" style="border-color:#7f5fff;color:#7f5fff" onclick="openTopicsManager()">⚙ MANAGE TOPICS</button>
|
||||
<button class="btn btn-sm" style="border-color:#00d4ff;color:#00d4ff" onclick="runIntentGenerator()">▶ RUN GENERATOR</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
|
||||
@@ -1513,7 +1655,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
|
||||
<div class="card" style="padding:24px;margin:20px 0">
|
||||
<div style="font-size:0.7rem;letter-spacing:2px;color:var(--cyan);margin-bottom:8px">INFRASTRUCTURE REFERENCE</div>
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;margin-bottom:16px">Complete server map, credentials, deployment workflow, service configs, and phone system reference.</div>
|
||||
<a href="downloads/INFRASTRUCTURE-REFERENCE.md" download="INFRASTRUCTURE-REFERENCE.md"
|
||||
<a href="?action=docs_download" download="INFRASTRUCTURE-REFERENCE.md"
|
||||
style="display:inline-block;padding:8px 20px;background:rgba(0,212,255,0.1);border:1px solid var(--cyan);color:var(--cyan);font-size:0.7rem;letter-spacing:2px;text-decoration:none">
|
||||
↓ DOWNLOAD INFRASTRUCTURE-REFERENCE.MD
|
||||
</a>
|
||||
@@ -2040,6 +2182,12 @@ let _alertFilter = 'active';
|
||||
let _modalCb = null;
|
||||
|
||||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
// For values embedded inside a single-quoted JS string literal within an HTML attribute
|
||||
// (e.g. onclick="fn('${escJs(x)}')"). Escaping the quote via esc() alone is NOT enough:
|
||||
// the browser HTML-decodes the attribute before parsing it as JS, so '/" would
|
||||
// just turn back into a literal ' before the JS parser ever sees it. Backslash-escaping
|
||||
// survives that decode step, so JS-escape first, then HTML-escape on top.
|
||||
function escJs(s){ return esc(String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,'\\r')); }
|
||||
function ts(s){ if(!s) return '—'; const d=new Date(s); return d.toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
|
||||
function ago(s){ if(!s) return '—'; const sec=Math.floor((Date.now()-new Date(s))/1000); if(sec<60) return sec+'s ago'; if(sec<3600) return Math.floor(sec/60)+'m ago'; return Math.floor(sec/3600)+'h ago'; }
|
||||
function fmtUp(s){ const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60); return (d>0?d+'d ':'')+h+'h '+m+'m'; }
|
||||
@@ -2158,7 +2306,8 @@ const CRON_DEFS = [
|
||||
{id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'},
|
||||
{id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'},
|
||||
{id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true},
|
||||
{id:'do_server_backup',label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
|
||||
{id:'do_server_backup', label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
|
||||
{id:'kb_intent_generator', label:'KB Intent Generator', schedule:'Daily 3am', host:'jarvis'},
|
||||
];
|
||||
function wBtn(col) {
|
||||
const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)';
|
||||
@@ -2185,12 +2334,422 @@ function wToast(msg,err=false) {
|
||||
t.style.opacity='1';t.textContent=msg;
|
||||
setTimeout(()=>{t.style.opacity='0';},3000);
|
||||
}
|
||||
async function runIntentGenerator() {
|
||||
const modalHtml = `
|
||||
<div style="font-family:var(--mono);font-size:0.72rem;line-height:1.8">
|
||||
<div id="ig-history" style="background:#0a0f14;border:1px solid var(--border);border-radius:3px;padding:8px 12px;margin-bottom:10px;font-size:0.6rem;color:var(--text-dim)">Loading history...</div>
|
||||
<div id="ig-status" style="color:var(--cyan);margin-bottom:8px;font-size:0.65rem;letter-spacing:1px">LAUNCHING...</div>
|
||||
<div id="ig-log" style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:10px 12px;min-height:160px;max-height:320px;overflow-y:auto;white-space:pre-wrap;font-size:0.62rem;line-height:1.7;color:var(--text-dim)">Waiting for first output...</div>
|
||||
<div id="ig-stats" style="margin-top:10px;display:flex;gap:20px;font-size:0.6rem;color:var(--text-dim)"></div>
|
||||
</div>`;
|
||||
|
||||
openModal('▶ KB INTENT GENERATOR', modalHtml, null, null);
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
|
||||
const logEl = document.getElementById('ig-log');
|
||||
const statEl = document.getElementById('ig-status');
|
||||
const statsEl = document.getElementById('ig-stats');
|
||||
const historyEl = document.getElementById('ig-history');
|
||||
|
||||
let _igStop = false;
|
||||
let _igDone = false;
|
||||
let lastLine = 0;
|
||||
let dotted = 0;
|
||||
|
||||
// Close just stops polling — server job keeps running
|
||||
const stopAndClose = () => {
|
||||
_igStop = true;
|
||||
closeModal();
|
||||
if (!_igDone) toast('KB generator running in background', 'ok');
|
||||
};
|
||||
document.getElementById('modalSave').onclick = stopAndClose;
|
||||
document.getElementById('modalClose').onclick = stopAndClose;
|
||||
|
||||
// Load history panel (async, non-blocking)
|
||||
api('intent_gen_history').then(h => {
|
||||
if (!historyEl) return;
|
||||
let html = '';
|
||||
if (h?.last_success) {
|
||||
html += `<span style="color:var(--green)">✓ Last success: ${h.last_success}`;
|
||||
if (h.last_success_count != null) html += ` (+${h.last_success_count} intents)`;
|
||||
html += '</span>';
|
||||
} else {
|
||||
html += '<span style="color:var(--text-dim)">No recorded successes in log</span>';
|
||||
}
|
||||
if (h?.failures?.length) {
|
||||
html += `<br><span style="color:var(--red)">⚠ ${h.failures.length} failure line(s) in last 7 days:</span>`;
|
||||
h.failures.slice(-3).forEach(f => {
|
||||
html += `<div style="color:var(--red);opacity:0.7;margin-left:8px">${f.replace(/</g,'<')}</div>`;
|
||||
});
|
||||
}
|
||||
historyEl.innerHTML = html || '<span>No history found</span>';
|
||||
}).catch(() => { if (historyEl) historyEl.textContent = 'History unavailable'; });
|
||||
|
||||
// Snapshot log position BEFORE triggering
|
||||
const snap = await api('intent_gen_log', {snapshot:1});
|
||||
lastLine = snap?.next_line ?? 0;
|
||||
|
||||
// Kick off the generator (JARVIS_FORCE_RUN=1 set server-side — bypasses 20h guard)
|
||||
const kick = await api('worker_action', {worker_type:'cron', worker_id:'kb_intent_generator', waction:'run'});
|
||||
if (!kick || !kick.ok) {
|
||||
statEl.style.color = 'var(--red)';
|
||||
statEl.textContent = 'LAUNCH FAILED: ' + (kick?.error || 'unknown error');
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
return;
|
||||
}
|
||||
statEl.textContent = 'RUNNING — polling log every 3s...';
|
||||
|
||||
async function pollLog() {
|
||||
if (_igStop) return;
|
||||
try {
|
||||
const res = await api('intent_gen_log', {since: lastLine});
|
||||
if (res && Array.isArray(res.lines) && res.lines.length) {
|
||||
lastLine = res.next_line;
|
||||
const isFirst = logEl.textContent === 'Waiting for first output...';
|
||||
if (isFirst) logEl.textContent = '';
|
||||
res.lines.forEach(line => {
|
||||
const div = document.createElement('div');
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('fail'))
|
||||
div.style.color = 'var(--red)';
|
||||
else if (lower.includes('skip') || lower.includes('warn'))
|
||||
div.style.color = 'var(--yellow)';
|
||||
else if (lower.includes('insert') || lower.includes('added') || lower.includes('done') || lower.includes('complete') || lower.includes('cleanup') || lower.includes('force-run'))
|
||||
div.style.color = 'var(--green)';
|
||||
else
|
||||
div.style.color = 'var(--text-dim)';
|
||||
div.textContent = line;
|
||||
logEl.appendChild(div);
|
||||
});
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
|
||||
const lastLines = res.lines.join(' ').toLowerCase();
|
||||
if (lastLines.includes('kb intent generator: done') || lastLines.includes('total inserted') || lastLines.includes('cleanup complete')) {
|
||||
_igDone = true;
|
||||
statEl.style.color = 'var(--green)';
|
||||
statEl.textContent = 'COMPLETE';
|
||||
const total = (res.lines.join('\n').match(/inserted[:\s]+(\d+)/i) || [])[1];
|
||||
if (total) statsEl.innerHTML = `<span style="color:var(--green)">+${total} intents added</span>`;
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
return;
|
||||
}
|
||||
dotted = 0;
|
||||
} else {
|
||||
dotted++;
|
||||
if (dotted < 30) {
|
||||
const waitDiv = document.createElement('div');
|
||||
waitDiv.style.color = 'rgba(0,212,255,0.3)';
|
||||
waitDiv.textContent = '· waiting for output' + '.'.repeat(dotted % 4);
|
||||
const last = logEl.lastChild;
|
||||
if (last && last.textContent && last.textContent.startsWith('· waiting')) {
|
||||
logEl.replaceChild(waitDiv, last);
|
||||
} else {
|
||||
if (logEl.textContent === 'Waiting for first output...') logEl.textContent = '';
|
||||
logEl.appendChild(waitDiv);
|
||||
}
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
} else {
|
||||
statEl.style.color = 'var(--yellow)';
|
||||
statEl.textContent = 'RUNNING IN BACKGROUND (check cron log for results)';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
if (!_igStop) setTimeout(pollLog, 3000);
|
||||
}
|
||||
|
||||
setTimeout(pollLog, 2000);
|
||||
}
|
||||
|
||||
let _topicsData = [];
|
||||
let _topicsCats = [];
|
||||
|
||||
async function openTopicsManager() {
|
||||
const modalHtml = `
|
||||
<div style="font-family:var(--mono);font-size:0.72rem">
|
||||
<div id="tm-view-list">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap">
|
||||
<input id="tm-search" type="text" placeholder="Search topics…" style="flex:1;min-width:140px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" oninput="tmFilter()">
|
||||
<select id="tm-cat-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
|
||||
<option value="">ALL CATEGORIES</option>
|
||||
</select>
|
||||
<select id="tm-active-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
|
||||
<option value="">ALL STATUS</option>
|
||||
<option value="1">ACTIVE</option>
|
||||
<option value="0">DISABLED</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-green" onclick="tmEditTopic(null)">+ ADD</button>
|
||||
</div>
|
||||
<div id="tm-stats" style="color:var(--text-dim);font-size:0.6rem;margin-bottom:6px;letter-spacing:0.5px"></div>
|
||||
<div id="tm-list" style="max-height:370px;overflow-y:auto"><div class="loading">LOADING…</div></div>
|
||||
</div>
|
||||
<div id="tm-view-edit" style="display:none">
|
||||
<div style="margin-bottom:12px">
|
||||
<button class="btn btn-sm" onclick="tmShowList()">← BACK</button>
|
||||
<span id="tm-edit-title" style="color:var(--cyan);margin-left:12px;font-size:0.65rem;letter-spacing:1px"></span>
|
||||
</div>
|
||||
<div style="display:grid;gap:8px">
|
||||
<input type="hidden" id="tm-edit-id">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC ID (slug)
|
||||
<input id="tm-edit-tid" type="text" placeholder="e.g. math_arith" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
|
||||
</label>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">CATEGORY
|
||||
<input id="tm-edit-cat" type="text" placeholder="e.g. mathematics" list="tm-cat-dl" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
|
||||
<datalist id="tm-cat-dl"></datalist>
|
||||
</label>
|
||||
</div>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC NAME
|
||||
<input id="tm-edit-name" type="text" placeholder="e.g. Arithmetic and number sense" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
|
||||
</label>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">DESCRIPTION (subtopics to cover — comma-separated)
|
||||
<textarea id="tm-edit-desc" rows="5" placeholder="e.g. fractions, decimals, order of operations, prime numbers…" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;resize:vertical;box-sizing:border-box"></textarea>
|
||||
</label>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim);display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input id="tm-edit-active" type="checkbox" checked style="cursor:pointer"> ACTIVE (included in rotation)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal('⚙ KB TOPIC MANAGER', modalHtml, null, null);
|
||||
const saveBtn = document.getElementById('modalSave');
|
||||
const closeBtn = document.getElementById('modalClose');
|
||||
saveBtn.textContent = 'SAVE TOPIC';
|
||||
saveBtn.style.display = 'none';
|
||||
saveBtn.onclick = tmSaveTopic;
|
||||
closeBtn.onclick = closeModal;
|
||||
|
||||
await tmLoadTopics();
|
||||
}
|
||||
|
||||
async function tmLoadTopics() {
|
||||
const res = await api('kb_topics');
|
||||
_topicsData = res?.topics || [];
|
||||
_topicsCats = res?.categories || [];
|
||||
|
||||
const catSel = document.getElementById('tm-cat-filter');
|
||||
if (catSel) {
|
||||
const cur = catSel.value;
|
||||
while (catSel.options.length > 1) catSel.remove(1);
|
||||
_topicsCats.forEach(c => catSel.add(new Option(c.toUpperCase(), c)));
|
||||
if (cur) catSel.value = cur;
|
||||
}
|
||||
const dl = document.getElementById('tm-cat-dl');
|
||||
if (dl) { dl.innerHTML = ''; _topicsCats.forEach(c => { const o = document.createElement('option'); o.value=c; dl.appendChild(o); }); }
|
||||
|
||||
tmFilter();
|
||||
}
|
||||
|
||||
function tmFilter() {
|
||||
const q = (document.getElementById('tm-search')?.value || '').toLowerCase();
|
||||
const cat = document.getElementById('tm-cat-filter')?.value || '';
|
||||
const active = document.getElementById('tm-active-filter')?.value;
|
||||
|
||||
const rows = _topicsData.filter(t => {
|
||||
if (cat && t.category !== cat) return false;
|
||||
if (active !== '' && active !== null && active !== undefined && String(t.active) !== String(active)) return false;
|
||||
if (q && !`${t.topic_name} ${t.topic_id} ${t.category} ${t.description}`.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = _topicsData.length;
|
||||
const activeCount = _topicsData.filter(t => t.active == 1).length;
|
||||
const statsEl = document.getElementById('tm-stats');
|
||||
if (statsEl) statsEl.innerHTML =
|
||||
`<span style="color:var(--cyan)">${total} topics</span> | ` +
|
||||
`<span style="color:var(--green)">${activeCount} active</span> | ` +
|
||||
`<span style="color:var(--yellow)">${total-activeCount} disabled</span>` +
|
||||
(rows.length < total ? ` | <span style="color:var(--text-dim)">${rows.length} shown</span>` : '');
|
||||
|
||||
const listEl = document.getElementById('tm-list');
|
||||
if (!listEl) return;
|
||||
if (!rows.length) {
|
||||
listEl.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No topics match filter</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = `<table style="width:100%;border-collapse:collapse">
|
||||
<thead><tr style="color:var(--cyan);font-size:0.58rem;border-bottom:1px solid var(--border)">
|
||||
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC ID</th>
|
||||
<th style="padding:4px 6px;text-align:left;font-weight:normal">CATEGORY</th>
|
||||
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC NAME</th>
|
||||
<th style="padding:4px 6px;text-align:center;font-weight:normal">ON</th>
|
||||
<th style="padding:4px 6px;text-align:center;font-weight:normal">RUNS</th>
|
||||
<th style="padding:4px 6px;text-align:right;font-weight:normal">ACTIONS</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(t => `
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.65rem">
|
||||
<td style="padding:4px 6px;color:var(--text-dim);font-size:0.57rem">${esc(t.topic_id)}</td>
|
||||
<td style="padding:4px 6px"><span style="background:var(--bg2);padding:1px 5px;border-radius:2px;font-size:0.57rem;white-space:nowrap">${esc(t.category)}</span></td>
|
||||
<td style="padding:4px 6px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.description)}">${esc(t.topic_name)}</td>
|
||||
<td style="padding:4px 6px;text-align:center">
|
||||
<input type="checkbox" ${t.active==1?'checked':''} onchange="tmToggle(${t.id},this)" style="cursor:pointer;accent-color:var(--green)">
|
||||
</td>
|
||||
<td style="padding:4px 6px;text-align:center;color:var(--text-dim);font-size:0.6rem">${t.run_count||0}</td>
|
||||
<td style="padding:4px 6px;text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-sm btn-yellow" style="padding:2px 7px;font-size:0.58rem" onclick="tmEditTopic(${t.id})">EDIT</button>
|
||||
<button class="btn btn-sm btn-red" style="padding:2px 7px;font-size:0.58rem;margin-left:3px" onclick="tmDelete(${t.id})">DEL</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function tmShowList() {
|
||||
document.getElementById('tm-view-list').style.display = '';
|
||||
document.getElementById('tm-view-edit').style.display = 'none';
|
||||
document.getElementById('modalSave').style.display = 'none';
|
||||
}
|
||||
|
||||
function tmEditTopic(id) {
|
||||
document.getElementById('tm-view-list').style.display = 'none';
|
||||
document.getElementById('tm-view-edit').style.display = '';
|
||||
document.getElementById('modalSave').style.display = '';
|
||||
|
||||
const tidEl = document.getElementById('tm-edit-tid');
|
||||
if (id) {
|
||||
const t = _topicsData.find(x => x.id == id);
|
||||
if (!t) return;
|
||||
document.getElementById('tm-edit-title').textContent = 'EDIT TOPIC: ' + t.topic_id;
|
||||
document.getElementById('tm-edit-id').value = t.id;
|
||||
tidEl.value = t.topic_id;
|
||||
tidEl.readOnly = true;
|
||||
tidEl.style.opacity = '0.6';
|
||||
document.getElementById('tm-edit-cat').value = t.category;
|
||||
document.getElementById('tm-edit-name').value = t.topic_name;
|
||||
document.getElementById('tm-edit-desc').value = t.description;
|
||||
document.getElementById('tm-edit-active').checked = t.active == 1;
|
||||
} else {
|
||||
document.getElementById('tm-edit-title').textContent = 'ADD NEW TOPIC';
|
||||
document.getElementById('tm-edit-id').value = '';
|
||||
tidEl.value = '';
|
||||
tidEl.readOnly = false;
|
||||
tidEl.style.opacity = '1';
|
||||
document.getElementById('tm-edit-cat').value = '';
|
||||
document.getElementById('tm-edit-name').value = '';
|
||||
document.getElementById('tm-edit-desc').value = '';
|
||||
document.getElementById('tm-edit-active').checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function tmSaveTopic() {
|
||||
const id = document.getElementById('tm-edit-id').value;
|
||||
const data = {
|
||||
id: id ? parseInt(id) : 0,
|
||||
topic_id: document.getElementById('tm-edit-tid').value.trim(),
|
||||
category: document.getElementById('tm-edit-cat').value.trim(),
|
||||
topic_name: document.getElementById('tm-edit-name').value.trim(),
|
||||
description: document.getElementById('tm-edit-desc').value.trim(),
|
||||
active: document.getElementById('tm-edit-active').checked ? 1 : 0,
|
||||
};
|
||||
if (!data.topic_id || !data.category || !data.topic_name || !data.description) {
|
||||
toast('All fields are required', 'err'); return;
|
||||
}
|
||||
apiPost('kb_topic_save', data, async (res) => {
|
||||
toast(res?.msg || 'Saved', 'ok');
|
||||
tmShowList();
|
||||
await tmLoadTopics();
|
||||
});
|
||||
}
|
||||
|
||||
async function tmToggle(id, el) {
|
||||
const prev = !el.checked;
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'kb_topic_toggle');
|
||||
fd.append('id', id);
|
||||
try {
|
||||
const r = await fetch(location.href, {method:'POST', body:fd});
|
||||
const d = await r.json();
|
||||
if (d.error) { toast(d.error, 'err'); el.checked = prev; return; }
|
||||
const t = _topicsData.find(x => x.id == id);
|
||||
if (t) t.active = d.active ? 1 : 0;
|
||||
tmFilter();
|
||||
} catch(e) { toast('Toggle failed', 'err'); el.checked = prev; }
|
||||
}
|
||||
|
||||
async function tmDelete(id) {
|
||||
const t = _topicsData.find(x => x.id == id);
|
||||
const name = t?.topic_name || 'this topic';
|
||||
openModal('CONFIRM DELETE',
|
||||
`<div style="font-family:var(--mono);font-size:0.75rem;line-height:2;padding:4px 0">Delete topic:<br>` +
|
||||
`<span style="color:var(--red)">${esc(name)}</span><br>` +
|
||||
`<span style="color:var(--text-dim);font-size:0.65rem">This cannot be undone.</span></div>`,
|
||||
() => { apiPost('kb_topic_delete', {id}, async () => { toast('Topic deleted', 'ok'); await openTopicsManager(); }); },
|
||||
'DELETE');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async function arcSetup() {
|
||||
const modalHtml = `
|
||||
<div style="font-family:var(--mono);font-size:0.72rem;line-height:1.8">
|
||||
<div id="as-status" style="color:var(--cyan);margin-bottom:8px;font-size:0.65rem;letter-spacing:1px">LAUNCHING...</div>
|
||||
<div id="as-log" style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:10px 12px;min-height:160px;max-height:360px;overflow-y:auto;white-space:pre-wrap;font-size:0.62rem;line-height:1.7;color:var(--text-dim)">Waiting for output...</div>
|
||||
</div>`;
|
||||
|
||||
openModal("⚙ ARC REACTOR SETUP", modalHtml, null, null);
|
||||
document.getElementById("modalSave").textContent = "CLOSE";
|
||||
let _stop = false;
|
||||
document.getElementById("modalSave").onclick = () => { _stop = true; closeModal(); loadWorkers(); };
|
||||
document.getElementById("modalClose").onclick = () => { _stop = true; closeModal(); loadWorkers(); };
|
||||
|
||||
const logEl = document.getElementById("as-log");
|
||||
const statEl = document.getElementById("as-status");
|
||||
|
||||
const snap = await api("arc_setup_log", {snapshot:1});
|
||||
let lastLine = snap?.next_line ?? 0;
|
||||
|
||||
const kick = await api("worker", {type:"daemon", id:"arc_reactor", action:"setup"});
|
||||
if (!kick?.ok) {
|
||||
statEl.style.color = "var(--red)";
|
||||
statEl.textContent = "LAUNCH FAILED: " + (kick?.msg || kick?.error || "unknown");
|
||||
return;
|
||||
}
|
||||
statEl.textContent = "RUNNING — polling every 2s...";
|
||||
|
||||
async function pollLog() {
|
||||
if (_stop) return;
|
||||
try {
|
||||
const res = await api("arc_setup_log", {since: lastLine});
|
||||
if (res?.lines?.length) {
|
||||
lastLine = res.next_line;
|
||||
if (logEl.textContent === "Waiting for output...") logEl.textContent = "";
|
||||
res.lines.forEach(line => {
|
||||
const d = document.createElement("div");
|
||||
const l = line.toLowerCase();
|
||||
if (l.includes("error") || l.includes("fail")) d.style.color = "var(--red)";
|
||||
else if (l.includes("warn") || l.includes("skip")) d.style.color = "var(--yellow)";
|
||||
else if (l.includes("ok") || l.includes("done") || l.includes("active") ||
|
||||
l.includes("success") || l.includes("restart") || l.includes("enabled")) d.style.color = "var(--green)";
|
||||
else d.style.color = "var(--text-dim)";
|
||||
d.textContent = line;
|
||||
logEl.appendChild(d);
|
||||
});
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
const joined = res.lines.join(" ").toLowerCase();
|
||||
if (joined.includes("systemctl restart") || joined.includes("active") ||
|
||||
joined.includes("done") || joined.includes("failed")) {
|
||||
statEl.style.color = (joined.includes("error") || joined.includes("fail")) ? "var(--red)" : "var(--green)";
|
||||
statEl.textContent = (joined.includes("error") || joined.includes("fail")) ? "SETUP FAILED" : "SETUP COMPLETE";
|
||||
document.getElementById("modalSave").textContent = "CLOSE";
|
||||
loadWorkers(); return;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
setTimeout(pollLog, 2000);
|
||||
}
|
||||
setTimeout(pollLog, 1500);
|
||||
}
|
||||
|
||||
async function workerAction(type,id,action) {
|
||||
if (type === 'agent' && action === 'update') {
|
||||
await agentUpdateFlow(id);
|
||||
return;
|
||||
}
|
||||
const res=await api('worker_action',{worker_type:type,worker_id:id,action});
|
||||
const res=await api('worker_action',{worker_type:type,worker_id:id,waction:action});
|
||||
if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);}
|
||||
else wToast((res&&res.error)||'Action failed',true);
|
||||
}
|
||||
@@ -2215,7 +2774,7 @@ async function agentUpdateFlow(agentId) {
|
||||
};
|
||||
|
||||
// Dispatch command
|
||||
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update'});
|
||||
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update'});
|
||||
if (!res || !res.ok) {
|
||||
log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)');
|
||||
document.getElementById('modalSave').style.display = '';
|
||||
@@ -2226,7 +2785,7 @@ async function agentUpdateFlow(agentId) {
|
||||
log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)');
|
||||
|
||||
// Poll agent_commands for the result (max 90s)
|
||||
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update_status'}).catch(()=>null);
|
||||
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update_status'}).catch(()=>null);
|
||||
// Actually poll via workers_list for version change
|
||||
const deadline = Date.now() + 90000;
|
||||
let done = false;
|
||||
@@ -2330,7 +2889,10 @@ async function loadWorkers() {
|
||||
<td class="ts">jarvis-do :7474</td>
|
||||
<td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td>
|
||||
<td class="ts">${rinfo}</td>
|
||||
<td><button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">↻ RESTART</button></td>
|
||||
<td style="white-space:nowrap">
|
||||
<button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">↻ RESTART</button>
|
||||
<button onclick="arcSetup()" style="${wBtn('cyan')};margin-left:6px">⚙ SETUP</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
async function loadDashboard() {
|
||||
@@ -2533,7 +3095,7 @@ function renderNetwork() {
|
||||
<td class="ts">${ago(d.last_seen)}</td>
|
||||
<td><div class="actions-col">
|
||||
<button class="btn btn-xs" onclick="pingDev('${esc(d.ip)}',this)">PING</button>
|
||||
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${esc(d.ip)}','${esc(d.alias||'')}','${esc(d.device_type||'')}')">NAME</button>
|
||||
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${escJs(d.ip)}','${escJs(d.alias||'')}','${escJs(d.device_type||'')}')">NAME</button>
|
||||
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
|
||||
</div></td>`;
|
||||
}, null, null);
|
||||
@@ -2612,7 +3174,7 @@ async function loadAlerts() {
|
||||
<td class="ts">${ts(a.created_at)}</td>
|
||||
<td><div class="actions-col">
|
||||
${!a.resolved?`<button class="btn btn-xs btn-green" onclick="apiPost('alerts_resolve',{id:${a.id}},()=>{toast('Resolved','ok');loadAlerts()})">RESOLVE</button>`:''}
|
||||
<button class="btn btn-xs btn-yellow" onclick="alertModal(${a.id},'${esc(a.alert_type)}','${esc(a.title)}','${esc(a.message||'')}','${esc(a.severity)}')">EDIT</button>
|
||||
<button class="btn btn-xs btn-yellow" onclick="alertModal(${a.id},'${escJs(a.alert_type)}','${escJs(a.title)}','${escJs(a.message||'')}','${escJs(a.severity)}')">EDIT</button>
|
||||
<button class="btn btn-xs btn-red" onclick="apiPost('alerts_delete',{id:${a.id}},()=>{toast('Deleted','ok');loadAlerts()})">DEL</button>
|
||||
</div></td>`, null, null);
|
||||
}
|
||||
@@ -2722,7 +3284,7 @@ function renderIntents(intents) {
|
||||
<td>${i.active?'<span class="badge badge-green">ON</span>':'<span class="badge badge-dim">OFF</span>'}</td>
|
||||
<td><div class="actions-col">
|
||||
<button class="btn btn-xs" onclick="apiPost('intents_toggle',{id:${i.id}},()=>{toast('Toggled','ok');loadIntents()})">${i.active?'DISABLE':'ENABLE'}</button>
|
||||
<button class="btn btn-xs btn-yellow" onclick='intentModal(${i.id},"${esc(i.intent_name)}","${esc(i.pattern)}",${JSON.stringify(i.response_template||"")},"${esc(i.action_type)}",${i.priority},${i.active})'>EDIT</button>
|
||||
<button class="btn btn-xs btn-yellow" onclick="intentModal(${i.id},'${escJs(i.intent_name)}','${escJs(i.pattern)}','${escJs(i.response_template||'')}','${escJs(i.action_type)}',${i.priority},${i.active})">EDIT</button>
|
||||
<button class="btn btn-xs btn-red" onclick="apiPost('intents_delete',{id:${i.id}},()=>{toast('Deleted','ok');loadIntents()})">DEL</button>
|
||||
</div></td>`, null, null);
|
||||
}
|
||||
@@ -2954,7 +3516,7 @@ async function loadNews() {
|
||||
<div style="font-size:0.75rem">${esc(c.title)}</div>
|
||||
${c.url?`<div style="font-size:0.6rem;color:var(--dim)">${esc(c.url)}</div>`:''}
|
||||
</div>
|
||||
<button class="btn btn-xs btn-yellow" onclick='newsCustomModal(${c.id},"${esc(c.title)}","${esc(c.url||"")}")'>EDIT</button>
|
||||
<button class="btn btn-xs btn-yellow" onclick="newsCustomModal(${c.id},'${escJs(c.title)}','${escJs(c.url||'')}')">EDIT</button>
|
||||
<button class="btn btn-xs btn-red" onclick="apiPost('news_custom_delete',{id:${c.id}},()=>{toast('Deleted','ok');loadNews()})">DEL</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
@@ -4339,7 +4901,7 @@ async function loadCalFeeds() {
|
||||
<td>${ts(f.last_sync)}</td>
|
||||
<td>${f.last_count||0}</td>
|
||||
<td>${f.active?'<span class="badge badge-green">ACTIVE</span>':'<span class="badge badge-red">PAUSED</span>'}</td>
|
||||
<td><button class="btn btn-xs" onclick='calFeedModal(${JSON.stringify(f)})'>EDIT</button>
|
||||
<td><button class="btn btn-xs" onclick="calFeedModal(${esc(JSON.stringify(f))})">EDIT</button>
|
||||
<button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
@@ -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,14 +50,14 @@ 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",
|
||||
"poll_interval": 30,
|
||||
"heartbeat_every": 10,
|
||||
"update_check_hours": 24,
|
||||
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"]
|
||||
"watch_services": []
|
||||
}
|
||||
JSONEOF
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
|
||||
@@ -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):
|
||||
@@ -247,7 +281,7 @@ def get_uptime() -> dict:
|
||||
return {}
|
||||
|
||||
def get_services(cfg: dict) -> list:
|
||||
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
|
||||
watch = cfg.get("watch_services", [])
|
||||
statuses = []
|
||||
for svc in watch:
|
||||
try:
|
||||
@@ -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()
|
||||
+6
-1
@@ -27,7 +27,12 @@ if (!$_skipSession) {
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
$_allowedOrigins = ['https://jarvis.orbishosting.com', 'http://jarvis.orbishosting.com'];
|
||||
$_origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (in_array($_origin, $_allowedOrigins, true)) {
|
||||
header('Access-Control-Allow-Origin: ' . $_origin);
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
// ── GLOBALS ──────────────────────────────────────────────────────────
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
// For values embedded inside a single-quoted JS string literal within an HTML attribute
|
||||
// (e.g. onclick="fn('${escJs(x)}')"). escHtml() alone isn't enough there: the browser
|
||||
// HTML-decodes the attribute before parsing it as JS, so an encoded quote would just
|
||||
// turn back into a literal ' before the JS parser ever sees it.
|
||||
function escJs(s) {
|
||||
return escHtml(String(s == null ? '' : s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,'\\r'));
|
||||
}
|
||||
let sessionToken = '';
|
||||
let sessionUser = '';
|
||||
let sessionId = 'session_' + Date.now();
|
||||
@@ -397,7 +407,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 +560,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() {
|
||||
@@ -581,15 +606,15 @@ function renderNetworkStatus(n) {
|
||||
agent_id: d.agent_id, hostname: d.name};
|
||||
const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : '';
|
||||
const badge = d.source === 'agent'
|
||||
? `<span style="font-size:0.53rem;color:var(--cyan);letter-spacing:1px;margin-left:4px">${(d.agent_type||'AGENT').toUpperCase()}</span>` : '';
|
||||
? `<span style="font-size:0.53rem;color:var(--cyan);letter-spacing:1px;margin-left:4px">${escHtml((d.agent_type||'AGENT').toUpperCase())}</span>` : '';
|
||||
const del = d.deletable
|
||||
? `<button onclick="deleteNetworkDevice('${d.ip}',event)" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:0.9rem;padding:0 2px;opacity:0.5;flex-shrink:0" title="Remove">×</button>` : '';
|
||||
? `<button onclick="deleteNetworkDevice('${escJs(d.ip)}',event)" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:0.9rem;padding:0 2px;opacity:0.5;flex-shrink:0" title="Remove">×</button>` : '';
|
||||
const bl = d.source === 'agent' ? 'border-left:2px solid ' + (alive ? 'var(--green)' : 'var(--red)') + ';' : '';
|
||||
return `<div class="device-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" style="${bl}display:flex;align-items:center">
|
||||
return `<div class="device-item" data-ctx-key="${ctxKey}" onclick="selectContext('${escJs(ctxKey)}')" style="${bl}display:flex;align-items:center">
|
||||
<div class="device-status ${alive?'on':'off'}" style="flex-shrink:0"></div>
|
||||
<div class="device-info" style="flex:1;min-width:0">
|
||||
<div class="device-name" style="display:flex;align-items:center">${d.name||d.ip}${badge}</div>
|
||||
<div class="device-ip">${d.ip||''}${lat}</div>
|
||||
<div class="device-name" style="display:flex;align-items:center">${escHtml(d.name||d.ip)}${badge}</div>
|
||||
<div class="device-ip">${escHtml(d.ip||'')}${lat}</div>
|
||||
</div>${del}
|
||||
</div>`;
|
||||
}
|
||||
@@ -669,7 +694,7 @@ async function loadProxmox() {
|
||||
type_label:vm.type||'qemu', uptime:vm.uptime||0};
|
||||
return `<div class="vm-card" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this VM">
|
||||
<div class="vm-header">
|
||||
<span class="vm-name">${vm.name}</span>
|
||||
<span class="vm-name">${escHtml(vm.name)}</span>
|
||||
<span style="color:${statusColor};font-size:0.65rem">● ${(vm.status||'').toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="vm-metrics">
|
||||
@@ -1016,10 +1041,11 @@ async function loadNews() {
|
||||
const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30);
|
||||
_panelCtx[ctxKey] = {type:'news', label:a.title,
|
||||
title:a.title, source:a.source, pub:a.pub||'', category:cat};
|
||||
const titleDisplay = a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title;
|
||||
html += `<div class="news-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this story">
|
||||
<div class="news-source">${a.source}</div>
|
||||
<div class="news-title">${a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title}</div>
|
||||
${a.pub ? '<div class="news-time">' + a.pub + '</div>' : ''}
|
||||
<div class="news-source">${escHtml(a.source)}</div>
|
||||
<div class="news-title">${escHtml(titleDisplay)}</div>
|
||||
${a.pub ? '<div class="news-time">' + escHtml(a.pub) + '</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -1390,6 +1416,7 @@ function enterVoiceMode(source) {
|
||||
}
|
||||
|
||||
function exitVoiceMode() {
|
||||
if (document.body.classList.contains('kiosk-mode')) return;
|
||||
voiceMode = false;
|
||||
voiceMuted = false;
|
||||
updateMicBtn();
|
||||
@@ -1739,3 +1766,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">${escHtml(ag.hostname)}</span>
|
||||
<span style="font-size:0.58rem;color:var(--text-dim)">${escHtml(ag.agent_type.toUpperCase())} · ${escHtml(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('${escJs(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('${escJs(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">
|
||||
|
||||
@@ -57,7 +57,7 @@ else
|
||||
"poll_interval": 30,
|
||||
"heartbeat_every": 10,
|
||||
"update_check_hours": 24,
|
||||
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"]
|
||||
"watch_services": []
|
||||
}
|
||||
JSONEOF
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
|
||||
+5
-25
@@ -13,8 +13,8 @@ if (!defined('WEBHOOK_SECRET')) {
|
||||
echo json_encode(['error' => 'Webhook not configured']);
|
||||
exit;
|
||||
}
|
||||
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
|
||||
define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');
|
||||
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
|
||||
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])) {
|
||||
@@ -59,20 +50,9 @@ if (!isset($repoMap[$repo])) {
|
||||
}
|
||||
|
||||
$path = $repoMap[$repo];
|
||||
|
||||
// NovaCPX lives on a private VM — the VM polls GitHub every minute via cron
|
||||
// This webhook receipt confirms GitHub delivered the push notification
|
||||
if ($path === '__NOVACPX_VM__') {
|
||||
$commit = $data['after'] ?? 'HEAD';
|
||||
$msg = "[" . date('Y-m-d H:i:s') . "] NovaCPX push by $pusher (commit: $commit) — VM will deploy within 1 min";
|
||||
file_put_contents(DEPLOY_LOG, $msg . "\n", FILE_APPEND | LOCK_EX);
|
||||
echo json_encode(['ok' => true, 'queued' => 'novacpx', 'commit' => $commit]);
|
||||
exit;
|
||||
}
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
|
||||
file_put_contents(DEPLOY_QUEUE, $path . "\n", FILE_APPEND | LOCK_EX);
|
||||
|
||||
$msg = "[" . date('Y-m-d H:i:s') . "] Queued deploy: $repo by $pusher -> $path";
|
||||
file_put_contents(DEPLOY_LOG, $msg . "\n", FILE_APPEND | LOCK_EX);
|
||||
file_put_contents(DEPLOY_LOG, "[$ts] Queued deploy: $repo by $pusher -> $path\n", FILE_APPEND | LOCK_EX);
|
||||
|
||||
echo json_encode(['ok' => true, 'queued' => $repo, 'path' => $path]);
|
||||
|
||||
Reference in New Issue
Block a user