mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Initial commit: JARVIS AI dashboard v2.3
- 4-tier chat: HA control → Ollama → Groq → Claude - Push-based agent system with heartbeat/metrics - Network monitoring, alerts, Proxmox, Home Assistant - Windows + Linux agent installers - Stats cache cron, facts collector, KB engine
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
# Credentials - never commit
|
||||
api/config.php
|
||||
backup/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,29 @@
|
||||
# JARVIS
|
||||
|
||||
Iron Man-style AI assistant for home and network management.
|
||||
|
||||
## Features
|
||||
- Home Assistant control (lights, climate, scenes, switches)
|
||||
- Proxmox VM management (start/stop/status)
|
||||
- 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API
|
||||
- Real-time status bar (HA, Proxmox, DigitalOcean)
|
||||
- Iron Man HUD at jarvis.orbishosting.com
|
||||
|
||||
## Stack
|
||||
- PHP 8.x / Apache / MySQL on Ubuntu 24.04
|
||||
- Ollama VM at 10.48.200.95 (llama3.2:1b)
|
||||
- Groq API (llama-3.3-70b / compound-mini with web search)
|
||||
- Claude API (Anthropic) final fallback
|
||||
|
||||
## Setup
|
||||
cp api/config.example.php api/config.php
|
||||
Fill in all credentials in config.php before running.
|
||||
|
||||
## Key Files
|
||||
- public/index.html Iron Man HUD frontend
|
||||
- public/api.php API router
|
||||
- api/config.example.php Config template
|
||||
- api/endpoints/chat.php 4-tier chat handler
|
||||
- api/endpoints/facts_collector.php HA entity sync cron
|
||||
- api/lib/kb_engine.php KB intent engine
|
||||
- api/lib/db.php PDO database wrapper
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS Configuration Template
|
||||
* Copy to config.php and fill in your values.
|
||||
*/
|
||||
|
||||
define(chr(39).'JARVIS_USER_NAME'.chr(39), chr(39).'Your Name'.chr(39));
|
||||
define(chr(39).'JARVIS_USER_TITLE'.chr(39), chr(39).'Mr. Blair'.chr(39));
|
||||
define(chr(39).'JARVIS_CODENAME'.chr(39), chr(39).'JARVIS'.chr(39));
|
||||
|
||||
define(chr(39).'DB_HOST'.chr(39), chr(39).'localhost'.chr(39));
|
||||
define(chr(39).'DB_NAME'.chr(39), chr(39).'jarvis_db'.chr(39));
|
||||
define(chr(39).'DB_USER'.chr(39), chr(39).'jarvis_user'.chr(39));
|
||||
define(chr(39).'DB_PASS'.chr(39), chr(39).'YOUR_DB_PASSWORD'.chr(39));
|
||||
|
||||
define(chr(39).'CLAUDE_API_KEY'.chr(39), chr(39).'sk-ant-api03-...'.chr(39));
|
||||
define(chr(39)+'CLAUDE_MODEL'.chr(39), chr(39)+'claude-sonnet-4-6'.chr(39));
|
||||
define(chr(39)+'CLAUDE_MAX_TOKENS'.chr(39), 1024);
|
||||
|
||||
define(chr(39)+'GROQ_API_KEY'.chr(39), chr(39)+'gsk_...'.chr(39));
|
||||
define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'groq/compound-mini'.chr(39));
|
||||
define(chr(39)+'GROQ_MODEL_GENERAL'.chr(39), chr(39)+'llama-3.3-70b-versatile'.chr(39));
|
||||
define(chr(39)+'GROQ_TIMEOUT'.chr(39), 30);
|
||||
|
||||
define(chr(39)+'OLLAMA_HOST'.chr(39), chr(39)+'http://10.48.200.95:11434'.chr(39));
|
||||
define(chr(39)+'OLLAMA_MODEL_PRIMARY'.chr(39), chr(39)+'llama3.2:1b'.chr(39));
|
||||
define(chr(39)+'OLLAMA_MODEL_HEAVY'.chr(39), chr(39)+'llama3.1:70b'.chr(39));
|
||||
define(chr(39)+'OLLAMA_TIMEOUT'.chr(39), 90);
|
||||
|
||||
define(chr(39)+'LOCAL_SUBNET'.chr(39), chr(39)+'10.48.200'.chr(39));
|
||||
define(chr(39)+'LOCAL_GATEWAY'.chr(39), chr(39)+'10.48.200.1'.chr(39));
|
||||
define(chr(39)+'JARVIS_IP'.chr(39), chr(39)+'10.48.200.84'.chr(39));
|
||||
define(chr(39)+'DO_SERVER_IP'.chr(39), chr(39)+'0.0.0.0'.chr(39));
|
||||
define(chr(39)+'DO_SSH_USER'.chr(39), chr(39)+'root'.chr(39));
|
||||
define(chr(39)+'DO_SSH_PASS'.chr(39), chr(39)+'YOUR_DO_SSH_PASSWORD'.chr(39));
|
||||
|
||||
define(chr(39)+'PROXMOX_HOST'.chr(39), chr(39)+'10.48.200.90'.chr(39));
|
||||
define(chr(39)+'PROXMOX_PORT'.chr(39), 8006);
|
||||
define(chr(39)+'PROXMOX_NODE'.chr(39), chr(39)+'pve'.chr(39));
|
||||
define(chr(39)+'PROXMOX_USER'.chr(39), chr(39)+'root@pam'.chr(39));
|
||||
define(chr(39)+'PROXMOX_TOKEN_ID'.chr(39), chr(39)+'jarvis'.chr(39));
|
||||
define(chr(39)+'PROXMOX_TOKEN_VAL'.chr(39), chr(39)+'YOUR_PROXMOX_API_TOKEN'.chr(39));
|
||||
|
||||
define(chr(39)+'HA_URL'.chr(39), chr(39)+'http://10.48.200.97:8123'.chr(39));
|
||||
define(chr(39)+'HA_TOKEN'.chr(39), chr(39)+'YOUR_HA_LONG_LIVED_TOKEN'.chr(39));
|
||||
|
||||
define(chr(39)+'SESSION_LIFETIME'.chr(39), 86400 * 7);
|
||||
define(chr(39)+'SITE_URL'.chr(39), chr(39)+'https://jarvis.orbishosting.com'.chr(39));
|
||||
|
||||
error_reporting(0);
|
||||
ini_set(chr(39)+'display_errors'.chr(39), 0);
|
||||
ini_set(chr(39)+'log_errors'.chr(39), 1);
|
||||
ini_set(chr(39)+'error_log'.chr(39), chr(39)+'/var/log/apache2/jarvis_errors.log'.chr(39));
|
||||
date_default_timezone_set(chr(39)+'America/Chicago'.chr(39));
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS Agent API
|
||||
* Handles registration, heartbeats, metrics, HA state pushes, and command polling
|
||||
* from remote jarvis-agent clients on external networks.
|
||||
*
|
||||
* Auth: POST /api/agent/register → uses AGENT_REGISTRATION_KEY
|
||||
* All other actions → uses X-Agent-Key header (per-agent key)
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$agentAction = $action ?? ($parts[1] ?? '');
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function agent_error(int $code, string $msg): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['error' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function agent_ok(array $payload = []): void {
|
||||
echo json_encode(array_merge(['ok' => true], $payload));
|
||||
exit;
|
||||
}
|
||||
|
||||
function generate_api_key(): string {
|
||||
return bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
function get_agent_by_key(string $key): ?array {
|
||||
$rows = JarvisDB::query(
|
||||
'SELECT * FROM registered_agents WHERE api_key = ? LIMIT 1',
|
||||
[$key]
|
||||
);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
function update_agent_seen(string $agentId, string $status = 'online'): void {
|
||||
JarvisDB::query(
|
||||
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
|
||||
[$status, $agentId]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Auth (all actions except register) ───────────────────────────────────────
|
||||
|
||||
$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? '';
|
||||
$browserActions = ['list', 'status', 'myip'];
|
||||
|
||||
if ($agentAction !== 'register') {
|
||||
if (in_array($agentAction, $browserActions)) {
|
||||
$agent = null; // browser-accessible via session auth already validated by api.php
|
||||
} else {
|
||||
if (empty($agentKey)) agent_error(401, 'X-Agent-Key header required');
|
||||
$agent = get_agent_by_key($agentKey);
|
||||
if (!$agent) agent_error(401, 'Invalid agent key');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
switch ($agentAction) {
|
||||
|
||||
// ── REGISTER ─────────────────────────────────────────────────────────────
|
||||
case 'register':
|
||||
if ($method !== 'POST') agent_error(405, 'POST only');
|
||||
$regKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ($data['registration_key'] ?? '');
|
||||
if ($regKey !== AGENT_REGISTRATION_KEY) agent_error(403, 'Invalid registration key');
|
||||
|
||||
$hostname = trim($data['hostname'] ?? '');
|
||||
$agentType = $data['agent_type'] ?? 'linux';
|
||||
$ipAddress = $data['ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$capabilities = $data['capabilities'] ?? [];
|
||||
$agentId = $data['agent_id'] ?? ($hostname . '_' . substr(md5($hostname . $ipAddress), 0, 8));
|
||||
|
||||
if (!$hostname) agent_error(400, 'hostname required');
|
||||
if (!in_array($agentType, ['linux', 'homeassistant', 'proxmox', 'windows'])) agent_error(400, 'Invalid agent_type');
|
||||
|
||||
// Upsert agent
|
||||
$existing = JarvisDB::query('SELECT api_key FROM registered_agents WHERE agent_id = ?', [$agentId]);
|
||||
if ($existing) {
|
||||
$apiKey = $existing[0]['api_key'];
|
||||
JarvisDB::query(
|
||||
'UPDATE registered_agents SET hostname=?, agent_type=?, ip_address=?, capabilities=?, last_seen=NOW(), status="online" WHERE agent_id=?',
|
||||
[$hostname, $agentType, $ipAddress, json_encode($capabilities), $agentId]
|
||||
);
|
||||
} else {
|
||||
$apiKey = generate_api_key();
|
||||
JarvisDB::query(
|
||||
'INSERT INTO registered_agents (agent_id, hostname, agent_type, ip_address, api_key, capabilities, last_seen, status) VALUES (?,?,?,?,?,?,NOW(),"online")',
|
||||
[$agentId, $hostname, $agentType, $ipAddress, $apiKey, json_encode($capabilities)]
|
||||
);
|
||||
}
|
||||
|
||||
agent_ok(['agent_id' => $agentId, 'api_key' => $apiKey]);
|
||||
|
||||
// ── HEARTBEAT ────────────────────────────────────────────────────────────
|
||||
case 'heartbeat':
|
||||
update_agent_seen($agent['agent_id']);
|
||||
|
||||
// Return any pending commands for this agent
|
||||
$commands = JarvisDB::query(
|
||||
'SELECT id, command_type, command_data FROM agent_commands WHERE agent_id = ? AND status = "pending" ORDER BY created_at ASC LIMIT 10',
|
||||
[$agent['agent_id']]
|
||||
);
|
||||
|
||||
// Mark as delivered
|
||||
if ($commands) {
|
||||
$ids = implode(',', array_column($commands, 'id'));
|
||||
JarvisDB::query("UPDATE agent_commands SET status='delivered', delivered_at=NOW() WHERE id IN ($ids)");
|
||||
foreach ($commands as &$cmd) {
|
||||
$cmd['command_data'] = json_decode($cmd['command_data'] ?? '{}', true);
|
||||
}
|
||||
}
|
||||
|
||||
agent_ok(['commands' => $commands ?: []]);
|
||||
|
||||
// ── METRICS ──────────────────────────────────────────────────────────────
|
||||
case 'metrics':
|
||||
if ($method !== 'POST') agent_error(405, 'POST only');
|
||||
update_agent_seen($agent['agent_id']);
|
||||
|
||||
$metricType = $data['type'] ?? 'system';
|
||||
$metricData = $data['data'] ?? [];
|
||||
|
||||
JarvisDB::query(
|
||||
'INSERT INTO agent_metrics (agent_id, metric_type, metric_data) VALUES (?,?,?)',
|
||||
[$agent['agent_id'], $metricType, json_encode($metricData)]
|
||||
);
|
||||
|
||||
// Prune old metrics (keep 24h)
|
||||
JarvisDB::query(
|
||||
'DELETE FROM agent_metrics WHERE agent_id = ? AND recorded_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)',
|
||||
[$agent['agent_id']]
|
||||
);
|
||||
|
||||
agent_ok();
|
||||
|
||||
// ── HA STATE PUSH ────────────────────────────────────────────────────────
|
||||
case 'ha_state':
|
||||
if ($method !== 'POST') agent_error(405, 'POST only');
|
||||
update_agent_seen($agent['agent_id']);
|
||||
|
||||
$entities = $data['entities'] ?? [];
|
||||
if (empty($entities)) agent_error(400, 'entities array required');
|
||||
|
||||
foreach ($entities as $e) {
|
||||
$entityId = $e['entity_id'] ?? '';
|
||||
$entityName = $e['name'] ?? $e['friendly_name'] ?? $entityId;
|
||||
$domain = explode('.', $entityId)[0] ?? 'unknown';
|
||||
$state = $e['state'] ?? '';
|
||||
$attrs = $e['attributes'] ?? [];
|
||||
$lastChanged = $e['last_changed'] ?? date('Y-m-d H:i:s');
|
||||
|
||||
if (!$entityId) continue;
|
||||
|
||||
JarvisDB::query(
|
||||
'INSERT INTO ha_entities (agent_id, entity_id, entity_name, domain, state, attributes, last_changed, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,NOW())
|
||||
ON DUPLICATE KEY UPDATE entity_name=VALUES(entity_name), state=VALUES(state), attributes=VALUES(attributes), last_changed=VALUES(last_changed), updated_at=NOW()',
|
||||
[$agent['agent_id'], $entityId, $entityName, $domain, $state, json_encode($attrs), $lastChanged]
|
||||
);
|
||||
}
|
||||
|
||||
// Also update kb_facts for compatibility with existing KB engine
|
||||
$entityMap = [];
|
||||
$all = JarvisDB::query('SELECT entity_id, entity_name, domain, state, attributes FROM ha_entities WHERE agent_id = ?', [$agent['agent_id']]);
|
||||
foreach ($all as $row) {
|
||||
$entityMap[$row['entity_id']] = [
|
||||
'entity_id' => $row['entity_id'],
|
||||
'name' => $row['entity_name'],
|
||||
'domain' => $row['domain'],
|
||||
'state' => $row['state'],
|
||||
'attributes'=> json_decode($row['attributes'] ?? '{}', true),
|
||||
];
|
||||
}
|
||||
JarvisDB::query(
|
||||
'INSERT INTO kb_facts (fact_key, fact_value, fact_type) VALUES ("ha/entity_map", ?, "json")
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()',
|
||||
[json_encode($entityMap)]
|
||||
);
|
||||
|
||||
agent_ok(['accepted' => count($entities)]);
|
||||
|
||||
// ── COMMAND RESULT ───────────────────────────────────────────────────────
|
||||
case 'command_result':
|
||||
if ($method !== 'POST') agent_error(405, 'POST only');
|
||||
$cmdId = (int)($data['command_id'] ?? 0);
|
||||
$result = $data['result'] ?? [];
|
||||
$status = ($data['success'] ?? true) ? 'executed' : 'failed';
|
||||
|
||||
JarvisDB::query(
|
||||
'UPDATE agent_commands SET status=?, executed_at=NOW(), result=? WHERE id=? AND agent_id=?',
|
||||
[$status, json_encode($result), $cmdId, $agent['agent_id']]
|
||||
);
|
||||
agent_ok();
|
||||
|
||||
// ── LIST (admin: get all agents status) ──────────────────────────────────
|
||||
case 'list':
|
||||
// Mark agents offline if last_seen > 2 minutes ago
|
||||
JarvisDB::query(
|
||||
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
|
||||
);
|
||||
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, capabilities FROM registered_agents ORDER BY agent_type, hostname');
|
||||
foreach ($agents as &$a) {
|
||||
$a['capabilities'] = json_decode($a['capabilities'] ?? '[]', true);
|
||||
}
|
||||
agent_ok(['agents' => $agents, 'my_ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
|
||||
|
||||
// ── LATEST METRICS (for dashboard display) ───────────────────────────────
|
||||
case 'status':
|
||||
$agentIdReq = $data['agent_id'] ?? ($parts[2] ?? '');
|
||||
$whereAgent = $agentIdReq ? 'AND agent_id = ?' : '';
|
||||
$params = $agentIdReq ? [$agentIdReq] : [];
|
||||
|
||||
$latest = JarvisDB::query(
|
||||
"SELECT agent_id, metric_type, metric_data, recorded_at
|
||||
FROM agent_metrics
|
||||
WHERE (agent_id, metric_type, recorded_at) IN (
|
||||
SELECT agent_id, metric_type, MAX(recorded_at)
|
||||
FROM agent_metrics $whereAgent
|
||||
GROUP BY agent_id, metric_type
|
||||
)
|
||||
ORDER BY agent_id, metric_type",
|
||||
$params
|
||||
);
|
||||
|
||||
$grouped = [];
|
||||
foreach ($latest as $row) {
|
||||
$grouped[$row['agent_id']][$row['metric_type']] = json_decode($row['metric_data'], true);
|
||||
$grouped[$row['agent_id']]['recorded_at'] = $row['recorded_at'];
|
||||
}
|
||||
|
||||
agent_ok(['metrics' => $grouped]);
|
||||
|
||||
// ── MY IP (browser client IP detection) ──────────────────────────────────
|
||||
case 'myip':
|
||||
agent_ok(['ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
|
||||
|
||||
default:
|
||||
agent_error(404, 'Unknown agent action: ' . $agentAction);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS Alerts API
|
||||
* GET /api/alerts — return active alerts (auto-generates agent alerts first)
|
||||
* POST /api/alerts/resolve — resolve an alert by id
|
||||
* POST /api/alerts — manually create an alert
|
||||
*/
|
||||
|
||||
// ── Auto-generate alerts from agent data ─────────────────────────────────────
|
||||
|
||||
function refresh_agent_alerts(): void {
|
||||
// Thresholds
|
||||
$CPU_WARN = 85;
|
||||
$MEM_WARN = 85;
|
||||
$DISK_WARN = 88;
|
||||
$DISK_CRIT = 95;
|
||||
|
||||
// ── Mark auto-resolve alerts whose condition cleared ──────────────────────
|
||||
// We'll re-evaluate below and upsert; first collect keys that are still active
|
||||
$still_active = [];
|
||||
|
||||
// ── Offline agents ────────────────────────────────────────────────────────
|
||||
$offline = JarvisDB::query(
|
||||
"SELECT agent_id, hostname FROM registered_agents
|
||||
WHERE status='offline' OR last_seen < DATE_SUB(NOW(), INTERVAL 3 MINUTE)"
|
||||
);
|
||||
foreach ($offline as $ag) {
|
||||
$key = 'agent:' . $ag['agent_id'] . ':offline';
|
||||
upsert_alert($key, 'critical', 'Agent Offline: ' . $ag['hostname'],
|
||||
'JARVIS Agent on ' . $ag['hostname'] . ' is not responding. Last contact was more than 3 minutes ago.');
|
||||
$still_active[$key] = true;
|
||||
}
|
||||
|
||||
// ── Metric-based alerts ───────────────────────────────────────────────────
|
||||
// Get latest system metrics for each agent
|
||||
$latest = JarvisDB::query(
|
||||
"SELECT m.agent_id, m.metric_data, a.hostname
|
||||
FROM agent_metrics m
|
||||
JOIN registered_agents a ON a.agent_id = m.agent_id
|
||||
WHERE m.metric_type = 'system'
|
||||
AND (m.agent_id, m.recorded_at) IN (
|
||||
SELECT agent_id, MAX(recorded_at) FROM agent_metrics
|
||||
WHERE metric_type = 'system'
|
||||
GROUP BY agent_id
|
||||
)
|
||||
AND m.recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
|
||||
);
|
||||
|
||||
foreach ($latest as $row) {
|
||||
$d = json_decode($row['metric_data'] ?? '{}', true);
|
||||
$hn = $row['hostname'];
|
||||
$id = $row['agent_id'];
|
||||
|
||||
// CPU
|
||||
$cpu = (float)($d['cpu_percent'] ?? 0);
|
||||
if ($cpu >= $CPU_WARN) {
|
||||
$key = 'agent:' . $id . ':cpu_high';
|
||||
$sev = $cpu >= 95 ? 'critical' : 'warning';
|
||||
upsert_alert($key, $sev, 'High CPU: ' . $hn,
|
||||
round($cpu, 1) . '% CPU utilization on ' . $hn . '. Sustained high load detected.');
|
||||
$still_active[$key] = true;
|
||||
}
|
||||
|
||||
// Memory
|
||||
$mem_pct = (float)($d['memory']['percent'] ?? 0);
|
||||
if ($mem_pct >= $MEM_WARN) {
|
||||
$key = 'agent:' . $id . ':mem_high';
|
||||
$sev = $mem_pct >= 95 ? 'critical' : 'warning';
|
||||
upsert_alert($key, $sev, 'High Memory: ' . $hn,
|
||||
round($mem_pct, 1) . '% memory used on ' . $hn .
|
||||
' (' . round($d['memory']['used_mb'] ?? 0) . '/' .
|
||||
round($d['memory']['total_mb'] ?? 0) . ' MB).');
|
||||
$still_active[$key] = true;
|
||||
}
|
||||
|
||||
// Disk
|
||||
foreach (($d['disk'] ?? []) as $disk) {
|
||||
$pct = (int)($disk['percent'] ?? 0);
|
||||
if ($pct >= $DISK_WARN) {
|
||||
$mount = $disk['mount'] ?? '/';
|
||||
$key = 'agent:' . $id . ':disk:' . str_replace('/', '_', $mount);
|
||||
$sev = $pct >= $DISK_CRIT ? 'critical' : 'warning';
|
||||
upsert_alert($key, $sev, 'Disk Full: ' . $hn . ' ' . $mount,
|
||||
$mount . ' is ' . $pct . '% full on ' . $hn .
|
||||
' (' . ($disk['used'] ?? '?') . ' of ' . ($disk['size'] ?? '?') . ' used).');
|
||||
$still_active[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Services down
|
||||
foreach (($d['services'] ?? []) as $svc) {
|
||||
if (($svc['status'] ?? '') === 'active') continue;
|
||||
if (($svc['status'] ?? '') === 'unknown') continue; // not watched/installed
|
||||
$svcName = $svc['service'] ?? '';
|
||||
$key = 'agent:' . $id . ':svc:' . $svcName;
|
||||
upsert_alert($key, 'warning', 'Service Down: ' . $svcName . ' on ' . $hn,
|
||||
$svcName . ' is ' . ($svc['status'] ?? 'inactive') . ' on ' . $hn . '.');
|
||||
$still_active[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-resolve alerts whose condition has cleared ────────────────────────
|
||||
if (!empty($still_active)) {
|
||||
$active_keys = array_keys($still_active);
|
||||
// Get all auto-resolvable alerts that are unresolved
|
||||
$open_auto = JarvisDB::query(
|
||||
"SELECT id, source_key FROM alerts WHERE resolved=0 AND auto_resolve=1 AND source_key IS NOT NULL"
|
||||
);
|
||||
foreach ($open_auto as $row) {
|
||||
if (!isset($still_active[$row['source_key']])) {
|
||||
JarvisDB::query(
|
||||
'UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?',
|
||||
[$row['id']]
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nothing active — resolve all auto alerts
|
||||
JarvisDB::query(
|
||||
"UPDATE alerts SET resolved=1, resolved_at=NOW()
|
||||
WHERE resolved=0 AND auto_resolve=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function upsert_alert(string $key, string $sev, string $title, string $msg): void {
|
||||
$existing = JarvisDB::query(
|
||||
'SELECT id, severity FROM alerts WHERE source_key=? AND resolved=0 LIMIT 1',
|
||||
[$key]
|
||||
);
|
||||
if ($existing) {
|
||||
// Update severity/message if changed (e.g., warning → critical)
|
||||
if ($existing[0]['severity'] !== $sev) {
|
||||
JarvisDB::query(
|
||||
'UPDATE alerts SET severity=?, title=?, message=?, created_at=NOW() WHERE id=?',
|
||||
[$sev, $title, $msg, $existing[0]['id']]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
JarvisDB::query(
|
||||
'INSERT INTO alerts (alert_type, title, message, severity, source_key, auto_resolve) VALUES (?,?,?,?,?,1)',
|
||||
['agent', $title, $msg, $sev, $key]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Rate-limit agent alert refresh to once per 60 seconds via kb_facts lock
|
||||
$last_refresh = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='agent' AND fact_key='alert_refresh' LIMIT 1");
|
||||
$last_ts = !empty($last_refresh) ? (int)$last_refresh[0]['fact_value'] : 0;
|
||||
if (time() - $last_ts >= 60) {
|
||||
JarvisDB::query(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host) VALUES ('agent', 'alert_refresh', ?, 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||
[time()]
|
||||
);
|
||||
refresh_agent_alerts();
|
||||
}
|
||||
$alerts = JarvisDB::query(
|
||||
'SELECT * FROM alerts WHERE resolved=0 ORDER BY severity DESC, created_at DESC LIMIT 30'
|
||||
);
|
||||
echo json_encode(['alerts' => $alerts ?: [], 'count' => count($alerts ?: [])]);
|
||||
|
||||
} elseif ($method === 'POST' && ($action === 'resolve' || ($data['action'] ?? '') === 'resolve')) {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
JarvisDB::query('UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', [$id]);
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} elseif ($method === 'POST') {
|
||||
JarvisDB::query(
|
||||
'INSERT INTO alerts (alert_type, title, message, severity) VALUES (?,?,?,?)',
|
||||
[$data['type'] ?? 'system', $data['title'] ?? 'Alert', $data['message'] ?? '', $data['severity'] ?? 'info']
|
||||
);
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// Auth endpoint
|
||||
if ($method === 'POST') {
|
||||
$username = trim($data['username'] ?? '');
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
if (!$username || !$password) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Credentials required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = JarvisDB::single(
|
||||
'SELECT * FROM users WHERE username = ?', [$username]
|
||||
);
|
||||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$_SESSION['jarvis_token'] = $token;
|
||||
$_SESSION['jarvis_user_id'] = $user['id'];
|
||||
$_SESSION['jarvis_name'] = $user['display_name'];
|
||||
|
||||
JarvisDB::execute(
|
||||
'UPDATE users SET last_seen = NOW() WHERE id = ?', [$user['id']]
|
||||
);
|
||||
|
||||
// Use stored address preference for greeting
|
||||
$addrRow = JarvisDB::query(
|
||||
"SELECT pref_value FROM kb_preferences WHERE pref_key='user_title' LIMIT 1"
|
||||
);
|
||||
$userAddr = $addrRow[0]['pref_value'] ?? $user['display_name'];
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'display_name' => $userAddr,
|
||||
'greeting' => "Welcome back, {$userAddr}. All systems are online and awaiting your command.",
|
||||
]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid credentials']);
|
||||
}
|
||||
} elseif ($method === 'DELETE') {
|
||||
session_destroy();
|
||||
echo json_encode(['success' => true, 'message' => 'Session terminated.']);
|
||||
} else {
|
||||
// Check session status
|
||||
$loggedIn = !empty($_SESSION['jarvis_token']);
|
||||
echo json_encode([
|
||||
'authenticated' => $loggedIn,
|
||||
'name' => $_SESSION['jarvis_name'] ?? null,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
<?php
|
||||
// JARVIS Chat — Intent Engine → Ollama → Claude fallback chain
|
||||
|
||||
if ($method !== 'POST') {
|
||||
echo json_encode(['error' => 'POST only']); exit;
|
||||
}
|
||||
|
||||
$message = trim($data['message'] ?? '');
|
||||
$sessionId = $data['session_id'] ?? session_id();
|
||||
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
|
||||
|
||||
if (!$message) {
|
||||
echo json_encode(['error' => 'Message required']); exit;
|
||||
}
|
||||
|
||||
// Build context string from selected panel item
|
||||
$ctxSnippet = '';
|
||||
if ($panelCtx && is_array($panelCtx)) {
|
||||
$type = $panelCtx['type'] ?? '';
|
||||
switch ($type) {
|
||||
case 'vm':
|
||||
$ctxSnippet = sprintf(
|
||||
'[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]',
|
||||
$panelCtx['name'] ?? '?',
|
||||
$panelCtx['vmid'] ?? '?',
|
||||
$panelCtx['status'] ?? '?',
|
||||
$panelCtx['cpu'] ?? '?',
|
||||
$panelCtx['mem_mb'] ?? '?',
|
||||
$panelCtx['maxmem_mb'] ?? '?',
|
||||
$panelCtx['type_label'] ?? 'qemu'
|
||||
);
|
||||
break;
|
||||
case 'network':
|
||||
$ctxSnippet = sprintf(
|
||||
'[Selected Device: %s — IP: %s, Status: %s%s]',
|
||||
$panelCtx['name'] ?? '?',
|
||||
$panelCtx['ip'] ?? '?',
|
||||
$panelCtx['status'] ?? '?',
|
||||
$panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : ''
|
||||
);
|
||||
break;
|
||||
case 'alert':
|
||||
$ctxSnippet = sprintf(
|
||||
'[Selected Alert: %s — Severity: %s, Message: %s]',
|
||||
$panelCtx['title'] ?? '?',
|
||||
$panelCtx['severity'] ?? '?',
|
||||
$panelCtx['message'] ?? '?'
|
||||
);
|
||||
break;
|
||||
case 'news':
|
||||
$ctxSnippet = sprintf(
|
||||
'[Selected News Story: "%s" — Source: %s, Published: %s]',
|
||||
$panelCtx['title'] ?? '?',
|
||||
$panelCtx['source'] ?? '?',
|
||||
$panelCtx['pub'] ?? 'unknown'
|
||||
);
|
||||
break;
|
||||
case 'ha':
|
||||
$ctxSnippet = sprintf(
|
||||
'[Selected Home Device: %s (%s) — Current State: %s]',
|
||||
$panelCtx['name'] ?? '?',
|
||||
$panelCtx['entity_id'] ?? '?',
|
||||
$panelCtx['state'] ?? '?'
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
JarvisDB::insert(
|
||||
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
|
||||
[$sessionId, 'user', $message]
|
||||
);
|
||||
|
||||
// Conversation history
|
||||
$history = JarvisDB::query(
|
||||
'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10',
|
||||
[$sessionId]
|
||||
);
|
||||
$history = array_reverse($history);
|
||||
|
||||
$reply = null;
|
||||
$source = 'unknown';
|
||||
|
||||
// ── Load user address preference ──────────────────────────────────────────
|
||||
$prefRows = JarvisDB::query(
|
||||
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
|
||||
);
|
||||
$prefs = [];
|
||||
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
|
||||
$userName = $prefs['user_name'] ?? 'Myron';
|
||||
$userTitle = $prefs['user_title'] ?? 'Mr. Blair';
|
||||
// Address to use in responses
|
||||
$userAddr = $userTitle;
|
||||
|
||||
// ── Tier 0.1: Name preference change ─────────────────────────────────────
|
||||
if (!$reply) {
|
||||
$lc = strtolower($message);
|
||||
// Patterns: "call me X", "refer to me as X", "address me as X", "my name is X",
|
||||
// "don't call me X", "stop calling me X", "just call me X"
|
||||
if (preg_match(
|
||||
'/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i',
|
||||
$message, $m
|
||||
)) {
|
||||
$newName = trim(preg_replace('/\s+/', ' ', $m[1]));
|
||||
// Strip trailing punctuation
|
||||
$newName = rtrim($newName, '.!?,;');
|
||||
if (strlen($newName) >= 2 && strlen($newName) <= 30) {
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
|
||||
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
|
||||
[$newName]
|
||||
);
|
||||
$userTitle = $newName;
|
||||
$userAddr = $newName;
|
||||
$reply = "Understood. I'll address you as {$newName} from now on.";
|
||||
$source = 'intent:name_pref';
|
||||
}
|
||||
} elseif (preg_match(
|
||||
'/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i',
|
||||
$message, $m
|
||||
)) {
|
||||
// "don't call me {$userAddr}" — switch to first name
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
|
||||
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
|
||||
[$userName]
|
||||
);
|
||||
$userTitle = $userName;
|
||||
$userAddr = $userName;
|
||||
$reply = "Of course. I'll call you {$userName} going forward.";
|
||||
$source = 'intent:name_pref';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 0: Home Assistant Control ───────────────────────────────────────
|
||||
// Uses entity_map stored by facts_collector to resolve natural language → entity
|
||||
$haEntityMapRow = JarvisDB::query(
|
||||
'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=? LIMIT 1',
|
||||
['ha', 'entity_map']
|
||||
);
|
||||
$haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value']))
|
||||
? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? [])
|
||||
: [];
|
||||
|
||||
// Scene keywords for one-shot activations (no on/off needed)
|
||||
$sceneKeywords = [
|
||||
'good night' => 'scene.good_night',
|
||||
'goodnight' => 'scene.good_night',
|
||||
'good morning' => 'scene.good_morning',
|
||||
'goodmorning' => 'scene.good_morning',
|
||||
'goodbye' => 'scene.goodbye',
|
||||
'bye' => 'scene.goodbye',
|
||||
'kitchen lights on' => 'scene.kitchen_lights_on',
|
||||
'kitchen on' => 'scene.kitchen_lights_on',
|
||||
'kitchen lights off' => 'scene.kitchen_lights_off',
|
||||
'kitchen off' => 'scene.kitchen_lights_off',
|
||||
'front porch lights' => 'scene.outdoors_front_porch_lights',
|
||||
'porch scene' => 'scene.outdoors_front_porch_lights',
|
||||
'office dawn' => 'scene.office_ocean_dawn',
|
||||
];
|
||||
|
||||
$msgLower = strtolower(trim($message));
|
||||
|
||||
// Check for scene activation first
|
||||
$sceneId = null;
|
||||
foreach ($sceneKeywords as $kw => $sid) {
|
||||
if (strpos($msgLower, $kw) !== false) {
|
||||
$sceneId = $sid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sceneId) {
|
||||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||||
$ch = curl_init($haUrl . '/api/services/scene/turn_on');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]),
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
|
||||
]);
|
||||
$haResp = curl_exec($ch);
|
||||
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($haCode === 200) {
|
||||
$sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId));
|
||||
$reply = "Activating {$sceneName}, {$userAddr}.";
|
||||
$source = 'ha:scene';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for device on/off control
|
||||
if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch)
|
||||
|| (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) {
|
||||
$turnOn = (bool) preg_match('/\bon\b/i', $message);
|
||||
$turnOff = (bool) preg_match('/\boff\b/i', $message);
|
||||
$haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null);
|
||||
|
||||
if ($haService && !empty($haEntityMap)) {
|
||||
// Find best matching entity
|
||||
$bestEid = null;
|
||||
$bestScore = 0;
|
||||
$bestName = '';
|
||||
|
||||
// Special: "all lights" / "everything"
|
||||
if (preg_match('/(all|everything|every)/i', $message)) {
|
||||
$bestEid = '__all_lights__';
|
||||
$bestName = 'All lights';
|
||||
$bestScore = 1;
|
||||
}
|
||||
|
||||
if (!$bestEid) {
|
||||
// Build search terms from message (remove control words with word boundaries)
|
||||
$searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?)\b/i', ' ', $msgLower);
|
||||
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
|
||||
|
||||
foreach ($haEntityMap as $eid => $info) {
|
||||
$nameLower = strtolower($info['name']);
|
||||
// Score: exact substring match = 10, word overlap = words matched
|
||||
if ($searchMsg && strpos($nameLower, $searchMsg) !== false) {
|
||||
$score = 10;
|
||||
} else {
|
||||
$words = array_filter(explode(' ', $searchMsg));
|
||||
$score = 0;
|
||||
foreach ($words as $w) {
|
||||
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
|
||||
}
|
||||
}
|
||||
if ($score > $bestScore) {
|
||||
$bestScore = $score;
|
||||
$bestEid = $eid;
|
||||
$bestName = $info['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestEid && $bestScore > 0) {
|
||||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||||
|
||||
if ($bestEid === '__all_lights__') {
|
||||
// Turn all lights on/off via domain targeting
|
||||
$lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light'));
|
||||
$payload = ['entity_id' => $lightIds];
|
||||
$svcDomain = 'light';
|
||||
} else {
|
||||
$domain = $haEntityMap[$bestEid]['domain'] ?? 'switch';
|
||||
$payload = ['entity_id' => $bestEid];
|
||||
$svcDomain = $domain;
|
||||
}
|
||||
|
||||
$ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
|
||||
]);
|
||||
$haResp = curl_exec($ch);
|
||||
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($haCode === 200) {
|
||||
$action = ($haService === 'turn_on') ? 'activated' : 'deactivated';
|
||||
$label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName;
|
||||
$reply = "{$label} {$action}, {$userAddr}.";
|
||||
$source = 'ha:' . $haService;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status query for HA entities
|
||||
if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message)
|
||||
&& !preg_match('/(turn|switch|put)/i', $message)) {
|
||||
// Status query - find the entity and report its state
|
||||
$searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower);
|
||||
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
|
||||
$bestEid = null; $bestScore = 0; $bestName = ''; $bestState = '';
|
||||
foreach ($haEntityMap as $eid => $info) {
|
||||
$nameLower = strtolower($info['name']);
|
||||
$words = array_filter(explode(' ', $searchMsg));
|
||||
$score = 0;
|
||||
foreach ($words as $w) {
|
||||
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
|
||||
}
|
||||
if ($score > $bestScore) {
|
||||
$bestScore = $score; $bestEid = $eid;
|
||||
$bestName = $info['name']; $bestState = $info['state'];
|
||||
}
|
||||
}
|
||||
if ($bestEid && $bestScore > 0) {
|
||||
$reply = "The {$bestName} is currently {$bestState}, {$userAddr}.";
|
||||
$source = 'ha:status';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Tier 0.5: Network Device Management ──────────────────────────────────
|
||||
if (!$reply) {
|
||||
// Flow state stored in kb_facts (session_write_close() is called before this runs)
|
||||
$flowKey = substr(session_id() ?: 'anon', 0, 32);
|
||||
// Expire stale chat flows older than 10 minutes
|
||||
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)");
|
||||
$flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]);
|
||||
$devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null;
|
||||
|
||||
// Continue an active multi-step add-device flow
|
||||
if ($devState && isset($devState['step'])) {
|
||||
switch ($devState['step']) {
|
||||
case 'waiting_ip':
|
||||
if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) {
|
||||
$newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']);
|
||||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
|
||||
$reply = "Got it — {$m[1]}. What should I call this device?";
|
||||
} else {
|
||||
$reply = "Please give me a valid IP address such as 192.168.1.100.";
|
||||
}
|
||||
$source = 'device:flow';
|
||||
break;
|
||||
|
||||
case 'waiting_name':
|
||||
$cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message));
|
||||
$newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']);
|
||||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
|
||||
$reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip.";
|
||||
$source = 'device:flow';
|
||||
break;
|
||||
|
||||
case 'waiting_type':
|
||||
$rawType = strtolower(trim($message));
|
||||
$allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown'];
|
||||
$devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown');
|
||||
$devIp = $devState['ip'] ?? '';
|
||||
$devName = $devState['name'] ?? '';
|
||||
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
|
||||
if ($devIp && $devName) {
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
|
||||
VALUES (?,?,?,'unknown',NOW())
|
||||
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
|
||||
[$devIp, $devName, $devType]
|
||||
);
|
||||
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status.";
|
||||
} else {
|
||||
$reply = "Something went wrong with the device registration. Please try again.";
|
||||
}
|
||||
$source = 'device:added';
|
||||
break;
|
||||
|
||||
default:
|
||||
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
|
||||
}
|
||||
}
|
||||
|
||||
// Start a new add-device flow or handle inline add
|
||||
if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) {
|
||||
$hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM);
|
||||
$hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM);
|
||||
$hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM);
|
||||
if ($hasIp && $hasName) {
|
||||
$devIp = $ipM[1];
|
||||
$devName = trim($nameM[1]);
|
||||
$devType = $hasType ? trim($typeM[1]) : 'unknown';
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
|
||||
VALUES (?,?,?,'unknown',NOW())
|
||||
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
|
||||
[$devIp, $devName, $devType]
|
||||
);
|
||||
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring.";
|
||||
$source = 'device:added';
|
||||
} elseif ($hasIp) {
|
||||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]);
|
||||
$reply = "I found the address {$ipM[1]}. What should I call this device?";
|
||||
$source = 'device:flow';
|
||||
} else {
|
||||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]);
|
||||
$reply = "I'll add a new device to network monitoring. What's its IP address?";
|
||||
$source = 'device:flow';
|
||||
}
|
||||
}
|
||||
|
||||
// Remove / delete device
|
||||
if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) {
|
||||
$target = trim($dm[2]);
|
||||
$isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target);
|
||||
$rows = $isIp
|
||||
? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target])
|
||||
: JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
|
||||
if (count($rows)) {
|
||||
$dev = $rows[0];
|
||||
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]);
|
||||
$label = $dev['alias'] ?? $dev['ip'];
|
||||
$reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring.";
|
||||
$source = 'device:removed';
|
||||
}
|
||||
}
|
||||
|
||||
// Update device name or type
|
||||
if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) {
|
||||
$target = trim($um[2]);
|
||||
$newVal = trim($um[3]);
|
||||
$rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
|
||||
if (count($rows)) {
|
||||
$dev = $rows[0];
|
||||
JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]);
|
||||
$reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'.";
|
||||
$source = 'device:updated';
|
||||
}
|
||||
}
|
||||
|
||||
// List devices
|
||||
if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) {
|
||||
$rows = JarvisDB::query(
|
||||
"SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias"
|
||||
);
|
||||
if (count($rows)) {
|
||||
$items = array_map(fn($r) =>
|
||||
($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'),
|
||||
$rows
|
||||
);
|
||||
$reply = "Monitored devices: " . implode('; ', $items) . '.';
|
||||
} else {
|
||||
$reply = "No named devices in network monitoring yet. Say 'add a device' to get started.";
|
||||
}
|
||||
$source = 'device:list';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 0.8: Weather & News intents ─────────────────────────────────────
|
||||
if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) {
|
||||
$wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1");
|
||||
if ($wRow && !empty($wRow[0]['data'])) {
|
||||
$wd = json_decode($wRow[0]['data'], true);
|
||||
$c = $wd['current'] ?? [];
|
||||
$fc = $wd['forecast'] ?? [];
|
||||
$today = $fc[0] ?? [];
|
||||
$tomorrow = $fc[1] ?? [];
|
||||
$reply = sprintf(
|
||||
'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' .
|
||||
'Today\'s range: %d–%d°F. ' .
|
||||
'Tomorrow: %s, %d–%d°F, %d%% chance of rain.',
|
||||
$c['icon'] ?? '',
|
||||
$c['desc'] ?? '',
|
||||
$c['temp'] ?? 0,
|
||||
$c['feels'] ?? 0,
|
||||
$c['humidity'] ?? 0,
|
||||
$c['wind'] ?? 0,
|
||||
$today['low'] ?? 0,
|
||||
$today['high'] ?? 0,
|
||||
$tomorrow['desc'] ?? '',
|
||||
$tomorrow['low'] ?? 0,
|
||||
$tomorrow['high'] ?? 0,
|
||||
$tomorrow['rain_pct'] ?? 0
|
||||
);
|
||||
$source = 'intent:weather';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) {
|
||||
$nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1");
|
||||
if ($nRow && !empty($nRow[0]['data'])) {
|
||||
$nd = json_decode($nRow[0]['data'], true);
|
||||
$cats = $nd['categories'] ?? [];
|
||||
$lines = [];
|
||||
foreach ($cats as $cat => $articles) {
|
||||
if (!empty($articles)) {
|
||||
$top3 = array_slice($articles, 0, 3);
|
||||
foreach ($top3 as $a) {
|
||||
$lines[] = '[' . $a['source'] . '] ' . $a['title'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($lines) {
|
||||
$reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.';
|
||||
} else {
|
||||
$reply = 'News feed is still loading, Sir. Please try again in a moment.';
|
||||
}
|
||||
$source = 'intent:news';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
|
||||
if (!$reply) {
|
||||
$matched = KBEngine::match($message);
|
||||
if ($matched && $matched['action'] === 'response') {
|
||||
$reply = $matched['reply'];
|
||||
$source = 'intent:' . $matched['intent'];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 2: Ollama local LLM (fast local fallback) ───────────────────────
|
||||
if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) {
|
||||
$ollamaHost = OLLAMA_HOST;
|
||||
$ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b';
|
||||
$timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45;
|
||||
|
||||
$ollamaMessages = [];
|
||||
$ollamaMessages[] = ['role' => 'system', 'content' =>
|
||||
"You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " .
|
||||
'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.'];
|
||||
$ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
|
||||
|
||||
$promptParts = [];
|
||||
foreach ($ollamaMessages as $msg) {
|
||||
$role = ucfirst($msg['role']);
|
||||
$promptParts[] = "{$role}: {$msg['content']}";
|
||||
}
|
||||
$promptParts[] = 'Assistant:';
|
||||
$promptStr = implode("\n\n", $promptParts);
|
||||
|
||||
$payload = [
|
||||
'model' => $ollamaModel,
|
||||
'prompt' => $promptStr,
|
||||
'stream' => false,
|
||||
'options' => ['temperature' => 0.7, 'num_predict' => 150],
|
||||
];
|
||||
|
||||
$ch = curl_init($ollamaHost . '/api/generate');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
]);
|
||||
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200 && $resp) {
|
||||
$decoded = json_decode($resp, true);
|
||||
$text = $decoded['response'] ?? null;
|
||||
if ($text) {
|
||||
$reply = trim($text);
|
||||
$source = 'ollama:' . $ollamaModel;
|
||||
}
|
||||
}
|
||||
// Silently fall through to Groq if Ollama fails or times out
|
||||
}
|
||||
|
||||
// ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ─────────────
|
||||
if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
|
||||
$needsSearch = (bool) preg_match(
|
||||
'/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i',
|
||||
$message
|
||||
);
|
||||
$groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL;
|
||||
|
||||
$groqMessages = [['role' => 'system', 'content' =>
|
||||
"You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " .
|
||||
"(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " .
|
||||
'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') . '.'],
|
||||
];
|
||||
foreach (array_slice($history, -6) as $h) {
|
||||
$groqMessages[] = ['role' => $h['role'], 'content' => $h['content']];
|
||||
}
|
||||
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
|
||||
$groqMessages[] = ['role' => 'user', 'content' => $userMsg];
|
||||
|
||||
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => $groqModel,
|
||||
'messages' => $groqMessages,
|
||||
'max_tokens' => 400,
|
||||
'temperature' => 0.7,
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . GROQ_API_KEY,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => GROQ_TIMEOUT,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200 && $resp) {
|
||||
$decoded = json_decode($resp, true);
|
||||
$text = $decoded['choices'][0]['message']['content'] ?? null;
|
||||
if ($text) {
|
||||
$reply = trim($text);
|
||||
$source = 'groq:' . $groqModel;
|
||||
}
|
||||
}
|
||||
// Silently fall through to Claude if Groq fails
|
||||
}
|
||||
|
||||
// ── Tier 4: Claude API (final fallback) ──────────────────────────────────
|
||||
if (!$reply) {
|
||||
// Live context for Claude
|
||||
$systemContext = '';
|
||||
try {
|
||||
$memLines = file('/proc/meminfo');
|
||||
$mem = [];
|
||||
foreach ($memLines as $l) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$memPct = $mem['MemTotal'] > 0
|
||||
? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100)
|
||||
: '?';
|
||||
$sec = (int) file_get_contents('/proc/uptime');
|
||||
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
|
||||
$load = explode(' ', file_get_contents('/proc/loadavg'));
|
||||
$systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$alerts = JarvisDB::query(
|
||||
'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3'
|
||||
);
|
||||
if ($alerts) {
|
||||
$systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n";
|
||||
}
|
||||
|
||||
$kbContext = KBEngine::getContextSummary();
|
||||
|
||||
$systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision.
|
||||
|
||||
Infrastructure:
|
||||
- Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04)
|
||||
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
|
||||
- Proxmox Host: 10.48.200.90 (manages all VMs)
|
||||
- Home Assistant: 10.48.200.97:8123
|
||||
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
|
||||
- Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
|
||||
- Network: 10.48.200.0/24, FortiGate firewall
|
||||
|
||||
Live data:
|
||||
{$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') . "
|
||||
Today: " . date('l, F j Y, g:i A T') . "
|
||||
|
||||
Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers.";
|
||||
|
||||
$messages = [];
|
||||
foreach ($history as $h) {
|
||||
$messages[] = ['role' => $h['role'], 'content' => $h['content']];
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
|
||||
|
||||
if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') {
|
||||
$reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands.";
|
||||
$source = 'fallback:no-key';
|
||||
} else {
|
||||
$payload = [
|
||||
'model' => CLAUDE_MODEL,
|
||||
'max_tokens' => CLAUDE_MAX_TOKENS,
|
||||
'system' => $systemPrompt,
|
||||
'messages' => $messages,
|
||||
];
|
||||
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'x-api-key: ' . CLAUDE_API_KEY,
|
||||
'anthropic-version: 2023-06-01',
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200) {
|
||||
$decoded = json_decode($resp, true);
|
||||
$reply = $decoded['content'][0]['text'] ?? null;
|
||||
$source = 'claude';
|
||||
} else {
|
||||
$err = json_decode($resp, true);
|
||||
$errMsg = $err['error']['message'] ?? '';
|
||||
if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) {
|
||||
$reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational.";
|
||||
} else {
|
||||
$reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg;
|
||||
}
|
||||
$source = 'claude:error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Final fallback ─────────────────────────────────────────────────────────
|
||||
if (!$reply) {
|
||||
$reply = "My systems are processing your request, {$userAddr}. Please try again momentarily.";
|
||||
$source = 'fallback';
|
||||
}
|
||||
|
||||
// Save reply and learn
|
||||
JarvisDB::insert(
|
||||
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
|
||||
[$sessionId, 'assistant', $reply]
|
||||
);
|
||||
KBEngine::learnFromConversation($message, $reply);
|
||||
|
||||
echo json_encode([
|
||||
'reply' => $reply,
|
||||
'source' => $source,
|
||||
'session_id' => $sessionId,
|
||||
'timestamp' => date('c'),
|
||||
]);
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
// Digital Ocean server monitoring via SSH
|
||||
|
||||
function sshCommand(string $cmd): string {
|
||||
$sshCmd = sprintf(
|
||||
'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 %s@%s %s 2>/dev/null',
|
||||
escapeshellarg(DO_SSH_PASS),
|
||||
escapeshellarg(DO_SSH_USER),
|
||||
escapeshellarg(DO_SERVER_IP),
|
||||
escapeshellarg($cmd)
|
||||
);
|
||||
return shell_exec($sshCmd) ?? '';
|
||||
}
|
||||
|
||||
// Check if sshpass is available
|
||||
$hasSshpass = trim(shell_exec('which sshpass 2>/dev/null'));
|
||||
if (!$hasSshpass) {
|
||||
echo json_encode([
|
||||
'error' => 'sshpass not installed on Jarvis server. Run: sudo apt-get install sshpass',
|
||||
'ip' => DO_SERVER_IP,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Gather DO server stats in one SSH session
|
||||
$statsRaw = sshCommand("echo CPU:$(grep 'cpu ' /proc/stat | awk '{u=\$2+\$4; t=\$2+\$3+\$4+\$5; print u/t*100}');echo MEM_TOTAL:\$(grep MemTotal /proc/meminfo | awk '{print \$2}');echo MEM_FREE:\$(grep MemAvailable /proc/meminfo | awk '{print \$2}');echo UPTIME:\$(cat /proc/uptime | awk '{print int(\$1)}');echo DISK_USED:\$(df / | tail -1 | awk '{print \$5}');echo LOAD:\$(cat /proc/loadavg | awk '{print \$1}')");
|
||||
|
||||
$stats = [];
|
||||
foreach (explode("\n", trim($statsRaw)) as $line) {
|
||||
[$key, $val] = explode(':', $line, 2) + [null, null];
|
||||
if ($key) $stats[$key] = trim($val ?? '');
|
||||
}
|
||||
|
||||
// Get running services on DO
|
||||
$services = sshCommand("systemctl is-active lsphp85 lshttpd nginx apache2 mysql mariadb php8.1-fpm 2>/dev/null | paste <(echo -e 'lsphp85\nlshttpd\nnginx\napache2\nmysql\nmariadb\nphp8.1-fpm') - | awk '{print \$1\":\"\$2}'");
|
||||
$svcMap = [];
|
||||
foreach (explode("\n", trim($services)) as $line) {
|
||||
if (!$line) continue;
|
||||
[$name, $status] = explode(':', $line, 2) + [null, null];
|
||||
if ($name && $status && trim($status) === 'active') {
|
||||
$svcMap[trim($name)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get disk usage per site
|
||||
$siteDisk = sshCommand("du -sh /home/*/public_html 2>/dev/null | sort -h");
|
||||
$sites = [];
|
||||
foreach (explode("\n", trim($siteDisk)) as $line) {
|
||||
if (preg_match('/^([\d.]+\w)\s+\/home\/([^\/]+)/', $line, $m)) {
|
||||
$sites[$m[2]] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
$cpuPct = isset($stats['CPU']) ? round((float)$stats['CPU'], 1) : null;
|
||||
$memTotal = (int)($stats['MEM_TOTAL'] ?? 0);
|
||||
$memFree = (int)($stats['MEM_FREE'] ?? 0);
|
||||
$memUsed = $memTotal - $memFree;
|
||||
$uptimeSec = (int)($stats['UPTIME'] ?? 0);
|
||||
$uptimeDays = intdiv($uptimeSec, 86400);
|
||||
$uptimeHrs = intdiv($uptimeSec % 86400, 3600);
|
||||
|
||||
echo json_encode([
|
||||
'ip' => DO_SERVER_IP,
|
||||
'reachable' => !empty($statsRaw),
|
||||
'cpu_pct' => $cpuPct,
|
||||
'memory' => [
|
||||
'total_mb' => round($memTotal / 1024),
|
||||
'used_mb' => round($memUsed / 1024),
|
||||
'percent' => $memTotal > 0 ? round(($memUsed/$memTotal)*100,1) : 0,
|
||||
],
|
||||
'disk_used_pct' => $stats['DISK_USED'] ?? null,
|
||||
'load_1m' => (float)($stats['LOAD'] ?? 0),
|
||||
'uptime' => "{$uptimeDays}d {$uptimeHrs}h",
|
||||
'services' => $svcMap,
|
||||
'sites' => $sites,
|
||||
'timestamp' => date('c'),
|
||||
]);
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS Facts Collector
|
||||
* HTTP endpoint: /api/facts/collect (POST or GET)
|
||||
* CLI/cron: php facts_collector.php
|
||||
* Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table.
|
||||
*/
|
||||
|
||||
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed');
|
||||
|
||||
// Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
|
||||
if (!class_exists('KBEngine')) {
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
require_once __DIR__ . '/../lib/kb_engine.php';
|
||||
}
|
||||
|
||||
function collect_all(): array {
|
||||
$results = [];
|
||||
$ttl = 300; // 5-minute TTL on live facts
|
||||
|
||||
// ── System ────────────────────────────────────────────────────────────
|
||||
try {
|
||||
$stat1 = file_get_contents('/proc/stat');
|
||||
usleep(200000);
|
||||
$stat2 = file_get_contents('/proc/stat');
|
||||
|
||||
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d");
|
||||
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d");
|
||||
$dIdle = $cpu2[3] - $cpu1[3];
|
||||
$dTotal = array_sum($cpu2) - array_sum($cpu1);
|
||||
$cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0;
|
||||
KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl);
|
||||
|
||||
$memLines = file('/proc/meminfo');
|
||||
$mem = [];
|
||||
foreach ($memLines as $l) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$total = round($mem['MemTotal'] / 1048576, 1);
|
||||
$avail = round($mem['MemAvailable'] / 1048576, 1);
|
||||
$used = round($total - $avail, 1);
|
||||
$free = round($mem['MemFree'] / 1048576, 1);
|
||||
$memPct = $total > 0 ? round($used / $total * 100) : 0;
|
||||
KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl);
|
||||
|
||||
$la = explode(' ', file_get_contents('/proc/loadavg'));
|
||||
KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl);
|
||||
|
||||
$sec = (int) file_get_contents('/proc/uptime');
|
||||
KBEngine::storeFact('system', 'uptime',
|
||||
intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours',
|
||||
'local', $ttl);
|
||||
|
||||
$df = disk_free_space('/');
|
||||
$dt = disk_total_space('/');
|
||||
KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl);
|
||||
KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl);
|
||||
|
||||
$results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)";
|
||||
} catch (Exception $e) {
|
||||
$results['system'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// ── Network ───────────────────────────────────────────────────────────
|
||||
try {
|
||||
$watchlist = [
|
||||
'gateway' => '10.48.200.1',
|
||||
'proxmox' => '10.48.200.90',
|
||||
'ollama' => '10.48.200.95',
|
||||
'fusionpbx' => '10.48.200.96',
|
||||
'ha' => '10.48.200.97',
|
||||
'do_server' => '165.22.1.228',
|
||||
];
|
||||
$online = 0;
|
||||
$total = count($watchlist);
|
||||
foreach ($watchlist as $name => $ip) {
|
||||
exec('ping -c1 -W1 ' . escapeshellarg($ip) . ' > /dev/null 2>&1', $o, $code);
|
||||
$up = ($code === 0);
|
||||
if ($up) $online++;
|
||||
KBEngine::storeFact('network', "host_{$name}", $up ? 'online' : 'offline', $ip, $ttl);
|
||||
}
|
||||
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
|
||||
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
|
||||
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
|
||||
$results['network'] = "ok ({$online}/{$total} online)";
|
||||
} catch (Exception $e) {
|
||||
$results['network'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// ── Proxmox ───────────────────────────────────────────────────────────
|
||||
try {
|
||||
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
|
||||
$base = 'https://' . PROXMOX_HOST . ':' . PROXMOX_PORT . '/api2/json';
|
||||
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
|
||||
|
||||
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
|
||||
$vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth);
|
||||
$cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth);
|
||||
|
||||
if (isset($nd['data'])) {
|
||||
$cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1);
|
||||
$memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1);
|
||||
$memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1);
|
||||
$memPct = $memT > 0 ? round($memU / $memT * 100) : 0;
|
||||
KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl);
|
||||
KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl);
|
||||
KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl);
|
||||
KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl);
|
||||
}
|
||||
$all = array_merge($vms['data'] ?? [], $cts['data'] ?? []);
|
||||
$running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running'));
|
||||
KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl);
|
||||
KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl);
|
||||
$results['proxmox'] = "ok ({$running}/" . count($all) . " running)";
|
||||
} else {
|
||||
$results['proxmox'] = 'skipped (no token)';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$results['proxmox'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// ── Home Assistant ────────────────────────────────────────────────────
|
||||
try {
|
||||
if (defined('HA_URL') && defined('HA_TOKEN') && HA_TOKEN !== 'YOUR_HA_TOKEN_HERE') {
|
||||
$haUrl = HA_URL;
|
||||
$haToken = HA_TOKEN;
|
||||
$haHdr = ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'];
|
||||
|
||||
// Fetch all entity states
|
||||
$ch = curl_init($haUrl . '/api/states');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $haHdr,
|
||||
CURLOPT_TIMEOUT => 12,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200) {
|
||||
$allStates = json_decode($resp, true) ?? [];
|
||||
|
||||
// Domains to index for control
|
||||
$controlDomains = ['light','switch','input_boolean','climate','cover','fan',
|
||||
'scene','script','lawn_mower','vacuum','media_player'];
|
||||
// Switch keywords to skip (camera/HACS settings, not real devices)
|
||||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
||||
'_siren_on','_email_on','_manual_record','_infrared_',
|
||||
'do_not_disturb','matter_server','zerotier','mariadb',
|
||||
'spotify','file_editor','ssh_web','uptime_kuma',
|
||||
'adguard_home_','adguard_protection','adguard_parental',
|
||||
'adguard_safe','adguard_filter','adguard_query',
|
||||
'assist_microphone','folding_home','music_assistant',
|
||||
'get_hacs','mealie','mosquitto','social_to',
|
||||
'motion_detection','front_yard_record','down_hill_record',
|
||||
'camera1_record','back_yard_record','nvr_'];
|
||||
|
||||
$entityMap = [];
|
||||
$statusCount = ['online' => 0, 'offline' => 0, 'unavailable' => 0];
|
||||
|
||||
foreach ($allStates as $s) {
|
||||
$eid = $s['entity_id'];
|
||||
$domain = explode('.', $eid)[0];
|
||||
$name = $s['attributes']['friendly_name'] ?? $eid;
|
||||
$state = $s['state'];
|
||||
|
||||
if (!in_array($domain, $controlDomains)) continue;
|
||||
|
||||
// Skip camera/HACS internals for switches
|
||||
if ($domain === 'switch') {
|
||||
$skip = false;
|
||||
foreach ($skipKeywords as $kw) {
|
||||
if (strpos($eid, $kw) !== false) { $skip = true; break; }
|
||||
}
|
||||
if ($skip) continue;
|
||||
}
|
||||
|
||||
$entityMap[$eid] = ['name' => $name, 'state' => $state, 'domain' => $domain];
|
||||
|
||||
if ($state === 'unavailable' || $state === 'unknown') {
|
||||
$statusCount['unavailable']++;
|
||||
} elseif (in_array($state, ['on','open','playing','mowing','home','active','idle'])) {
|
||||
$statusCount['online']++;
|
||||
} elseif (in_array($state, ['off','closed','paused','docked','away'])) {
|
||||
$statusCount['offline']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Store entity map as JSON for chat.php to use
|
||||
KBEngine::storeFact('ha', 'entity_map', json_encode($entityMap), 'ha', 270);
|
||||
KBEngine::storeFact('ha', 'entity_count', count($entityMap), 'ha', $ttl);
|
||||
KBEngine::storeFact('ha', 'online_count', $statusCount['online'], 'ha', $ttl);
|
||||
KBEngine::storeFact('ha', 'offline_count', $statusCount['offline'], 'ha', $ttl);
|
||||
KBEngine::storeFact('ha', 'unavail_count', $statusCount['unavailable'], 'ha', $ttl);
|
||||
KBEngine::storeFact('ha', 'ha_status', 'online', 'ha', $ttl);
|
||||
|
||||
// Store individual sensor facts
|
||||
$sensorDomains = ['sensor','binary_sensor','weather'];
|
||||
$interestingPatterns = ['temperature','humidity','battery','power','energy',
|
||||
'voltage','current','illuminance','co2','pm25'];
|
||||
foreach ($allStates as $s) {
|
||||
$domain = explode('.', $s['entity_id'])[0];
|
||||
if (!in_array($domain, $sensorDomains)) continue;
|
||||
$eid = $s['entity_id'];
|
||||
foreach ($interestingPatterns as $pat) {
|
||||
if (strpos($eid, $pat) !== false) {
|
||||
$name = $s['attributes']['friendly_name'] ?? $eid;
|
||||
$unit = $s['attributes']['unit_of_measurement'] ?? '';
|
||||
KBEngine::storeFact('ha_sensors', $eid,
|
||||
$s['state'] . ($unit ? " {$unit}" : ''), 'ha', $ttl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results['ha'] = sprintf('ok (%d entities, %d on, %d off, %d unavail)',
|
||||
count($entityMap), $statusCount['online'],
|
||||
$statusCount['offline'], $statusCount['unavailable']);
|
||||
} else {
|
||||
KBEngine::storeFact('ha', 'ha_status', 'unreachable', 'ha', $ttl);
|
||||
$results['ha'] = "unreachable (HTTP {$code})";
|
||||
}
|
||||
} else {
|
||||
KBEngine::storeFact('ha', 'ha_status', 'token not configured', 'ha', $ttl);
|
||||
$results['ha'] = 'skipped (no token)';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
KBEngine::storeFact('ha', 'ha_status', 'error', 'ha', $ttl);
|
||||
$results['ha'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// ── Digital Ocean ─────────────────────────────────────────────────────
|
||||
try {
|
||||
exec('ping -c1 -W2 165.22.1.228 > /dev/null 2>&1', $o2, $doCode);
|
||||
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
|
||||
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
|
||||
$results['do_server'] = "ok ({$doStatus})";
|
||||
} catch (Exception $e) {
|
||||
$results['do_server'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// ── Ollama ────────────────────────────────────────────────────────────
|
||||
try {
|
||||
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
|
||||
$ch = curl_init($ollamaHost . '/api/tags');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200) {
|
||||
$models = json_decode($resp, true)['models'] ?? [];
|
||||
$names = array_column($models, 'name');
|
||||
KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null);
|
||||
KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl);
|
||||
KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl);
|
||||
foreach ($models as $m) {
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?)
|
||||
ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()',
|
||||
[$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)]
|
||||
);
|
||||
}
|
||||
$results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')';
|
||||
} else {
|
||||
KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl);
|
||||
$results['ollama'] = 'unreachable (VM may be booting)';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$results['ollama'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
function pve_api_get(string $url, string $authHeader): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [$authHeader],
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return $resp ? (json_decode($resp, true) ?? []) : [];
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────
|
||||
$results = collect_all();
|
||||
if ($isCLI) {
|
||||
echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n";
|
||||
foreach ($results as $k => $v) {
|
||||
echo " {$k}: {$v}\n";
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
// Home Assistant endpoint — entities served from api_cache (refreshed every 5 min by cron)
|
||||
// Live service calls (turn on/off) still go direct to HA.
|
||||
|
||||
function haRequest(string $path, string $method = 'GET', array $payload = []): ?array {
|
||||
if (HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init(HA_URL . '/api' . $path);
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . HA_TOKEN,
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
}
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($code >= 200 && $code < 300 && $resp) {
|
||||
return json_decode($resp, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$configured = !(HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false);
|
||||
|
||||
if (!$configured) {
|
||||
echo json_encode(['configured' => false, 'message' => 'HA token not configured.', 'entities' => []]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Live service call (toggle device) — always direct to HA, never cached
|
||||
if ($method === 'POST' && $action === 'service') {
|
||||
$domain = $data['domain'] ?? '';
|
||||
$service = $data['service'] ?? '';
|
||||
$entity_id = $data['entity_id'] ?? '';
|
||||
if ($domain && $service && $entity_id) {
|
||||
$result = haRequest("/services/{$domain}/{$service}", 'POST', ['entity_id' => $entity_id]);
|
||||
echo json_encode(['success' => true, 'result' => $result]);
|
||||
} else {
|
||||
echo json_encode(['error' => 'Missing domain/service/entity_id']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve entities from cache (populated by stats_cache.php cron, every 5 min)
|
||||
$cached = JarvisDB::query(
|
||||
'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1',
|
||||
['ha_entities']
|
||||
);
|
||||
|
||||
if ($cached && !empty($cached[0]['data'])) {
|
||||
$row = $cached[0];
|
||||
$data_out = json_decode($row['data'], true);
|
||||
$data_out['cache_age_s'] = (int)(time() - (int)$row['updated_ts']);
|
||||
echo json_encode($data_out);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'configured' => true,
|
||||
'ha_version' => 'unknown',
|
||||
'location' => 'Home',
|
||||
'entity_count' => 0,
|
||||
'entities' => [],
|
||||
'cached_at' => null,
|
||||
'cache_age_s' => -1,
|
||||
'message' => 'Cache warming up — first update in under 5 minutes.',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
// Network monitoring endpoint
|
||||
|
||||
function pingHost(string $ip): array {
|
||||
$cmd = 'ping -c 1 -W 1 ' . escapeshellarg($ip) . ' 2>/dev/null';
|
||||
$out = shell_exec($cmd);
|
||||
$alive = $out && strpos($out, '1 received') !== false;
|
||||
$latency = null;
|
||||
if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) {
|
||||
$latency = (float)$m[1];
|
||||
}
|
||||
return ['alive' => $alive, 'latency_ms' => $latency];
|
||||
}
|
||||
|
||||
function scanSubnet(string $prefix, int $timeout = 10): array {
|
||||
$cmd = 'nmap -sn --host-timeout 1s ' . escapeshellarg($prefix . '.0/24') .
|
||||
' -oG - 2>/dev/null | grep "Up$" | awk \'{print $2}\'';
|
||||
$out = shell_exec($cmd) ?? '';
|
||||
$hosts = array_filter(explode("\n", trim($out)));
|
||||
return array_values($hosts);
|
||||
}
|
||||
|
||||
function getArpTable(): array {
|
||||
$out = shell_exec('arp -n 2>/dev/null') ?? '';
|
||||
$devices = [];
|
||||
foreach (explode("\n", trim($out)) as $line) {
|
||||
if (preg_match('/^([\d.]+)\s+\w+\s+([\w:]+)/', $line, $m)) {
|
||||
$devices[$m[1]] = strtolower($m[2]);
|
||||
}
|
||||
}
|
||||
return $devices;
|
||||
}
|
||||
|
||||
$action = $action ?? 'status';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
if ($action === 'scan') {
|
||||
$liveHosts = scanSubnet(LOCAL_SUBNET, 8);
|
||||
$arp = getArpTable();
|
||||
$known = JarvisDB::query('SELECT * FROM network_devices');
|
||||
$knownMap = [];
|
||||
foreach ($known as $d) $knownMap[$d['ip']] = $d;
|
||||
|
||||
$devices = [];
|
||||
foreach ($liveHosts as $ip) {
|
||||
$mac = $arp[$ip] ?? null;
|
||||
$known_dev = $knownMap[$ip] ?? null;
|
||||
$nsOut = shell_exec('timeout 1 nslookup ' . escapeshellarg($ip) . ' 2>/dev/null | grep "name ="');
|
||||
$hostname = null;
|
||||
if ($nsOut && preg_match('/name = (.+)\./', $nsOut, $nm)) {
|
||||
$hostname = rtrim($nm[1], '.');
|
||||
}
|
||||
$devices[] = [
|
||||
'ip' => $ip,
|
||||
'mac' => $mac,
|
||||
'hostname' => $hostname,
|
||||
'alias' => $known_dev['alias'] ?? null,
|
||||
'type' => $known_dev['device_type'] ?? 'unknown',
|
||||
'status' => 'online',
|
||||
];
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO network_devices (ip, mac, hostname, status, last_seen) VALUES (?,?,?,\'online\',NOW())
|
||||
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=VALUES(hostname), status=\'online\', last_seen=NOW()',
|
||||
[$ip, $mac, $hostname]
|
||||
);
|
||||
}
|
||||
foreach ($knownMap as $ip => $dev) {
|
||||
if (!in_array($ip, $liveHosts)) {
|
||||
JarvisDB::execute('UPDATE network_devices SET status=\'offline\' WHERE ip=?', [$ip]);
|
||||
}
|
||||
}
|
||||
echo json_encode(['devices' => $devices, 'count' => count($devices), 'scanned_at' => date('c')]);
|
||||
|
||||
} elseif ($action === 'add' && $method === 'POST') {
|
||||
$ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP);
|
||||
$alias = substr(trim($data['alias'] ?? ''), 0, 100);
|
||||
$type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($data['type'] ?? 'device'));
|
||||
if (!$ip) { echo json_encode(['error' => 'Invalid IP address']); exit; }
|
||||
if (!$alias) { echo json_encode(['error' => 'Name is required']); exit; }
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO network_devices (ip, alias, device_type, status) VALUES (?,?,?,\'unknown\')
|
||||
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)',
|
||||
[$ip, $alias, $type]
|
||||
);
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} elseif ($action === 'delete' && $method === 'POST') {
|
||||
$ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP);
|
||||
if (!$ip) { echo json_encode(['error' => 'Invalid IP']); exit; }
|
||||
// Don't allow deleting agent-managed entries
|
||||
$isAgent = JarvisDB::query('SELECT id FROM registered_agents WHERE ip_address=? LIMIT 1', [$ip]);
|
||||
if (!empty($isAgent)) { echo json_encode(['error' => 'Cannot delete agent-managed device']); exit; }
|
||||
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$ip]);
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} else {
|
||||
// Status: unified device list from agents + user-managed DB entries + external services
|
||||
$devices = [];
|
||||
|
||||
// Mark agents offline if not heard from in 2 minutes
|
||||
JarvisDB::execute(
|
||||
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
|
||||
);
|
||||
|
||||
// 1. Agent-based devices — status from heartbeat, no ping from DO needed
|
||||
$agents = JarvisDB::query(
|
||||
'SELECT agent_id, hostname, ip_address, status, last_seen, agent_type FROM registered_agents ORDER BY hostname'
|
||||
);
|
||||
$agentIPs = [];
|
||||
foreach ($agents as $ag) {
|
||||
$agentIPs[] = $ag['ip_address'];
|
||||
$devices[] = [
|
||||
'ip' => $ag['ip_address'],
|
||||
'name' => $ag['hostname'],
|
||||
'type' => 'agent',
|
||||
'agent_id' => $ag['agent_id'],
|
||||
'agent_type' => $ag['agent_type'],
|
||||
'alive' => $ag['status'] === 'online',
|
||||
'status' => $ag['status'],
|
||||
'last_seen' => $ag['last_seen'],
|
||||
'source' => 'agent',
|
||||
'deletable' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// 2. User-managed devices from DB (named/aliased entries not covered by agents)
|
||||
$pinned = JarvisDB::query(
|
||||
'SELECT ip, alias, device_type, status, last_seen FROM network_devices
|
||||
WHERE alias IS NOT NULL AND alias != "" ORDER BY alias'
|
||||
);
|
||||
foreach ($pinned as $dev) {
|
||||
if (in_array($dev['ip'], $agentIPs)) continue; // agent already covers this IP
|
||||
$ping = pingHost($dev['ip']);
|
||||
$newStatus = $ping['alive'] ? 'online' : 'offline';
|
||||
JarvisDB::execute(
|
||||
'UPDATE network_devices SET status=?, last_seen=NOW() WHERE ip=?',
|
||||
[$newStatus, $dev['ip']]
|
||||
);
|
||||
$devices[] = [
|
||||
'ip' => $dev['ip'],
|
||||
'name' => $dev['alias'],
|
||||
'type' => $dev['device_type'] ?: 'device',
|
||||
'alive' => $ping['alive'],
|
||||
'latency_ms' => $ping['latency_ms'],
|
||||
'status' => $newStatus,
|
||||
'last_seen' => $dev['last_seen'],
|
||||
'source' => 'db',
|
||||
'deletable' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. External services we can actually ping from DO
|
||||
$external = [
|
||||
['ip' => '134.209.72.226', 'name' => 'FusionPBX DO', 'type' => 'server'],
|
||||
];
|
||||
foreach ($external as $host) {
|
||||
if (in_array($host['ip'], $agentIPs)) continue;
|
||||
$ping = pingHost($host['ip']);
|
||||
$devices[] = array_merge($host, [
|
||||
'alive' => $ping['alive'],
|
||||
'latency_ms' => $ping['latency_ms'],
|
||||
'status' => $ping['alive'] ? 'online' : 'offline',
|
||||
'source' => 'static',
|
||||
'deletable' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
echo json_encode(['devices' => $devices, 'timestamp' => date('c')]);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// News endpoint — serves from api_cache (refreshed every 30 min by cron)
|
||||
|
||||
$cached = JarvisDB::query(
|
||||
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
|
||||
['news']
|
||||
);
|
||||
|
||||
if ($cached && !empty($cached[0]['data'])) {
|
||||
$out = json_decode($cached[0]['data'], true);
|
||||
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
|
||||
echo json_encode($out);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'categories' => [],
|
||||
'total' => 0,
|
||||
'cache_age_s' => -1,
|
||||
'message' => 'News feed warming up — available within 5 minutes.',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// Proxmox API endpoint — serves from api_cache, refreshed every 5 min by cron
|
||||
|
||||
$isConfigured = !(PROXMOX_HOST === '10.48.200.X' || PROXMOX_TOKEN_VAL === 'YOUR_TOKEN_VALUE_HERE');
|
||||
|
||||
if (!$isConfigured) {
|
||||
echo json_encode([
|
||||
'configured' => false,
|
||||
'message' => 'Proxmox API token not yet configured.',
|
||||
'vms' => [], 'nodes' => [],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Serve from cache (refreshed by stats_cache.php cron every 5 min)
|
||||
$cached = JarvisDB::query(
|
||||
'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1',
|
||||
['proxmox']
|
||||
);
|
||||
|
||||
if ($cached && !empty($cached[0]['data'])) {
|
||||
$row = $cached[0];
|
||||
$data = json_decode($row['data'], true);
|
||||
// Add cache age to response
|
||||
$data['cache_age_s'] = (int)(time() - (int)$row['updated_ts']);
|
||||
echo json_encode($data);
|
||||
} else {
|
||||
// Cache empty — return placeholder so UI shows something useful
|
||||
echo json_encode([
|
||||
'configured' => true,
|
||||
'node' => PROXMOX_NODE,
|
||||
'node_status' => null,
|
||||
'vms' => [],
|
||||
'containers' => [],
|
||||
'vm_count' => 0,
|
||||
'ct_count' => 0,
|
||||
'cached_at' => null,
|
||||
'cache_age_s' => -1,
|
||||
'message' => 'Cache warming up — first update in under 5 minutes.',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS Stats Cache Collector
|
||||
* Runs every 5 min via cron. Fetches Proxmox + HA data and stores in api_cache.
|
||||
* Keeps live API calls out of the request path.
|
||||
*/
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../../api/lib/db.php';
|
||||
|
||||
function curlGet(string $url, array $headers, int $timeout = 10): ?string {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
]);
|
||||
$out = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($err) { echo "[cache] curl error: $err\n"; return null; }
|
||||
return ($code >= 200 && $code < 300) ? $out : null;
|
||||
}
|
||||
|
||||
function cacheStore(string $key, $data): void {
|
||||
$json = is_string($data) ? $data : json_encode($data);
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW())
|
||||
ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()',
|
||||
[$key, $json]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Proxmox ──────────────────────────────────────────────────────────────
|
||||
if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') {
|
||||
$pveBase = 'https://' . PROXMOX_HOST . ':' . PROXMOX_PORT . '/api2/json';
|
||||
$pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL];
|
||||
|
||||
$nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth);
|
||||
$vmsRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/qemu", $pveAuth);
|
||||
$lxcRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/lxc", $pveAuth);
|
||||
|
||||
$nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null;
|
||||
$vms = $vmsRaw ? (json_decode($vmsRaw, true)['data'] ?? []) : [];
|
||||
$lxcs = $lxcRaw ? (json_decode($lxcRaw, true)['data'] ?? []) : [];
|
||||
|
||||
$vmDetails = [];
|
||||
foreach ($vms as $vm) {
|
||||
$vmDetails[] = [
|
||||
'vmid' => $vm['vmid'],
|
||||
'name' => $vm['name'] ?? 'VM-' . $vm['vmid'],
|
||||
'status' => $vm['status'] ?? 'unknown',
|
||||
'cpu' => round(($vm['cpu'] ?? 0) * 100, 1),
|
||||
'mem_mb' => round(($vm['mem'] ?? 0) / 1048576),
|
||||
'maxmem_mb' => round(($vm['maxmem'] ?? 0) / 1048576),
|
||||
'disk_gb' => round(($vm['disk'] ?? 0) / 1073741824, 1),
|
||||
'uptime' => $vm['uptime'] ?? 0,
|
||||
'netin' => $vm['netin'] ?? 0,
|
||||
'netout' => $vm['netout'] ?? 0,
|
||||
];
|
||||
}
|
||||
$lxcDetails = [];
|
||||
foreach ($lxcs as $lxc) {
|
||||
$lxcDetails[] = [
|
||||
'vmid' => $lxc['vmid'],
|
||||
'name' => $lxc['name'] ?? 'CT-' . $lxc['vmid'],
|
||||
'status' => $lxc['status'] ?? 'unknown',
|
||||
'cpu' => round(($lxc['cpu'] ?? 0) * 100, 1),
|
||||
'mem_mb' => round(($lxc['mem'] ?? 0) / 1048576),
|
||||
'maxmem_mb' => round(($lxc['maxmem'] ?? 0) / 1048576),
|
||||
'type' => 'lxc',
|
||||
];
|
||||
}
|
||||
|
||||
cacheStore('proxmox', [
|
||||
'configured' => true,
|
||||
'node' => PROXMOX_NODE,
|
||||
'node_status' => $nodeStatus,
|
||||
'vms' => $vmDetails,
|
||||
'containers' => $lxcDetails,
|
||||
'vm_count' => count($vmDetails),
|
||||
'ct_count' => count($lxcDetails),
|
||||
'cached_at' => date('c'),
|
||||
]);
|
||||
echo '[cache] Proxmox: ' . count($vmDetails) . ' VMs, ' . count($lxcDetails) . " CTs cached\n";
|
||||
}
|
||||
|
||||
// ── Home Assistant ────────────────────────────────────────────────────────
|
||||
if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false) {
|
||||
$haHeaders = [
|
||||
'Authorization: Bearer ' . HA_TOKEN,
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
$statesRaw = curlGet(HA_URL . '/api/states', $haHeaders, 15);
|
||||
$configRaw = curlGet(HA_URL . '/api/config', $haHeaders, 8);
|
||||
|
||||
$states = $statesRaw ? json_decode($statesRaw, true) : [];
|
||||
$config = $configRaw ? json_decode($configRaw, true) : [];
|
||||
|
||||
$interesting = ['light','switch','sensor','climate','binary_sensor','cover',
|
||||
'media_player','camera','alarm_control_panel','lock','fan','input_boolean'];
|
||||
$grouped = [];
|
||||
foreach (($states ?? []) as $entity) {
|
||||
$domain = explode('.', $entity['entity_id'])[0];
|
||||
if (!in_array($domain, $interesting)) continue;
|
||||
if (strpos($entity['entity_id'], 'adguard') !== false) continue;
|
||||
if (!isset($grouped[$domain])) $grouped[$domain] = [];
|
||||
$grouped[$domain][] = [
|
||||
'entity_id' => $entity['entity_id'],
|
||||
'name' => $entity['attributes']['friendly_name'] ?? $entity['entity_id'],
|
||||
'state' => $entity['state'],
|
||||
'last_changed' => $entity['last_changed'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
cacheStore('ha_entities', [
|
||||
'configured' => true,
|
||||
'ha_version' => $config['version'] ?? 'unknown',
|
||||
'location' => $config['location_name'] ?? 'Home',
|
||||
'entity_count' => count($states ?? []),
|
||||
'entities' => $grouped,
|
||||
'cached_at' => date('c'),
|
||||
]);
|
||||
$total = array_sum(array_map('count', $grouped));
|
||||
echo "[cache] HA: $total entities across " . count($grouped) . " domains cached\n";
|
||||
}
|
||||
|
||||
// ── Weather (wttr.in — refresh every 30 min) ──────────────────────────────
|
||||
$weatherRow = JarvisDB::query(
|
||||
"SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='weather' LIMIT 1"
|
||||
);
|
||||
$weatherAge = $weatherRow ? (time() - (int)$weatherRow[0]['ts']) : PHP_INT_MAX;
|
||||
|
||||
if ($weatherAge > 1800) {
|
||||
// wttr.in code → icon mapping
|
||||
$wttrIcon = function(int $code): string {
|
||||
if ($code === 113) return 'SUNNY';
|
||||
if ($code === 116) return 'PARTLY CLOUDY';
|
||||
if (in_array($code, [119,122])) return 'CLOUDY';
|
||||
if (in_array($code, [143,248,260])) return 'FOGGY';
|
||||
if (in_array($code, [176,263,266,293,296])) return 'LIGHT RAIN';
|
||||
if (in_array($code, [299,302,305,308,353,356,359])) return 'RAIN';
|
||||
if (in_array($code, [317,320,362,365])) return 'SLEET';
|
||||
if (in_array($code, [323,326,329,332,335,338,368,371])) return 'SNOW';
|
||||
if (in_array($code, [386,389,392,395,200])) return 'STORMS';
|
||||
if (in_array($code, [281,284,311,314])) return 'FREEZING RAIN';
|
||||
return 'MIXED';
|
||||
};
|
||||
$wttrEmoji = function(int $code): string {
|
||||
if ($code === 113) return 'Sunny';
|
||||
if ($code === 116) return 'Partly Cloudy';
|
||||
if (in_array($code, [119,122])) return 'Cloudy';
|
||||
if (in_array($code, [143,248,260])) return 'Foggy';
|
||||
if (in_array($code, [176,263,266,293,296])) return 'Light Rain';
|
||||
if (in_array($code, [299,302,305,308,353,356,359])) return 'Rain';
|
||||
if (in_array($code, [317,320,362,365])) return 'Sleet';
|
||||
if (in_array($code, [323,326,329,332,335,338,368,371])) return 'Snow';
|
||||
if (in_array($code, [386,389,392,395,200])) return 'Thunderstorm';
|
||||
return 'Mixed';
|
||||
};
|
||||
|
||||
$weatherRaw = curlGet(
|
||||
'https://wttr.in/FortWorth,TX?format=j1',
|
||||
['User-Agent: curl/7.88 Jarvis/1.0'],
|
||||
15
|
||||
);
|
||||
|
||||
if ($weatherRaw) {
|
||||
$w = json_decode($weatherRaw, true);
|
||||
$cu = $w['current_condition'][0] ?? [];
|
||||
$days = $w['weather'] ?? [];
|
||||
|
||||
$curCode = (int)($cu['weatherCode'] ?? 113);
|
||||
$forecast = [];
|
||||
foreach (array_slice($days, 0, 4) as $day) {
|
||||
// max rain chance across 8 hourly slots
|
||||
$rainPct = 0;
|
||||
foreach ($day['hourly'] ?? [] as $h) {
|
||||
$rainPct = max($rainPct, (int)($h['chanceofrain'] ?? 0));
|
||||
}
|
||||
$dayCode = (int)($day['hourly'][4]['weatherCode'] ?? 113);
|
||||
$forecast[] = [
|
||||
'date' => $day['date'] ?? '',
|
||||
'day' => date('D', strtotime($day['date'] ?? 'now')),
|
||||
'high' => (int)($day['maxtempF'] ?? 0),
|
||||
'low' => (int)($day['mintempF'] ?? 0),
|
||||
'rain_pct' => $rainPct,
|
||||
'desc' => $wttrEmoji($dayCode),
|
||||
'icon' => $wttrIcon($dayCode),
|
||||
];
|
||||
}
|
||||
|
||||
cacheStore('weather', [
|
||||
'source' => 'wttr.in',
|
||||
'location' => 'Fort Worth, TX',
|
||||
'current' => [
|
||||
'temp' => (int)($cu['temp_F'] ?? 0),
|
||||
'feels' => (int)($cu['FeelsLikeF'] ?? 0),
|
||||
'humidity' => (int)($cu['humidity'] ?? 0),
|
||||
'wind' => (int)($cu['windspeedMiles'] ?? 0),
|
||||
'desc' => $wttrEmoji($curCode),
|
||||
'icon' => $wttrIcon($curCode),
|
||||
'cloud' => (int)($cu['cloudcover'] ?? 0),
|
||||
'vis' => (int)($cu['visibility'] ?? 0),
|
||||
],
|
||||
'forecast' => $forecast,
|
||||
'cached_at' => date('c'),
|
||||
]);
|
||||
echo '[cache] Weather: ' . ($cu['temp_F'] ?? '?') . "°F, " . $wttrEmoji($curCode) . " (wttr.in) cached\n";
|
||||
} else {
|
||||
echo "[cache] Weather: wttr.in fetch failed\n";
|
||||
}
|
||||
} else {
|
||||
echo '[cache] Weather: fresh (' . round($weatherAge/60) . " min old)\n";
|
||||
}
|
||||
|
||||
// ── News (RSS feeds — refresh every 30 min) ───────────────────────────────
|
||||
$newsRow = JarvisDB::query(
|
||||
"SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='news' LIMIT 1"
|
||||
);
|
||||
$newsAge = $newsRow ? (time() - (int)$newsRow[0]['ts']) : PHP_INT_MAX;
|
||||
|
||||
if ($newsAge > 1800) {
|
||||
$feeds = [
|
||||
'headlines' => [
|
||||
'https://feeds.bbci.co.uk/news/rss.xml',
|
||||
'https://feeds.npr.org/1001/rss.xml',
|
||||
'https://feeds.abcnews.com/abcnews/topstories',
|
||||
],
|
||||
'technology' => [
|
||||
'http://feeds.arstechnica.com/arstechnica/index',
|
||||
'https://www.theverge.com/rss/index.xml',
|
||||
],
|
||||
];
|
||||
|
||||
function parseRss(string $xml, int $max = 5): array {
|
||||
$items = [];
|
||||
if (!$xml) return $items;
|
||||
libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
$dom->loadXML($xml);
|
||||
$nodes = $dom->getElementsByTagName('item');
|
||||
$count = 0;
|
||||
foreach ($nodes as $node) {
|
||||
if ($count >= $max) break;
|
||||
$title = $node->getElementsByTagName('title')->item(0)?->textContent ?? '';
|
||||
$link = $node->getElementsByTagName('link')->item(0)?->textContent ?? '';
|
||||
$pub = $node->getElementsByTagName('pubDate')->item(0)?->textContent ?? '';
|
||||
$title = html_entity_decode(trim($title), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
if ($title && strlen($title) > 5) {
|
||||
$items[] = [
|
||||
'title' => $title,
|
||||
'link' => trim($link),
|
||||
'pub' => $pub ? date('M j g:ia', strtotime($pub)) : '',
|
||||
'source' => '',
|
||||
];
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
$allNews = [];
|
||||
foreach ($feeds as $category => $urls) {
|
||||
$allNews[$category] = [];
|
||||
foreach ($urls as $url) {
|
||||
$xml = curlGet($url, ['User-Agent: Mozilla/5.0 Jarvis/1.0'], 12);
|
||||
$items = parseRss($xml, 4);
|
||||
// Tag source from URL
|
||||
$src = preg_match('/bbc/i', $url) ? 'BBC' :
|
||||
(preg_match('/npr/i', $url) ? 'NPR' :
|
||||
(preg_match('/abcnews/i', $url) ? 'ABC' :
|
||||
(preg_match('/arstechnica/i', $url) ? 'Ars Technica' :
|
||||
(preg_match('/theverge/i', $url) ? 'The Verge' : 'News'))));
|
||||
foreach ($items as &$it) { $it['source'] = $src; }
|
||||
$allNews[$category] = array_merge($allNews[$category], $items);
|
||||
if (count($allNews[$category]) >= 8) break;
|
||||
}
|
||||
// Trim to 8 per category
|
||||
$allNews[$category] = array_slice($allNews[$category], 0, 8);
|
||||
}
|
||||
|
||||
$totalItems = array_sum(array_map('count', $allNews));
|
||||
cacheStore('news', [
|
||||
'categories' => $allNews,
|
||||
'cached_at' => date('c'),
|
||||
'total' => $totalItems,
|
||||
]);
|
||||
echo "[cache] News: $totalItems articles cached\n";
|
||||
} else {
|
||||
echo '[cache] News: fresh (' . round($newsAge/60) . " min old)\n";
|
||||
}
|
||||
|
||||
echo '[cache] Done at ' . date('Y-m-d H:i:s') . "\n";
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
// System stats endpoint — reads /proc directly, no shell injection risk
|
||||
|
||||
function getCpuUsage(): float {
|
||||
$s1 = file('/proc/stat')[0];
|
||||
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s1, $m1);
|
||||
usleep(200000);
|
||||
$s2 = file('/proc/stat')[0];
|
||||
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s2, $m2);
|
||||
$idle1 = $m1[4] + $m1[5];
|
||||
$total1 = array_sum(array_slice($m1, 1));
|
||||
$idle2 = $m2[4] + $m2[5];
|
||||
$total2 = array_sum(array_slice($m2, 1));
|
||||
$dTotal = $total2 - $total1;
|
||||
$dIdle = $idle2 - $idle1;
|
||||
return $dTotal > 0 ? round((($dTotal - $dIdle) / $dTotal) * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
function getMemory(): array {
|
||||
$lines = file('/proc/meminfo');
|
||||
$mem = [];
|
||||
foreach ($lines as $l) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$total = $mem['MemTotal'] ?? 0;
|
||||
$available = $mem['MemAvailable'] ?? 0;
|
||||
$used = $total - $available;
|
||||
return [
|
||||
'total_mb' => round($total / 1024),
|
||||
'used_mb' => round($used / 1024),
|
||||
'free_mb' => round($available / 1024),
|
||||
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
function getDisk(): array {
|
||||
$disks = [];
|
||||
foreach (disk_total_space('/') as $dummy) break; // warm up
|
||||
$total = disk_total_space('/');
|
||||
$free = disk_free_space('/');
|
||||
$used = $total - $free;
|
||||
return [
|
||||
'total_gb' => round($total / 1073741824, 1),
|
||||
'used_gb' => round($used / 1073741824, 1),
|
||||
'free_gb' => round($free / 1073741824, 1),
|
||||
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
function getDisk2(): array {
|
||||
$total = disk_total_space('/');
|
||||
$free = disk_free_space('/');
|
||||
$used = $total - $free;
|
||||
return [
|
||||
'total_gb' => round($total / 1073741824, 1),
|
||||
'used_gb' => round($used / 1073741824, 1),
|
||||
'free_gb' => round($free / 1073741824, 1),
|
||||
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
function getUptime(): string {
|
||||
$secs = (int)file_get_contents('/proc/uptime');
|
||||
$d = intdiv($secs, 86400); $h = intdiv($secs % 86400, 3600);
|
||||
$m = intdiv($secs % 3600, 60);
|
||||
return "{$d}d {$h}h {$m}m";
|
||||
}
|
||||
|
||||
function getLoadAvg(): array {
|
||||
$l = explode(' ', file_get_contents('/proc/loadavg'));
|
||||
return ['1m' => (float)$l[0], '5m' => (float)$l[1], '15m' => (float)$l[2]];
|
||||
}
|
||||
|
||||
function getNetworkIO(): array {
|
||||
$lines = file('/proc/net/dev');
|
||||
$ifaces = [];
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, ':') === false) continue;
|
||||
[$name, $stats] = explode(':', $line, 2);
|
||||
$name = trim($name);
|
||||
if ($name === 'lo') continue;
|
||||
$vals = preg_split('/\s+/', trim($stats));
|
||||
$ifaces[$name] = [
|
||||
'rx_mb' => round($vals[0] / 1048576, 2),
|
||||
'tx_mb' => round($vals[8] / 1048576, 2),
|
||||
];
|
||||
}
|
||||
return $ifaces;
|
||||
}
|
||||
|
||||
function getServices(): array {
|
||||
$services = ['apache2', 'mysql'];
|
||||
$result = [];
|
||||
foreach ($services as $svc) {
|
||||
$out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null');
|
||||
$result[$svc] = trim($out ?? '') === 'active';
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function getTopProcesses(): array {
|
||||
$out = shell_exec("ps aux --sort=-%cpu | awk 'NR>1 && NR<=6 {print $11\",\"$3\",\"$4}' 2>/dev/null");
|
||||
$procs = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
[$cmd, $cpu, $mem] = explode(',', $line, 3);
|
||||
$procs[] = ['cmd' => basename($cmd), 'cpu' => (float)$cpu, 'mem' => (float)$mem];
|
||||
}
|
||||
return $procs;
|
||||
}
|
||||
|
||||
$cpu = getCpuUsage();
|
||||
$memory = getMemory();
|
||||
$disk = getDisk2();
|
||||
|
||||
$stats = [
|
||||
'cpu' => $cpu,
|
||||
'memory' => $memory,
|
||||
'disk' => $disk,
|
||||
'uptime' => getUptime(),
|
||||
'load' => getLoadAvg(),
|
||||
'network_io' => getNetworkIO(),
|
||||
'services' => getServices(),
|
||||
'processes' => getTopProcesses(),
|
||||
'hostname' => gethostname(),
|
||||
'ip' => JARVIS_IP,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
// Log to history
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO metrics_history (metric_name, metric_value, host) VALUES (?,?,?),(?,?,?),(?,?,?)',
|
||||
['cpu', $cpu, 'jarvis', 'memory', $memory['percent'], 'jarvis', 'disk', $disk['percent'], 'jarvis']
|
||||
);
|
||||
|
||||
echo json_encode($stats);
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// Weather endpoint — serves from api_cache (refreshed every 30 min by cron)
|
||||
|
||||
$cached = JarvisDB::query(
|
||||
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
|
||||
['weather']
|
||||
);
|
||||
|
||||
if ($cached && !empty($cached[0]['data'])) {
|
||||
$out = json_decode($cached[0]['data'], true);
|
||||
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
|
||||
echo json_encode($out);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'current' => null,
|
||||
'forecast' => [],
|
||||
'cache_age_s' => -1,
|
||||
'message' => 'Weather data warming up — available within 5 minutes.',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
class JarvisDB {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function get(): PDO {
|
||||
if (self::$pdo === null) {
|
||||
self::$pdo = new PDO(
|
||||
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
|
||||
DB_USER, DB_PASS,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false]
|
||||
);
|
||||
}
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
public static function query(string $sql, array $params = []): array {
|
||||
$stmt = self::get()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function execute(string $sql, array $params = []): int {
|
||||
$stmt = self::get()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public static function single(string $sql, array $params = []): ?array {
|
||||
$rows = self::query($sql, $params);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
public static function insert(string $sql, array $params = []): int {
|
||||
$stmt = self::get()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int)self::get()->lastInsertId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS Knowledge Base + Intent Engine
|
||||
* Matches user input against intent patterns, substitutes live facts from kb_facts.
|
||||
* Returns a response if matched, or null to escalate to Ollama/Claude.
|
||||
*/
|
||||
|
||||
class KBEngine {
|
||||
|
||||
/**
|
||||
* Try to match the input against known intents.
|
||||
* Returns ['reply' => string, 'intent' => string] or null if no match.
|
||||
*/
|
||||
public static function match(string $input): ?array {
|
||||
$intents = JarvisDB::query(
|
||||
'SELECT * FROM kb_intents WHERE active=1 ORDER BY priority DESC, id ASC'
|
||||
);
|
||||
if (!$intents) return null;
|
||||
|
||||
foreach ($intents as $intent) {
|
||||
$pat = '~' . $intent['pattern'] . '~';
|
||||
if (@preg_match($pat, $input)) {
|
||||
$reply = self::fillTemplate(
|
||||
$intent['response_template'],
|
||||
$intent['fact_category']
|
||||
);
|
||||
return [
|
||||
'reply' => $reply,
|
||||
'intent' => $intent['intent_name'],
|
||||
'action' => $intent['action_type'],
|
||||
];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace {placeholder} tokens in a template with values from kb_facts,
|
||||
* plus built-in dynamic tokens like {current_time}.
|
||||
*/
|
||||
private static function fillTemplate(string $template, ?string $category): string {
|
||||
// Built-in tokens
|
||||
$builtins = [
|
||||
'current_time' => date('g:i A'),
|
||||
'current_date' => date('l, F j Y'),
|
||||
];
|
||||
|
||||
// Fetch all facts for this category (and null-category universal facts)
|
||||
$facts = [];
|
||||
if ($category) {
|
||||
$rows = JarvisDB::query(
|
||||
'SELECT fact_key, fact_value FROM kb_facts
|
||||
WHERE category = ?',
|
||||
[$category]
|
||||
);
|
||||
foreach ($rows as $r) {
|
||||
$facts[$r['fact_key']] = $r['fact_value'];
|
||||
}
|
||||
}
|
||||
// Also pull network facts for network tokens used in any template
|
||||
if (strpos($template, '{online_count}') !== false || strpos($template, '{total_count}') !== false) {
|
||||
$netRows = JarvisDB::query(
|
||||
"SELECT fact_key, fact_value FROM kb_facts
|
||||
WHERE category='network'"
|
||||
);
|
||||
foreach ($netRows as $r) {
|
||||
$facts[$r['fact_key']] = $r['fact_value'];
|
||||
}
|
||||
}
|
||||
|
||||
$allTokens = array_merge($builtins, $facts);
|
||||
|
||||
// Replace placeholders
|
||||
return preg_replace_callback('/\{([a-z0-9_]+)\}/', function ($m) use ($allTokens) {
|
||||
return $allTokens[$m[1]] ?? '[unknown]';
|
||||
}, $template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a fact in kb_facts (upsert).
|
||||
*/
|
||||
public static function storeFact(
|
||||
string $category,
|
||||
string $key,
|
||||
string $value,
|
||||
string $host = 'local',
|
||||
?int $ttlSeconds = null
|
||||
): void {
|
||||
$expires = $ttlSeconds ? gmdate('Y-m-d H:i:s', time() + $ttlSeconds) : null;
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_facts (category, fact_key, fact_value, host, expires_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), expires_at=VALUES(expires_at)',
|
||||
[$category, $key, $value, $host, $expires]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn from conversation — store interesting facts the user mentions.
|
||||
*/
|
||||
public static function learnFromConversation(string $input, string $reply): void {
|
||||
// Preference learning: user states a preference
|
||||
if (preg_match('/(?i)i (prefer|like|want|always)\s+(.+?)(?:\.|$)/', $input, $m)) {
|
||||
$pref = trim($m[2]);
|
||||
if (strlen($pref) < 120) {
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_preferences (pref_key, pref_value)
|
||||
VALUES (?,?)
|
||||
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)',
|
||||
['learned_' . md5($pref), $pref]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a summary of what the KB knows (for system prompt injection).
|
||||
*/
|
||||
public static function getContextSummary(): string {
|
||||
// Exclude entity_map — too large for Ollama 1B tokenizer
|
||||
$facts = JarvisDB::query(
|
||||
"SELECT category, fact_key, fact_value FROM kb_facts
|
||||
WHERE fact_key != 'entity_map'
|
||||
ORDER BY category, updated_at DESC"
|
||||
);
|
||||
if (!$facts) return '';
|
||||
|
||||
$byCategory = [];
|
||||
foreach ($facts as $f) {
|
||||
$byCategory[$f['category']][] = "{$f['fact_key']}={$f['fact_value']}";
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ($byCategory as $cat => $items) {
|
||||
$lines[] = strtoupper($cat) . ': ' . implode(', ', array_slice($items, 0, 8));
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
Options -Indexes
|
||||
RewriteEngine On
|
||||
|
||||
# Route all /api/* requests to api.php
|
||||
RewriteCond %{REQUEST_URI} ^/api(/|$)
|
||||
RewriteRule ^api(/.*)?$ api.php [QSA,L]
|
||||
|
||||
# Everything else serves static files or index.html
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
# JARVIS Agent Installer — macOS
|
||||
# Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY
|
||||
|
||||
set -e
|
||||
|
||||
JARVIS_URL=""
|
||||
REG_KEY=""
|
||||
INSTALL_DIR="$HOME/.jarvis-agent"
|
||||
CONFIG_DIR="$HOME/.jarvis-agent"
|
||||
PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist"
|
||||
SERVICE_LABEL="com.jarvis.agent"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--jarvis-url) JARVIS_URL="$2"; shift 2 ;;
|
||||
--key) REG_KEY="$2"; shift 2 ;;
|
||||
*) echo "Unknown arg: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$JARVIS_URL" ]]; then
|
||||
read -rp "JARVIS URL (e.g. https://jarvis.orbishosting.com): " JARVIS_URL
|
||||
fi
|
||||
if [[ -z "$REG_KEY" ]]; then
|
||||
read -rp "Registration key: " REG_KEY
|
||||
fi
|
||||
|
||||
JARVIS_URL="${JARVIS_URL%/}"
|
||||
|
||||
# Check for Python3
|
||||
PYTHON3=$(command -v python3 2>/dev/null || command -v /usr/bin/python3 2>/dev/null || "")
|
||||
if [[ -z "$PYTHON3" ]]; then
|
||||
echo "Python 3 is required. Install it with:"
|
||||
echo " brew install python3"
|
||||
echo " or download from https://www.python.org/downloads/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using Python: $PYTHON3 ($($PYTHON3 --version))"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Download or copy agent script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -f "$SCRIPT_DIR/jarvis-agent.py" ]]; then
|
||||
cp "$SCRIPT_DIR/jarvis-agent.py" "$INSTALL_DIR/jarvis-agent.py"
|
||||
else
|
||||
echo "Downloading agent..."
|
||||
curl -sSL "https://raw.githubusercontent.com/myronblair/jarvis/master/agent/jarvis-agent.py" \
|
||||
-o "$INSTALL_DIR/jarvis-agent.py"
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/jarvis-agent.py"
|
||||
|
||||
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
|
||||
|
||||
# Write config
|
||||
if [[ ! -f "$CONFIG_DIR/config.json" ]]; then
|
||||
cat > "$CONFIG_DIR/config.json" << JSONEOF
|
||||
{
|
||||
"jarvis_url": "$JARVIS_URL",
|
||||
"registration_key": "$REG_KEY",
|
||||
"hostname": "$HOSTNAME",
|
||||
"agent_type": "linux",
|
||||
"poll_interval": 30,
|
||||
"heartbeat_every": 10,
|
||||
"watch_services": []
|
||||
}
|
||||
JSONEOF
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
fi
|
||||
|
||||
# Override state path in agent for macOS
|
||||
STATE_PATH="$INSTALL_DIR/state.json"
|
||||
|
||||
# Write launchd plist
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
cat > "$PLIST_PATH" << PLISTEOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$SERVICE_LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$PYTHON3</string>
|
||||
<string>$INSTALL_DIR/jarvis-agent.py</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>JARVIS_CONFIG</key>
|
||||
<string>$CONFIG_DIR/config.json</string>
|
||||
<key>JARVIS_STATE</key>
|
||||
<string>$STATE_PATH</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$INSTALL_DIR/jarvis-agent.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$INSTALL_DIR/jarvis-agent.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLISTEOF
|
||||
|
||||
# Load the service
|
||||
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
||||
launchctl load "$PLIST_PATH"
|
||||
|
||||
sleep 2
|
||||
if launchctl list | grep -q "$SERVICE_LABEL"; then
|
||||
echo ""
|
||||
echo "✓ JARVIS Agent installed and running."
|
||||
echo " View logs: tail -f $INSTALL_DIR/jarvis-agent.log"
|
||||
echo " Config: $CONFIG_DIR/config.json"
|
||||
echo " Stop: launchctl unload $PLIST_PATH"
|
||||
else
|
||||
echo "⚠ Agent installed but not running. Check logs:"
|
||||
echo " tail -f $INSTALL_DIR/jarvis-agent.log"
|
||||
fi
|
||||
@@ -0,0 +1,152 @@
|
||||
# JARVIS Agent Installer — Windows (PowerShell)
|
||||
# Run as Administrator:
|
||||
# Set-ExecutionPolicy Bypass -Scope Process
|
||||
# .\install-windows.ps1 -JarvisUrl https://jarvis.orbishosting.com -Key YOUR_KEY
|
||||
# Or one-liner (from PowerShell as Admin):
|
||||
# irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
|
||||
param(
|
||||
[string]$JarvisUrl = "https://jarvis.orbishosting.com",
|
||||
[string]$Key = "",
|
||||
[string]$AgentName = $env:COMPUTERNAME.ToLower()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$InstallDir = "C:\ProgramData\jarvis-agent"
|
||||
$AgentScript = "$InstallDir\jarvis-agent.py"
|
||||
$ConfigFile = "$InstallDir\config.json"
|
||||
$TaskName = "JARVIS-Agent"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ====================================" -ForegroundColor Cyan
|
||||
Write-Host " JARVIS Agent Installer v2.2 " -ForegroundColor Cyan
|
||||
Write-Host " ====================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ── Require admin ─────────────────────────────────────────────────────────────
|
||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "Run PowerShell as Administrator and try again."
|
||||
}
|
||||
|
||||
# ── Prompt if not provided ────────────────────────────────────────────────────
|
||||
$JarvisUrl = $JarvisUrl.TrimEnd("/")
|
||||
if (-not $Key) { $Key = Read-Host "Enter registration key" }
|
||||
|
||||
# ── Find Python 3 ─────────────────────────────────────────────────────────────
|
||||
Write-Host "Checking Python 3..." -NoNewline
|
||||
$python = $null
|
||||
$searchPaths = @(
|
||||
"python", "python3", "py",
|
||||
"$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Python\Python310\python.exe",
|
||||
"C:\Python312\python.exe", "C:\Python311\python.exe"
|
||||
)
|
||||
foreach ($p in $searchPaths) {
|
||||
try {
|
||||
$ver = & $p --version 2>&1
|
||||
if ("$ver" -match "Python 3") { $python = $p; break }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (-not $python) {
|
||||
Write-Host " not found." -ForegroundColor Yellow
|
||||
Write-Host "Installing Python 3.12 via winget..." -ForegroundColor Yellow
|
||||
try {
|
||||
winget install Python.Python.3.12 --silent --accept-package-agreements --accept-source-agreements
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" +
|
||||
[System.Environment]::GetEnvironmentVariable("PATH","User")
|
||||
foreach ($p in @("python","python3")) {
|
||||
try { $ver = & $p --version 2>&1; if ("$ver" -match "Python 3") { $python = $p; break } } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (-not $python) { Write-Error "Python 3 not found. Install from https://python.org then re-run." }
|
||||
|
||||
# Resolve full path for task scheduler (PS 5.1 compatible — no ?. operator)
|
||||
$_pyCmd = Get-Command $python -ErrorAction SilentlyContinue
|
||||
$pythonPath = if ($_pyCmd) { $_pyCmd.Source } else { $null }
|
||||
if (-not $pythonPath -or -not (Test-Path $pythonPath)) {
|
||||
foreach ($p in @("$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
"C:\Python312\python.exe")) {
|
||||
if (Test-Path $p) { $pythonPath = $p; break }
|
||||
}
|
||||
}
|
||||
if (-not $pythonPath) { $pythonPath = $python }
|
||||
Write-Host " $pythonPath" -ForegroundColor Green
|
||||
|
||||
# ── Create install directory ──────────────────────────────────────────────────
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
Write-Host "Install dir: $InstallDir"
|
||||
|
||||
# ── Download Windows agent script ─────────────────────────────────────────────
|
||||
Write-Host "Downloading jarvis-agent-windows.py..." -NoNewline
|
||||
try {
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
$wc = New-Object System.Net.WebClient
|
||||
$wc.Headers.Add("User-Agent", "JARVIS-Installer/1.0")
|
||||
$wc.DownloadFile("$JarvisUrl/agent/jarvis-agent-windows.py", $AgentScript)
|
||||
Write-Host " done." -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Error "Download failed from $JarvisUrl/agent/jarvis-agent-windows.py`nError: $_"
|
||||
}
|
||||
|
||||
# ── Write config ──────────────────────────────────────────────────────────────
|
||||
$agentId = "${AgentName}_windows"
|
||||
$config = [ordered]@{
|
||||
jarvis_url = $JarvisUrl
|
||||
host_header = ""
|
||||
ssl_verify = $true
|
||||
registration_key = $Key
|
||||
agent_type = "windows"
|
||||
hostname = $AgentName
|
||||
agent_id = $agentId
|
||||
poll_interval = 30
|
||||
heartbeat_every = 10
|
||||
watch_services = @("WinDefend", "Spooler")
|
||||
} | ConvertTo-Json -Depth 3
|
||||
|
||||
[System.IO.File]::WriteAllText($ConfigFile, $config, [System.Text.UTF8Encoding]::new($false))
|
||||
Write-Host "Config: $ConfigFile"
|
||||
|
||||
# ── Register scheduled task ───────────────────────────────────────────────────
|
||||
try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute "`"$pythonPath`"" `
|
||||
-Argument "`"$AgentScript`"" -WorkingDirectory $InstallDir
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
|
||||
-RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-StartWhenAvailable -MultipleInstances IgnoreNew
|
||||
|
||||
# Run as current user (not SYSTEM) so per-user Python install is accessible
|
||||
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Highest
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||
-Settings $settings -Principal $principal `
|
||||
-Description "JARVIS AI System Monitoring Agent" -Force | Out-Null
|
||||
|
||||
Write-Host "Scheduled task '$TaskName' registered." -ForegroundColor Green
|
||||
|
||||
# ── Start now ─────────────────────────────────────────────────────────────────
|
||||
Write-Host "Starting agent..." -NoNewline
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Start-Sleep -Seconds 3
|
||||
$state = (Get-ScheduledTask -TaskName $TaskName).State
|
||||
Write-Host " $state" -ForegroundColor $(if ($state -eq "Running") {"Green"} else {"Yellow"})
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Installation complete!" -ForegroundColor Green
|
||||
Write-Host " Machine : $AgentName ($agentId)" -ForegroundColor White
|
||||
Write-Host " JARVIS : $JarvisUrl" -ForegroundColor White
|
||||
Write-Host " Logs : $InstallDir\jarvis-agent.log" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " Useful commands:" -ForegroundColor Gray
|
||||
Write-Host " Get-Content '$InstallDir\jarvis-agent.log' -Tail 20 -Wait" -ForegroundColor Gray
|
||||
Write-Host " Stop-ScheduledTask -TaskName '$TaskName'" -ForegroundColor Gray
|
||||
Write-Host " Start-ScheduledTask -TaskName '$TaskName'" -ForegroundColor Gray
|
||||
Write-Host " Unregister-ScheduledTask -TaskName '$TaskName' -Confirm:`$false" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
# JARVIS Agent Installer
|
||||
# Usage: curl -sSL https://raw.githubusercontent.com/myronblair/jarvis/master/agent/install.sh | sudo bash
|
||||
# Or: sudo bash install.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_REGISTRATION_KEY
|
||||
|
||||
set -e
|
||||
|
||||
JARVIS_URL=""
|
||||
REG_KEY=""
|
||||
AGENT_TYPE="linux"
|
||||
INSTALL_DIR="/opt/jarvis-agent"
|
||||
CONFIG_DIR="/etc/jarvis-agent"
|
||||
STATE_DIR="/var/lib/jarvis-agent"
|
||||
SERVICE_NAME="jarvis-agent"
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--jarvis-url) JARVIS_URL="$2"; shift 2 ;;
|
||||
--key) REG_KEY="$2"; shift 2 ;;
|
||||
--type) AGENT_TYPE="$2"; shift 2 ;;
|
||||
*) echo "Unknown arg: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Interactive prompts if not provided ──────────────────────────────────────
|
||||
if [[ -z "$JARVIS_URL" ]]; then
|
||||
read -rp "JARVIS URL (e.g. https://jarvis.orbishosting.com): " JARVIS_URL
|
||||
fi
|
||||
if [[ -z "$REG_KEY" ]]; then
|
||||
read -rp "Registration key: " REG_KEY
|
||||
fi
|
||||
|
||||
JARVIS_URL="${JARVIS_URL%/}"
|
||||
|
||||
echo ""
|
||||
echo "Installing JARVIS Agent..."
|
||||
echo " URL: $JARVIS_URL"
|
||||
echo " Type: $AGENT_TYPE"
|
||||
echo ""
|
||||
|
||||
# ── Install Python3 if needed ─────────────────────────────────────────────────
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
echo "Installing python3..."
|
||||
apt-get update -qq && apt-get install -y python3 || yum install -y python3 || dnf install -y python3
|
||||
fi
|
||||
|
||||
# ── Create directories ────────────────────────────────────────────────────────
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR"
|
||||
|
||||
# ── Copy agent script ─────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -f "$SCRIPT_DIR/jarvis-agent.py" ]]; then
|
||||
cp "$SCRIPT_DIR/jarvis-agent.py" "$INSTALL_DIR/jarvis-agent.py"
|
||||
else
|
||||
echo "Downloading agent script..."
|
||||
curl -sSL "https://raw.githubusercontent.com/myronblair/jarvis/master/agent/jarvis-agent.py" \
|
||||
-o "$INSTALL_DIR/jarvis-agent.py"
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/jarvis-agent.py"
|
||||
|
||||
# ── Write config ──────────────────────────────────────────────────────────────
|
||||
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
|
||||
|
||||
if [[ -f "$CONFIG_DIR/config.json" ]]; then
|
||||
echo "Config already exists at $CONFIG_DIR/config.json — skipping (keeping existing settings)."
|
||||
else
|
||||
cat > "$CONFIG_DIR/config.json" << JSONEOF
|
||||
{
|
||||
"jarvis_url": "$JARVIS_URL",
|
||||
"registration_key": "$REG_KEY",
|
||||
"hostname": "$HOSTNAME",
|
||||
"agent_type": "$AGENT_TYPE",
|
||||
"poll_interval": 30,
|
||||
"heartbeat_every": 10,
|
||||
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"]
|
||||
}
|
||||
JSONEOF
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
echo "Config written to $CONFIG_DIR/config.json"
|
||||
fi
|
||||
|
||||
# ── Write systemd service ─────────────────────────────────────────────────────
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVCEOF
|
||||
[Unit]
|
||||
Description=JARVIS Monitoring Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 $INSTALL_DIR/jarvis-agent.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
|
||||
# ── Enable and start ──────────────────────────────────────────────────────────
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
echo ""
|
||||
echo "✓ JARVIS Agent installed and running."
|
||||
echo " View logs: journalctl -u $SERVICE_NAME -f"
|
||||
echo " Config: $CONFIG_DIR/config.json"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠ Agent installed but not running. Check logs:"
|
||||
echo " journalctl -u $SERVICE_NAME -n 30"
|
||||
fi
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD.
|
||||
Install via PowerShell: iwr https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
Config: C:\\ProgramData\\jarvis-agent\\config.json
|
||||
Logs: C:\\ProgramData\\jarvis-agent\\jarvis-agent.log
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent")
|
||||
CONFIG_PATH = INSTALL_DIR / "config.json"
|
||||
STATE_PATH = INSTALL_DIR / "state.json"
|
||||
LOG_PATH = INSTALL_DIR / "jarvis-agent.log"
|
||||
AGENT_VERSION = "2.2"
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_log_file = None
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{ts}] {msg}"
|
||||
print(line, flush=True)
|
||||
try:
|
||||
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.")
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH, encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_PATH.exists():
|
||||
with open(STATE_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# ── HTTP ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
import ssl as _ssl
|
||||
|
||||
def _make_ssl_ctx(verify: bool):
|
||||
if not verify:
|
||||
ctx = _ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
_host_header: str = ""
|
||||
|
||||
def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
|
||||
ssl_verify: bool = True) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("User-Agent", "JARVIS-Agent-Windows/1.0")
|
||||
if _host_header:
|
||||
req.add_header("Host", _host_header)
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _make_ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def api_get(url: str, headers: dict = {}, timeout: int = 10,
|
||||
ssl_verify: bool = True) -> dict:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "JARVIS-Agent-Windows/1.0")
|
||||
if _host_header:
|
||||
req.add_header("Host", _host_header)
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _make_ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── Metrics ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _ps(script: str, timeout: int = 8) -> str:
|
||||
"""Run a PowerShell one-liner and return stdout."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_local_ip() -> str:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
_last_cpu_counters = None
|
||||
|
||||
def get_cpu_percent() -> float:
|
||||
global _last_cpu_counters
|
||||
try:
|
||||
out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average")
|
||||
return round(float(out), 1)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_memory() -> dict:
|
||||
try:
|
||||
out = _ps("$o=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$o.TotalVisibleMemorySize;free=$o.FreePhysicalMemory}|ConvertTo-Json")
|
||||
d = json.loads(out)
|
||||
total_kb = int(d.get("total", 0))
|
||||
free_kb = int(d.get("free", 0))
|
||||
used_kb = total_kb - free_kb
|
||||
if total_kb == 0:
|
||||
return {}
|
||||
return {
|
||||
"total_mb": round(total_kb / 1024, 1),
|
||||
"used_mb": round(used_kb / 1024, 1),
|
||||
"free_mb": round(free_kb / 1024, 1),
|
||||
"percent": round(used_kb / total_kb * 100, 1),
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_disk() -> list:
|
||||
try:
|
||||
out = _ps("Get-PSDrive -PSProvider FileSystem | Where-Object{$_.Used -ne $null} | Select-Object Name,@{n='used';e={[math]::Round($_.Used/1GB,2)}},@{n='free';e={[math]::Round($_.Free/1GB,2)}} | ConvertTo-Json")
|
||||
if not out:
|
||||
return []
|
||||
items = json.loads(out)
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
disks = []
|
||||
for d in items:
|
||||
used = float(d.get("used", 0))
|
||||
free = float(d.get("free", 0))
|
||||
total = used + free
|
||||
pct = round(used / total * 100, 1) if total else 0
|
||||
disks.append({
|
||||
"mount": d.get("Name", "?") + ":\\",
|
||||
"size": f"{round(total, 1)}G",
|
||||
"used": f"{used}G",
|
||||
"avail": f"{free}G",
|
||||
"percent": str(int(pct)),
|
||||
})
|
||||
return disks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_uptime() -> dict:
|
||||
try:
|
||||
out = _ps("(Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalSeconds")
|
||||
secs = float(out)
|
||||
days = int(secs // 86400)
|
||||
hours = int((secs % 86400) // 3600)
|
||||
minutes = int((secs % 3600) // 60)
|
||||
return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes,
|
||||
"human": f"{days}d {hours}h {minutes}m"}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_services(cfg: dict) -> list:
|
||||
watch = cfg.get("watch_services", ["WinDefend", "wuauserv", "Spooler"])
|
||||
statuses = []
|
||||
for svc in watch:
|
||||
try:
|
||||
out = _ps(f"(Get-Service -Name '{svc}' -ErrorAction SilentlyContinue).Status")
|
||||
status = "active" if out.lower() == "running" else (out.lower() or "unknown")
|
||||
statuses.append({"service": svc, "status": status})
|
||||
except Exception:
|
||||
statuses.append({"service": svc, "status": "unknown"})
|
||||
return statuses
|
||||
|
||||
def detect_capabilities(cfg: dict) -> list:
|
||||
caps = ["metrics", "commands"]
|
||||
if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists():
|
||||
caps.append("docker")
|
||||
return caps
|
||||
|
||||
def collect_metrics(cfg: dict) -> dict:
|
||||
return {
|
||||
"hostname": cfg.get("hostname", socket.gethostname()),
|
||||
"cpu_percent": get_cpu_percent(),
|
||||
"memory": get_memory(),
|
||||
"disk": get_disk(),
|
||||
"uptime": get_uptime(),
|
||||
"load": [0, 0, 0],
|
||||
"services": get_services(cfg),
|
||||
"platform": "Windows",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
# ── Registration ───────────────────────────────────────────────────────────────
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
hostname = cfg.get("hostname", socket.gethostname().lower())
|
||||
agent_type = cfg.get("agent_type", "linux")
|
||||
ip = get_local_ip()
|
||||
capabilities = detect_capabilities(cfg)
|
||||
agent_id = cfg.get("agent_id", f"{hostname}_{hostname[:8]}")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", True))
|
||||
|
||||
log(f"[JARVIS] Registering as '{agent_id}' from {ip}...")
|
||||
|
||||
result = api_post(
|
||||
f"{cfg['jarvis_url']}/api/agent/register",
|
||||
{"hostname": hostname, "agent_type": agent_type, "ip_address": ip,
|
||||
"capabilities": capabilities, "agent_id": agent_id},
|
||||
headers={"X-Registration-Key": cfg["registration_key"]},
|
||||
ssl_verify=ssl_verify,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
log(f"[ERROR] Registration failed: {result['error']}")
|
||||
return ""
|
||||
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = result.get("agent_id", agent_id)
|
||||
save_state(state)
|
||||
log(f"[JARVIS] Registered. agent_id={state['agent_id']}")
|
||||
return api_key
|
||||
|
||||
# ── Command execution ──────────────────────────────────────────────────────────
|
||||
|
||||
def execute_command(cmd: dict, cfg: dict) -> dict:
|
||||
cmd_type = cmd.get("command_type", "")
|
||||
cmd_data = cmd.get("command_data", {})
|
||||
try:
|
||||
if cmd_type == "ping":
|
||||
host = cmd_data.get("host", "8.8.8.8")
|
||||
r = subprocess.run(["ping", "-n", "3", host], capture_output=True, text=True, timeout=15)
|
||||
return {"success": r.returncode == 0, "output": r.stdout}
|
||||
|
||||
elif cmd_type == "update":
|
||||
log("[CMD] Self-update requested")
|
||||
return {"success": True, "message": "Windows agent self-update not implemented"}
|
||||
|
||||
elif cmd_type == "shell":
|
||||
if not cmd_data.get("allowed", False):
|
||||
return {"success": False, "error": "Shell commands not enabled"}
|
||||
cmd_str = cmd_data.get("command", "")
|
||||
r = subprocess.run(["powershell", "-NoProfile", "-Command", cmd_str],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown command type: {cmd_type}"}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Command timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── Main loop ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
global _host_header
|
||||
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", True))
|
||||
_host_header = cfg.get("host_header", "")
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
api_key = state.get("api_key", "")
|
||||
if not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
log("[ERROR] Could not register with JARVIS. Retrying in 60s...")
|
||||
time.sleep(60)
|
||||
main()
|
||||
return
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_metrics = 0
|
||||
log(f"[JARVIS] Agent v{AGENT_VERSION} (Windows) running. Connecting to {jarvis_url} every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
try:
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify)
|
||||
if "error" in hb:
|
||||
log(f"[WARN] Heartbeat failed: {hb['error']}")
|
||||
else:
|
||||
for cmd in hb.get("commands", []):
|
||||
log(f"[CMD] Executing: {cmd['command_type']}")
|
||||
result = execute_command(cmd, cfg)
|
||||
api_post(f"{jarvis_url}/api/agent/command_result",
|
||||
{"command_id": cmd["id"], "success": result.get("success", False), "result": result},
|
||||
headers, ssl_verify=ssl_verify)
|
||||
|
||||
if now - last_metrics >= poll_interval:
|
||||
metrics = collect_metrics(cfg)
|
||||
r = api_post(f"{jarvis_url}/api/agent/metrics",
|
||||
{"system": metrics}, headers, ssl_verify=ssl_verify)
|
||||
if "error" not in r:
|
||||
last_metrics = now
|
||||
|
||||
except Exception as e:
|
||||
log(f"[ERROR] Loop error: {e}")
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Agent — lightweight system monitor for Linux machines.
|
||||
Registers with JARVIS, reports metrics, and executes commands.
|
||||
|
||||
Install: sudo bash /opt/jarvis-agent/install.sh
|
||||
Config: /etc/jarvis-agent/config.json
|
||||
Logs: journalctl -u jarvis-agent -f
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/config.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/state.json"
|
||||
AGENT_VERSION = "2.3" # bumped on each release
|
||||
|
||||
# ── Config helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
import ssl as _ssl
|
||||
|
||||
def _make_ssl_ctx(verify: bool) -> _ssl.SSLContext | None:
|
||||
if not verify:
|
||||
ctx = _ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
_host_header: str = "" # set from config at startup
|
||||
|
||||
def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
|
||||
ssl_verify: bool = True) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("User-Agent", "JARVIS-Agent/1.0")
|
||||
if _host_header:
|
||||
req.add_header("Host", _host_header)
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _make_ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def api_get(url: str, headers: dict = {}, timeout: int = 10,
|
||||
ssl_verify: bool = True) -> dict:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "JARVIS-Agent/1.0")
|
||||
if _host_header:
|
||||
req.add_header("Host", _host_header)
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _make_ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_local_ip() -> str:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
def detect_capabilities(cfg: dict) -> list:
|
||||
caps = ["metrics", "commands"]
|
||||
# Check for Proxmox
|
||||
if os.path.exists("/usr/bin/pvesh") or os.path.exists("/usr/sbin/pveversion"):
|
||||
caps.append("proxmox")
|
||||
# Check for Docker
|
||||
if os.path.exists("/usr/bin/docker") or os.path.exists("/usr/local/bin/docker"):
|
||||
caps.append("docker")
|
||||
# Check for Ollama
|
||||
if os.path.exists("/usr/local/bin/ollama") or os.path.exists("/usr/bin/ollama"):
|
||||
caps.append("ollama")
|
||||
# Check for Home Assistant
|
||||
if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"):
|
||||
caps.append("homeassistant")
|
||||
return caps
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
"""Register with JARVIS. Returns api_key."""
|
||||
hostname = cfg.get("hostname", socket.gethostname())
|
||||
agent_type = cfg.get("agent_type", "linux")
|
||||
ip = get_local_ip()
|
||||
capabilities = detect_capabilities(cfg)
|
||||
agent_id = cfg.get("agent_id", f"{hostname}_{socket.gethostname()[:8]}")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", True))
|
||||
|
||||
print(f"[JARVIS] Registering as '{agent_id}' ({agent_type}) from {ip}...", flush=True)
|
||||
|
||||
result = api_post(
|
||||
f"{cfg['jarvis_url']}/api/agent/register",
|
||||
{
|
||||
"hostname": hostname,
|
||||
"agent_type": agent_type,
|
||||
"ip_address": ip,
|
||||
"capabilities": capabilities,
|
||||
"agent_id": agent_id,
|
||||
},
|
||||
headers={"X-Registration-Key": cfg["registration_key"]},
|
||||
ssl_verify=ssl_verify,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"[ERROR] Registration failed: {result['error']}", flush=True)
|
||||
return ""
|
||||
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = result.get("agent_id", agent_id)
|
||||
save_state(state)
|
||||
print(f"[JARVIS] Registered. agent_id={state['agent_id']}", flush=True)
|
||||
return api_key
|
||||
|
||||
# ── Metrics collection ────────────────────────────────────────────────────────
|
||||
|
||||
def read_cpu_percent() -> float:
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
line = f.readline()
|
||||
fields = list(map(int, line.split()[1:]))
|
||||
idle = fields[3]
|
||||
total = sum(fields)
|
||||
return round((1 - idle / total) * 100, 1) if total else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
_last_cpu = None
|
||||
|
||||
def get_cpu_percent() -> float:
|
||||
global _last_cpu
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
line = f.readline()
|
||||
fields = list(map(int, line.split()[1:]))
|
||||
idle = fields[3] + fields[4] # idle + iowait
|
||||
total = sum(fields)
|
||||
if _last_cpu:
|
||||
d_idle = idle - _last_cpu[0]
|
||||
d_total = total - _last_cpu[1]
|
||||
result = round((1 - d_idle / d_total) * 100, 1) if d_total else 0.0
|
||||
else:
|
||||
result = 0.0
|
||||
_last_cpu = (idle, total)
|
||||
return result
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_memory() -> dict:
|
||||
mem = {}
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if parts[0] in ("MemTotal:", "MemAvailable:", "MemFree:", "Buffers:", "Cached:"):
|
||||
mem[parts[0].rstrip(":")] = int(parts[1])
|
||||
total = mem.get("MemTotal", 0)
|
||||
available = mem.get("MemAvailable", 0)
|
||||
used = total - available
|
||||
return {
|
||||
"total_mb": round(total / 1024, 1),
|
||||
"used_mb": round(used / 1024, 1),
|
||||
"free_mb": round(available / 1024, 1),
|
||||
"percent": round(used / total * 100, 1) if total else 0,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_disk() -> list:
|
||||
disks = []
|
||||
try:
|
||||
result = subprocess.run(["df", "-h", "--output=source,fstype,size,used,avail,pcent,target"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
lines = result.stdout.strip().split("\n")[1:]
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) >= 7:
|
||||
mount = parts[6]
|
||||
if not any(mount.startswith(x) for x in ["/sys", "/proc", "/dev/pts", "/run", "/snap"]):
|
||||
disks.append({
|
||||
"mount": mount,
|
||||
"size": parts[2],
|
||||
"used": parts[3],
|
||||
"avail": parts[4],
|
||||
"percent": parts[5].rstrip("%"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return disks
|
||||
|
||||
def get_uptime() -> dict:
|
||||
try:
|
||||
with open("/proc/uptime") as f:
|
||||
secs = float(f.read().split()[0])
|
||||
days = int(secs // 86400)
|
||||
hours = int((secs % 86400) // 3600)
|
||||
minutes = int((secs % 3600) // 60)
|
||||
return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes,
|
||||
"human": f"{days}d {hours}h {minutes}m"}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_services(cfg: dict) -> list:
|
||||
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
|
||||
statuses = []
|
||||
for svc in watch:
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3)
|
||||
statuses.append({"service": svc, "status": r.stdout.strip()})
|
||||
except Exception:
|
||||
statuses.append({"service": svc, "status": "unknown"})
|
||||
return statuses
|
||||
|
||||
def get_load() -> list:
|
||||
try:
|
||||
with open("/proc/loadavg") as f:
|
||||
parts = f.read().split()
|
||||
return [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||
except Exception:
|
||||
return [0, 0, 0]
|
||||
|
||||
def collect_metrics(cfg: dict) -> dict:
|
||||
# First reading for CPU delta
|
||||
get_cpu_percent()
|
||||
time.sleep(1)
|
||||
return {
|
||||
"hostname": cfg.get("hostname", socket.gethostname()),
|
||||
"cpu_percent": get_cpu_percent(),
|
||||
"memory": get_memory(),
|
||||
"disk": get_disk(),
|
||||
"uptime": get_uptime(),
|
||||
"load": get_load(),
|
||||
"services": get_services(cfg),
|
||||
"platform": platform.system(),
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
# ── Proxmox metrics ───────────────────────────────────────────────────────────
|
||||
|
||||
def collect_proxmox_metrics(cfg: dict) -> dict | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pvesh", "get", "/nodes/pve/status", "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
node_status = json.loads(result.stdout)
|
||||
vms_result = subprocess.run(
|
||||
["pvesh", "get", "/nodes/pve/qemu", "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
vms = json.loads(vms_result.stdout)
|
||||
return {"node": node_status, "vms": vms}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── Command execution ─────────────────────────────────────────────────────────
|
||||
|
||||
def execute_command(cmd: dict) -> dict:
|
||||
cmd_type = cmd.get("command_type", "")
|
||||
cmd_data = cmd.get("command_data", {})
|
||||
|
||||
try:
|
||||
if cmd_type == "restart_service":
|
||||
svc = cmd_data.get("service", "")
|
||||
if not svc or "/" in svc:
|
||||
return {"success": False, "error": "Invalid service name"}
|
||||
r = subprocess.run(["systemctl", "restart", svc], capture_output=True, text=True, timeout=30)
|
||||
return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr}
|
||||
|
||||
elif cmd_type == "get_logs":
|
||||
svc = cmd_data.get("service", "")
|
||||
lines = min(int(cmd_data.get("lines", 50)), 200)
|
||||
if not svc or "/" in svc:
|
||||
return {"success": False, "error": "Invalid service name"}
|
||||
r = subprocess.run(["journalctl", "-u", svc, "-n", str(lines), "--no-pager"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return {"success": True, "output": r.stdout}
|
||||
|
||||
elif cmd_type == "ping":
|
||||
host = cmd_data.get("host", "8.8.8.8")
|
||||
r = subprocess.run(["ping", "-c", "3", "-W", "2", host], capture_output=True, text=True, timeout=15)
|
||||
return {"success": r.returncode == 0, "output": r.stdout}
|
||||
|
||||
elif cmd_type == "update":
|
||||
updated = self_update(cfg)
|
||||
return {"success": True, "updated": updated}
|
||||
|
||||
elif cmd_type == "shell":
|
||||
# Only allow if explicitly enabled in config
|
||||
if not cmd_data.get("allowed", False):
|
||||
return {"success": False, "error": "Shell commands not enabled"}
|
||||
cmd_str = cmd_data.get("command", "")
|
||||
r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30)
|
||||
return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown command type: {cmd_type}"}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Command timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
global _host_header
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", True))
|
||||
_host_header = cfg.get("host_header", "")
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
# Register if no API key yet
|
||||
api_key = state.get("api_key", "")
|
||||
if not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
|
||||
time.sleep(60)
|
||||
main()
|
||||
return
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_metrics = 0
|
||||
last_update_chk = 0
|
||||
update_interval = int(cfg.get("update_check_hours", 24)) * 3600
|
||||
tick = 0
|
||||
|
||||
print(f"[JARVIS] Agent v{AGENT_VERSION} running. Polling {jarvis_url} every {heartbeat_every}s.", flush=True)
|
||||
|
||||
while True:
|
||||
tick += 1
|
||||
now = time.time()
|
||||
|
||||
try:
|
||||
# Heartbeat + get commands
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify)
|
||||
if "error" in hb:
|
||||
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
||||
else:
|
||||
commands = hb.get("commands", [])
|
||||
for cmd in commands:
|
||||
print(f"[CMD] Executing: {cmd['command_type']}", flush=True)
|
||||
result = execute_command(cmd)
|
||||
api_post(f"{jarvis_url}/api/agent/command_result",
|
||||
{"command_id": cmd["id"], "success": result.get("success", False), "result": result},
|
||||
headers, ssl_verify=ssl_verify)
|
||||
|
||||
# Self-update check (every update_interval seconds, default 24h)
|
||||
if now - last_update_chk >= update_interval:
|
||||
last_update_chk = now
|
||||
self_update(cfg) # restarts process if update found
|
||||
|
||||
# Push metrics every poll_interval seconds
|
||||
if now - last_metrics >= poll_interval:
|
||||
metrics = collect_metrics(cfg)
|
||||
api_post(f"{jarvis_url}/api/agent/metrics",
|
||||
{"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify)
|
||||
|
||||
# Proxmox metrics if available
|
||||
if "proxmox" in detect_capabilities(cfg):
|
||||
px = collect_proxmox_metrics(cfg)
|
||||
if px:
|
||||
api_post(f"{jarvis_url}/api/agent/metrics",
|
||||
{"type": "proxmox", "data": px}, headers, ssl_verify=ssl_verify)
|
||||
|
||||
last_metrics = now
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Loop error: {e}", flush=True)
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
|
||||
# ── Self-update ────────────────────────────────────────────────────────────────
|
||||
|
||||
def self_update(cfg: dict) -> bool:
|
||||
"""Check JARVIS server for a newer version of this script. If different, replace and restart."""
|
||||
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
|
||||
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
|
||||
update_url = cfg.get("update_url", default_update_url)
|
||||
if not update_url:
|
||||
return False
|
||||
script_path = os.path.abspath(__file__)
|
||||
try:
|
||||
req = urllib.request.Request(update_url)
|
||||
req.add_header("User-Agent", "JARVIS-Agent/1.0")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
new_content = resp.read()
|
||||
with open(script_path, "rb") as f:
|
||||
current = f.read()
|
||||
if new_content != current:
|
||||
print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True)
|
||||
with open(script_path, "wb") as f:
|
||||
f.write(new_content)
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[JARVIS] Self-update check failed: {e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
# Kill any stale Task Scheduler approach
|
||||
Unregister-ScheduledTask -TaskName 'JARVIS-Agent' -Confirm:$false -ErrorAction SilentlyContinue
|
||||
|
||||
# Create a VBScript launcher (runs Python silently, no console window)
|
||||
$vbs = 'Set WShell = CreateObject("WScript.Shell")' + "`r`n" +
|
||||
'WShell.Run """C:\Users\myron\AppData\Local\Programs\Python\Python312\pythonw.exe"" ""C:\ProgramData\jarvis-agent\jarvis-agent.py""", 0, False'
|
||||
|
||||
[System.IO.File]::WriteAllText('C:\ProgramData\jarvis-agent\start-agent.vbs', $vbs, [System.Text.ASCIIEncoding]::new())
|
||||
|
||||
# Add to user startup folder so it runs at every login
|
||||
$startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
|
||||
Copy-Item 'C:\ProgramData\jarvis-agent\start-agent.vbs' "$startupDir\JARVIS-Agent.vbs" -Force
|
||||
Write-Host "Startup entry created: $startupDir\JARVIS-Agent.vbs" -ForegroundColor Green
|
||||
|
||||
# Kill any existing python process running the agent
|
||||
Get-Process python*, pythonw* -ErrorAction SilentlyContinue | Where-Object {$_.CommandLine -like '*jarvis-agent*'} | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Launch now
|
||||
Write-Host "Starting agent..." -ForegroundColor Cyan
|
||||
Start-Process 'C:\Users\myron\AppData\Local\Programs\Python\Python312\pythonw.exe' -ArgumentList 'C:\ProgramData\jarvis-agent\jarvis-agent.py' -WorkingDirectory 'C:\ProgramData\jarvis-agent'
|
||||
Start-Sleep -Seconds 4
|
||||
|
||||
# Confirm running
|
||||
$proc = Get-Process pythonw -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Write-Host "Agent running — PID $($proc.Id)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "pythonw not found — check C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Show log tail
|
||||
Start-Sleep -Seconds 2
|
||||
if (Test-Path 'C:\ProgramData\jarvis-agent\jarvis-agent.log') {
|
||||
Write-Host "`nLog:" -ForegroundColor Cyan
|
||||
Get-Content 'C:\ProgramData\jarvis-agent\jarvis-agent.log' -Tail 10
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/**
|
||||
* JARVIS API Router
|
||||
*/
|
||||
require_once __DIR__ . '/../api/config.php';
|
||||
require_once __DIR__ . '/../api/lib/db.php';
|
||||
require_once __DIR__ . '/../api/lib/kb_engine.php';
|
||||
|
||||
session_start();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$path = trim(parse_url($uri, PHP_URL_PATH), '/');
|
||||
$parts = explode('/', $path);
|
||||
|
||||
// Remove 'api' prefix if present
|
||||
if (($parts[0] ?? '') === 'api') array_shift($parts);
|
||||
$endpoint = $parts[0] ?? '';
|
||||
$action = $parts[1] ?? '';
|
||||
|
||||
// Auth check (except login and ping)
|
||||
if ($endpoint !== 'auth' && $endpoint !== 'agent') {
|
||||
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
|
||||
if (empty($token) || $token !== ($_SESSION['jarvis_token'] ?? '')) {
|
||||
$localIP = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$isLocal = in_array($localIP, ['127.0.0.1', '::1', JARVIS_IP]);
|
||||
if (!$isLocal && $endpoint !== 'ping') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized', 'code' => 401]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($endpoint !== 'auth') session_write_close(); // Skip for auth so login can write session token
|
||||
|
||||
$body = file_get_contents('php://input');
|
||||
$data = json_decode($body, true) ?? [];
|
||||
|
||||
switch ($endpoint) {
|
||||
case 'ping':
|
||||
echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]);
|
||||
break;
|
||||
case 'auth':
|
||||
require __DIR__ . '/../api/endpoints/auth.php';
|
||||
break;
|
||||
case 'chat':
|
||||
require __DIR__ . '/../api/endpoints/chat.php';
|
||||
break;
|
||||
case 'system':
|
||||
require __DIR__ . '/../api/endpoints/system.php';
|
||||
break;
|
||||
case 'network':
|
||||
require __DIR__ . '/../api/endpoints/network.php';
|
||||
break;
|
||||
case 'proxmox':
|
||||
require __DIR__ . '/../api/endpoints/proxmox.php';
|
||||
break;
|
||||
case 'ha':
|
||||
require __DIR__ . '/../api/endpoints/ha.php';
|
||||
break;
|
||||
case 'do':
|
||||
require __DIR__ . '/../api/endpoints/do_server.php';
|
||||
break;
|
||||
case 'alerts':
|
||||
require __DIR__ . '/../api/endpoints/alerts.php';
|
||||
break;
|
||||
case 'facts':
|
||||
require __DIR__ . '/../api/endpoints/facts_collector.php';
|
||||
break;
|
||||
case 'weather':
|
||||
require __DIR__ . '/../api/endpoints/weather.php';
|
||||
break;
|
||||
case 'news':
|
||||
require __DIR__ . '/../api/endpoints/news.php';
|
||||
break;
|
||||
case "agent":
|
||||
require __DIR__ . '/../api/endpoints/agent.php';
|
||||
break;
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user