mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e68bb7d165 | |||
| 2f4b4ef5c3 | |||
| 21e0b81a98 | |||
| 95d49f15cb | |||
| 51b598dd5d | |||
| 6f0459be85 | |||
| a6d4365f16 | |||
| 383de0146c | |||
| aaf9f9d56a | |||
| aa88a2f73b | |||
| f1d73e7b6a | |||
| 572f1b1816 | |||
| 1838e02d56 | |||
| 178040c18b | |||
| 45845a1f61 | |||
| 52ddee3e78 | |||
| ab1aa16ac8 | |||
| 1979c5f667 | |||
| 1b071f4f67 | |||
| 5cbaeda730 | |||
| 5140573be0 | |||
| b7aea1371c | |||
| 49694e76e1 | |||
| 04510ac39f | |||
| 38ab8d2977 | |||
| ca66152f45 | |||
| 0b7f2d013b | |||
| 8085a113d5 | |||
| b85e8dd16f | |||
| 188f6f8f10 | |||
| 7f6397b514 | |||
| dd2f48193b | |||
| 1e57a7c90c |
@@ -434,7 +434,7 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Heartbeat + get commands
|
# 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:
|
if "error" in hb:
|
||||||
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+13
-6
@@ -37,11 +37,18 @@ function get_agent_by_key(string $key): ?array {
|
|||||||
return $rows[0] ?? null;
|
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 {
|
||||||
JarvisDB::query(
|
if ($version !== null) {
|
||||||
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
|
JarvisDB::query(
|
||||||
[$status, $agentId]
|
'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) ───────────────────────────────────────
|
// ── Auth (all actions except register) ───────────────────────────────────────
|
||||||
@@ -104,7 +111,7 @@ switch ($agentAction) {
|
|||||||
|
|
||||||
// ── HEARTBEAT ────────────────────────────────────────────────────────────
|
// ── HEARTBEAT ────────────────────────────────────────────────────────────
|
||||||
case '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
|
// Return any pending commands for this agent
|
||||||
$commands = JarvisDB::query(
|
$commands = JarvisDB::query(
|
||||||
|
|||||||
@@ -2302,7 +2302,7 @@ if (!$reply) {
|
|||||||
$sec = (int) file_get_contents('/proc/uptime');
|
$sec = (int) file_get_contents('/proc/uptime');
|
||||||
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
|
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
|
||||||
$load = explode(' ', file_get_contents('/proc/loadavg'));
|
$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) {}
|
} catch (Exception $e) {}
|
||||||
|
|
||||||
$alerts = JarvisDB::query(
|
$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.
|
$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:
|
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)
|
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
|
||||||
- Proxmox Host: 10.48.200.90 (manages all VMs)
|
- Proxmox Host: 10.48.200.90 (manages all VMs)
|
||||||
- Home Assistant: 10.48.200.97:8123
|
- Home Assistant: 10.48.200.97:8123
|
||||||
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
|
- 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
|
- Network: 10.48.200.0/24, FortiGate firewall
|
||||||
|
|
||||||
Live data:
|
Live data:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ $dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
|
|||||||
$diskPct = trim($dfOut);
|
$diskPct = trim($dfOut);
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
$svcNames = ["lshttpd", "mysql", "redis"];
|
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
|
||||||
$svcMap = [];
|
$svcMap = [];
|
||||||
foreach ($svcNames as $s) {
|
foreach ($svcNames as $s) {
|
||||||
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
|
$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
|
// Site health from kb_facts
|
||||||
$siteLabels = [
|
$siteLabels = [
|
||||||
"jarvis" => "jarvis.orbishosting.com",
|
"jarvis" => "jarvis.orbishosting.com:1972",
|
||||||
"tomsjavajive" => "tomsjavajive.com",
|
"tomsjavajive" => "tomsjavajive.com",
|
||||||
"epictravelexp"=> "epictravelexpeditions.com",
|
"epictravelexp"=> "epictravelexpeditions.com",
|
||||||
"parkersling" => "parkerslingshotrentals.com",
|
"parkersling" => "parkerslingshotrentals.com",
|
||||||
@@ -59,8 +59,24 @@ foreach ($rows as $r) {
|
|||||||
$uptimeDays = intdiv($uptime, 86400);
|
$uptimeDays = intdiv($uptime, 86400);
|
||||||
$uptimeHrs = intdiv($uptime % 86400, 3600);
|
$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([
|
echo json_encode([
|
||||||
"ip" => DO_SERVER_IP,
|
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
|
||||||
"reachable" => true,
|
"reachable" => true,
|
||||||
"cpu_pct" => getCpuPct(),
|
"cpu_pct" => getCpuPct(),
|
||||||
"memory" => [
|
"memory" => [
|
||||||
@@ -73,5 +89,6 @@ echo json_encode([
|
|||||||
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
|
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
|
||||||
"services" => $svcMap,
|
"services" => $svcMap,
|
||||||
"sites" => $sites,
|
"sites" => $sites,
|
||||||
|
"do_server" => $doMet,
|
||||||
"timestamp" => date("c"),
|
"timestamp" => date("c"),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -80,26 +80,15 @@ function collect_all(): array {
|
|||||||
$results['system'] = 'error: ' . $e->getMessage();
|
$results['system'] = 'error: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Network ───────────────────────────────────────────────────────────
|
// ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ──
|
||||||
try {
|
try {
|
||||||
$watchlist = [
|
$rows = JarvisDB::query(
|
||||||
'gateway' => '10.48.200.1',
|
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
|
||||||
'proxmox' => '10.48.200.90',
|
);
|
||||||
'ollama' => '10.48.200.95',
|
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
|
||||||
'fusionpbx' => '10.48.200.96',
|
$total = count($rows);
|
||||||
'ha' => '10.48.200.97',
|
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
|
||||||
'do_server' => '165.22.1.228',
|
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
|
||||||
];
|
|
||||||
$online = 0;
|
|
||||||
$total = count($watchlist);
|
|
||||||
foreach ($watchlist as $name => $ip) {
|
|
||||||
exec('ping -c1 -W1 ' . escapeshellarg($ip) . ' > /dev/null 2>&1', $o, $code);
|
|
||||||
$up = ($code === 0);
|
|
||||||
if ($up) $online++;
|
|
||||||
KBEngine::storeFact('network', "host_{$name}", $up ? 'online' : 'offline', $ip, $ttl);
|
|
||||||
}
|
|
||||||
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
|
|
||||||
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
|
|
||||||
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
|
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
|
||||||
$results['network'] = "ok ({$online}/{$total} online)";
|
$results['network'] = "ok ({$online}/{$total} online)";
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -111,7 +100,7 @@ function collect_all(): array {
|
|||||||
$results['proxmox'] = 'skipped (fresh)';
|
$results['proxmox'] = 'skipped (fresh)';
|
||||||
} else try {
|
} else try {
|
||||||
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
|
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;
|
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
|
||||||
|
|
||||||
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
|
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
|
||||||
@@ -146,7 +135,7 @@ function collect_all(): array {
|
|||||||
|
|
||||||
// ── Digital Ocean ─────────────────────────────────────────────────────
|
// ── Digital Ocean ─────────────────────────────────────────────────────
|
||||||
try {
|
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';
|
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
|
||||||
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
|
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
|
||||||
$results['do_server'] = "ok ({$doStatus})";
|
$results['do_server'] = "ok ({$doStatus})";
|
||||||
@@ -160,7 +149,7 @@ function collect_all(): array {
|
|||||||
} else try {
|
} else try {
|
||||||
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
|
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
|
||||||
$ch = curl_init($ollamaHost . '/api/tags');
|
$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);
|
$resp = curl_exec($ch);
|
||||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
@@ -192,7 +181,7 @@ function collect_all(): array {
|
|||||||
$results['sites'] = 'skipped (fresh)';
|
$results['sites'] = 'skipped (fresh)';
|
||||||
} else try {
|
} else try {
|
||||||
$sites = [
|
$sites = [
|
||||||
'jarvis' => 'https://jarvis.orbishosting.com',
|
"jarvis" => "http://jarvis.orbishosting.com:1972",
|
||||||
'tomsjavajive' => 'https://tomsjavajive.com',
|
'tomsjavajive' => 'https://tomsjavajive.com',
|
||||||
'epictravelexp'=> 'https://epictravelexpeditions.com',
|
'epictravelexp'=> 'https://epictravelexpeditions.com',
|
||||||
'parkersling' => 'https://parkerslingshotrentals.com',
|
'parkersling' => 'https://parkerslingshotrentals.com',
|
||||||
@@ -202,9 +191,15 @@ function collect_all(): array {
|
|||||||
];
|
];
|
||||||
$down = [];
|
$down = [];
|
||||||
foreach ($sites as $key => $url) {
|
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, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
@@ -234,9 +229,10 @@ function pve_api_get(string $url, string $authHeader): array {
|
|||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
CURLOPT_HTTPHEADER => [$authHeader],
|
CURLOPT_HTTPHEADER => [$authHeader],
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
CURLOPT_TIMEOUT => 8,
|
CURLOPT_TIMEOUT => 5,
|
||||||
]);
|
]);
|
||||||
$resp = curl_exec($ch);
|
$resp = curl_exec($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|||||||
+21
-9
@@ -83,15 +83,27 @@ if ($method === 'POST' && $action === 'service') {
|
|||||||
// Serve entities from ha_entities table (real-time agent push data)
|
// Serve entities from ha_entities table (real-time agent push data)
|
||||||
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
||||||
'device_tracker','event','image','person','zone','tts','conversation',
|
'device_tracker','event','image','person','zone','tts','conversation',
|
||||||
'assist_satellite','input_button'];
|
'assist_satellite','input_button','media_player','scene','water_heater'];
|
||||||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
$skipKeywords = [
|
||||||
'_siren_on','_email_on','_manual_record','_infrared_',
|
// HACS / system toggles
|
||||||
'do_not_disturb','matter_server','zerotier','mariadb',
|
'pre_release','get_hacs','matter_server','zerotier','mariadb',
|
||||||
'spotify_connect','file_editor','ssh_web','uptime_kuma',
|
'spotify_connect','file_editor','ssh_web','uptime_kuma','adguard_',
|
||||||
'adguard_','folding_home','music_assistant','get_hacs','mealie',
|
'folding_home','music_assistant','mealie','mosquitto','social_to',
|
||||||
'mosquitto','social_to','motion_detection',
|
'assist_microphone','cec_scanner','esphome_device_builder',
|
||||||
'front_yard_record','down_hill_record','camera1_record',
|
// Camera controls
|
||||||
'back_yard_record','nvr_','assist_microphone'];
|
'_record','_ftp_','_push_','_hub_ringtone','_siren_on',
|
||||||
|
'_email_on','_manual_record','_infrared_','motion_detection',
|
||||||
|
'front_yard_record','down_hill_record','camera1_record',
|
||||||
|
'back_yard_record','nvr_',
|
||||||
|
// Echo / smart display noise
|
||||||
|
'do_not_disturb',
|
||||||
|
// Konnected security panel switches
|
||||||
|
'floodlight',
|
||||||
|
'konnected',
|
||||||
|
// Energy / power monitoring (sensors, not controls)
|
||||||
|
'_energy','_power','_voltage','_current','_consumption',
|
||||||
|
'electricity_maps',
|
||||||
|
];
|
||||||
|
|
||||||
$rows = JarvisDB::query(
|
$rows = JarvisDB::query(
|
||||||
"SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts
|
"SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function cacheStore(string $key, $data): void {
|
|||||||
|
|
||||||
// ── Proxmox ──────────────────────────────────────────────────────────────
|
// ── Proxmox ──────────────────────────────────────────────────────────────
|
||||||
if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') {
|
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];
|
$pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL];
|
||||||
|
|
||||||
// Cluster resources API — returns all VMs/CTs from ALL nodes (pve + pve2)
|
// 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) : [];
|
$config = $configRaw ? json_decode($configRaw, true) : [];
|
||||||
|
|
||||||
// Controllable domains only — skip read-only sensors to keep list manageable
|
// Controllable domains only — skip read-only sensors to keep list manageable
|
||||||
$interesting = ['light','switch','scene','media_player','alarm_control_panel',
|
$interesting = ['light','switch','alarm_control_panel',
|
||||||
'lawn_mower','water_heater','fan','lock','cover','climate','input_boolean'];
|
'lawn_mower','fan','lock','cover','climate','input_boolean'];
|
||||||
// Switches that are HA internals / camera settings, not physical devices
|
// Switches that are HA internals / camera settings, not physical devices
|
||||||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
||||||
'_siren_on','_email_on','_manual_record','_infrared_',
|
'_siren_on','_email_on','_manual_record','_infrared_',
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function getNetworkIO(): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getServices(): 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 = [];
|
$result = [];
|
||||||
foreach ($services as $svc) {
|
foreach ($services as $svc) {
|
||||||
$out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null');
|
$out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null');
|
||||||
|
|||||||
+5
-5
@@ -688,8 +688,8 @@ async def handle_screenshot(payload: dict) -> dict:
|
|||||||
try:
|
try:
|
||||||
snap_text = json.dumps(result, indent=2)[:3000]
|
snap_text = json.dumps(result, indent=2)[:3000]
|
||||||
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
|
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")
|
analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
|
||||||
provider_used = "claude"
|
provider_used = "groq"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
analysis = f"Analysis unavailable: {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. "
|
"for Myron. Be direct about severity and what action to take. "
|
||||||
"No markdown, no headers."
|
"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
|
# Update the most recent guardian event with AI analysis
|
||||||
await db_execute(
|
await db_execute(
|
||||||
"""UPDATE guardian_events SET ai_analysis=%s
|
"""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:
|
async def handle_sitrep(payload: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Situation Report — comprehensive health briefing across all field stations.
|
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")
|
detail = payload.get("detail", "full")
|
||||||
provider = payload.get("provider", "claude")
|
provider = payload.get("provider", "groq")
|
||||||
|
|
||||||
log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
|
log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
|
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
|
||||||
**Last Updated:** 2026-06-14
|
**Last Updated:** 2026-06-18
|
||||||
**Owner:** Myron Blair — myronblair@outlook.com
|
**Owner:** Myron Blair — myronblair@outlook.com
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,7 +29,7 @@ INTERNET
|
|||||||
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
|
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
|
||||||
│ (proxied DNS for public sites)
|
│ (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)
|
└─► [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 113 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
|
||||||
│ ├── VM 118 10.48.200.18 Homebridge
|
│ ├── VM 118 10.48.200.18 Homebridge
|
||||||
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
|
│ ├── 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
|
│ └── CT110 10.48.200.19 WireGuard exit container
|
||||||
│
|
│
|
||||||
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
|
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
|
||||||
@@ -73,13 +73,13 @@ FortiGate Port Forwards:
|
|||||||
| **OS** | Ubuntu 22.04 LTS |
|
| **OS** | Ubuntu 22.04 LTS |
|
||||||
| **Panel** | CyberPanel (OpenLiteSpeed) |
|
| **Panel** | CyberPanel (OpenLiteSpeed) |
|
||||||
| **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` |
|
| **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:**
|
**Key Paths:**
|
||||||
- All sites: `/home/<domain>/public_html/`
|
- All sites: `/home/<domain>/public_html/`
|
||||||
- JARVIS: `/home/jarvis.orbishosting.com/`
|
|
||||||
- Deploy log: `/home/jarvis.orbishosting.com/logs/deploy.log`
|
- Deploy log: per-site (website deploys only)
|
||||||
- Watchdog log: `/home/jarvis.orbishosting.com/logs/watchdog.log`
|
- Watchdog log: `/usr/local/lsws/logs/watchdog.log`
|
||||||
- Infra repo: `/opt/infra`
|
- Infra repo: `/opt/infra`
|
||||||
|
|
||||||
**Services running:**
|
**Services running:**
|
||||||
@@ -87,7 +87,7 @@ FortiGate Port Forwards:
|
|||||||
- MySQL 8 — all site databases on localhost
|
- MySQL 8 — all site databases on localhost
|
||||||
- Redis — session/cache
|
- Redis — session/cache
|
||||||
- PHP 8.5 (`lsphp85`) — runtime for all sites
|
- 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`
|
**CyberPanel Web UI:** `https://165.22.1.228:8090`
|
||||||
Login: `myron / Joker1974!!!`
|
Login: `myron / Joker1974!!!`
|
||||||
@@ -102,7 +102,7 @@ Login: `myron / Joker1974!!!`
|
|||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **IP** | 134.209.72.226 |
|
| **IP** | 134.209.72.226 |
|
||||||
| **OS** | Debian (DigitalOcean droplet) |
|
| **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 |
|
| **Direct SSH** | Only from: 107.178.2.130 / 97.154.109.245 |
|
||||||
| **Purpose** | VoIP phone system — handles all inbound/outbound calls |
|
| **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)
|
### VM 210 — Ollama Local LLM (PVE1)
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **IP** | 10.48.200.95 |
|
| **IP** | 10.48.200.210 |
|
||||||
| **OS** | Ubuntu (cloud image) |
|
| **OS** | Ubuntu (cloud image) |
|
||||||
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
|
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
|
||||||
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat |
|
| **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 Agent** | ID: `ollama-ai_ubuntu` |
|
||||||
|
|
||||||
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
|
**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 |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **URL** | https://jarvis.orbishosting.com |
|
| **URL** | http://jarvis.orbishosting.com:1972 |
|
||||||
| **Path** | `/home/jarvis.orbishosting.com/` |
|
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
|
||||||
| **GitHub** | `myronblair/jarvis` |
|
| **GitHub** | `myronblair/jarvis` |
|
||||||
| **Login** | `myron / Joker1974!!!` |
|
| **Login** | `myron / Joker1974!!!` |
|
||||||
| **Purpose** | Iron Man-style AI home dashboard with voice control, smart home, media, planner |
|
| **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
|
## 7. JARVIS AI SYSTEM
|
||||||
|
|
||||||
**URL:** https://jarvis.orbishosting.com
|
**URL:** http://jarvis.orbishosting.com:1972
|
||||||
**Files:** `/home/jarvis.orbishosting.com/` on DO
|
**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!`
|
**DB:** `jarvis_db` — `jarvis_user / J4rv1s_Pr0t0c0l_2026!`
|
||||||
**Login:** `myron / Joker1974!!!`
|
**Login:** `myron / Joker1974!!!`
|
||||||
**Admin portal:** https://jarvis.orbishosting.com/admin
|
**Admin portal:** http://jarvis.orbishosting.com:1972/admin
|
||||||
|
|
||||||
### Architecture (end-to-end)
|
### Architecture (end-to-end)
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ Voice (browser mic)
|
|||||||
→ /api/chat.php (4-tier AI)
|
→ /api/chat.php (4-tier AI)
|
||||||
Tier 0.7: KB intents / planner (tasks, appointments)
|
Tier 0.7: KB intents / planner (tasks, appointments)
|
||||||
Tier 1: Knowledge Base (MySQL)
|
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 2: Groq (cloud, model: compound-beta-mini)
|
||||||
Tier 3: Claude API (Anthropic, fallback)
|
Tier 3: Claude API (Anthropic, fallback)
|
||||||
→ ElevenLabs TTS → browser speaker
|
→ ElevenLabs TTS → browser speaker
|
||||||
@@ -501,7 +501,7 @@ Webhook secret: `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1`
|
|||||||
### Agent System
|
### Agent System
|
||||||
Agents installed on all servers — phone home every 10s (heartbeat) / 30s (metrics).
|
Agents installed on all servers — phone home every 10s (heartbeat) / 30s (metrics).
|
||||||
Registration key: `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518`
|
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
|
### Self-Healing Watchdog
|
||||||
`/usr/local/bin/jarvis-watchdog.sh` — runs every 5 min (root cron on DO)
|
`/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!!!` |
|
| phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` |
|
||||||
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
|
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
|
||||||
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
|
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
|
||||||
| JARVIS | https://jarvis.orbishosting.com | myron | `Joker1974!!!` |
|
| JARVIS | http://jarvis.orbishosting.com:1972 | myron | `Joker1974!!!` |
|
||||||
| JARVIS Admin | https://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
|
| JARVIS Admin | http://jarvis.orbishosting.com:1972/admin | myron | `Joker1974!!!` |
|
||||||
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
|
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
|
||||||
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
|
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
|
||||||
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
|
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ set -e
|
|||||||
|
|
||||||
HOSTNAME_ARG="${1:-$(hostname -s)}"
|
HOSTNAME_ARG="${1:-$(hostname -s)}"
|
||||||
AGENT_TYPE="${2:-linux}"
|
AGENT_TYPE="${2:-linux}"
|
||||||
JARVIS_URL="https://165.22.1.228"
|
JARVIS_URL="http://10.48.200.211"
|
||||||
JARVIS_HOST="jarvis.orbishosting.com"
|
JARVIS_HOST=""
|
||||||
INSTALL_DIR="/opt/jarvis-agent"
|
INSTALL_DIR="/opt/jarvis-agent"
|
||||||
CONFIG_DIR="/etc/jarvis-agent"
|
CONFIG_DIR="/etc/jarvis-agent"
|
||||||
STATE_DIR="/var/lib/jarvis-agent"
|
STATE_DIR="/var/lib/jarvis-agent"
|
||||||
@@ -38,7 +38,7 @@ mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR"
|
|||||||
|
|
||||||
# ── Download agent ─────────────────────────────────────────────────────────────
|
# ── Download agent ─────────────────────────────────────────────────────────────
|
||||||
echo "Downloading 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
|
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
|
chmod +x "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,45 @@ AGENT_VERSION = "3.1"
|
|||||||
# ── Config helpers ────────────────────────────────────────────────────────────
|
# ── Config helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
|
legacy_path = "/opt/jarvis-agent/config.json"
|
||||||
|
|
||||||
if not os.path.exists(CONFIG_PATH):
|
if not os.path.exists(CONFIG_PATH):
|
||||||
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True)
|
if os.path.exists(legacy_path):
|
||||||
sys.exit(1)
|
print(f"[JARVIS] Config found at legacy path {legacy_path} - migrating...", flush=True)
|
||||||
with open(CONFIG_PATH) as f:
|
Path(CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
return json.load(f)
|
with open(legacy_path) as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
|
||||||
|
# Migrate old key names so the agent self-heals instead of crash-looping
|
||||||
|
import re as _re
|
||||||
|
changed = False
|
||||||
|
if "server_url" in cfg and "jarvis_url" not in cfg:
|
||||||
|
cfg["jarvis_url"] = cfg.pop("server_url")
|
||||||
|
print("[JARVIS] Config migrated: server_url -> jarvis_url", flush=True)
|
||||||
|
changed = True
|
||||||
|
if "api_key" in cfg and "registration_key" not in cfg:
|
||||||
|
cfg["registration_key"] = cfg.pop("api_key")
|
||||||
|
print("[JARVIS] Config migrated: api_key -> registration_key", flush=True)
|
||||||
|
changed = True
|
||||||
|
if "hostname" not in cfg:
|
||||||
|
cfg["hostname"] = socket.gethostname()
|
||||||
|
changed = True
|
||||||
|
if "ssl_verify" not in cfg:
|
||||||
|
cfg["ssl_verify"] = not bool(_re.match(r"https?://\d+\.\d+\.\d+\.\d+", cfg.get("jarvis_url", "")))
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
print("[JARVIS] Config saved after migration.", flush=True)
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
def load_state() -> dict:
|
def load_state() -> dict:
|
||||||
if os.path.exists(STATE_PATH):
|
if os.path.exists(STATE_PATH):
|
||||||
@@ -265,11 +299,23 @@ def get_load() -> list:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return [0, 0, 0]
|
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:
|
def collect_metrics(cfg: dict) -> dict:
|
||||||
# First reading for CPU delta
|
# First reading for CPU delta
|
||||||
get_cpu_percent()
|
get_cpu_percent()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
return {
|
metrics = {
|
||||||
"hostname": cfg.get("hostname", socket.gethostname()),
|
"hostname": cfg.get("hostname", socket.gethostname()),
|
||||||
"cpu_percent": get_cpu_percent(),
|
"cpu_percent": get_cpu_percent(),
|
||||||
"memory": get_memory(),
|
"memory": get_memory(),
|
||||||
@@ -280,6 +326,10 @@ def collect_metrics(cfg: dict) -> dict:
|
|||||||
"platform": platform.system(),
|
"platform": platform.system(),
|
||||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
}
|
}
|
||||||
|
nordvpn = get_nordvpn_status()
|
||||||
|
if nordvpn is not None:
|
||||||
|
metrics["nordvpn"] = nordvpn
|
||||||
|
return metrics
|
||||||
|
|
||||||
# ── Proxmox metrics ───────────────────────────────────────────────────────────
|
# ── Proxmox metrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -384,7 +434,7 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Heartbeat + get commands
|
# 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:
|
if "error" in hb:
|
||||||
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1a9e8e24e5aee8f27a5900b6340373023ff2171e844e71e451eecdbf3b2b0f03 jarvis-agent.py
|
6ba92a1ad4f91a218cbc4ce6834c55e8f56a0e22fca04278d77260958e429d5b
|
||||||
|
|||||||
@@ -1208,3 +1208,174 @@ body::after{
|
|||||||
/* ── AGENT TOPOLOGY ────────────────────────────────────────────────── */
|
/* ── AGENT TOPOLOGY ────────────────────────────────────────────────── */
|
||||||
#agentTopoCanvas{background:transparent;border-top:1px solid rgba(0,212,255,0.08);display:block}
|
#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)}
|
#agent-topo-btn.active{background:rgba(0,212,255,0.15);border-color:rgba(0,212,255,0.5)}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
FIRE HD 8 (12th Gen) TABLET MODE
|
||||||
|
Applied via body.tablet-mode — set automatically on Silk UA detection
|
||||||
|
Target: 1280×800 landscape, 189 PPI, touch-only input
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Prevent accidental text selection on touch; restore for inputs */
|
||||||
|
body.tablet-mode { -webkit-user-select:none; user-select:none; }
|
||||||
|
body.tablet-mode input,
|
||||||
|
body.tablet-mode textarea { -webkit-user-select:auto; user-select:auto; }
|
||||||
|
|
||||||
|
/* ── TOPBAR — taller row, bigger tap zones ─────────────────────── */
|
||||||
|
body.tablet-mode #topBar {
|
||||||
|
height:54px;
|
||||||
|
padding:0 12px;
|
||||||
|
}
|
||||||
|
body.tablet-mode #clock { font-size:1.1rem; letter-spacing:3px; }
|
||||||
|
body.tablet-mode .tb-logo { font-size:0.95rem; }
|
||||||
|
|
||||||
|
/* Toolbar buttons — min 40px touch target */
|
||||||
|
body.tablet-mode .btn-panels,
|
||||||
|
body.tablet-mode .btn-camera {
|
||||||
|
font-size:0.62rem;
|
||||||
|
letter-spacing:1.5px;
|
||||||
|
padding:9px 13px;
|
||||||
|
min-height:40px;
|
||||||
|
margin-right:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme color dots — bigger tap area */
|
||||||
|
body.tablet-mode #themeBar { gap:6px; }
|
||||||
|
body.tablet-mode .theme-btn {
|
||||||
|
width:20px; height:20px;
|
||||||
|
font-size:0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swap + logout */
|
||||||
|
body.tablet-mode #btn-swap-panels {
|
||||||
|
font-size:0.65rem;
|
||||||
|
padding:7px 11px;
|
||||||
|
}
|
||||||
|
body.tablet-mode .btn-logout {
|
||||||
|
font-size:0.72rem;
|
||||||
|
padding:7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MAIN LAYOUT — narrower side panels → wider center ──────────── */
|
||||||
|
/* 220+220 side cols → center gets ~808px instead of ~660px */
|
||||||
|
body.tablet-mode #mainLayout {
|
||||||
|
grid-template-columns:220px 1fr 220px;
|
||||||
|
padding:8px;
|
||||||
|
gap:8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PANELS — tighter padding, larger text ──────────────────────── */
|
||||||
|
body.tablet-mode .panel { padding:11px; }
|
||||||
|
body.tablet-mode .panel-title {
|
||||||
|
font-size:0.67rem;
|
||||||
|
letter-spacing:2.5px;
|
||||||
|
margin-bottom:9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metric rows */
|
||||||
|
body.tablet-mode .metric-label { font-size:0.75rem; }
|
||||||
|
body.tablet-mode .service-row { font-size:0.75rem; padding:6px 0; }
|
||||||
|
body.tablet-mode .val-row { font-size:0.75rem; padding:4px 0; }
|
||||||
|
body.tablet-mode .device-item { font-size:0.75rem; padding:7px 0; }
|
||||||
|
body.tablet-mode .device-name { font-size:0.75rem; }
|
||||||
|
body.tablet-mode .device-ip { font-size:0.68rem; }
|
||||||
|
body.tablet-mode .vm-card { font-size:0.75rem; padding:9px 10px; }
|
||||||
|
|
||||||
|
/* Scrollable side panels — smooth touch inertia */
|
||||||
|
body.tablet-mode #leftPanel,
|
||||||
|
body.tablet-mode #rightPanel {
|
||||||
|
-webkit-overflow-scrolling:touch;
|
||||||
|
overscroll-behavior:contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CENTER — arc reactor + chat ────────────────────────────────── */
|
||||||
|
/* Scale reactor down so chat gets more vertical room */
|
||||||
|
body.tablet-mode #arcReactor { width:180px; height:180px; }
|
||||||
|
body.tablet-mode .arc-ring.r1 { width:180px; height:180px; }
|
||||||
|
body.tablet-mode .arc-ring.r2 { width:159px; height:159px; }
|
||||||
|
body.tablet-mode .arc-ring.r3 { width:139px; height:139px; }
|
||||||
|
body.tablet-mode .arc-ring.r4 { width:118px; height:118px; }
|
||||||
|
body.tablet-mode .arc-ring.r5 { width:94px; height:94px; }
|
||||||
|
body.tablet-mode .arc-ring.r6 { width:72px; height:72px; }
|
||||||
|
body.tablet-mode .arc-ring.r7 { width:51px; height:51px; }
|
||||||
|
body.tablet-mode .arc-core { width:30px; height:30px; }
|
||||||
|
|
||||||
|
/* Chat messages — comfortable reading size */
|
||||||
|
body.tablet-mode .msg {
|
||||||
|
font-size:0.95rem;
|
||||||
|
line-height:1.55;
|
||||||
|
padding:11px 14px;
|
||||||
|
}
|
||||||
|
body.tablet-mode .msg.user { font-size:0.88rem; }
|
||||||
|
body.tablet-mode .msg.system { font-size:0.78rem; }
|
||||||
|
|
||||||
|
/* Touch-scroll chat log */
|
||||||
|
body.tablet-mode #chatLog {
|
||||||
|
-webkit-overflow-scrolling:touch;
|
||||||
|
overscroll-behavior:contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input row — 16px prevents Silk from zooming on focus */
|
||||||
|
body.tablet-mode #textInput {
|
||||||
|
font-size:1rem;
|
||||||
|
min-height:46px;
|
||||||
|
padding:12px 14px;
|
||||||
|
}
|
||||||
|
body.tablet-mode #sendBtn {
|
||||||
|
font-size:0.68rem;
|
||||||
|
min-height:46px;
|
||||||
|
padding:0 18px;
|
||||||
|
}
|
||||||
|
body.tablet-mode #micBtn {
|
||||||
|
width:52px; height:52px;
|
||||||
|
flex-shrink:0;
|
||||||
|
}
|
||||||
|
body.tablet-mode #searchBtn {
|
||||||
|
min-height:46px !important;
|
||||||
|
padding:0 13px !important;
|
||||||
|
font-size:1.1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TABS — bigger tap targets ──────────────────────────────────── */
|
||||||
|
body.tablet-mode .tab {
|
||||||
|
font-size:0.58rem;
|
||||||
|
letter-spacing:1.5px;
|
||||||
|
padding:9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HA TABLE — more readable on 8" ────────────────────────────── */
|
||||||
|
body.tablet-mode .ha-thead th { font-size:0.55rem; padding:6px 3px 8px; }
|
||||||
|
body.tablet-mode .ha-row td { font-size:0.74rem; padding:6px 3px; }
|
||||||
|
|
||||||
|
/* Toggle slider — bigger for fat fingers */
|
||||||
|
body.tablet-mode .ha-toggle { width:36px; height:18px; }
|
||||||
|
body.tablet-mode .ha-slider::before { width:12px; height:12px; left:2px; top:2px; }
|
||||||
|
body.tablet-mode .ha-toggle input:checked + .ha-slider::before { transform:translateX(18px); }
|
||||||
|
|
||||||
|
/* ── DISABLE HOVER-RISE — not meaningful on touch ───────────────── */
|
||||||
|
body.tablet-mode .panel:hover {
|
||||||
|
transform:translateY(var(--pty,0px)) !important;
|
||||||
|
border-color:var(--panel-border) !important;
|
||||||
|
box-shadow:none !important;
|
||||||
|
transition:none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ALERTS ──────────────────────────────────────────────────────── */
|
||||||
|
body.tablet-mode .alert-item { font-size:0.75rem; padding:9px 11px; }
|
||||||
|
|
||||||
|
/* ── BOTTOM BAR ─────────────────────────────────────────────────── */
|
||||||
|
body.tablet-mode #bottomBar { font-size:0.7rem; height:34px; }
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
KIOSK MODE — hide noisy panels, keep it clean on Fire tablet
|
||||||
|
Only active when body.kiosk-mode (fullscreen)
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
body.kiosk-mode #server-panel { display:none !important; }
|
||||||
|
body.kiosk-mode #network-status-panel { display:none !important; }
|
||||||
|
body.kiosk-mode #tab-btn-agents { display:none !important; }
|
||||||
|
body.kiosk-mode #tab-btn-guardian { display:none !important; }
|
||||||
|
body.kiosk-mode #tab-agents { display:none !important; }
|
||||||
|
body.kiosk-mode #tab-guardian { display:none !important; }
|
||||||
|
body.kiosk-mode #bb-ha-item { display:none !important; }
|
||||||
|
body.kiosk-mode #bb-agents-item { display:none !important; }
|
||||||
|
body.kiosk-mode #bb-memory-item { display:none !important; }
|
||||||
|
body.kiosk-mode #bb-pve-item { display:none !important; }
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ let _refreshTick = 0;
|
|||||||
let selectedContext = null;
|
let selectedContext = null;
|
||||||
const _panelCtx = {};
|
const _panelCtx = {};
|
||||||
let _haEntities = {};
|
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() {
|
async function refreshAll() {
|
||||||
_refreshTick++;
|
_refreshTick++;
|
||||||
@@ -550,6 +550,21 @@ function renderDO(d) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).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() {
|
async function loadNetwork() {
|
||||||
@@ -1390,6 +1405,7 @@ function enterVoiceMode(source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exitVoiceMode() {
|
function exitVoiceMode() {
|
||||||
|
if (document.body.classList.contains('kiosk-mode')) return;
|
||||||
voiceMode = false;
|
voiceMode = false;
|
||||||
voiceMuted = false;
|
voiceMuted = false;
|
||||||
updateMicBtn();
|
updateMicBtn();
|
||||||
@@ -1739,3 +1755,67 @@ document.addEventListener('keydown', function(e) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── FIRE HD 8 TABLET DETECTION ────────────────────────────────────────────────────────
|
||||||
|
const IS_SILK = /Silk\//i.test(navigator.userAgent);
|
||||||
|
const IS_FIRE = /KFTT|KFOT|KFJWI|KFSOWI|KFTHWI|KFTHWA|KFAPWI|KFAPWA|KFARWI|KFASWI|KFMEWI|KFFOWI|KFSAWA|KFMAWI|KFGIWI|KFDOWI|KFTBWI|KFTRWI|KFKAWI/i.test(navigator.userAgent);
|
||||||
|
function isTablet() { return IS_SILK || IS_FIRE; }
|
||||||
|
|
||||||
|
function applyTabletMode() {
|
||||||
|
document.body.classList.add("tablet-mode");
|
||||||
|
const kb = document.getElementById("kioskBtn");
|
||||||
|
if (kb) kb.title = "Full-screen kiosk (Fire HD 8 layout active)";
|
||||||
|
}
|
||||||
|
if (isTablet()) applyTabletMode();
|
||||||
|
|
||||||
|
// ── KIOSK MODE ────────────────────────────────────────────────────────────────────────
|
||||||
|
let _wakeLock = null;
|
||||||
|
|
||||||
|
async function toggleKiosk() {
|
||||||
|
const btn = document.getElementById("kioskBtn");
|
||||||
|
const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||||
|
|
||||||
|
if (!isFs) {
|
||||||
|
applyTabletMode();
|
||||||
|
const el = document.documentElement;
|
||||||
|
const req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
|
||||||
|
if (req) req.call(el).catch(() => {});
|
||||||
|
if ("wakeLock" in navigator) {
|
||||||
|
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
|
||||||
|
}
|
||||||
|
document.body.classList.add("kiosk-mode");
|
||||||
|
// Kiosk: silently activate mic + voice mode (no TTS greeting)
|
||||||
|
if (typeof wakeFromSleep === "function" && isAsleep) wakeFromSleep();
|
||||||
|
voiceMode = true; voiceMuted = false; voiceLastCmd = Date.now(); updateMicBtn();
|
||||||
|
if (typeof startListening === "function" && !isListening) startListening();
|
||||||
|
if (btn) { btn.textContent = "⧞ EXIT"; btn.style.color = "var(--cyan)"; }
|
||||||
|
} else {
|
||||||
|
const ex = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen;
|
||||||
|
if (ex) ex.call(document).catch(() => {});
|
||||||
|
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
|
||||||
|
document.body.classList.remove("kiosk-mode");
|
||||||
|
if (typeof stopListening === "function") stopListening();
|
||||||
|
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
|
||||||
|
if (!isTablet()) document.body.classList.remove("tablet-mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", async () => {
|
||||||
|
if (_wakeLock && document.visibilityState === "visible") {
|
||||||
|
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _onFsChange() {
|
||||||
|
const btn = document.getElementById("kioskBtn");
|
||||||
|
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
|
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
|
||||||
|
document.body.classList.remove("kiosk-mode");
|
||||||
|
if (typeof stopListening === "function") stopListening();
|
||||||
|
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
|
||||||
|
if (!isTablet()) document.body.classList.remove("tablet-mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("fullscreenchange", _onFsChange);
|
||||||
|
document.addEventListener("webkitfullscreenchange", _onFsChange);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ var _sleepRefreshTimer = null;
|
|||||||
var SLEEP_CMDS = /\b(good\s*night(\s*jarvis)?|go\s*to\s*sleep|sleep\s*mode|shut\s*(down|off)\s*(jarvis|for\s*the\s*night)|go\s*offline|going\s*offline|jarvis\s*(go\s*)?(offline|sleep|shutdown)|stand\s*by\s*mode|power\s*down(\s*jarvis)?|signing\s*off)\b/i;
|
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() {
|
function enterSleepMode() {
|
||||||
|
if (document.body.classList.contains("kiosk-mode")) return;
|
||||||
if (isAsleep) return;
|
if (isAsleep) return;
|
||||||
isAsleep = true;
|
isAsleep = true;
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ function closeNetMap(){
|
|||||||
function _nmBuild(devices){
|
function _nmBuild(devices){
|
||||||
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
|
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
|
||||||
// Hub
|
// 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
|
// Bucket
|
||||||
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
|
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
|
||||||
// Deduplicate agent devices by hostname (same logical host registered twice)
|
// Deduplicate agent devices by hostname (same logical host registered twice)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
|||||||
|
// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
|
||||||
|
let _missionsOpenCards = new Set();
|
||||||
|
|
||||||
|
async function loadMissionsHud() {
|
||||||
|
const el = document.getElementById('missions-hud');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const missions = await api('arc?action=missions');
|
||||||
|
const list = Array.isArray(missions) ? missions : [];
|
||||||
|
|
||||||
|
let html = '<button class="mission-new-btn" onclick="window.open(\'/admin#missions\',\'_blank\')">◈ MANAGE MISSIONS IN ADMIN</button>';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
html += '<div class="comms-empty">◈ NO MISSIONS<br><span style="opacity:0.5">Create workflows in Admin → Mission Ops</span></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
|
||||||
|
for (const m of list) {
|
||||||
|
const isOpen = _missionsOpenCards.has(m.id);
|
||||||
|
const icon = trigIcons[m.trigger_type] || '◈';
|
||||||
|
const enabled = m.enabled;
|
||||||
|
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
|
||||||
|
html += `<div class="mission-card${isOpen?' open':''}" id="mission-card-${m.id}">
|
||||||
|
<div class="mission-card-head" onclick="toggleMissionCard(${m.id})">
|
||||||
|
<span style="opacity:${enabled?1:0.35}">${icon}</span>
|
||||||
|
<span class="mission-card-name" style="opacity:${enabled?1:0.45}">${escHtml(m.name)}</span>
|
||||||
|
<span class="mission-card-trigger">${m.trigger_type.replace('_',' ').toUpperCase()}</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${m.run_count||0} runs</span>
|
||||||
|
</div>
|
||||||
|
<div class="mission-card-body">
|
||||||
|
${m.description ? `<div style="font-size:0.58rem;color:var(--text-dim);margin:6px 0">${escHtml(m.description)}</div>` : ''}
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);margin:4px 0">Last run: ${lastRun} · ${m.run_count||0} total runs</div>
|
||||||
|
<div class="mission-run-bar">
|
||||||
|
<button class="mission-run-btn" id="mission-run-btn-${m.id}" onclick="hudRunMission(${m.id})"${!enabled?' disabled title="Mission disabled"':''}>▶ RUN NOW</button>
|
||||||
|
</div>
|
||||||
|
<div id="mission-run-result-${m.id}" style="font-family:var(--font-mono);font-size:0.52rem;margin-top:6px;min-height:12px"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
if (el) el.innerHTML = '<div class="comms-empty">MISSIONS OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMissionCard(id) {
|
||||||
|
const card = document.getElementById('mission-card-' + id);
|
||||||
|
if (!card) return;
|
||||||
|
if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
|
||||||
|
else _missionsOpenCards.add(id);
|
||||||
|
card.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hudRunMission(id) {
|
||||||
|
const btn = document.getElementById('mission-run-btn-' + id);
|
||||||
|
const res = document.getElementById('mission-run-result-' + id);
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
|
||||||
|
if (res) res.textContent = '';
|
||||||
|
try {
|
||||||
|
const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
|
||||||
|
const s = data.status || 'done';
|
||||||
|
const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
|
||||||
|
if (res) res.style.color = color;
|
||||||
|
if (res) res.textContent = `◈ ${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
|
||||||
|
setTimeout(loadMissionsHud, 2000);
|
||||||
|
} catch(e) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
|
||||||
|
if (res) res.textContent = '✗ Run failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DIRECTIVES HUD ────────────────────────────────────────────────────────────
|
||||||
|
let _dirOpenCards = new Set();
|
||||||
|
|
||||||
|
async function loadDirectivesHud() {
|
||||||
|
const el = document.getElementById('directives-hud');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const d = await api('directives/list?status=active');
|
||||||
|
const list = (d.directives || []);
|
||||||
|
|
||||||
|
let html = '<button class="dir-admin-btn" onclick="window.open(\'/admin#directives\',\'_blank\')">◈ MANAGE IN ADMIN</button>';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
html += '<div class="comms-empty">◈ NO ACTIVE DIRECTIVES<br><span style="opacity:0.5">Create objectives in Admin → Directives</span></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'};
|
||||||
|
for (const dir of list) {
|
||||||
|
const pct = Math.min(100, Math.round(dir.progress || 0));
|
||||||
|
const isOpen = _dirOpenCards.has(dir.id);
|
||||||
|
const color = catColors[dir.category] || 'var(--cyan)';
|
||||||
|
const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644';
|
||||||
|
const daysLeft = dir.target_date
|
||||||
|
? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null;
|
||||||
|
const dueTxt = daysLeft !== null
|
||||||
|
? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`)
|
||||||
|
: '';
|
||||||
|
const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)';
|
||||||
|
|
||||||
|
html += `<div class="dir-card${isOpen?' open':''}" id="dir-card-${dir.id}">
|
||||||
|
<div class="dir-card-head" onclick="toggleDirCard(${dir.id})">
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${color};flex-shrink:0">${dir.category.toUpperCase()}</span>
|
||||||
|
<span class="dir-card-title" style="color:${color}">${escHtml(dir.title)}</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${fillColor};flex-shrink:0">${pct}%</span>
|
||||||
|
${dueTxt ? `<span style="font-family:var(--font-mono);font-size:0.48rem;color:${dueColor};flex-shrink:0">${dueTxt}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="dir-card-body">
|
||||||
|
<div class="dir-progress-bar"><div class="dir-progress-fill" style="width:${pct}%;background:${fillColor}"></div></div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim);margin-bottom:6px">${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS</div>
|
||||||
|
<button onclick="hudDirectiveReview(${dir.id})" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.2);border-radius:3px;padding:3px 8px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ AI REVIEW</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
if (el) el.innerHTML = '<div class="comms-empty">DIRECTIVES OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDirCard(id) {
|
||||||
|
const card = document.getElementById('dir-card-' + id);
|
||||||
|
if (!card) return;
|
||||||
|
if (_dirOpenCards.has(id)) _dirOpenCards.delete(id);
|
||||||
|
else _dirOpenCards.add(id);
|
||||||
|
card.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hudDirectiveReview(id) {
|
||||||
|
const res = await api('arc?action=job_create', 'POST', {
|
||||||
|
type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6,
|
||||||
|
});
|
||||||
|
if (res.job_id) {
|
||||||
|
addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`);
|
||||||
|
speak(`Directive review underway. I'll brief you on your progress in a moment.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MEMORY CORE — bottom bar count ────────────────────────────────────────────
|
||||||
|
async function updateMemoryCount() {
|
||||||
|
try {
|
||||||
|
const stats = await api('memory?action=stats');
|
||||||
|
const el = document.getElementById('bb-memory-count');
|
||||||
|
const dot = document.getElementById('bb-memory-dot');
|
||||||
|
if (el && stats) {
|
||||||
|
const total = stats.total || 0;
|
||||||
|
el.textContent = total + ' FACTS';
|
||||||
|
if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
|
||||||
|
const _clrOpenCards = new Set();
|
||||||
|
|
||||||
|
async function updateClearanceBanner() {
|
||||||
|
try {
|
||||||
|
const pending = await api('arc?action=clearance_pending');
|
||||||
|
const list = Array.isArray(pending) ? pending : [];
|
||||||
|
const count = list.length;
|
||||||
|
const banner = document.getElementById('clearance-banner');
|
||||||
|
const badge = document.getElementById('clr-tab-badge');
|
||||||
|
const bcount = document.getElementById('clr-banner-count');
|
||||||
|
if (banner) {
|
||||||
|
if (count > 0) {
|
||||||
|
banner.classList.add('active');
|
||||||
|
if (bcount) bcount.textContent = count;
|
||||||
|
} else {
|
||||||
|
banner.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (badge) {
|
||||||
|
if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
|
||||||
|
else badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClearanceHud() {
|
||||||
|
const el = document.getElementById('clearance-hud');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const [pendingRes, rulesRes, historyRes] = await Promise.all([
|
||||||
|
api('arc?action=clearance_pending'),
|
||||||
|
api('arc?action=clearance_rules'),
|
||||||
|
api('arc?action=clearance_history&limit=20')
|
||||||
|
]);
|
||||||
|
const pending = Array.isArray(pendingRes) ? pendingRes : [];
|
||||||
|
const rules = Array.isArray(rulesRes) ? rulesRes : [];
|
||||||
|
const history = Array.isArray(historyRes) ? historyRes : [];
|
||||||
|
|
||||||
|
let html = '<button class="clr-admin-btn" onclick="window.open(\'/admin#clearance\',\'_blank\')">◈ MANAGE CLEARANCE RULES IN ADMIN</button>';
|
||||||
|
|
||||||
|
// Pending requests
|
||||||
|
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:#ff6680;margin:8px 0 4px">PENDING AUTHORIZATION (${pending.length})</div>`;
|
||||||
|
if (!pending.length) {
|
||||||
|
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">◈ NO PENDING CLEARANCE REQUESTS</div>';
|
||||||
|
} else {
|
||||||
|
for (const cr of pending) {
|
||||||
|
const isOpen = _clrOpenCards.has(cr.id);
|
||||||
|
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
|
||||||
|
const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
|
||||||
|
const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
|
||||||
|
html += `<div class="clr-card${isOpen?' open':''}" id="clr-card-${cr.id}">
|
||||||
|
<div class="clr-card-head" onclick="toggleClrCard(${cr.id})">
|
||||||
|
<span class="clr-card-type">${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
|
||||||
|
<span class="clr-card-risk ${cr.risk_level}">${cr.risk_level.toUpperCase()}</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">#${cr.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="clr-card-body">
|
||||||
|
<div class="clr-card-desc">${escHtml(cr.description || 'No description')}</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:4px">
|
||||||
|
Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
|
||||||
|
</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:6px;word-break:break-all">
|
||||||
|
Payload: ${escHtml(JSON.stringify(pl))}
|
||||||
|
</div>
|
||||||
|
<div class="clr-action-bar">
|
||||||
|
<button class="clr-approve-btn" onclick="hudClearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
|
||||||
|
<button class="clr-deny-btn" onclick="hudClearanceDecide(${cr.id},'deny')">✕ DENY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:12px 0 4px">CLEARANCE RULES</div>`;
|
||||||
|
if (!rules.length) {
|
||||||
|
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">No rules configured</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px;margin-bottom:8px">';
|
||||||
|
for (const r of rules) {
|
||||||
|
const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
|
||||||
|
const enLabel = r.enabled ? 'ON' : 'OFF';
|
||||||
|
const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
|
||||||
|
const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
|
||||||
|
html += `<div class="clr-rule-row">
|
||||||
|
<span class="clr-rule-type">${r.job_type.replace(/_/g,' ').toUpperCase()}</span>
|
||||||
|
<span class="clr-card-risk ${r.risk_level}" style="font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px;border:1px solid">${r.risk_level.toUpperCase()}</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${reqLabel}${autoTxt}</span>
|
||||||
|
<button class="clr-rule-toggle ${enClass}" onclick="hudClearanceRuleToggle(${r.id},${r.enabled?0:1})">${enLabel}</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent history
|
||||||
|
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">RECENT HISTORY</div>`;
|
||||||
|
const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
|
||||||
|
if (!recentDecided.length) {
|
||||||
|
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3)">No history yet</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px">';
|
||||||
|
for (const h of recentDecided) {
|
||||||
|
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
|
||||||
|
html += `<div class="clr-history-row">
|
||||||
|
<span class="clr-status-${h.status}">◈</span>
|
||||||
|
<span style="flex:1">${h.job_type.replace(/_/g,' ').toUpperCase()}</span>
|
||||||
|
<span class="clr-status-${h.status}">${h.status.toUpperCase()}</span>
|
||||||
|
<span style="color:rgba(255,255,255,0.3)">${ts}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
await updateClearanceBanner();
|
||||||
|
} catch(e) {
|
||||||
|
if (el) el.innerHTML = '<div class="comms-empty">CLEARANCE SYSTEM OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClrCard(id) {
|
||||||
|
const card = document.getElementById('clr-card-' + id);
|
||||||
|
if (!card) return;
|
||||||
|
if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
|
||||||
|
else _clrOpenCards.add(id);
|
||||||
|
card.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hudClearanceDecide(id, action) {
|
||||||
|
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
|
||||||
|
if (!confirm(`${label} clearance request #${id}?`)) return;
|
||||||
|
const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
|
||||||
|
try {
|
||||||
|
const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
|
||||||
|
const msg = action === 'approve'
|
||||||
|
? `◈ Clearance #${id} authorized. Job dispatched.`
|
||||||
|
: `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
|
||||||
|
addMessage('jarvis', msg);
|
||||||
|
speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
|
||||||
|
await loadClearanceHud();
|
||||||
|
} catch(e) {
|
||||||
|
addMessage('system', 'Clearance action failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hudClearanceRuleToggle(id, newEnabled) {
|
||||||
|
try {
|
||||||
|
await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
|
||||||
|
await loadClearanceHud();
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
const [listData, metricsData] = await Promise.all([
|
||||||
|
api('agent/list'),
|
||||||
|
api('agent/status')
|
||||||
|
]);
|
||||||
|
const agents = listData.agents || [];
|
||||||
|
const metrics = metricsData.metrics || {};
|
||||||
|
// Fetch sparkline data (non-blocking)
|
||||||
|
api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
|
||||||
|
renderAgentsTab(agents, metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNetworkDevice() {
|
||||||
|
const ip = prompt('IP address (e.g. 10.48.200.43):');
|
||||||
|
if (!ip) return;
|
||||||
|
const name = prompt('Device name (e.g. Yealink Phone):');
|
||||||
|
if (!name) return;
|
||||||
|
const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device';
|
||||||
|
const r = await api('network/add', 'POST', {ip, alias: name, type});
|
||||||
|
if (r.error) { alert('Error: ' + r.error); return; }
|
||||||
|
loadNetwork();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNetworkDevice(ip, evt) {
|
||||||
|
evt.stopPropagation();
|
||||||
|
if (!confirm('Remove ' + ip + ' from the network list?')) return;
|
||||||
|
const r = await api('network/delete', 'POST', {ip});
|
||||||
|
if (r.error) { alert('Error: ' + r.error); return; }
|
||||||
|
loadNetwork();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _agentSparkData = {};
|
||||||
|
function sparkline(points, width=80, height=20, color='var(--cyan)') {
|
||||||
|
if (!points || points.length < 2) return '';
|
||||||
|
const max = Math.max(...points, 1);
|
||||||
|
const min = Math.min(...points);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const step = width / (points.length - 1);
|
||||||
|
const pts = points.map((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = height - ((v - min) / range) * (height - 2) - 1;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}).join(' ');
|
||||||
|
return `<svg width="${width}" height="${height}" style="overflow:visible;display:block">
|
||||||
|
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>
|
||||||
|
<circle cx="${((points.length-1)*step).toFixed(1)}" cy="${(height - ((points[points.length-1]-min)/range)*(height-2)-1).toFixed(1)}" r="2" fill="${color}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentsTab(agents, metrics) {
|
||||||
|
const el = document.getElementById('agents-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!agents.length) {
|
||||||
|
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;margin-top:20px">NO AGENTS REGISTERED</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = agents.map(ag => {
|
||||||
|
const m = metrics[ag.agent_id] || {};
|
||||||
|
const sys = m.system || {};
|
||||||
|
const alive = ag.status === 'online';
|
||||||
|
const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--';
|
||||||
|
const mem = sys.memory ? Math.round(sys.memory.percent) : '--';
|
||||||
|
const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--';
|
||||||
|
const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--';
|
||||||
|
const disks = sys.disk || [];
|
||||||
|
const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null;
|
||||||
|
const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE');
|
||||||
|
const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--';
|
||||||
|
|
||||||
|
const gauge = (val, unit='%', warn=80, crit=90) => {
|
||||||
|
const v = typeof val === 'number' ? val : parseInt(val);
|
||||||
|
if (isNaN(v)) return `<span style="color:var(--text-dim)">--</span>`;
|
||||||
|
const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)';
|
||||||
|
return `<div style="display:flex;align-items:center;gap:4px">
|
||||||
|
<div style="width:50px;height:5px;background:rgba(255,255,255,0.1);border-radius:3px;flex-shrink:0">
|
||||||
|
<div style="width:${Math.min(v,100)}%;height:100%;background:${col};border-radius:3px;transition:width 0.5s"></div>
|
||||||
|
</div>
|
||||||
|
<span style="color:${col};font-size:0.65rem">${v}${unit}</span>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true)
|
||||||
|
.map(s => `<span style="color:${s.status==='active'?'var(--green)':'var(--red)'};font-size:0.58rem;margin-right:6px">${s.service}: ${s.status}</span>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const ctxKey = 'agent_' + ag.agent_id;
|
||||||
|
_panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id,
|
||||||
|
hostname: ag.hostname, status: ag.status, cpu, mem};
|
||||||
|
|
||||||
|
return `<div class="alert-item ${alive ? '' : 'critical'}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')"
|
||||||
|
style="flex-direction:column;align-items:stretch;border-left:3px solid ${alive ? 'var(--green)' : 'var(--red)'}">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||||
|
<div style="width:8px;height:8px;border-radius:50%;background:${alive ? 'var(--green)' : 'var(--red)'};box-shadow:${alive ? '0 0 6px var(--green)' : 'none'};flex-shrink:0"></div>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${ag.hostname}</span>
|
||||||
|
<span style="font-size:0.58rem;color:var(--text-dim)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</span>
|
||||||
|
<span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
|
||||||
|
</div>
|
||||||
|
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px">
|
||||||
|
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
|
||||||
|
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
|
||||||
|
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">CPU 2H</div>
|
||||||
|
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">MEM 2H</div>
|
||||||
|
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
|
||||||
|
${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${alive ? `<div style="display:flex;gap:5px;margin-top:6px">
|
||||||
|
<button onclick="event.stopPropagation();agentScreenshot('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SCREENSHOT</button>
|
||||||
|
<button onclick="event.stopPropagation();agentSysinfo('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">⚡ SYSINFO</button>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAgentModal() {
|
||||||
|
const os = detectOS();
|
||||||
|
const title = document.getElementById('agentModalTitle');
|
||||||
|
const content = document.getElementById('agentModalContent');
|
||||||
|
const modal = document.getElementById('agentModal');
|
||||||
|
const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
|
||||||
|
const baseUrl = 'https://jarvis.orbishosting.com/agent';
|
||||||
|
const jUrl = window.location.origin;
|
||||||
|
|
||||||
|
if (os === 'tablet') {
|
||||||
|
title.textContent = '● JARVIS — TABLET / MOBILE';
|
||||||
|
content.innerHTML =
|
||||||
|
'<div style="color:var(--cyan);font-size:0.75rem;margin-bottom:12px">✓ You\'re viewing JARVIS on a tablet or mobile device.</div>' +
|
||||||
|
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.6">The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).<br><br>' +
|
||||||
|
'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.</div>';
|
||||||
|
} else if (_agentOnline) {
|
||||||
|
title.textContent = '● AGENT CONNECTED';
|
||||||
|
content.innerHTML =
|
||||||
|
'<div style="color:var(--green);font-size:0.75rem;margin-bottom:12px">✓ JARVIS Agent is active on this machine.</div>' +
|
||||||
|
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.8">' +
|
||||||
|
'<b style="color:var(--text)">Host:</b> ' + (_myAgent?.hostname||'—') + '<br>' +
|
||||||
|
'<b style="color:var(--text)">IP:</b> ' + (_myAgent?.ip_address||'—') + '<br>' +
|
||||||
|
'<b style="color:var(--text)">Type:</b> ' + (_myAgent?.agent_type||'—').toUpperCase() + '<br>' +
|
||||||
|
'<b style="color:var(--text)">Reporting:</b> CPU · Memory · Disk · Services · Uptime</div>';
|
||||||
|
} else {
|
||||||
|
const inst = {
|
||||||
|
windows: {
|
||||||
|
label:'Windows',
|
||||||
|
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
|
||||||
|
dl: baseUrl+'/install-windows.ps1',
|
||||||
|
note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
|
||||||
|
},
|
||||||
|
mac: {
|
||||||
|
label:'macOS',
|
||||||
|
cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
|
||||||
|
dl: baseUrl+'/install-mac.sh',
|
||||||
|
note:'Run in Terminal. Installs as a launchd background service.'
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
label:'Linux',
|
||||||
|
cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
|
||||||
|
dl: baseUrl+'/install.sh',
|
||||||
|
note:'Run in terminal. Installs as a systemd service.'
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
label:'Your System',
|
||||||
|
cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
|
||||||
|
dl: 'https://jarvis.orbishosting.com/agent/',
|
||||||
|
note:'Choose your platform installer from the JARVIS agent directory.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const i = inst[os] || inst.unknown;
|
||||||
|
const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase();
|
||||||
|
title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM');
|
||||||
|
content.innerHTML =
|
||||||
|
'<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:1px;margin-bottom:8px">DETECTED: ' + osBadge + '</div>' +
|
||||||
|
'<div style="color:var(--text-dim);font-size:0.65rem;margin-bottom:12px">'+i.note+'</div>' +
|
||||||
|
'<pre id="agentCmdPre">'+i.cmd+'</pre>' +
|
||||||
|
'<a class="agent-dl-btn" href="'+i.dl+'" target="_blank">↓ DOWNLOAD INSTALLER</a>' +
|
||||||
|
'<div style="color:var(--text-dim);font-size:0.6rem;margin-top:16px;opacity:0.7">After install, the AGENT indicator turns green within 30 seconds.</div>';
|
||||||
|
}
|
||||||
|
modal.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target === document.getElementById('agentModal'))
|
||||||
|
document.getElementById('agentModal').classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── SITES MANAGER ────────────────────────────────────────────────────
|
||||||
|
let sitesData = {};
|
||||||
|
|
||||||
|
function openSitesModal() {
|
||||||
|
document.getElementById('sitesModal').style.display = 'flex';
|
||||||
|
loadSites();
|
||||||
|
}
|
||||||
|
function closeSitesModal() {
|
||||||
|
document.getElementById('sitesModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
// Close on backdrop click
|
||||||
|
document.getElementById('sitesModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeSitesModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSites() {
|
||||||
|
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem;letter-spacing:2px">LOADING SITE SETTINGS...</div>';
|
||||||
|
const res = await api('sites');
|
||||||
|
if (!res.success) {
|
||||||
|
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:#f44;font-size:0.65rem">FAILED TO LOAD SETTINGS</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sitesData = res.sites;
|
||||||
|
// Pre-fill global key from first site
|
||||||
|
const firstKey = Object.values(res.sites)[0]?.api_key || '';
|
||||||
|
document.getElementById('global-api-key').value = firstKey;
|
||||||
|
renderSiteCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSiteCards() {
|
||||||
|
const grid = document.getElementById('sites-grid');
|
||||||
|
let html = '';
|
||||||
|
for (const [id, s] of Object.entries(sitesData)) {
|
||||||
|
html += `
|
||||||
|
<div style="background:rgba(0,212,255,0.02);border:1px solid rgba(0,212,255,0.12);padding:16px">
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:2px">${s.name.toUpperCase()}</div>
|
||||||
|
<div style="color:var(--text-dim);font-size:0.58rem">${s.url}</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM EMAIL</div>
|
||||||
|
<input id="${id}-from_email" type="text" value="${s.from_email || ''}"
|
||||||
|
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM NAME</div>
|
||||||
|
<input id="${id}-from_name" type="text" value="${s.from_name || ''}"
|
||||||
|
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">ADMIN NOTIFICATION EMAIL</div>
|
||||||
|
<input id="${id}-admin_email" type="text" value="${s.admin_email || ''}"
|
||||||
|
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<button onclick="saveSite('${id}')"
|
||||||
|
style="background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-mono);font-size:0.58rem;letter-spacing:2px;padding:6px 16px;cursor:pointer">
|
||||||
|
SAVE
|
||||||
|
</button>
|
||||||
|
<span id="${id}-status" style="font-size:0.58rem;color:var(--text-dim)"></span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
grid.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushApiKey() {
|
||||||
|
const key = document.getElementById('global-api-key').value.trim();
|
||||||
|
const status = document.getElementById('push-status');
|
||||||
|
if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; }
|
||||||
|
status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...';
|
||||||
|
const res = await api('sites', 'POST', {action:'push_key', api_key:key});
|
||||||
|
if (res.success) {
|
||||||
|
const ok = Object.values(res.results).filter(Boolean).length;
|
||||||
|
const total = Object.keys(res.results).length;
|
||||||
|
status.style.color = ok === total ? 'var(--cyan)' : '#fa0';
|
||||||
|
status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`;
|
||||||
|
for (const id of Object.keys(sitesData)) sitesData[id].api_key = key;
|
||||||
|
} else {
|
||||||
|
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSite(id) {
|
||||||
|
const status = document.getElementById(id + '-status');
|
||||||
|
status.style.color='var(--text-dim)'; status.textContent='SAVING...';
|
||||||
|
const res = await api('sites', 'POST', {
|
||||||
|
action: 'save',
|
||||||
|
site: id,
|
||||||
|
from_email: document.getElementById(id+'-from_email').value.trim(),
|
||||||
|
from_name: document.getElementById(id+'-from_name').value.trim(),
|
||||||
|
admin_email: document.getElementById(id+'-admin_email').value.trim(),
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
status.style.color='var(--cyan)'; status.textContent='✓ SAVED';
|
||||||
|
setTimeout(() => { status.textContent=''; }, 3000);
|
||||||
|
} else {
|
||||||
|
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VISION PROTOCOL — screenshot lightbox ────────────────────────────────────
|
||||||
|
function openVisionLightbox(title) {
|
||||||
|
const lb = document.getElementById('vision-lightbox');
|
||||||
|
document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL';
|
||||||
|
document.getElementById('vision-lb-img').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-img').src = '';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = '';
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'block';
|
||||||
|
lb.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVisionLightbox() {
|
||||||
|
document.getElementById('vision-lightbox').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agentScreenshot(hostname) {
|
||||||
|
openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase());
|
||||||
|
const arcRes = await api('arc?action=job_create', 'POST', {
|
||||||
|
type: 'screenshot',
|
||||||
|
payload: {agent: hostname, analyze: true},
|
||||||
|
priority: 8,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!arcRes || !arcRes.job_id) {
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for result
|
||||||
|
const jobId = arcRes.job_id;
|
||||||
|
let tries = 0;
|
||||||
|
const poll = async () => {
|
||||||
|
tries++;
|
||||||
|
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
|
||||||
|
if (job && job.status === 'done') {
|
||||||
|
const r = job.result || {};
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
if (r.has_image && r.screenshot_id) {
|
||||||
|
// Fetch full screenshot with image
|
||||||
|
const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null);
|
||||||
|
if (full && full.image_b64) {
|
||||||
|
const img = document.getElementById('vision-lb-img');
|
||||||
|
img.src = 'data:image/png;base64,' + full.image_b64;
|
||||||
|
img.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('vision-lb-analysis').textContent =
|
||||||
|
r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2));
|
||||||
|
} else if (job && job.status === 'failed') {
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error');
|
||||||
|
} else if (tries < 30) {
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
} else {
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agentSysinfo(hostname) {
|
||||||
|
openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase());
|
||||||
|
const arcRes = await api('arc?action=job_create', 'POST', {
|
||||||
|
type: 'sysinfo',
|
||||||
|
payload: {agent: hostname, analyze: true},
|
||||||
|
priority: 7,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!arcRes || !arcRes.job_id) {
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = arcRes.job_id;
|
||||||
|
let tries = 0;
|
||||||
|
const poll = async () => {
|
||||||
|
tries++;
|
||||||
|
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
|
||||||
|
if (job && job.status === 'done') {
|
||||||
|
const r = job.result || {};
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
const snap = r.snapshot || {};
|
||||||
|
const snapText = Object.entries(snap)
|
||||||
|
.filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k))
|
||||||
|
.map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`)
|
||||||
|
.join('\n');
|
||||||
|
document.getElementById('vision-lb-analysis').textContent =
|
||||||
|
(r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2));
|
||||||
|
} else if (job && job.status === 'failed') {
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error');
|
||||||
|
} else if (tries < 20) {
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
} else {
|
||||||
|
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||||
|
document.getElementById('vision-lb-analysis').textContent = 'Timed out.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeVisionLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
// ── ARC REACTOR STATUS ────────────────────────────────────────────────
|
||||||
|
let _arcOnline = false;
|
||||||
|
let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
|
||||||
|
|
||||||
|
async function checkArcStatus() {
|
||||||
|
const dot = document.getElementById('bb-arc-dot');
|
||||||
|
const sta = document.getElementById('bb-arc-status');
|
||||||
|
if (!dot || !sta) return;
|
||||||
|
try {
|
||||||
|
const d = await api('arc?action=status');
|
||||||
|
if (d && d.online) {
|
||||||
|
_arcOnline = true;
|
||||||
|
dot.className = 'bb-dot online';
|
||||||
|
const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
|
||||||
|
sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
|
||||||
|
_arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
|
||||||
|
done: d.jobs_done||0, failed: d.jobs_failed||0 };
|
||||||
|
} else {
|
||||||
|
_arcOnline = false;
|
||||||
|
dot.className = 'bb-dot offline';
|
||||||
|
sta.textContent = 'OFFLINE';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
_arcOnline = false;
|
||||||
|
dot.className = 'bb-dot offline';
|
||||||
|
sta.textContent = 'OFFLINE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit a job to the Arc Reactor and return job_id
|
||||||
|
async function arcSubmitJob(type, payload, priority) {
|
||||||
|
payload = payload || {};
|
||||||
|
priority = priority || 5;
|
||||||
|
const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
|
||||||
|
return d.job_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll a job until done or failed (max 120s), calling onProgress each tick
|
||||||
|
async function arcWaitJob(jobId, onProgress) {
|
||||||
|
var start = Date.now();
|
||||||
|
while (Date.now() - start < 120000) {
|
||||||
|
const d = await api('arc?action=job_get&id=' + jobId);
|
||||||
|
if (onProgress) onProgress(d);
|
||||||
|
if (d.status === 'done') return d;
|
||||||
|
if (d.status === 'failed') throw new Error(d.error || 'Job failed');
|
||||||
|
await new Promise(function(r){ setTimeout(r, 1500); });
|
||||||
|
}
|
||||||
|
throw new Error('Arc Reactor job timed out');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
|
||||||
|
let _intelPollTimer = null;
|
||||||
|
let _intelActiveJobs = new Set();
|
||||||
|
let _intelLastLoad = 0;
|
||||||
|
|
||||||
|
async function loadIntel() {
|
||||||
|
const el = document.getElementById('intel-list');
|
||||||
|
if (!el) return;
|
||||||
|
_intelLastLoad = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch recent research + tool_loop jobs
|
||||||
|
const [resJobs, toolJobs] = await Promise.all([
|
||||||
|
api('arc?action=jobs&status=&limit=20').catch(() => []),
|
||||||
|
Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
|
||||||
|
|
||||||
|
if (!jobs.length) {
|
||||||
|
el.innerHTML = '<div class="intel-empty">◈ NO INTEL JOBS<br><span style="opacity:0.5">Say "research [topic]" to activate</span></div>';
|
||||||
|
stopIntelPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for active jobs
|
||||||
|
const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running');
|
||||||
|
if (hasActive) startIntelPolling(); else stopIntelPolling();
|
||||||
|
|
||||||
|
let html = '<button class="intel-new-btn" onclick="intelPrompt()">⚡ NEW RESEARCH</button>';
|
||||||
|
for (const job of jobs) {
|
||||||
|
const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running';
|
||||||
|
const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running';
|
||||||
|
const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase();
|
||||||
|
const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM';
|
||||||
|
|
||||||
|
// Get result details if done
|
||||||
|
let bodyHtml = '';
|
||||||
|
if (job.status === 'done' && job.result) {
|
||||||
|
let r = job.result;
|
||||||
|
if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} }
|
||||||
|
if (typeof r === 'object') {
|
||||||
|
const synthesis = (r.synthesis || r.result || r.response || '').trim();
|
||||||
|
const sources = r.sources || [];
|
||||||
|
const query = r.query || r.task || '';
|
||||||
|
const provider = r.provider || '';
|
||||||
|
|
||||||
|
bodyHtml = `<div class="intel-card-body">`;
|
||||||
|
if (provider) bodyHtml += `<div style="font-size:0.55rem;color:var(--text-dim);margin:6px 0 2px;font-family:var(--font-mono)">PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}</div>`;
|
||||||
|
if (synthesis) bodyHtml += `<div class="synthesis">${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}</div>`;
|
||||||
|
if (sources.length) {
|
||||||
|
bodyHtml += '<div class="intel-sources"><div style="font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin-bottom:4px;font-family:var(--font-display)">SOURCES</div>';
|
||||||
|
sources.slice(0,5).forEach((s,i) => {
|
||||||
|
const title = escHtml((s.title||s.url||'').substring(0,60));
|
||||||
|
const url = escHtml(s.url||'');
|
||||||
|
bodyHtml += `<div class="intel-source">${i+1}. <a href="${url}" target="_blank" rel="noopener">${title||url}</a></div>`;
|
||||||
|
});
|
||||||
|
bodyHtml += '</div>';
|
||||||
|
}
|
||||||
|
bodyHtml += '</div>';
|
||||||
|
}
|
||||||
|
} else if (job.status === 'running' || job.status === 'queued') {
|
||||||
|
const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...';
|
||||||
|
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--text-dim);padding:8px 0;font-family:var(--font-mono)">${typeMsg}</div></div>`;
|
||||||
|
} else if (job.status === 'failed' && job.error) {
|
||||||
|
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--red);padding:8px 0;font-family:var(--font-mono)">${escHtml(job.error.substring(0,200))}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : '';
|
||||||
|
const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : '';
|
||||||
|
|
||||||
|
html += `<div class="intel-card${(isOpen && bodyHtml) ? ' open':''}" id="intel-card-${job.id}">
|
||||||
|
<div class="intel-card-head" onclick="toggleIntelCard(${job.id})">
|
||||||
|
<span style="font-size:0.55rem;color:var(--text-dim);font-family:var(--font-mono);flex-shrink:0">${typeLabel}</span>
|
||||||
|
<span class="intel-card-query">#${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))}</span>
|
||||||
|
<span style="font-size:0.55rem;color:var(--text-dim);flex-shrink:0;font-family:var(--font-mono)">${ts}</span>
|
||||||
|
<span class="intel-card-status ${statusClass}">${statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
${bodyHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
if (el) el.innerHTML = '<div class="intel-empty">INTEL OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIntelCard(id) {
|
||||||
|
const card = document.getElementById('intel-card-' + id);
|
||||||
|
if (!card) return;
|
||||||
|
if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id);
|
||||||
|
else _intelActiveJobs.add(id);
|
||||||
|
card.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startIntelPolling() {
|
||||||
|
if (_intelPollTimer) return;
|
||||||
|
_intelPollTimer = setInterval(() => {
|
||||||
|
if (document.getElementById('tab-intel')?.classList.contains('active')) {
|
||||||
|
loadIntel();
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopIntelPolling() {
|
||||||
|
if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function intelPrompt() {
|
||||||
|
const input = document.getElementById('textInput');
|
||||||
|
if (input) { input.value = 'research '; input.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when arc_job is returned from chat response
|
||||||
|
function onArcJobStarted(jobId, jobType) {
|
||||||
|
const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
|
||||||
|
if (commsTypes.includes(jobType)) {
|
||||||
|
const commsBtn = document.getElementById('tab-btn-comms');
|
||||||
|
if (commsBtn) commsBtn.click();
|
||||||
|
startCommsPolling();
|
||||||
|
} else {
|
||||||
|
_intelActiveJobs.add(jobId);
|
||||||
|
const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]');
|
||||||
|
if (intelTab) intelTab.click();
|
||||||
|
startIntelPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMMS PROTOCOL — email triage HUD ────────────────────────────────────
|
||||||
|
let _commsPollTimer = null;
|
||||||
|
let _commsFilter = 'priority';
|
||||||
|
let _commsOpenCards = new Set();
|
||||||
|
|
||||||
|
async function loadComms() {
|
||||||
|
const el = document.getElementById('comms-list');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter);
|
||||||
|
const items = Array.isArray(res) ? res : (res.items || []);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
el.innerHTML = '<button class="comms-triage-btn" onclick="commsTriageNow()">◈ TRIAGE INBOX NOW</button>'
|
||||||
|
+ '<div class="comms-empty">◈ NO TRIAGE DATA<br><span style="opacity:0.5">Say "check my email" to activate</span></div>';
|
||||||
|
stopCommsPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
|
||||||
|
const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'ℹ', promo:'📢', spam:'🗑'};
|
||||||
|
|
||||||
|
let html = '<div style="display:flex;gap:5px;margin-bottom:5px">';
|
||||||
|
html += '<button class="comms-triage-btn" style="flex:3;margin-bottom:0" onclick="commsTriageNow()">◈ TRIAGE INBOX</button>';
|
||||||
|
html += '<button class="comms-compose-btn" style="flex:2;margin-bottom:0" onclick="commsShowCompose()">+ COMPOSE</button>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div class="comms-header-bar">';
|
||||||
|
for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) {
|
||||||
|
html += `<div class="comms-filter-btn${_commsFilter===f?' active':''}" onclick="commsSetFilter('${f}')">${label}</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const cat = item.category || 'info';
|
||||||
|
const icon = catIcons[cat] || '◈';
|
||||||
|
const prio = item.priority || 0;
|
||||||
|
const isOpen = _commsOpenCards.has(item.id);
|
||||||
|
const hasReply = item.draft_reply && item.draft_reply.trim().length > 5;
|
||||||
|
|
||||||
|
html += `<div class="comms-card${isOpen?' open':''}" id="comms-card-${item.id}">
|
||||||
|
<div class="comms-card-head" onclick="toggleCommsCard(${item.id})">
|
||||||
|
<span class="comms-card-cat ${cat}">${icon} ${cat.toUpperCase()}</span>
|
||||||
|
<span class="comms-card-subject">${escHtml((item.subject||'(no subject)').substring(0,60))}</span>
|
||||||
|
<span class="comms-prio">${prio}/10</span>
|
||||||
|
</div>
|
||||||
|
<div class="comms-card-body">
|
||||||
|
<div class="comms-card-from">FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}</div>
|
||||||
|
<div class="comms-card-summary">${escHtml(item.summary||'')}</div>
|
||||||
|
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft" id="comms-draft-${item.id}">${escHtml(item.draft_reply)}</div>` : ''}
|
||||||
|
<div style="display:flex;gap:5px;margin-top:8px">
|
||||||
|
${hasReply ? `<button class="comms-send-btn" id="comms-send-${item.id}" onclick="commsSendReply(${item.id})">◈ SEND REPLY</button>` : ''}
|
||||||
|
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY</button>` : ''}
|
||||||
|
<button onclick="commsDismiss(${item.id})" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">DISMISS</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
if (el) el.innerHTML = '<div class="comms-empty">COMMS OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCommsCard(id) {
|
||||||
|
const card = document.getElementById('comms-card-' + id);
|
||||||
|
if (!card) return;
|
||||||
|
if (_commsOpenCards.has(id)) _commsOpenCards.delete(id);
|
||||||
|
else _commsOpenCards.add(id);
|
||||||
|
card.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function commsSetFilter(f) {
|
||||||
|
_commsFilter = f;
|
||||||
|
loadComms();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commsDismiss(id) {
|
||||||
|
await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {});
|
||||||
|
loadComms();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commsCopyReply(id) {
|
||||||
|
const draft = document.querySelector(`#comms-draft-${id}`);
|
||||||
|
if (draft) {
|
||||||
|
navigator.clipboard.writeText(draft.innerText).catch(() => {});
|
||||||
|
const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
|
||||||
|
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commsSendReply(id) {
|
||||||
|
const btn = document.getElementById('comms-send-' + id);
|
||||||
|
const draft = document.getElementById('comms-draft-' + id);
|
||||||
|
if (!btn || !draft) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '◈ SENDING…';
|
||||||
|
try {
|
||||||
|
const res = await api('arc', 'POST', {
|
||||||
|
action: 'job_create',
|
||||||
|
type: 'send_email',
|
||||||
|
payload: { triage_id: id, content: draft.innerText },
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
if (res.job_id) {
|
||||||
|
btn.textContent = '◈ SENT ✓';
|
||||||
|
btn.style.color = '#00ff88';
|
||||||
|
setTimeout(() => loadComms(), 3000);
|
||||||
|
loadCommsOutbox();
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '◈ SEND REPLY';
|
||||||
|
alert('Send failed: ' + (res.error || 'unknown error'));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '◈ SEND REPLY';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commsShowCompose() {
|
||||||
|
const existing = document.getElementById('comms-compose-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'comms-compose-modal';
|
||||||
|
modal.id = 'comms-compose-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="comms-compose-inner">
|
||||||
|
<div class="comms-compose-title">◈ COMPOSE MESSAGE</div>
|
||||||
|
<select id="cc-account" class="comms-compose-field" style="cursor:pointer">
|
||||||
|
<option value="gmail">Gmail</option>
|
||||||
|
<option value="icloud">iCloud</option>
|
||||||
|
</select>
|
||||||
|
<input id="cc-to" class="comms-compose-field" placeholder="To: email address" type="email">
|
||||||
|
<input id="cc-subject" class="comms-compose-field" placeholder="Subject">
|
||||||
|
<textarea id="cc-instructions" class="comms-compose-field" rows="4" placeholder="Describe what to say (AI will draft it)"></textarea>
|
||||||
|
<div id="cc-preview" style="display:none">
|
||||||
|
<div class="comms-draft-label">DRAFTED MESSAGE</div>
|
||||||
|
<div class="comms-draft" id="cc-preview-body" style="max-height:200px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="comms-compose-actions">
|
||||||
|
<button class="comms-send-btn" style="flex:1" onclick="commsComposeDraft()">◈ DRAFT</button>
|
||||||
|
<button class="comms-send-btn" style="flex:1;display:none" id="cc-send-btn" onclick="commsComposeAndSend()">◈ SEND NOW</button>
|
||||||
|
<button onclick="document.getElementById('comms-compose-modal').remove()" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
<div id="cc-status" style="font-family:var(--font-mono);font-size:0.55rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ccDraftedBody = '';
|
||||||
|
|
||||||
|
async function commsComposeDraft() {
|
||||||
|
const to = document.getElementById('cc-to')?.value.trim();
|
||||||
|
const subject = document.getElementById('cc-subject')?.value.trim();
|
||||||
|
const instructions = document.getElementById('cc-instructions')?.value.trim();
|
||||||
|
const account = document.getElementById('cc-account')?.value;
|
||||||
|
const status = document.getElementById('cc-status');
|
||||||
|
if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
|
||||||
|
if (status) status.textContent = '◈ DRAFTING…';
|
||||||
|
try {
|
||||||
|
const res = await api('arc', 'POST', {
|
||||||
|
action: 'job_create', type: 'compose_email',
|
||||||
|
payload: { recipient: to, subject, instructions, account, auto_send: false },
|
||||||
|
priority: 7,
|
||||||
|
});
|
||||||
|
if (!res.job_id) throw new Error(res.error || 'No job');
|
||||||
|
// poll for result
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = async () => {
|
||||||
|
const job = await api('arc?action=job_get&id=' + res.job_id);
|
||||||
|
if (job.status === 'done' && job.result?.drafted_body) {
|
||||||
|
_ccDraftedBody = job.result.drafted_body;
|
||||||
|
document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
|
||||||
|
document.getElementById('cc-preview').style.display = 'block';
|
||||||
|
document.getElementById('cc-send-btn').style.display = '';
|
||||||
|
if (status) status.textContent = '◈ DRAFT READY — Review and send';
|
||||||
|
} else if (job.status === 'failed') {
|
||||||
|
if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
|
||||||
|
} else if (attempts++ < 20) {
|
||||||
|
setTimeout(poll, 1500);
|
||||||
|
} else {
|
||||||
|
if (status) status.textContent = '◈ Job still running — check INTEL tab';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(poll, 1500);
|
||||||
|
} catch(e) {
|
||||||
|
if (status) status.textContent = '✗ Error: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commsComposeAndSend() {
|
||||||
|
const to = document.getElementById('cc-to')?.value.trim();
|
||||||
|
const subject = document.getElementById('cc-subject')?.value.trim();
|
||||||
|
const account = document.getElementById('cc-account')?.value;
|
||||||
|
const status = document.getElementById('cc-status');
|
||||||
|
const btn = document.getElementById('cc-send-btn');
|
||||||
|
if (!to || !_ccDraftedBody) return;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
|
||||||
|
if (status) status.textContent = '◈ TRANSMITTING…';
|
||||||
|
try {
|
||||||
|
const res = await api('arc', 'POST', {
|
||||||
|
action: 'job_create', type: 'send_email',
|
||||||
|
payload: { to_email: to, subject, body: _ccDraftedBody, account },
|
||||||
|
priority: 9,
|
||||||
|
});
|
||||||
|
if (res.job_id) {
|
||||||
|
if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('comms-compose-modal')?.remove();
|
||||||
|
loadCommsOutbox();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||||||
|
if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||||||
|
if (status) status.textContent = '✗ Error: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommsOutbox() {
|
||||||
|
const el = document.getElementById('comms-outbox');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const data = await api('arc?action=comms_sent&limit=20');
|
||||||
|
const sent = Array.isArray(data) ? data : (data.sent || []);
|
||||||
|
if (!sent.length) {
|
||||||
|
el.innerHTML = '<div class="comms-empty" style="padding:10px">No sent messages yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
|
||||||
|
let html = '';
|
||||||
|
for (const m of sent) {
|
||||||
|
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
|
||||||
|
const sc = m.status || 'sent';
|
||||||
|
html += `<div class="comms-outbox-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<div class="comms-outbox-to">TO: ${escHtml((m.to_email||'').substring(0,40))}</div>
|
||||||
|
<span class="comms-outbox-status ${sc}">${sc.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comms-outbox-subj">${escHtml((m.subject||'(no subject)').substring(0,60))}</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${ts} · ${m.account||'gmail'}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
el.innerHTML = '<div class="comms-empty" style="padding:10px">OUTBOX OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commsTriageNow() {
|
||||||
|
const input = document.getElementById('textInput');
|
||||||
|
if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCommsPolling() {
|
||||||
|
if (_commsPollTimer) return;
|
||||||
|
_commsPollTimer = setInterval(() => {
|
||||||
|
if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCommsPolling() {
|
||||||
|
if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GUARDIAN MODE ─────────────────────────────────────────────────────────────
|
||||||
|
let _guardianPollTimer = null;
|
||||||
|
let _guardianChatTimer = null;
|
||||||
|
let _guardianLastChat = '';
|
||||||
|
let _guardianUnread = 0;
|
||||||
|
|
||||||
|
async function loadGuardian() {
|
||||||
|
const el = document.getElementById('guardian-list');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statusData, eventsData] = await Promise.all([
|
||||||
|
api('arc?action=guardian_status').catch(() => ({})),
|
||||||
|
api('arc?action=guardian_events&limit=40').catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events = Array.isArray(eventsData) ? eventsData : [];
|
||||||
|
const status = statusData || {};
|
||||||
|
const counts = status.counts || {};
|
||||||
|
const unread = parseInt(counts.unread || 0);
|
||||||
|
const critU = parseInt(counts.critical_unread || 0);
|
||||||
|
|
||||||
|
_guardianUnread = unread;
|
||||||
|
_updateGuardianBadge(unread, critU);
|
||||||
|
if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
||||||
|
new Notification('JARVIS ALERT', {
|
||||||
|
body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.',
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastScan = status.last_scan
|
||||||
|
? new Date(status.last_scan + 'Z').toLocaleTimeString()
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
let html = `<div style="padding:6px 10px 4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--cyan)">◈ GUARDIAN MODE</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.5rem;color:${status.enabled?'var(--green)':'var(--red)'}">
|
||||||
|
${status.enabled ? '● ACTIVE' : '○ INACTIVE'}
|
||||||
|
</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim)">SCAN: ${lastScan}</span>
|
||||||
|
${unread ? `<button onclick="guardianAckAll()" class="guardian-ack-btn" style="margin-left:auto">ACK ALL (${unread})</button>` : '<span style="margin-left:auto"></span>'}
|
||||||
|
<button onclick="guardianSitrep()" style="background:rgba(0,212,255,0.08);border:1px solid var(--panel-border);color:var(--cyan);padding:3px 7px;border-radius:3px;font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SITREP</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (!events.length) {
|
||||||
|
html += '<div style="text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px">◈ ALL CLEAR<br><span style="opacity:0.5">Guardian is watching...</span></div>';
|
||||||
|
} else {
|
||||||
|
for (const ev of events) {
|
||||||
|
const sev = ev.severity || 'info';
|
||||||
|
const acked = ev.acknowledged;
|
||||||
|
const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : '';
|
||||||
|
const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',
|
||||||
|
mem_high:'⚡',disk_high:'💾',service_down:'✗',
|
||||||
|
service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈';
|
||||||
|
html += `<div class="guardian-event ${sev}${acked?' acked':''}" id="gev-${ev.id}">
|
||||||
|
<span class="guardian-sev ${sev}">${sev.toUpperCase()}</span>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="guardian-msg">${typeIco} ${escHtml(ev.message||'')}</div>
|
||||||
|
${ev.ai_analysis ? `<div class="guardian-ai">${escHtml(ev.ai_analysis.substring(0,200))}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
|
||||||
|
<span class="guardian-time">${ts}</span>
|
||||||
|
${!acked ? `<button class="guardian-ack-btn" onclick="guardianAck(${ev.id})">ACK</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
startGuardianPolling();
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
if (el) el.innerHTML = '<div style="text-align:center;padding:20px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim)">GUARDIAN OFFLINE</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateGuardianBadge(unread, critical) {
|
||||||
|
const dot = document.getElementById('bb-guardian-dot');
|
||||||
|
const badge = document.getElementById('bb-guardian-badge');
|
||||||
|
const status = document.getElementById('bb-guardian-status');
|
||||||
|
if (!dot) return;
|
||||||
|
dot.className = 'bb-dot';
|
||||||
|
if (critical > 0) {
|
||||||
|
dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)';
|
||||||
|
} else if (unread > 0) {
|
||||||
|
dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623';
|
||||||
|
} else {
|
||||||
|
dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)';
|
||||||
|
}
|
||||||
|
if (unread > 0) {
|
||||||
|
badge.textContent = unread; badge.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardianAck(id) {
|
||||||
|
await api('arc?action=guardian_ack&id=' + id).catch(() => {});
|
||||||
|
const ev = document.getElementById('gev-' + id);
|
||||||
|
if (ev) ev.classList.add('acked');
|
||||||
|
_guardianUnread = Math.max(0, _guardianUnread - 1);
|
||||||
|
_updateGuardianBadge(_guardianUnread, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardianAckAll() {
|
||||||
|
await api('arc?action=guardian_ack').catch(() => {});
|
||||||
|
loadGuardian();
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardianSitrep() {
|
||||||
|
const input = document.getElementById('textInput');
|
||||||
|
if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchGuardianTab() {
|
||||||
|
const btn = document.getElementById('tab-btn-guardian');
|
||||||
|
if (btn) btn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGuardianPolling() {
|
||||||
|
if (_guardianPollTimer) return;
|
||||||
|
_guardianPollTimer = setInterval(() => {
|
||||||
|
if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian();
|
||||||
|
else _refreshGuardianBadge();
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _refreshGuardianBadge() {
|
||||||
|
const s = await api('arc?action=guardian_status').catch(() => null);
|
||||||
|
if (!s) return;
|
||||||
|
const counts = s.counts || {};
|
||||||
|
_updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proactive chat polling — checks for guardian-injected messages every 30s
|
||||||
|
let _proactiveChatLastId = 0;
|
||||||
|
async function _pollProactiveChat() {
|
||||||
|
try {
|
||||||
|
const rows = await api('arc?action=guardian_chat').catch(() => []);
|
||||||
|
if (!Array.isArray(rows)) return;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.id > _proactiveChatLastId) {
|
||||||
|
_proactiveChatLastId = row.id;
|
||||||
|
// Don't spam on first load — only show messages from last 5 min
|
||||||
|
const age = Date.now() - new Date(row.created_at + 'Z').getTime();
|
||||||
|
if (age < 300000) {
|
||||||
|
addMessage('jarvis', row.message);
|
||||||
|
speak(row.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
|
||||||
|
function openSearchModal() {
|
||||||
|
document.getElementById('searchModal').style.display = 'flex';
|
||||||
|
document.getElementById('searchInput').focus();
|
||||||
|
}
|
||||||
|
function closeSearchModal() {
|
||||||
|
document.getElementById('searchModal').style.display = 'none';
|
||||||
|
document.getElementById('searchResults').innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
}
|
||||||
|
async function runSearch() {
|
||||||
|
const q = document.getElementById('searchInput').value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
const el = document.getElementById('searchResults');
|
||||||
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Searching...</div>';
|
||||||
|
try {
|
||||||
|
const d = await api('history?q=' + encodeURIComponent(q));
|
||||||
|
if (!d.results || !d.results.length) {
|
||||||
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">No results for "' + q + '"</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = d.results.map(r => {
|
||||||
|
const role = r.role === 'user' ? '👤' : '🤖';
|
||||||
|
const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||||
|
const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
|
||||||
|
return `<div style="background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:10px 12px">
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||||||
|
<span style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:var(--cyan)">${role} ${r.role.toUpperCase()}</span>
|
||||||
|
<span style="font-size:0.52rem;color:var(--text-dim)">${ts}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.68rem;color:var(--text-primary);line-height:1.4">${snippet.replace(/</g,'<')}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch(e) {
|
||||||
|
el.innerHTML = '<div style="color:var(--red);font-size:0.65rem;text-align:center;padding:20px">Search failed</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('searchModal')?.addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('searchModal')) closeSearchModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
|
||||||
|
const _shownSuggestions = new Set();
|
||||||
|
async function checkSuggestions() {
|
||||||
|
const d = await api('suggestions').catch(() => null);
|
||||||
|
if (!d || !d.suggestions || !d.suggestions.length) return;
|
||||||
|
for (const s of d.suggestions) {
|
||||||
|
const key = s.intent + ':' + d.hour + ':' + d.dow;
|
||||||
|
if (_shownSuggestions.has(key)) continue;
|
||||||
|
_shownSuggestions.add(key);
|
||||||
|
// Show as a soft suggestion chip in chat
|
||||||
|
const log = document.getElementById('chatLog');
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
|
||||||
|
chip.innerHTML = `<button onclick="sendSuggestion('${s.intent}',this)" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.25);border-radius:12px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:1px;padding:4px 12px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.background='rgba(0,212,255,0.12)'" onmouseout="this.style.background='rgba(0,212,255,0.06)'">◈ ${s.prompt}</button>`;
|
||||||
|
log.appendChild(chip);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
break; // show max one suggestion at a time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSuggestion(intent, btn) {
|
||||||
|
btn.closest('div').remove();
|
||||||
|
const prompts = {
|
||||||
|
'network_scan': 'run a network scan',
|
||||||
|
'jellyfin_now_playing': 'what is playing on Jellyfin',
|
||||||
|
'ha_scene': 'what scenes are available',
|
||||||
|
'planner:briefing': 'daily briefing',
|
||||||
|
'vm_suggestions': 'VM resource suggestions',
|
||||||
|
'focus_mode': 'focus mode',
|
||||||
|
};
|
||||||
|
const msg = prompts[intent] || intent.replace(/_/g,' ');
|
||||||
|
document.getElementById('textInput').value = msg;
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
|
||||||
|
function mobSwitch(which) {
|
||||||
|
if (window.innerWidth > 900) return;
|
||||||
|
const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
|
||||||
|
Object.entries(panels).forEach(([k, id]) => {
|
||||||
|
document.getElementById(id)?.classList.toggle('mob-active', k === which);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('mob-btn-' + which)?.classList.add('active');
|
||||||
|
if (which === 'right') loadNews();
|
||||||
|
}
|
||||||
|
function initMobile() {
|
||||||
|
if (window.innerWidth > 900) return;
|
||||||
|
['leftPanel','centerPanel','rightPanel'].forEach(id =>
|
||||||
|
document.getElementById(id)?.classList.remove('mob-active'));
|
||||||
|
document.getElementById('leftPanel')?.classList.add('mob-active');
|
||||||
|
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('mob-btn-left')?.classList.add('active');
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', initMobile);
|
||||||
|
|
||||||
|
// ── COMMAND PALETTE (Ctrl+K) ──────────────────────────────────────────────
|
||||||
|
const _PALETTE_COMMANDS = [
|
||||||
|
{ label: 'Run a network scan', q: 'run a network scan', group: 'Network' },
|
||||||
|
{ label: 'Show online devices', q: 'who is online on the network', group: 'Network' },
|
||||||
|
{ label: 'Proxmox status', q: 'proxmox status', group: 'Network' },
|
||||||
|
{ label: 'Check agent status', q: 'check all agents', group: 'Agents' },
|
||||||
|
{ label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' },
|
||||||
|
{ label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' },
|
||||||
|
{ label: 'Daily briefing', q: 'daily briefing', group: 'Planner' },
|
||||||
|
{ label: 'My tasks today', q: 'my tasks today', group: 'Planner' },
|
||||||
|
{ label: 'My calendar', q: 'my calendar', group: 'Planner' },
|
||||||
|
{ label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' },
|
||||||
|
{ label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' },
|
||||||
|
{ label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' },
|
||||||
|
{ label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' },
|
||||||
|
{ label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'},
|
||||||
|
{ label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'},
|
||||||
|
{ label: 'Focus mode', q: 'focus mode', group: 'UI' },
|
||||||
|
{ label: 'Show all panels', q: 'show all panels', group: 'UI' },
|
||||||
|
{ label: 'Check alerts', q: 'check alerts', group: 'System' },
|
||||||
|
{ label: 'Site health', q: 'site health', group: 'System' },
|
||||||
|
{ label: 'System status', q: 'system status', group: 'System' },
|
||||||
|
{ label: 'Check inbox', q: 'check inbox', group: 'Comms' },
|
||||||
|
{ label: 'Search history…', q: '', group: 'Chat', search: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
let _paletteOpen = false;
|
||||||
|
|
||||||
|
function openPalette() {
|
||||||
|
if (_paletteOpen) return;
|
||||||
|
_paletteOpen = true;
|
||||||
|
const ov = document.getElementById('cmdPalette');
|
||||||
|
if (!ov) return;
|
||||||
|
ov.style.display = 'flex';
|
||||||
|
const inp = document.getElementById('cmdPaletteInput');
|
||||||
|
inp.value = '';
|
||||||
|
renderPaletteItems('');
|
||||||
|
requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePalette() {
|
||||||
|
if (!_paletteOpen) return;
|
||||||
|
_paletteOpen = false;
|
||||||
|
const ov = document.getElementById('cmdPalette');
|
||||||
|
if (!ov) return;
|
||||||
|
ov.classList.remove('open');
|
||||||
|
setTimeout(() => { ov.style.display = 'none'; }, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaletteItems(q) {
|
||||||
|
const list = document.getElementById('cmdPaletteList');
|
||||||
|
if (!list) return;
|
||||||
|
const low = q.toLowerCase().trim();
|
||||||
|
const filtered = low
|
||||||
|
? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low))
|
||||||
|
: _PALETTE_COMMANDS;
|
||||||
|
|
||||||
|
let currentGroup = null;
|
||||||
|
list.innerHTML = '';
|
||||||
|
filtered.forEach((cmd, i) => {
|
||||||
|
if (cmd.group !== currentGroup) {
|
||||||
|
currentGroup = cmd.group;
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'cp-group';
|
||||||
|
g.textContent = cmd.group;
|
||||||
|
list.appendChild(g);
|
||||||
|
}
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'cp-item' + (i === 0 ? ' cp-active' : '');
|
||||||
|
row.dataset.q = cmd.q;
|
||||||
|
row.dataset.search = cmd.search ? '1' : '';
|
||||||
|
const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'),
|
||||||
|
m => `<mark>${m}</mark>`);
|
||||||
|
row.innerHTML = `<span class="cp-icon">◈</span><span class="cp-label">${lbl}</span><kbd class="cp-kbd">↵</kbd>`;
|
||||||
|
row.addEventListener('click', () => firePaletteItem(row));
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePaletteSelection(dir) {
|
||||||
|
const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item'));
|
||||||
|
if (!items.length) return;
|
||||||
|
const cur = items.findIndex(el => el.classList.contains('cp-active'));
|
||||||
|
const next = (cur + dir + items.length) % items.length;
|
||||||
|
items.forEach(el => el.classList.remove('cp-active'));
|
||||||
|
items[next].classList.add('cp-active');
|
||||||
|
items[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function firePaletteItem(el) {
|
||||||
|
if (!el) {
|
||||||
|
const active = document.querySelector('#cmdPaletteList .cp-active');
|
||||||
|
if (!active) return;
|
||||||
|
el = active;
|
||||||
|
}
|
||||||
|
const q = el.dataset.q;
|
||||||
|
const isSearch = el.dataset.search === '1';
|
||||||
|
closePalette();
|
||||||
|
if (isSearch) {
|
||||||
|
if (typeof openSearchModal === 'function') openSearchModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
document.getElementById('textInput').value = q;
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard events
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
_paletteOpen ? closePalette() : openPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_paletteOpen) return;
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); }
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter on type
|
||||||
|
document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
|
||||||
|
renderPaletteItems(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
document.getElementById('cmdPalette')?.addEventListener('click', e => {
|
||||||
|
if (e.target.id === 'cmdPalette') closePalette();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AGENT TOPOLOGY MAP ─────────────────────────────────────────────────────────────
|
||||||
|
let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = [];
|
||||||
|
|
||||||
|
function toggleAgentTopo() {
|
||||||
|
_agentTopoMode = !_agentTopoMode;
|
||||||
|
const btn = document.getElementById('agent-topo-btn');
|
||||||
|
const list = document.getElementById('agents-list');
|
||||||
|
const cvs = document.getElementById('agentTopoCanvas');
|
||||||
|
if (!btn || !list || !cvs) return;
|
||||||
|
btn.classList.toggle('active', _agentTopoMode);
|
||||||
|
if (_agentTopoMode) {
|
||||||
|
list.style.display = 'none'; cvs.style.display = 'block';
|
||||||
|
_buildAgentTopoData(); _drawAgentTopo();
|
||||||
|
} else {
|
||||||
|
list.style.display = 'block'; cvs.style.display = 'none';
|
||||||
|
if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildAgentTopoData() {
|
||||||
|
// Build node list from rendered agent cards
|
||||||
|
_agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}];
|
||||||
|
document.querySelectorAll('.agent-card').forEach(el => {
|
||||||
|
const nameEl = el.querySelector('.agent-name, [class*="name"]');
|
||||||
|
if (!nameEl) return;
|
||||||
|
const name = nameEl.textContent.trim();
|
||||||
|
const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online');
|
||||||
|
const lname = name.toLowerCase();
|
||||||
|
let type = 'linux';
|
||||||
|
if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox';
|
||||||
|
else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant';
|
||||||
|
else if (lname.includes('windows') || lname.includes('mini')) type = 'windows';
|
||||||
|
_agentTopoData.push({id:name, label:name.substring(0,12), online, type});
|
||||||
|
});
|
||||||
|
// Fallback: use last known registered agent list if cards not rendered
|
||||||
|
if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') {
|
||||||
|
(_lastAgents || []).forEach(a => {
|
||||||
|
_agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawAgentTopo() {
|
||||||
|
const cvs = document.getElementById('agentTopoCanvas');
|
||||||
|
if (!cvs || !_agentTopoMode) return;
|
||||||
|
const ctx = cvs.getContext('2d');
|
||||||
|
const rect = cvs.getBoundingClientRect();
|
||||||
|
const W = rect.width || 280, H = rect.height || 260;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
cvs.width = W * dpr; cvs.height = H * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68};
|
||||||
|
const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'};
|
||||||
|
// Assign positions
|
||||||
|
const byType = {};
|
||||||
|
_agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); });
|
||||||
|
_agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2;
|
||||||
|
Object.entries(byType).forEach(([tp, nodes]) => {
|
||||||
|
const rf = typeRing[tp] || 0.68;
|
||||||
|
const r = Math.min(W, H) / 2 * rf;
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2;
|
||||||
|
n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let t = 0;
|
||||||
|
function frame() {
|
||||||
|
if (!_agentTopoMode) return;
|
||||||
|
t += 0.007; ctx.clearRect(0, 0, W, H);
|
||||||
|
// Orbit rings
|
||||||
|
[0.28, 0.48, 0.68].forEach(rf => {
|
||||||
|
ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2);
|
||||||
|
ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke();
|
||||||
|
});
|
||||||
|
// Edges
|
||||||
|
_agentTopoData.slice(1).forEach(n => {
|
||||||
|
if (!n.x) return;
|
||||||
|
const col = typeColor[n.type] || '0,190,255';
|
||||||
|
ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y);
|
||||||
|
ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)';
|
||||||
|
ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke();
|
||||||
|
});
|
||||||
|
// Particles
|
||||||
|
_agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => {
|
||||||
|
const p = ((t*0.35+i*0.41)%1);
|
||||||
|
const col = typeColor[n.type]||'0,190,255';
|
||||||
|
const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p;
|
||||||
|
ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2);
|
||||||
|
ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill();
|
||||||
|
});
|
||||||
|
// Nodes
|
||||||
|
_agentTopoData.forEach((n,i) => {
|
||||||
|
if (!n.x) return;
|
||||||
|
const col = typeColor[n.type]||'0,190,255';
|
||||||
|
const nr = n.type==='hub' ? 13 : 7;
|
||||||
|
const pulse = Math.sin(t+i*0.9)*0.25+0.75;
|
||||||
|
if (n.online||n.type==='hub') {
|
||||||
|
const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5);
|
||||||
|
g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')');
|
||||||
|
g.addColorStop(1,'transparent');
|
||||||
|
ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2);
|
||||||
|
ctx.fillStyle=g; ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2);
|
||||||
|
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke();
|
||||||
|
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)';
|
||||||
|
ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace';
|
||||||
|
ctx.textAlign='center';
|
||||||
|
ctx.fillText(n.label, n.x, n.y+nr+9);
|
||||||
|
});
|
||||||
|
_agentTopoRaf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
frame();
|
||||||
|
}
|
||||||
+24
-14
@@ -53,7 +53,7 @@
|
|||||||
<div class="tb-center">
|
<div class="tb-center">
|
||||||
<div class="tb-stat">LOCAL <span id="tb-cpu">--</span>% CPU</div>
|
<div class="tb-stat">LOCAL <span id="tb-cpu">--</span>% CPU</div>
|
||||||
<div class="tb-stat">MEM <span id="tb-mem">--</span>%</div>
|
<div class="tb-stat">MEM <span id="tb-mem">--</span>%</div>
|
||||||
<div class="tb-stat">DO SERVER <span id="tb-do" class="text-dim">--</span></div>
|
<div class="tb-stat">JARVIS VM <span id="tb-do" class="text-dim">--</span></div>
|
||||||
<div class="tb-stat"><span id="tb-alerts" class="text-green">NO ALERTS</span></div>
|
<div class="tb-stat"><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 class="tb-stat" id="tb-planner" style="display:none"><span id="tb-planner-text" class="text-yellow"></span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
<div class="status-dot"></div>
|
<div class="status-dot"></div>
|
||||||
<button id="cameraBtn" class="btn-camera" onclick="toggleCamera()" title="Auto-mic when face detected (hands-free)">◉ CAMERA</button>
|
<button id="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="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>
|
<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">
|
<div id="themeBar" style="display:flex;gap:3px;align-items:center;margin-right:2px">
|
||||||
@@ -102,8 +103,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="weather-forecast" style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px"></div>
|
<div id="weather-forecast" style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel" id="server-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-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 -->
|
<!-- Metric bars + sparklines -->
|
||||||
<div class="metric-row">
|
<div class="metric-row">
|
||||||
@@ -143,6 +144,13 @@
|
|||||||
<div class="loading-shimmer" style="margin-bottom:4px"></div>
|
<div class="loading-shimmer" style="margin-bottom:4px"></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- CENTER: Arc Reactor + Chat -->
|
<!-- CENTER: Arc Reactor + Chat -->
|
||||||
@@ -195,7 +203,7 @@
|
|||||||
<div id="rightPanel">
|
<div id="rightPanel">
|
||||||
|
|
||||||
<!-- Network Status -->
|
<!-- 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>
|
<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>
|
<canvas id="topoCanvas" height="100"></canvas>
|
||||||
<div id="network-list" style="overflow-y:auto;flex:1;padding-right:2px">
|
<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 active" onclick="switchTab('ha')">HOME</div>
|
||||||
<div class="tab" onclick="switchTab('alerts')">ALERTS</div>
|
<div class="tab" onclick="switchTab('alerts')">ALERTS</div>
|
||||||
<div class="tab" onclick="switchTab('news')">NEWS</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" onclick="switchTab('sites')">SITES</div>
|
||||||
<div class="tab" id="tab-btn-intel" onclick="switchTab('intel')">INTEL</div>
|
<div class="tab" id="tab-btn-intel" onclick="switchTab('intel')">INTEL</div>
|
||||||
<div class="tab" id="tab-btn-comms" onclick="switchTab('comms')">COMMS</div>
|
<div class="tab" id="tab-btn-comms" onclick="switchTab('comms')">COMMS</div>
|
||||||
@@ -292,17 +300,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bb-item">
|
<div class="bb-item">
|
||||||
<div class="bb-dot" id="bb-do-dot"></div>
|
<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>
|
||||||
<div class="bb-item">
|
<div class="bb-item" id="bb-pve-item">
|
||||||
<div class="bb-dot" id="bb-pve-dot"></div>
|
<div class="bb-dot" id="bb-pve-dot"></div>
|
||||||
<span>PROXMOX</span> <span id="bb-pve-status">CHECKING</span>
|
<span>PROXMOX</span> <span id="bb-pve-status">CHECKING</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bb-item">
|
<div class="bb-item" id="bb-ha-item">
|
||||||
<div class="bb-dot" id="bb-ha-dot"></div>
|
<div class="bb-dot" id="bb-ha-dot"></div>
|
||||||
<span>HOME ASSISTANT</span> <span id="bb-ha-status">CHECKING</span>
|
<span>HOME ASSISTANT</span> <span id="bb-ha-status">CHECKING</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bb-item">
|
<div class="bb-item" id="bb-agents-item">
|
||||||
<div class="bb-dot" id="bb-agent-dot"></div>
|
<div class="bb-dot" id="bb-agent-dot"></div>
|
||||||
<span>AGENTS</span> <span id="bb-agent-status">--</span>
|
<span>AGENTS</span> <span id="bb-agent-status">--</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,12 +424,14 @@
|
|||||||
<!-- Hidden camera feed for face detection -->
|
<!-- Hidden camera feed for face detection -->
|
||||||
<video id="faceVideo" autoplay muted playsinline
|
<video id="faceVideo" autoplay muted playsinline
|
||||||
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px"></video>
|
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-effects.js?v=20260621k"></script>
|
||||||
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260617"></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=20260617"></script>
|
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260621k"></script>
|
||||||
<script data-cfasync="false" src="assets/js/jarvis-protocols.js?v=20260617"></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 -->
|
<!-- VISION LIGHTBOX -->
|
||||||
<div id="vision-lightbox">
|
<div id="vision-lightbox">
|
||||||
|
|||||||
+2
-11
@@ -14,7 +14,7 @@ if (!defined('WEBHOOK_SECRET')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
|
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');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
@@ -40,16 +40,7 @@ if ($ref !== 'refs/heads/main') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$repoMap = [
|
$repoMap = [
|
||||||
'jarvis' => '/home/jarvis.orbishosting.com',
|
'jarvis' => '/var/www/jarvis',
|
||||||
'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__',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!isset($repoMap[$repo])) {
|
if (!isset($repoMap[$repo])) {
|
||||||
|
|||||||
Reference in New Issue
Block a user