Compare commits

33 Commits

Author SHA1 Message Date
myron e68bb7d165 feat: HA filter — remove floodlights, water heater, scenes, media players 2026-06-22 03:58:57 +00:00
myron 2f4b4ef5c3 feat: HA tab — filter scenes/media_player, nightly full resync cron, remove JS polling
- ha.php skipDomains: added media_player, scene
- ha.php skipKeywords: konnected, energy/power/voltage/current, full camera list
- stats_cache.php: same filter updates, removed scene/media_player from sync
- Removed JS setInterval polling; entity state kept fresh by HA agent push
- Added nightly 3am cron for full HA entity resync
2026-06-22 03:56:47 +00:00
myron 21e0b81a98 feat: HA tab — filter konnected/energy/camera/media_player, add 30s auto-refresh
- Added to skipDomains: media_player
- Added to skipKeywords: konnected, energy/power/voltage/current,
  camera controls (infrared, email, FTP, push, siren, hub ringtone, manual record),
  system noise (CEC scanner, ESPHome builder, Echo DND)
- Auto-refresh every 30s when HA tab is active
2026-06-22 03:53:06 +00:00
myron 95d49f15cb fix: kiosk voice reliability — stopListening on exit, exitVoiceMode kiosk guard
- stopListening() called in both toggleKiosk exit and _onFsChange so mic
  stops when leaving kiosk (was staying live indefinitely)
- exitVoiceMode() now returns early if kiosk-mode active so the 30-min
  idle timer and face-detection loop cannot kill the always-on mic
2026-06-21 14:26:54 +00:00
myron 51b598dd5d fix: kiosk voice — use startListening() directly, no TTS greeting blocking mic 2026-06-21 05:20:56 +00:00
myron 6f0459be85 feat: kiosk auto-starts voice mode and blocks sleep — isolated patches only 2026-06-21 05:17:55 +00:00
myron a6d4365f16 feat: kiosk mode CSS hiding (safe) — no voice JS patches 2026-06-21 05:15:38 +00:00
myron 383de0146c revert: restore all files to 52ddee3 — kiosk JS patches broke JARVIS completely 2026-06-21 05:10:30 +00:00
myron aaf9f9d56a revert: restore safe JS, keep only kiosk-mode CSS class toggle — voice patches caused JS crash 2026-06-21 05:03:56 +00:00
myron aa88a2f73b fix: missing quotes around kiosk-mode string caused ReferenceError breaking all buttons 2026-06-21 04:59:21 +00:00
myron f1d73e7b6a feat: kiosk always-on mic — auto-start voice on kiosk entry, no sleep, no wake word needed 2026-06-21 04:55:29 +00:00
myron 572f1b1816 feat: show HTTPS redirect banner on Silk/tablet when loaded via HTTP (mic/camera fix) 2026-06-21 04:45:40 +00:00
myron 1838e02d56 feat: hide network status panel in kiosk mode; bump cache version 2026-06-21 04:41:33 +00:00
myron 178040c18b chore: bump asset version to 20260621 to bust Silk browser cache 2026-06-21 04:30:28 +00:00
myron 45845a1f61 feat: kiosk-mode hides server, agents, guardian panels + HA/agents/memory/proxmox from bottom bar
- Adds body.kiosk-mode class on fullscreen entry/exit
- Hides: #server-panel, #tab-agents, #tab-guardian, tab buttons
- Hides bottom bar: Home Assistant, Agents, Memory, Proxmox
- Falls back to INTEL tab if agents/guardian was active on kiosk entry
- All elements remain visible in normal/tablet mode
2026-06-21 04:07:32 +00:00
myron 52ddee3e78 Fire HD 8 tablet mode: auto-detect Silk UA, optimised layout + touch targets 2026-06-19 16:17:48 +00:00
myron ab1aa16ac8 Add kiosk mode button for Fire tablet Silk browser 2026-06-19 16:02:51 +00:00
myron 1979c5f667 fix: install-agent.sh default URL updated to http://10.48.200.211 (JARVIS VM) 2026-06-18 12:34:32 +00:00
myron 1b071f4f67 fix: repair broken define in webhook.php (missing closing quote from prior sed) 2026-06-18 04:44:36 +00:00
myron 5cbaeda730 docs: update INFRASTRUCTURE-REFERENCE and CLAUDE.md for JARVIS VM migration
- JARVIS moved from DO to PVE1 VM 211 (10.48.200.211, 8c/16GB)
- Access: http://jarvis.orbishosting.com:1972 (FortiGate VIP)
- Stack: nginx + PHP 8.3 + MariaDB + Redis + Arc Reactor
- Ollama VM IP: 10.48.200.95 → 10.48.200.210 (Reolink owns .95)
- FusionPBX SSH now direct via Tailscale (100.74.46.120)
- DO role: websites only (JARVIS fully removed)
- Agent URLs updated: http://10.48.200.211 (LAN direct)
- DO agent uses Tailscale: http://100.77.178.42

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 04:38:02 +00:00
myron 5140573be0 fix: update system.php service list for JARVIS VM (nginx/php-fpm/mariadb/redis/arc/agent) 2026-06-18 04:18:07 +00:00
myron b7aea1371c feat: add DO server (web host) monitoring block to JARVIS Server panel
- /api/do now includes do_server key with jarvis-do agent metrics
  (CPU, RAM, disk, uptime from Tailscale-connected DO server agent)
- Front page JARVIS SERVER panel has WEB HOST section with live
  CPU/RAM/DISK bars from DO server agent data
- Panel title updated to show 10.48.200.211 (JARVIS VM IP)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 04:08:54 +00:00
myron 49694e76e1 fix: update service monitor for JARVIS VM (nginx/php-fpm/mariadb instead of OLS/mysql) 2026-06-18 04:01:36 +00:00
myron 04510ac39f fix: update facts_collector for JARVIS VM (not DO web host)
- Site checks use external URLs instead of 127.0.0.1 loopback (JARVIS
  no longer shares a server with the websites)
- JARVIS site URL updated to port 1972
- Fixed syntax error in DO server ping exec call
- Removed Host header injection (not needed for external checks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 03:53:44 +00:00
myron 38ab8d2977 migrate: update all references from DO server to PVE1 JARVIS VM
- config.php: JARVIS_IP → 10.48.200.211, HA_URL → direct LAN 10.48.200.97
- facts_collector/stats_cache: Proxmox API → direct 10.48.200.90 (not DDNS)
- chat.php: system context updated to reflect PVE1/nginx instead of DO/OLS
- do_server.php: display IP → 10.48.200.211 (reads /proc for JARVIS VM stats)
- jarvis-app.js: service labels nginx/mariadb instead of lshttpd
- jarvis-overlays.js: network map JARVIS node IP → 10.48.200.211
- index.html: DO SERVER labels → JARVIS VM, cache bust v=20260618a
- jarvis-agents.js: agent install URL uses window.location.origin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:25:36 +00:00
myron ca66152f45 perf: fix facts_collector blocking cron that was saturating PHP workers
Three issues caused periodic worker saturation:
1. Network section pinged 5 private LAN IPs (10.48.200.x) unreachable
   from DO — each failed after 1s timeout = 5s wasted per run.
   Replaced with a fast DB query on registered_agents.
2. pve_api_get() had no CURLOPT_CONNECTTIMEOUT — added 3s limit so
   unreachable Proxmox fails fast instead of blocking the full 8s.
3. Ollama curl timeout reduced from 5s→3s total, added 2s connect limit.

Cron interpreter also changed from lsphp85 to php8.3 in crontab
(done directly on server) — lsphp85 adds ~8s LSAPI startup overhead
and consumes a PHP worker slot; php8.3 runs standalone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:40:13 +00:00
myron 0b7f2d013b refactor: Phase 3 — split jarvis-protocols.js into 3 panel files
A single SyntaxError in the 1668-line monolith kills every panel
(proven by the apostrophe bug on 2026-06-17). Split into:

  panels/jarvis-arc.js       (608 lines) — Arc Reactor, Intel, Comms, Guardian
  panels/jarvis-agents.js    (715 lines) — Missions, Directives, Memory,
                                           Clearance, Agents tab, Sites, Vision
  panels/jarvis-assistant.js (345 lines) — Chat History, Suggestions,
                                           Mobile, Command Palette, Topo map

A parse error in any one file now fails only that group of panels.
escHtml() stays in jarvis-arc.js (loads first) and remains global.
All other dependencies (api, speak, addMessage) come from jarvis-app.js.
Version param bumped to ?v=20260617b to force Cloudflare cache miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:10:31 +00:00
myron 8085a113d5 fix: sync public_html/agent/jarvis-agent.py with agent source
public_html/agent/ is what agents download for self-update.
It was 5 days out of date — missing the version-in-heartbeat fix
and all other v3.1 changes. Now mirrors agent/jarvis-agent.py exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 18:45:54 +00:00
myron b85e8dd16f fix: include version in heartbeat payload so Workers tab shows real versions
Heartbeat was sending {} — version only appeared in registration.
Agents that never re-register (most of them) stayed NULL in the DB.
Now every heartbeat carries {"version": AGENT_VERSION} so agent.php
can update the column on every check-in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 18:36:51 +00:00
myron 188f6f8f10 fix: persist agent version on every heartbeat
update_agent_seen() now updates version column when agents include it
in their heartbeat payload. Previously version was only stored on
registration, leaving the Workers tab showing NULL for agents that
hadn't re-registered since v3.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:19:56 +00:00
myron 7f6397b514 perf: route Guardian and Vision text analysis to Groq instead of Claude
Guardian anomaly alerts and SITREP are pure text reasoning — Groq's
llama-3.3-70b-versatile handles them at near-zero cost with lower
latency. Vision Protocol image analysis stays on Claude (claude-opus-
4-8) because Groq has no vision models. Text-only sysinfo snapshots
(no image captured) also move to Groq.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:06:15 +00:00
myron dd2f48193b fix: add data-cfasync=false to face-api.js to suppress Rocket Loader
One untagged script tag is enough for Cloudflare Rocket Loader to
activate its bootstrap and inject mainScript.js, which declares
mainScriptFlag. When mainScript.js loads twice (script + eval), it
throws SyntaxError: Identifier 'mainScriptFlag' has already been
declared. All script tags now have data-cfasync=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:43:29 +00:00
myron 1e57a7c90c fix: check sites locally to avoid Cloudflare CDN timeouts
facts_collector was checking https://jarvis.orbishosting.com from the
DO server itself — traffic routes through Cloudflare CDN which can
return 524 timeouts. All sites are hosted on this same OLS instance,
so check via http://127.0.0.1 with a Host header instead. This gives
direct OLS response without CDN overhead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:30:40 +00:00
22 changed files with 2117 additions and 1782 deletions
+1 -1
View File
@@ -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:
+9 -2
View File
@@ -37,12 +37,19 @@ function get_agent_by_key(string $key): ?array {
return $rows[0] ?? null;
}
function update_agent_seen(string $agentId, string $status = 'online'): void {
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,7 @@ switch ($agentAction) {
// ── HEARTBEAT ────────────────────────────────────────────────────────────
case 'heartbeat':
update_agent_seen($agent['agent_id']);
update_agent_seen($agent['agent_id'], 'online', trim($data['version'] ?? '') ?: null);
// Return any pending commands for this agent
$commands = JarvisDB::query(
+3 -3
View File
@@ -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:
+20 -3
View File
@@ -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"),
]);
+19 -23
View File
@@ -80,24 +80,13 @@ 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);
}
$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);
@@ -111,7 +100,7 @@ function collect_all(): array {
$results['proxmox'] = 'skipped (fresh)';
} else try {
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://orbisne.fortiddns.com:' . PROXMOX_PORT . '/api2/json';
$base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
@@ -146,7 +135,7 @@ function collect_all(): array {
// ── Digital Ocean ─────────────────────────────────────────────────────
try {
exec('ping -c1 -W2 165.22.1.228 > /dev/null 2>&1', $o2, $doCode);
exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);;
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
$results['do_server'] = "ok ({$doStatus})";
@@ -160,7 +149,7 @@ function collect_all(): array {
} else try {
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
$ch = curl_init($ollamaHost . '/api/tags');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
@@ -192,7 +181,7 @@ function collect_all(): array {
$results['sites'] = 'skipped (fresh)';
} else try {
$sites = [
'jarvis' => 'https://jarvis.orbishosting.com',
"jarvis" => "http://jarvis.orbishosting.com:1972",
'tomsjavajive' => 'https://tomsjavajive.com',
'epictravelexp'=> 'https://epictravelexpeditions.com',
'parkersling' => 'https://parkerslingshotrentals.com',
@@ -202,9 +191,15 @@ function collect_all(): array {
];
$down = [];
foreach ($sites as $key => $url) {
$ch = curl_init($url);
$parsed = parse_url($url);
$host = $parsed['host'] ?? $url;
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
// All JARVIS-hosted sites are served from this same OLS instance.
$localUrl = $url; // external check
$ch = curl_init($localUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
@@ -234,9 +229,10 @@ function pve_api_get(string $url, string $authHeader): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 8,
CURLOPT_TIMEOUT => 5,
]);
$resp = curl_exec($ch);
curl_close($ch);
+20 -8
View File
@@ -83,15 +83,27 @@ 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',
'assist_satellite','input_button','media_player','scene','water_heater'];
$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_','assist_microphone'];
'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
+3 -3
View File
@@ -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_',
+1 -1
View File
@@ -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');
+5 -5
View File
@@ -688,8 +688,8 @@ async def handle_screenshot(payload: dict) -> dict:
try:
snap_text = json.dumps(result, indent=2)[:3000]
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
analysis = await llm_call([{"role": "user", "content": prompt}], "claude")
provider_used = "claude"
analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
provider_used = "groq"
except Exception as e:
analysis = f"Analysis unavailable: {e}"
@@ -1022,7 +1022,7 @@ async def guardian_loop() -> None:
"for Myron. Be direct about severity and what action to take. "
"No markdown, no headers."
)
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "claude")
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
# Update the most recent guardian event with AI analysis
await db_execute(
"""UPDATE guardian_events SET ai_analysis=%s
@@ -1062,10 +1062,10 @@ async def _guardian_inject_chat(message: str) -> None:
async def handle_sitrep(payload: dict) -> dict:
"""
Situation Report comprehensive health briefing across all field stations.
payload: { detail: brief|full, provider: claude }
payload: { detail: brief|full, provider: groq }
"""
detail = payload.get("detail", "full")
provider = payload.get("provider", "claude")
provider = payload.get("provider", "groq")
log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
@@ -1,5 +1,5 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
**Last Updated:** 2026-06-14
**Last Updated:** 2026-06-18
**Owner:** Myron Blair — myronblair@outlook.com
---
@@ -29,7 +29,7 @@ INTERNET
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
│ (proxied DNS for public sites)
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites + JARVIS
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (6 sites)
└─► [FusionPBX 134.209.72.226] — FreeSWITCH PBX (SSH via DO relay)
@@ -42,7 +42,7 @@ HOME NETWORK (FortiGate router at 10.48.200.1)
│ ├── VM 113 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
│ ├── VM 118 10.48.200.18 Homebridge
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
│ ├── VM 210 10.48.200.95 Ollama (local LLM)
│ ├── VM 210 10.48.200.210 Ollama (local LLM) (local LLM)
│ └── CT110 10.48.200.19 WireGuard exit container
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
@@ -73,13 +73,13 @@ FortiGate Port Forwards:
| **OS** | Ubuntu 22.04 LTS |
| **Panel** | CyberPanel (OpenLiteSpeed) |
| **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` |
| **Purpose** | All public websites + JARVIS AI + webhook deploy system |
| **Purpose** | All public websites (6 sites) — webhook deploy for websites |
**Key Paths:**
- All sites: `/home/<domain>/public_html/`
- JARVIS: `/home/jarvis.orbishosting.com/`
- Deploy log: `/home/jarvis.orbishosting.com/logs/deploy.log`
- Watchdog log: `/home/jarvis.orbishosting.com/logs/watchdog.log`
- Deploy log: per-site (website deploys only)
- Watchdog log: `/usr/local/lsws/logs/watchdog.log`
- Infra repo: `/opt/infra`
**Services running:**
@@ -87,7 +87,7 @@ FortiGate Port Forwards:
- MySQL 8 — all site databases on localhost
- Redis — session/cache
- PHP 8.5 (`lsphp85`) — runtime for all sites
- Cron jobs: JARVIS deploy runner (every 1 min), facts collector (every 3 min), stats cache (every 5 min), watchdog (every 5 min)
- Cron jobs: website deploy runner (every 1 min), watchdog (every 5 min)
**CyberPanel Web UI:** `https://165.22.1.228:8090`
Login: `myron / Joker1974!!!`
@@ -102,7 +102,7 @@ Login: `myron / Joker1974!!!`
|-------|-------|
| **IP** | 134.209.72.226 |
| **OS** | Debian (DigitalOcean droplet) |
| **SSH** | Must relay via DO: `ssh root@165.22.1.228``ssh root@134.209.72.226` — password: `Joker1974!@#` |
| **SSH** | Direct via Tailscale: `ssh root@100.74.46.120` — password: `Joker1974!@#` |
| **Direct SSH** | Only from: 107.178.2.130 / 97.154.109.245 |
| **Purpose** | VoIP phone system — handles all inbound/outbound calls |
@@ -308,11 +308,11 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
### VM 210 — Ollama Local LLM (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.95 |
| **IP** | 10.48.200.210 |
| **OS** | Ubuntu (cloud image) |
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat |
| **API** | `http://10.48.200.95:11434` (Ollama REST API) |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
@@ -372,11 +372,11 @@ All sites are at `/home/<domain>/public_html/` on DO (165.22.1.228).
---
### jarvis.orbishosting.com — JARVIS AI Dashboard
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
| Field | Value |
|-------|-------|
| **URL** | https://jarvis.orbishosting.com |
| **Path** | `/home/jarvis.orbishosting.com/` |
| **URL** | http://jarvis.orbishosting.com:1972 |
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
| **GitHub** | `myronblair/jarvis` |
| **Login** | `myron / Joker1974!!!` |
| **Purpose** | Iron Man-style AI home dashboard with voice control, smart home, media, planner |
@@ -468,11 +468,11 @@ See Section 7 for full JARVIS details.
## 7. JARVIS AI SYSTEM
**URL:** https://jarvis.orbishosting.com
**Files:** `/home/jarvis.orbishosting.com/` on DO
**URL:** http://jarvis.orbishosting.com:1972
**Files:** `/var/www/jarvis/` on JARVIS VM (PVE1 VM 211 — 10.48.200.211, 8 cores, 16GB RAM)
**DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
**Login:** `myron / Joker1974!!!`
**Admin portal:** https://jarvis.orbishosting.com/admin
**Admin portal:** http://jarvis.orbishosting.com:1972/admin
### Architecture (end-to-end)
@@ -484,7 +484,7 @@ Voice (browser mic)
→ /api/chat.php (4-tier AI)
Tier 0.7: KB intents / planner (tasks, appointments)
Tier 1: Knowledge Base (MySQL)
Tier 1.5: Ollama (10.48.200.95:11434, llama3.2) — local LLM
Tier 1.5: Ollama (10.48.200.210:11434, llama3.2) — local LLM
Tier 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker
@@ -501,7 +501,7 @@ Webhook secret: `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1`
### Agent System
Agents installed on all servers — phone home every 10s (heartbeat) / 30s (metrics).
Registration key: `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518`
Install command: `curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s <hostname> <linux|proxmox>`
Install command: `curl -sk http://10.48.200.211/install-agent.sh | bash -s <hostname> <linux|proxmox>`
### Self-Healing Watchdog
`/usr/local/bin/jarvis-watchdog.sh` — runs every 5 min (root cron on DO)
@@ -680,8 +680,8 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
| phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` |
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
| JARVIS | https://jarvis.orbishosting.com | myron | `Joker1974!!!` |
| JARVIS Admin | https://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
| JARVIS | http://jarvis.orbishosting.com:1972 | myron | `Joker1974!!!` |
| JARVIS Admin | http://jarvis.orbishosting.com:1972/admin | myron | `Joker1974!!!` |
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
+3 -3
View File
@@ -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="http://10.48.200.211"
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
+53 -3
View File
@@ -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):
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:
return json.load(f)
cfg = json.load(f)
# Migrate old key names so the agent self-heals instead of crash-looping
import re as _re
changed = False
if "server_url" in cfg and "jarvis_url" not in cfg:
cfg["jarvis_url"] = cfg.pop("server_url")
print("[JARVIS] Config migrated: server_url -> jarvis_url", flush=True)
changed = True
if "api_key" in cfg and "registration_key" not in cfg:
cfg["registration_key"] = cfg.pop("api_key")
print("[JARVIS] Config migrated: api_key -> registration_key", flush=True)
changed = True
if "hostname" not in cfg:
cfg["hostname"] = socket.gethostname()
changed = True
if "ssl_verify" not in cfg:
cfg["ssl_verify"] = not bool(_re.match(r"https?://\d+\.\d+\.\d+\.\d+", cfg.get("jarvis_url", "")))
changed = True
if changed:
with open(CONFIG_PATH, "w") as f:
json.dump(cfg, f, indent=2)
print("[JARVIS] Config saved after migration.", flush=True)
return cfg
def load_state() -> dict:
if os.path.exists(STATE_PATH):
@@ -265,11 +299,23 @@ def get_load() -> list:
except Exception:
return [0, 0, 0]
def get_nordvpn_status() -> dict | None:
"""Check nordlynx WireGuard interface. Returns None if nordlynx not present on this host."""
try:
r = subprocess.run(["ip", "link", "show", "nordlynx"],
capture_output=True, text=True, timeout=3)
if r.returncode != 0:
return None
active = "UP,LOWER_UP" in r.stdout or "state UP" in r.stdout
return {"active": active, "interface": "nordlynx"}
except Exception:
return None
def collect_metrics(cfg: dict) -> dict:
# First reading for CPU delta
get_cpu_percent()
time.sleep(1)
return {
metrics = {
"hostname": cfg.get("hostname", socket.gethostname()),
"cpu_percent": get_cpu_percent(),
"memory": get_memory(),
@@ -280,6 +326,10 @@ def collect_metrics(cfg: dict) -> dict:
"platform": platform.system(),
"timestamp": datetime.utcnow().isoformat() + "Z",
}
nordvpn = get_nordvpn_status()
if nordvpn is not None:
metrics["nordvpn"] = nordvpn
return metrics
# ── Proxmox metrics ───────────────────────────────────────────────────────────
@@ -384,7 +434,7 @@ def main():
try:
# Heartbeat + get commands
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify)
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {"version": AGENT_VERSION}, headers, ssl_verify=ssl_verify)
if "error" in hb:
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
else:
+1 -1
View File
@@ -1 +1 @@
1a9e8e24e5aee8f27a5900b6340373023ff2171e844e71e451eecdbf3b2b0f03 jarvis-agent.py
6ba92a1ad4f91a218cbc4ce6834c55e8f56a0e22fca04278d77260958e429d5b
+171
View File
@@ -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; }
+81 -1
View File
@@ -397,7 +397,7 @@ let _refreshTick = 0;
let selectedContext = null;
const _panelCtx = {};
let _haEntities = {};
const _svcLabels = {lshttpd:'WEB',mysql:'MYSQL',redis:'REDIS',memcached:'MEMCACHE',postfix:'POSTFIX',dovecot:'DOVECOT','jarvis-agent':'AGENT'};
const _svcLabels = {nginx:'WEB','php8.3-fpm':'PHP',mariadb:'DB','redis-server':'REDIS','jarvis-arc':'ARC','jarvis-agent':'AGENT'};
async function refreshAll() {
_refreshTick++;
@@ -550,6 +550,21 @@ function renderDO(d) {
</div>`;
}).join('');
}
// WEB HOST (DO server agent metrics)
const ds = d.do_server || {};
const doStatus = document.getElementById('do-host-status');
const doCpu = document.getElementById('do-cpu');
const doMem = document.getElementById('do-mem');
const doDisk = document.getElementById('do-disk');
if (ds.online) {
if (doStatus) { doStatus.textContent = '●'; doStatus.style.color = 'var(--green)'; }
if (doCpu) doCpu.textContent = (ds.cpu || 0) + '%';
if (doMem) doMem.textContent = (ds.mem || 0) + '%';
if (doDisk) doDisk.textContent = (ds.disk || 0) + '%';
} else {
if (doStatus) { doStatus.textContent = '○'; doStatus.style.color = 'var(--red)'; }
}
}
async function loadNetwork() {
@@ -1390,6 +1405,7 @@ function enterVoiceMode(source) {
}
function exitVoiceMode() {
if (document.body.classList.contains('kiosk-mode')) return;
voiceMode = false;
voiceMuted = false;
updateMicBtn();
@@ -1739,3 +1755,67 @@ document.addEventListener('keydown', function(e) {
});
}
});
// ── FIRE HD 8 TABLET DETECTION ────────────────────────────────────────────────────────
const IS_SILK = /Silk\//i.test(navigator.userAgent);
const IS_FIRE = /KFTT|KFOT|KFJWI|KFSOWI|KFTHWI|KFTHWA|KFAPWI|KFAPWA|KFARWI|KFASWI|KFMEWI|KFFOWI|KFSAWA|KFMAWI|KFGIWI|KFDOWI|KFTBWI|KFTRWI|KFKAWI/i.test(navigator.userAgent);
function isTablet() { return IS_SILK || IS_FIRE; }
function applyTabletMode() {
document.body.classList.add("tablet-mode");
const kb = document.getElementById("kioskBtn");
if (kb) kb.title = "Full-screen kiosk (Fire HD 8 layout active)";
}
if (isTablet()) applyTabletMode();
// ── KIOSK MODE ────────────────────────────────────────────────────────────────────────
let _wakeLock = null;
async function toggleKiosk() {
const btn = document.getElementById("kioskBtn");
const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
if (!isFs) {
applyTabletMode();
const el = document.documentElement;
const req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
if (req) req.call(el).catch(() => {});
if ("wakeLock" in navigator) {
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
}
document.body.classList.add("kiosk-mode");
// Kiosk: silently activate mic + voice mode (no TTS greeting)
if (typeof wakeFromSleep === "function" && isAsleep) wakeFromSleep();
voiceMode = true; voiceMuted = false; voiceLastCmd = Date.now(); updateMicBtn();
if (typeof startListening === "function" && !isListening) startListening();
if (btn) { btn.textContent = "⧞ EXIT"; btn.style.color = "var(--cyan)"; }
} else {
const ex = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen;
if (ex) ex.call(document).catch(() => {});
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
document.body.classList.remove("kiosk-mode");
if (typeof stopListening === "function") stopListening();
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
if (!isTablet()) document.body.classList.remove("tablet-mode");
}
}
document.addEventListener("visibilitychange", async () => {
if (_wakeLock && document.visibilityState === "visible") {
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
}
});
function _onFsChange() {
const btn = document.getElementById("kioskBtn");
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
document.body.classList.remove("kiosk-mode");
if (typeof stopListening === "function") stopListening();
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
if (!isTablet()) document.body.classList.remove("tablet-mode");
}
}
document.addEventListener("fullscreenchange", _onFsChange);
document.addEventListener("webkitfullscreenchange", _onFsChange);
+2 -1
View File
@@ -5,6 +5,7 @@ var _sleepRefreshTimer = null;
var SLEEP_CMDS = /\b(good\s*night(\s*jarvis)?|go\s*to\s*sleep|sleep\s*mode|shut\s*(down|off)\s*(jarvis|for\s*the\s*night)|go\s*offline|going\s*offline|jarvis\s*(go\s*)?(offline|sleep|shutdown)|stand\s*by\s*mode|power\s*down(\s*jarvis)?|signing\s*off)\b/i;
function enterSleepMode() {
if (document.body.classList.contains("kiosk-mode")) return;
if (isAsleep) return;
isAsleep = true;
@@ -141,7 +142,7 @@ function closeNetMap(){
function _nmBuild(devices){
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
// Hub
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'165.22.1.228',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'10.48.200.211',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
// Bucket
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
// Deduplicate agent devices by hostname (same logical host registered twice)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
let _missionsOpenCards = new Set();
async function loadMissionsHud() {
const el = document.getElementById('missions-hud');
if (!el) return;
try {
const missions = await api('arc?action=missions');
const list = Array.isArray(missions) ? missions : [];
let html = '<button class="mission-new-btn" onclick="window.open(\'/admin#missions\',\'_blank\')">◈ MANAGE MISSIONS IN ADMIN</button>';
if (!list.length) {
html += '<div class="comms-empty">◈ NO MISSIONS<br><span style="opacity:0.5">Create workflows in Admin → Mission Ops</span></div>';
el.innerHTML = html;
return;
}
const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
for (const m of list) {
const isOpen = _missionsOpenCards.has(m.id);
const icon = trigIcons[m.trigger_type] || '◈';
const enabled = m.enabled;
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
html += `<div class="mission-card${isOpen?' open':''}" id="mission-card-${m.id}">
<div class="mission-card-head" onclick="toggleMissionCard(${m.id})">
<span style="opacity:${enabled?1:0.35}">${icon}</span>
<span class="mission-card-name" style="opacity:${enabled?1:0.45}">${escHtml(m.name)}</span>
<span class="mission-card-trigger">${m.trigger_type.replace('_',' ').toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${m.run_count||0} runs</span>
</div>
<div class="mission-card-body">
${m.description ? `<div style="font-size:0.58rem;color:var(--text-dim);margin:6px 0">${escHtml(m.description)}</div>` : ''}
<div style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);margin:4px 0">Last run: ${lastRun} · ${m.run_count||0} total runs</div>
<div class="mission-run-bar">
<button class="mission-run-btn" id="mission-run-btn-${m.id}" onclick="hudRunMission(${m.id})"${!enabled?' disabled title="Mission disabled"':''}> RUN NOW</button>
</div>
<div id="mission-run-result-${m.id}" style="font-family:var(--font-mono);font-size:0.52rem;margin-top:6px;min-height:12px"></div>
</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">MISSIONS OFFLINE</div>';
}
}
function toggleMissionCard(id) {
const card = document.getElementById('mission-card-' + id);
if (!card) return;
if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
else _missionsOpenCards.add(id);
card.classList.toggle('open');
}
async function hudRunMission(id) {
const btn = document.getElementById('mission-run-btn-' + id);
const res = document.getElementById('mission-run-result-' + id);
if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
if (res) res.textContent = '';
try {
const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
const s = data.status || 'done';
const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
if (res) res.style.color = color;
if (res) res.textContent = `${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
setTimeout(loadMissionsHud, 2000);
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
if (res) res.textContent = '✗ Run failed';
}
}
// ── DIRECTIVES HUD ────────────────────────────────────────────────────────────
let _dirOpenCards = new Set();
async function loadDirectivesHud() {
const el = document.getElementById('directives-hud');
if (!el) return;
try {
const d = await api('directives/list?status=active');
const list = (d.directives || []);
let html = '<button class="dir-admin-btn" onclick="window.open(\'/admin#directives\',\'_blank\')">◈ MANAGE IN ADMIN</button>';
if (!list.length) {
html += '<div class="comms-empty">◈ NO ACTIVE DIRECTIVES<br><span style="opacity:0.5">Create objectives in Admin → Directives</span></div>';
el.innerHTML = html;
return;
}
const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'};
for (const dir of list) {
const pct = Math.min(100, Math.round(dir.progress || 0));
const isOpen = _dirOpenCards.has(dir.id);
const color = catColors[dir.category] || 'var(--cyan)';
const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644';
const daysLeft = dir.target_date
? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null;
const dueTxt = daysLeft !== null
? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`)
: '';
const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)';
html += `<div class="dir-card${isOpen?' open':''}" id="dir-card-${dir.id}">
<div class="dir-card-head" onclick="toggleDirCard(${dir.id})">
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${color};flex-shrink:0">${dir.category.toUpperCase()}</span>
<span class="dir-card-title" style="color:${color}">${escHtml(dir.title)}</span>
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${fillColor};flex-shrink:0">${pct}%</span>
${dueTxt ? `<span style="font-family:var(--font-mono);font-size:0.48rem;color:${dueColor};flex-shrink:0">${dueTxt}</span>` : ''}
</div>
<div class="dir-card-body">
<div class="dir-progress-bar"><div class="dir-progress-fill" style="width:${pct}%;background:${fillColor}"></div></div>
<div style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim);margin-bottom:6px">${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS</div>
<button onclick="hudDirectiveReview(${dir.id})" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.2);border-radius:3px;padding:3px 8px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> AI REVIEW</button>
</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">DIRECTIVES OFFLINE</div>';
}
}
function toggleDirCard(id) {
const card = document.getElementById('dir-card-' + id);
if (!card) return;
if (_dirOpenCards.has(id)) _dirOpenCards.delete(id);
else _dirOpenCards.add(id);
card.classList.toggle('open');
}
async function hudDirectiveReview(id) {
const res = await api('arc?action=job_create', 'POST', {
type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6,
});
if (res.job_id) {
addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`);
speak(`Directive review underway. I'll brief you on your progress in a moment.`);
}
}
// ── MEMORY CORE — bottom bar count ────────────────────────────────────────────
async function updateMemoryCount() {
try {
const stats = await api('memory?action=stats');
const el = document.getElementById('bb-memory-count');
const dot = document.getElementById('bb-memory-dot');
if (el && stats) {
const total = stats.total || 0;
el.textContent = total + ' FACTS';
if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)';
}
} catch(e) {}
}
// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
const _clrOpenCards = new Set();
async function updateClearanceBanner() {
try {
const pending = await api('arc?action=clearance_pending');
const list = Array.isArray(pending) ? pending : [];
const count = list.length;
const banner = document.getElementById('clearance-banner');
const badge = document.getElementById('clr-tab-badge');
const bcount = document.getElementById('clr-banner-count');
if (banner) {
if (count > 0) {
banner.classList.add('active');
if (bcount) bcount.textContent = count;
} else {
banner.classList.remove('active');
}
}
if (badge) {
if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
else badge.style.display = 'none';
}
} catch(e) {}
}
async function loadClearanceHud() {
const el = document.getElementById('clearance-hud');
if (!el) return;
try {
const [pendingRes, rulesRes, historyRes] = await Promise.all([
api('arc?action=clearance_pending'),
api('arc?action=clearance_rules'),
api('arc?action=clearance_history&limit=20')
]);
const pending = Array.isArray(pendingRes) ? pendingRes : [];
const rules = Array.isArray(rulesRes) ? rulesRes : [];
const history = Array.isArray(historyRes) ? historyRes : [];
let html = '<button class="clr-admin-btn" onclick="window.open(\'/admin#clearance\',\'_blank\')">◈ MANAGE CLEARANCE RULES IN ADMIN</button>';
// Pending requests
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:#ff6680;margin:8px 0 4px">PENDING AUTHORIZATION (${pending.length})</div>`;
if (!pending.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">◈ NO PENDING CLEARANCE REQUESTS</div>';
} else {
for (const cr of pending) {
const isOpen = _clrOpenCards.has(cr.id);
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
html += `<div class="clr-card${isOpen?' open':''}" id="clr-card-${cr.id}">
<div class="clr-card-head" onclick="toggleClrCard(${cr.id})">
<span class="clr-card-type">${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
<span class="clr-card-risk ${cr.risk_level}">${cr.risk_level.toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">#${cr.id}</span>
</div>
<div class="clr-card-body">
<div class="clr-card-desc">${escHtml(cr.description || 'No description')}</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:4px">
Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:6px;word-break:break-all">
Payload: ${escHtml(JSON.stringify(pl))}
</div>
<div class="clr-action-bar">
<button class="clr-approve-btn" onclick="hudClearanceDecide(${cr.id},'approve')"> AUTHORIZE</button>
<button class="clr-deny-btn" onclick="hudClearanceDecide(${cr.id},'deny')"> DENY</button>
</div>
</div>
</div>`;
}
}
// Rules
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:12px 0 4px">CLEARANCE RULES</div>`;
if (!rules.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">No rules configured</div>';
} else {
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px;margin-bottom:8px">';
for (const r of rules) {
const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
const enLabel = r.enabled ? 'ON' : 'OFF';
const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
html += `<div class="clr-rule-row">
<span class="clr-rule-type">${r.job_type.replace(/_/g,' ').toUpperCase()}</span>
<span class="clr-card-risk ${r.risk_level}" style="font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px;border:1px solid">${r.risk_level.toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${reqLabel}${autoTxt}</span>
<button class="clr-rule-toggle ${enClass}" onclick="hudClearanceRuleToggle(${r.id},${r.enabled?0:1})">${enLabel}</button>
</div>`;
}
html += '</div>';
}
// Recent history
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">RECENT HISTORY</div>`;
const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
if (!recentDecided.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3)">No history yet</div>';
} else {
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px">';
for (const h of recentDecided) {
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
html += `<div class="clr-history-row">
<span class="clr-status-${h.status}"></span>
<span style="flex:1">${h.job_type.replace(/_/g,' ').toUpperCase()}</span>
<span class="clr-status-${h.status}">${h.status.toUpperCase()}</span>
<span style="color:rgba(255,255,255,0.3)">${ts}</span>
</div>`;
}
html += '</div>';
}
el.innerHTML = html;
await updateClearanceBanner();
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">CLEARANCE SYSTEM OFFLINE</div>';
}
}
function toggleClrCard(id) {
const card = document.getElementById('clr-card-' + id);
if (!card) return;
if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
else _clrOpenCards.add(id);
card.classList.toggle('open');
}
async function hudClearanceDecide(id, action) {
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
if (!confirm(`${label} clearance request #${id}?`)) return;
const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
try {
const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
const msg = action === 'approve'
? `◈ Clearance #${id} authorized. Job dispatched.`
: `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
addMessage('jarvis', msg);
speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
await loadClearanceHud();
} catch(e) {
addMessage('system', 'Clearance action failed.');
}
}
async function hudClearanceRuleToggle(id, newEnabled) {
try {
await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
await loadClearanceHud();
} catch(e) {}
}
async function loadAgents() {
const [listData, metricsData] = await Promise.all([
api('agent/list'),
api('agent/status')
]);
const agents = listData.agents || [];
const metrics = metricsData.metrics || {};
// Fetch sparkline data (non-blocking)
api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
renderAgentsTab(agents, metrics);
}
async function addNetworkDevice() {
const ip = prompt('IP address (e.g. 10.48.200.43):');
if (!ip) return;
const name = prompt('Device name (e.g. Yealink Phone):');
if (!name) return;
const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device';
const r = await api('network/add', 'POST', {ip, alias: name, type});
if (r.error) { alert('Error: ' + r.error); return; }
loadNetwork();
}
async function deleteNetworkDevice(ip, evt) {
evt.stopPropagation();
if (!confirm('Remove ' + ip + ' from the network list?')) return;
const r = await api('network/delete', 'POST', {ip});
if (r.error) { alert('Error: ' + r.error); return; }
loadNetwork();
}
let _agentSparkData = {};
function sparkline(points, width=80, height=20, color='var(--cyan)') {
if (!points || points.length < 2) return '';
const max = Math.max(...points, 1);
const min = Math.min(...points);
const range = max - min || 1;
const step = width / (points.length - 1);
const pts = points.map((v, i) => {
const x = i * step;
const y = height - ((v - min) / range) * (height - 2) - 1;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg width="${width}" height="${height}" style="overflow:visible;display:block">
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>
<circle cx="${((points.length-1)*step).toFixed(1)}" cy="${(height - ((points[points.length-1]-min)/range)*(height-2)-1).toFixed(1)}" r="2" fill="${color}"/>
</svg>`;
}
function renderAgentsTab(agents, metrics) {
const el = document.getElementById('agents-list');
if (!el) return;
if (!agents.length) {
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;margin-top:20px">NO AGENTS REGISTERED</div>';
return;
}
el.innerHTML = agents.map(ag => {
const m = metrics[ag.agent_id] || {};
const sys = m.system || {};
const alive = ag.status === 'online';
const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--';
const mem = sys.memory ? Math.round(sys.memory.percent) : '--';
const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--';
const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--';
const disks = sys.disk || [];
const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null;
const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE');
const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--';
const gauge = (val, unit='%', warn=80, crit=90) => {
const v = typeof val === 'number' ? val : parseInt(val);
if (isNaN(v)) return `<span style="color:var(--text-dim)">--</span>`;
const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)';
return `<div style="display:flex;align-items:center;gap:4px">
<div style="width:50px;height:5px;background:rgba(255,255,255,0.1);border-radius:3px;flex-shrink:0">
<div style="width:${Math.min(v,100)}%;height:100%;background:${col};border-radius:3px;transition:width 0.5s"></div>
</div>
<span style="color:${col};font-size:0.65rem">${v}${unit}</span>
</div>`;
};
const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true)
.map(s => `<span style="color:${s.status==='active'?'var(--green)':'var(--red)'};font-size:0.58rem;margin-right:6px">${s.service}: ${s.status}</span>`)
.join('');
const ctxKey = 'agent_' + ag.agent_id;
_panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id,
hostname: ag.hostname, status: ag.status, cpu, mem};
return `<div class="alert-item ${alive ? '' : 'critical'}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')"
style="flex-direction:column;align-items:stretch;border-left:3px solid ${alive ? 'var(--green)' : 'var(--red)'}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<div style="width:8px;height:8px;border-radius:50%;background:${alive ? 'var(--green)' : 'var(--red)'};box-shadow:${alive ? '0 0 6px var(--green)' : 'none'};flex-shrink:0"></div>
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${ag.hostname}</span>
<span style="font-size:0.58rem;color:var(--text-dim)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</span>
<span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
</div>
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px">
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px">
<div>
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">CPU 2H</div>
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
</div>
<div>
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">MEM 2H</div>
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
</div>
</div>` : ''}
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
</div>
${alive ? `<div style="display:flex;gap:5px;margin-top:6px">
<button onclick="event.stopPropagation();agentScreenshot('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> SCREENSHOT</button>
<button onclick="event.stopPropagation();agentSysinfo('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> SYSINFO</button>
</div>` : ''}
</div>`;
}).join('');
}
function openAgentModal() {
const os = detectOS();
const title = document.getElementById('agentModalTitle');
const content = document.getElementById('agentModalContent');
const modal = document.getElementById('agentModal');
const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
const baseUrl = 'https://jarvis.orbishosting.com/agent';
const jUrl = window.location.origin;
if (os === 'tablet') {
title.textContent = '● JARVIS — TABLET / MOBILE';
content.innerHTML =
'<div style="color:var(--cyan);font-size:0.75rem;margin-bottom:12px">✓ You\'re viewing JARVIS on a tablet or mobile device.</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.6">The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).<br><br>' +
'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.</div>';
} else if (_agentOnline) {
title.textContent = '● AGENT CONNECTED';
content.innerHTML =
'<div style="color:var(--green);font-size:0.75rem;margin-bottom:12px">✓ JARVIS Agent is active on this machine.</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.8">' +
'<b style="color:var(--text)">Host:</b> ' + (_myAgent?.hostname||'—') + '<br>' +
'<b style="color:var(--text)">IP:</b> ' + (_myAgent?.ip_address||'—') + '<br>' +
'<b style="color:var(--text)">Type:</b> ' + (_myAgent?.agent_type||'—').toUpperCase() + '<br>' +
'<b style="color:var(--text)">Reporting:</b> CPU · Memory · Disk · Services · Uptime</div>';
} else {
const inst = {
windows: {
label:'Windows',
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
dl: baseUrl+'/install-windows.ps1',
note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
},
mac: {
label:'macOS',
cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
dl: baseUrl+'/install-mac.sh',
note:'Run in Terminal. Installs as a launchd background service.'
},
linux: {
label:'Linux',
cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
dl: baseUrl+'/install.sh',
note:'Run in terminal. Installs as a systemd service.'
},
unknown: {
label:'Your System',
cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
dl: 'https://jarvis.orbishosting.com/agent/',
note:'Choose your platform installer from the JARVIS agent directory.'
}
};
const i = inst[os] || inst.unknown;
const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase();
title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM');
content.innerHTML =
'<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:1px;margin-bottom:8px">DETECTED: ' + osBadge + '</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;margin-bottom:12px">'+i.note+'</div>' +
'<pre id="agentCmdPre">'+i.cmd+'</pre>' +
'<a class="agent-dl-btn" href="'+i.dl+'" target="_blank">↓ DOWNLOAD INSTALLER</a>' +
'<div style="color:var(--text-dim);font-size:0.6rem;margin-top:16px;opacity:0.7">After install, the AGENT indicator turns green within 30 seconds.</div>';
}
modal.classList.add('open');
}
document.addEventListener('click', function(e) {
if (e.target === document.getElementById('agentModal'))
document.getElementById('agentModal').classList.remove('open');
});
// ── SITES MANAGER ────────────────────────────────────────────────────
let sitesData = {};
function openSitesModal() {
document.getElementById('sitesModal').style.display = 'flex';
loadSites();
}
function closeSitesModal() {
document.getElementById('sitesModal').style.display = 'none';
}
// Close on backdrop click
document.getElementById('sitesModal').addEventListener('click', function(e) {
if (e.target === this) closeSitesModal();
});
async function loadSites() {
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem;letter-spacing:2px">LOADING SITE SETTINGS...</div>';
const res = await api('sites');
if (!res.success) {
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:#f44;font-size:0.65rem">FAILED TO LOAD SETTINGS</div>';
return;
}
sitesData = res.sites;
// Pre-fill global key from first site
const firstKey = Object.values(res.sites)[0]?.api_key || '';
document.getElementById('global-api-key').value = firstKey;
renderSiteCards();
}
function renderSiteCards() {
const grid = document.getElementById('sites-grid');
let html = '';
for (const [id, s] of Object.entries(sitesData)) {
html += `
<div style="background:rgba(0,212,255,0.02);border:1px solid rgba(0,212,255,0.12);padding:16px">
<div style="margin-bottom:12px">
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:2px">${s.name.toUpperCase()}</div>
<div style="color:var(--text-dim);font-size:0.58rem">${s.url}</div>
</div>
<div style="margin-bottom:10px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM EMAIL</div>
<input id="${id}-from_email" type="text" value="${s.from_email || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="margin-bottom:10px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM NAME</div>
<input id="${id}-from_name" type="text" value="${s.from_name || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="margin-bottom:12px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">ADMIN NOTIFICATION EMAIL</div>
<input id="${id}-admin_email" type="text" value="${s.admin_email || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="display:flex;align-items:center;gap:10px">
<button onclick="saveSite('${id}')"
style="background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-mono);font-size:0.58rem;letter-spacing:2px;padding:6px 16px;cursor:pointer">
SAVE
</button>
<span id="${id}-status" style="font-size:0.58rem;color:var(--text-dim)"></span>
</div>
</div>`;
}
grid.innerHTML = html;
}
async function pushApiKey() {
const key = document.getElementById('global-api-key').value.trim();
const status = document.getElementById('push-status');
if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; }
status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...';
const res = await api('sites', 'POST', {action:'push_key', api_key:key});
if (res.success) {
const ok = Object.values(res.results).filter(Boolean).length;
const total = Object.keys(res.results).length;
status.style.color = ok === total ? 'var(--cyan)' : '#fa0';
status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`;
for (const id of Object.keys(sitesData)) sitesData[id].api_key = key;
} else {
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
}
}
async function saveSite(id) {
const status = document.getElementById(id + '-status');
status.style.color='var(--text-dim)'; status.textContent='SAVING...';
const res = await api('sites', 'POST', {
action: 'save',
site: id,
from_email: document.getElementById(id+'-from_email').value.trim(),
from_name: document.getElementById(id+'-from_name').value.trim(),
admin_email: document.getElementById(id+'-admin_email').value.trim(),
});
if (res.success) {
status.style.color='var(--cyan)'; status.textContent='✓ SAVED';
setTimeout(() => { status.textContent=''; }, 3000);
} else {
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
}
}
// ── VISION PROTOCOL — screenshot lightbox ────────────────────────────────────
function openVisionLightbox(title) {
const lb = document.getElementById('vision-lightbox');
document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL';
document.getElementById('vision-lb-img').style.display = 'none';
document.getElementById('vision-lb-img').src = '';
document.getElementById('vision-lb-analysis').textContent = '';
document.getElementById('vision-lb-spinner').style.display = 'block';
lb.classList.add('open');
}
function closeVisionLightbox() {
document.getElementById('vision-lightbox').classList.remove('open');
}
async function agentScreenshot(hostname) {
openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase());
const arcRes = await api('arc?action=job_create', 'POST', {
type: 'screenshot',
payload: {agent: hostname, analyze: true},
priority: 8,
}).catch(() => null);
if (!arcRes || !arcRes.job_id) {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.';
return;
}
// Poll for result
const jobId = arcRes.job_id;
let tries = 0;
const poll = async () => {
tries++;
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
if (job && job.status === 'done') {
const r = job.result || {};
document.getElementById('vision-lb-spinner').style.display = 'none';
if (r.has_image && r.screenshot_id) {
// Fetch full screenshot with image
const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null);
if (full && full.image_b64) {
const img = document.getElementById('vision-lb-img');
img.src = 'data:image/png;base64,' + full.image_b64;
img.style.display = 'block';
}
}
document.getElementById('vision-lb-analysis').textContent =
r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2));
} else if (job && job.status === 'failed') {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error');
} else if (tries < 30) {
setTimeout(poll, 2000);
} else {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.';
}
};
setTimeout(poll, 2000);
}
async function agentSysinfo(hostname) {
openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase());
const arcRes = await api('arc?action=job_create', 'POST', {
type: 'sysinfo',
payload: {agent: hostname, analyze: true},
priority: 7,
}).catch(() => null);
if (!arcRes || !arcRes.job_id) {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.';
return;
}
const jobId = arcRes.job_id;
let tries = 0;
const poll = async () => {
tries++;
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
if (job && job.status === 'done') {
const r = job.result || {};
document.getElementById('vision-lb-spinner').style.display = 'none';
const snap = r.snapshot || {};
const snapText = Object.entries(snap)
.filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k))
.map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`)
.join('\n');
document.getElementById('vision-lb-analysis').textContent =
(r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2));
} else if (job && job.status === 'failed') {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error');
} else if (tries < 20) {
setTimeout(poll, 2000);
} else {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Timed out.';
}
};
setTimeout(poll, 2000);
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeVisionLightbox();
});
+608
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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,'&lt;')}</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
View File
@@ -53,7 +53,7 @@
<div class="tb-center">
<div class="tb-stat">LOCAL&nbsp;<span id="tb-cpu">--</span>% CPU</div>
<div class="tb-stat">MEM&nbsp;<span id="tb-mem">--</span>%</div>
<div class="tb-stat">DO SERVER&nbsp;<span id="tb-do" class="text-dim">--</span></div>
<div class="tb-stat">JARVIS VM&nbsp;<span id="tb-do" class="text-dim">--</span></div>
<div class="tb-stat"><span id="tb-alerts" class="text-green">NO ALERTS</span></div>
<div class="tb-stat" id="tb-planner" style="display:none"><span id="tb-planner-text" class="text-yellow"></span></div>
</div>
@@ -65,6 +65,7 @@
<div class="status-dot"></div>
<button id="cameraBtn" class="btn-camera" onclick="toggleCamera()" title="Auto-mic when face detected (hands-free)">◉ CAMERA</button>
<button id="panelToggleBtn" class="btn-panels" onclick="togglePanels()" title="Toggle side panels (or say 'focus mode')">◧ PANELS</button>
<button id="kioskBtn" class="btn-panels" onclick="toggleKiosk()" title="Full-screen kiosk mode">⛶ KIOSK</button>
<button id="agentBtn" class="btn-agent" onclick="openAgentModal()" title="Install JARVIS Agent on this machine"><div class="agent-dot"></div>AGENT</button>
<div id="themeBar" style="display:flex;gap:3px;align-items:center;margin-right:2px">
@@ -102,8 +103,8 @@
</div>
<div id="weather-forecast" style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px"></div>
</div>
<div class="panel">
<div class="panel-title">JARVIS SERVER <span style="font-size:0.5rem;color:var(--text-dim)">165.22.1.228</span><div class="indicator"></div></div>
<div class="panel" id="server-panel">
<div class="panel-title">JARVIS SERVER <span style="font-size:0.5rem;color:var(--text-dim)">10.48.200.211</span><div class="indicator"></div></div>
<!-- Metric bars + sparklines -->
<div class="metric-row">
@@ -143,6 +144,13 @@
<div class="loading-shimmer" style="margin-bottom:4px"></div>
</div>
</div>
<!-- Web Host (DO Server) -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">WEB HOST <span id="do-host-status" style="color:var(--green)"></span></div>
<div id="do-host-stats" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;font-family:var(--font-mono);font-size:0.62rem">
<div><div style="color:var(--text-dim);font-size:0.52rem">CPU</div><div id="do-cpu">--%</div></div>
<div><div style="color:var(--text-dim);font-size:0.52rem">RAM</div><div id="do-mem">--%</div></div>
<div><div style="color:var(--text-dim);font-size:0.52rem">DISK</div><div id="do-disk">--%</div></div>
</div>
</div>
<!-- CENTER: Arc Reactor + Chat -->
@@ -195,7 +203,7 @@
<div id="rightPanel">
<!-- Network Status -->
<div class="panel" style="flex:0 1 auto;max-height:35%;display:flex;flex-direction:column;min-height:100px">
<div class="panel" id="network-status-panel" style="flex:0 1 auto;max-height:35%;display:flex;flex-direction:column;min-height:100px">
<div class="panel-title">NETWORK STATUS <div class="indicator"></div><span id="net-agent-count" style="font-size:0.6rem;color:var(--cyan);margin-left:auto"></span><button onclick="addNetworkDevice()" title="Add device" style="background:none;border:none;color:var(--cyan);cursor:pointer;font-size:1rem;padding:0 4px;margin-left:4px;line-height:1">+</button></div>
<canvas id="topoCanvas" height="100"></canvas>
<div id="network-list" style="overflow-y:auto;flex:1;padding-right:2px">
@@ -220,7 +228,7 @@
<div class="tab active" onclick="switchTab('ha')">HOME</div>
<div class="tab" onclick="switchTab('alerts')">ALERTS</div>
<div class="tab" onclick="switchTab('news')">NEWS</div>
<div class="tab" onclick="switchTab('agents')">AGENTS</div>
<div class="tab" id="tab-btn-agents" onclick="switchTab('agents')">AGENTS</div>
<div class="tab" onclick="switchTab('sites')">SITES</div>
<div class="tab" id="tab-btn-intel" onclick="switchTab('intel')">INTEL</div>
<div class="tab" id="tab-btn-comms" onclick="switchTab('comms')">COMMS</div>
@@ -292,17 +300,17 @@
</div>
<div class="bb-item">
<div class="bb-dot" id="bb-do-dot"></div>
<span>DO SERVER</span> <span id="bb-do-status">CHECKING</span>
<span>JARVIS VM</span> <span id="bb-do-status">CHECKING</span>
</div>
<div class="bb-item">
<div class="bb-item" id="bb-pve-item">
<div class="bb-dot" id="bb-pve-dot"></div>
<span>PROXMOX</span> <span id="bb-pve-status">CHECKING</span>
</div>
<div class="bb-item">
<div class="bb-item" id="bb-ha-item">
<div class="bb-dot" id="bb-ha-dot"></div>
<span>HOME ASSISTANT</span> <span id="bb-ha-status">CHECKING</span>
</div>
<div class="bb-item">
<div class="bb-item" id="bb-agents-item">
<div class="bb-dot" id="bb-agent-dot"></div>
<span>AGENTS</span> <span id="bb-agent-status">--</span>
</div>
@@ -416,12 +424,14 @@
<!-- Hidden camera feed for face detection -->
<video id="faceVideo" autoplay muted playsinline
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px"></video>
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
<script data-cfasync="false" src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
<script data-cfasync="false" src="assets/js/jarvis-effects.js?v=20260617"></script>
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260617"></script>
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260617"></script>
<script data-cfasync="false" src="assets/js/jarvis-protocols.js?v=20260617"></script>
<script data-cfasync="false" src="assets/js/jarvis-effects.js?v=20260621k"></script>
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260621k"></script>
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260621k"></script>
<script data-cfasync="false" src="assets/js/panels/jarvis-arc.js?v=20260621k"></script>
<script data-cfasync="false" src="assets/js/panels/jarvis-agents.js?v=20260621k"></script>
<script data-cfasync="false" src="assets/js/panels/jarvis-assistant.js?v=20260621k"></script>
<!-- VISION LIGHTBOX -->
<div id="vision-lightbox">
+2 -11
View File
@@ -14,7 +14,7 @@ if (!defined('WEBHOOK_SECRET')) {
exit;
}
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');
define('DEPLOY_LOG', '/var/www/jarvis/logs/deploy.log');
header('Content-Type: application/json');
@@ -40,16 +40,7 @@ if ($ref !== 'refs/heads/main') {
}
$repoMap = [
'jarvis' => '/home/jarvis.orbishosting.com',
'tomsjavajive' => '/home/tomsjavajive.com/public_html',
'epictravelexpeditions' => '/home/epictravelexpeditions.com/public_html',
'parkerslingshot' => '/home/epictravelexpeditions.com/parkerslingshot',
'parkerslingshotrentals' => '/home/parkerslingshotrentals.com/public_html',
'orbishosting' => '/home/orbishosting.com/public_html',
'orbis-hosting-portal' => '/home/orbis.orbishosting.com/public_html',
'tomtomgames' => '/home/tomtomgames.com/public_html',
'infra' => '/opt/infra',
'novacpx' => '__NOVACPX_VM__',
'jarvis' => '/var/www/jarvis',
];
if (!isset($repoMap[$repo])) {