Compare commits

4 Commits

Author SHA1 Message Date
myron 2f74b98bbc Admin panel: add Arc Reactor SETUP button, fix RESTART to use systemctl
- SETUP button in Workers > Daemons runs full deploy:
  copies reactor.py + requirements.txt from deploy/, creates venv,
  pip install, installs jarvis-arc.service, systemctl enable + restart
- RESTART button now calls systemctl restart jarvis-arc (was nohup pkill)
- arcSetup() JS function with confirm dialog and loading state
- Setup log written to /var/log/jarvis/arc-setup.log
2026-07-01 22:13:28 -05:00
myron ed15ff12dd Add _vision_call() helper with Claude -> Ollama -> graceful fallback
- Centralises all vision logic in one place instead of duplicated inline blocks
- Claude remains primary; set OLLAMA_VISION_MODEL env var to enable a local
  vision model (e.g. llava, moondream) as automatic fallback
- Graceful degradation message when no vision provider is available
- Both handle_screenshot and handle_vision now use _vision_call()
2026-07-01 21:37:11 -05:00
myron 3f18cec739 Add missing email_sent/email_actions/email_triage tables to schema; add sent_at column 2026-07-01 21:29:48 -05:00
myron 8911645c20 Add jarvis-backup.sh — daily mysqldump with 7-day retention
Backup script was never migrated when JARVIS moved from DO to VM 211.
Runs daily at 3am, stores compressed dumps in /var/backups/jarvis/,
logs to /var/log/jarvis/backup.log.
2026-07-01 21:22:02 -05:00
4 changed files with 326 additions and 146 deletions
+55
View File
@@ -479,3 +479,58 @@ CREATE TABLE IF NOT EXISTS `guardian_events` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Dump completed on 2026-06-29 23:15:44 -- Dump completed on 2026-06-29 23:15:44
CREATE TABLE IF NOT EXISTS `email_triage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) NOT NULL,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`date_received` datetime DEFAULT NULL,
`category` varchar(32) NOT NULL DEFAULT 'info',
`priority` int(11) NOT NULL DEFAULT 3,
`summary` text DEFAULT NULL,
`draft_reply` text DEFAULT NULL,
`action_taken` varchar(32) NOT NULL DEFAULT 'none',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_msg` (`msg_id`),
KEY `idx_category` (`category`),
KEY `idx_action` (`action_taken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) DEFAULT NULL,
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`received_at` datetime DEFAULT NULL,
`suggested_title` varchar(255) DEFAULT NULL,
`suggested_date` date DEFAULT NULL,
`task_id` int(11) DEFAULT NULL,
`appointment_id` int(11) DEFAULT NULL,
`dismissed` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_dismissed` (`dismissed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_sent` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`to_email` varchar(255) NOT NULL,
`to_name` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`body` text DEFAULT NULL,
`triage_id` int(11) DEFAULT NULL,
`status` varchar(32) NOT NULL DEFAULT 'sent',
`sent_at` timestamp NULL DEFAULT current_timestamp(),
`error` text DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_account` (`account`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# JARVIS database backup — runs daily, 7-day retention
# Lives in repo at /var/www/jarvis/deploy/jarvis-backup.sh
BACKUP_DIR="/var/backups/jarvis"
LOG="/var/log/jarvis/backup.log"
DB_NAME="jarvis_db"
DB_USER="jarvis_user"
DB_PASS="J4rv1s_Pr0t0c0l_2026!"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
OUTFILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting backup..." >> "$LOG"
if mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" | gzip > "$OUTFILE"; then
SIZE=$(du -sh "$OUTFILE" | cut -f1)
echo "[$(date)] Backup OK: $OUTFILE ($SIZE)" >> "$LOG"
else
echo "[$(date)] ERROR: mysqldump failed" >> "$LOG"
exit 1
fi
# 7-day retention
find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -mtime +7 -delete
echo "[$(date)] Cleanup done. Files kept: $(ls $BACKUP_DIR | wc -l)" >> "$LOG"
+67 -107
View File
@@ -41,16 +41,17 @@ DB_PORT = 3306
DB_USER = "jarvis_user" DB_USER = "jarvis_user"
DB_PASS = "J4rv1s_Pr0t0c0l_2026!" DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
DB_NAME = "jarvis_db" DB_NAME = "jarvis_db"
LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log" LOG_FILE = "/var/log/jarvis/arc_reactor.log"
POLL_INTERVAL = 3 POLL_INTERVAL = 3
HEARTBEAT_INTERVAL = 30 HEARTBEAT_INTERVAL = 30
CLAUDE_API_KEY = "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" CLAUDE_API_KEY = "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"
CLAUDE_MODEL = "claude-sonnet-4-6" CLAUDE_MODEL = "claude-sonnet-4-6"
GROQ_API_KEY = "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" GROQ_API_KEY = "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"
GROQ_MODEL = "llama-3.3-70b-versatile" GROQ_MODEL = "llama-3.3-70b-versatile"
OLLAMA_HOST = "http://10.48.200.210:11434" OLLAMA_HOST = "http://10.48.200.210:11434"
OLLAMA_MODEL = "llama3.1:8b" OLLAMA_MODEL = "llama3.1:8b"
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "") # e.g. "llava" or "moondream" -- empty = disabled
GMAIL_USER = "myronblair@gmail.com" GMAIL_USER = "myronblair@gmail.com"
GMAIL_PASS = "demsvdylwweacbcx" GMAIL_PASS = "demsvdylwweacbcx"
@@ -175,15 +176,56 @@ async def _ollama_call(messages: list, system: str = "") -> str:
data = await resp.json() data = await resp.json()
return data.get("response", "") return data.get("response", "")
async def _ollama_vision_call(image_b64: str, prompt: str) -> str:
async with aiohttp.ClientSession() as session: # -- VISION CALL ----------------------------------------------------------
async with session.post( async def _vision_call(image_b64: str, prompt: str) -> tuple:
f"{OLLAMA_HOST}/api/generate", """
json={"model": "llava:7b", "prompt": prompt, "images": [image_b64], "stream": False}, Vision provider cascade: Claude -> Ollama vision model -> graceful fallback.
timeout=aiohttp.ClientTimeout(total=120), Returns (analysis: str, provider: str).
) as resp: To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava").
data = await resp.json() """
return data.get("response", "").strip() # 1. Claude (primary)
if CLAUDE_API_KEY:
try:
import anthropic as _anthropic
_client = _anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
_msg = await _client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
]}],
)
text = _msg.content[0].text if _msg.content else ""
log.info("[VISION] Claude vision OK")
return text, "claude"
except Exception as e:
log.warning(f"[VISION] Claude failed ({e}), trying next provider")
# 2. Ollama vision model (if configured via OLLAMA_VISION_MODEL env var)
if OLLAMA_VISION_MODEL:
try:
async with aiohttp.ClientSession() as _sess:
_payload = {"model": OLLAMA_VISION_MODEL, "prompt": prompt,
"images": [image_b64], "stream": False}
async with _sess.post(f"{OLLAMA_HOST}/api/generate", json=_payload,
timeout=aiohttp.ClientTimeout(total=120)) as _resp:
if _resp.status == 200:
_data = await _resp.json()
text = _data.get("response", "")
log.info(f"[VISION] Ollama vision OK ({OLLAMA_VISION_MODEL})")
return text, f"ollama/{OLLAMA_VISION_MODEL}"
raise RuntimeError(f"Ollama HTTP {_resp.status}")
except Exception as e:
log.warning(f"[VISION] Ollama vision failed ({e})")
# 3. No vision provider available
msg = ("[Vision unavailable] Claude credits depleted and no local vision model configured. "
"To enable: pull a vision model on Ollama (e.g. 'ollama pull llava') then set "
"OLLAMA_VISION_MODEL=llava in the jarvis-arc systemd environment.")
log.warning("[VISION] All vision providers unavailable")
return msg, "none"
async def handle_llm(payload: dict) -> dict: async def handle_llm(payload: dict) -> dict:
message = payload.get("message", "") message = payload.get("message", "")
@@ -668,44 +710,19 @@ async def handle_screenshot(payload: dict) -> dict:
height = result.get("height", 0) height = result.get("height", 0)
file_size = result.get("file_size", 0) file_size = result.get("file_size", 0)
# Run vision analysis if we have an image — llava first, Claude fallback # Run Claude vision analysis if we have an image
analysis = "" analysis = ""
provider_used = "" provider_used = ""
if do_analyze and image_b64: if do_analyze and image_b64:
try: analysis, provider_used = await _vision_call(image_b64, analyze_prompt)
analysis = await _ollama_vision_call(image_b64, analyze_prompt) log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)")
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": analyze_prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
log.info(f"[VISION] Claude fallback analysis complete ({len(analysis)} chars)")
except Exception as e2:
log.warning(f"[VISION] Claude fallback also failed: {e2}")
analysis = f"Vision analysis unavailable: {e2}"
elif do_analyze and not image_b64 and result.get("snapshot_type") == "text": elif do_analyze and not image_b64 and result.get("snapshot_type") == "text":
# Text-only sysinfo snapshot — summarize with Ollama # Text-only sysinfo snapshot — summarize with LLM
try: try:
snap_text = json.dumps(result, indent=2)[:3000] snap_text = json.dumps(result, indent=2)[:3000]
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}" prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
analysis = await llm_call([{"role": "user", "content": prompt}], "ollama") analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
provider_used = "ollama" provider_used = "groq"
except Exception as e: except Exception as e:
analysis = f"Analysis unavailable: {e}" analysis = f"Analysis unavailable: {e}"
@@ -758,41 +775,9 @@ async def handle_vision(payload: dict) -> dict:
if not image_b64: if not image_b64:
raise ValueError("No image data provided") raise ValueError("No image data provided")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname} provider={provider}") log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}")
analysis = "" analysis, provider_used = await _vision_call(image_b64, prompt)
provider_used = provider
if provider == "ollama" or provider == "llava":
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
else:
# Default: llava first, Claude fallback
try:
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
except Exception as e2:
raise RuntimeError(f"Vision analysis failed (llava: {e}, claude: {e2})")
# Update stored screenshot if we have an ID # Update stored screenshot if we have an ID
if screenshot_id: if screenshot_id:
@@ -1053,7 +1038,7 @@ async def guardian_loop() -> None:
"for Myron. Be direct about severity and what action to take. " "for Myron. Be direct about severity and what action to take. "
"No markdown, no headers." "No markdown, no headers."
) )
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "ollama") ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
# Update the most recent guardian event with AI analysis # Update the most recent guardian event with AI analysis
await db_execute( await db_execute(
"""UPDATE guardian_events SET ai_analysis=%s """UPDATE guardian_events SET ai_analysis=%s
@@ -1082,7 +1067,7 @@ async def _guardian_inject_chat(message: str) -> None:
"""Write a proactive JARVIS message into the conversations table so the HUD picks it up.""" """Write a proactive JARVIS message into the conversations table so the HUD picks it up."""
try: try:
await db_execute( await db_execute(
"""INSERT INTO conversations (session_id, role, content, created_at) """INSERT INTO conversations (session_id, role, message, created_at)
VALUES ('guardian', 'assistant', %s, NOW())""", VALUES ('guardian', 'assistant', %s, NOW())""",
(message,) (message,)
) )
@@ -1096,7 +1081,7 @@ async def handle_sitrep(payload: dict) -> dict:
payload: { detail: brief|full, provider: groq } payload: { detail: brief|full, provider: groq }
""" """
detail = payload.get("detail", "full") detail = payload.get("detail", "full")
provider = payload.get("provider", "ollama") provider = payload.get("provider", "groq")
log.info(f"[GUARDIAN] SITREP requested (detail={detail})") log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
@@ -1837,7 +1822,7 @@ Keep the tone confident and action-oriented. Format with clear sections. Under 4
# Store in conversations so HUD can surface it # Store in conversations so HUD can surface it
await db_execute( await db_execute(
"INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())", "INSERT INTO conversations (session_id, role, message, created_at) VALUES ('guardian','assistant',%s,NOW())",
(review_text,) (review_text,)
) )
@@ -2240,31 +2225,6 @@ async def lifespan(app: FastAPI):
log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}") log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}")
await get_pool() await get_pool()
await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,)) await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,))
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_config (
id INT AUTO_INCREMENT PRIMARY KEY,
key_name VARCHAR(64) NOT NULL,
value VARCHAR(255) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_key (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info',
agent_id VARCHAR(64) NOT NULL DEFAULT '',
hostname VARCHAR(128) NOT NULL DEFAULT '',
metric VARCHAR(64) NOT NULL DEFAULT '',
value FLOAT NOT NULL DEFAULT 0,
threshold FLOAT NOT NULL DEFAULT 0,
message TEXT NOT NULL,
ai_analysis TEXT NOT NULL DEFAULT '',
acknowledged TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_severity (severity),
KEY idx_ack (acknowledged),
KEY idx_created (created_at),
KEY idx_agent (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
asyncio.create_task(job_poller()) asyncio.create_task(job_poller())
asyncio.create_task(heartbeat_loop()) asyncio.create_task(heartbeat_loop())
asyncio.create_task(guardian_loop()) asyncio.create_task(guardian_loop())
@@ -2784,13 +2744,13 @@ async def guardian_chat_events(since: str = ""):
"""Return proactive guardian messages injected into conversations.""" """Return proactive guardian messages injected into conversations."""
if since: if since:
rows = await db_fetchall( rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations " "SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC", "WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC",
(since,) (since,)
) )
else: else:
rows = await db_fetchall( rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations " "SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10" "WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10"
) )
return rows or [] return rows or []
+176 -38
View File
@@ -242,9 +242,7 @@ if ($action) {
$search = strtolower(trim($_GET['search'] ?? '')); $search = strtolower(trim($_GET['search'] ?? ''));
$skipDomains = ['sensor','binary_sensor','button','update','select','number', $skipDomains = ['sensor','binary_sensor','button','update','select','number',
'device_tracker','event','image','person','zone','tts','conversation', 'device_tracker','event','image','person','zone','tts','conversation',
'assist_satellite','input_button','media_player','scene','water_heater', 'assist_satellite','input_button'];
'alarm_control_panel','automation','script','calendar','notify',
'weather','camera','siren','remote','todo','lawn_mower'];
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
'_siren_on','_email_on','_manual_record','_infrared_', '_siren_on','_email_on','_manual_record','_infrared_',
'do_not_disturb','matter_server','zerotier','mariadb', 'do_not_disturb','matter_server','zerotier','mariadb',
@@ -489,25 +487,26 @@ if ($action) {
$arcCounts = []; $arcCounts = [];
foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt']; foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt'];
$cronLast = []; $cronLast = [];
$cronLog = '/home/jarvis.orbishosting.com/logs/cron.log'; $cronLog = '/var/log/jarvis/cron.log';
if (file_exists($cronLog)) { if (file_exists($cronLog)) {
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar' " . escapeshellarg($cronLog) . " | tail -60"))); $lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar\\|intent' " . escapeshellarg($cronLog) . " | tail -60")));
foreach ($lines as $line) { foreach ($lines as $line) {
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1]; if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1]; if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1]; if (preg_match('/^\\[(\\d{2}{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
if (preg_match('/^\\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\\].*intent/i', $line, $m)) $cronLast['kb_intent_generator'] = $m[1];
} }
} }
if (empty($cronLast['stats_cache'])) { if (empty($cronLast['stats_cache'])) {
$row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")'); $row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")');
if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t']; if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t'];
} }
$deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log'; $deployLog = '/var/log/jarvis/deploy.log';
if (file_exists($deployLog)) { if (file_exists($deployLog)) {
$last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1"); $last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1");
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1]; if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1];
} }
$wdLog = '/home/jarvis.orbishosting.com/logs/watchdog.log'; $wdLog = '/var/log/jarvis/watchdog.log';
if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog)); if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog));
$bkLog = '/var/backups/jarvis/backup.log'; $bkLog = '/var/backups/jarvis/backup.log';
if (file_exists($bkLog)) { if (file_exists($bkLog)) {
@@ -520,9 +519,9 @@ if ($action) {
break; break;
case 'worker_action': case 'worker_action':
$wType = $data['worker_type'] ?? ''; $wType = $_REQUEST['worker_type'] ?? '';
$wId = $data['worker_id'] ?? ''; $wId = $_REQUEST['worker_id'] ?? '';
$wAction = $data['action'] ?? ''; $wAction = $_REQUEST['waction'] ?? $_REQUEST['action'] ?? '';
if ($wType === 'agent' && $wAction === 'update') { if ($wType === 'agent' && $wAction === 'update') {
JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)', JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)',
[$wId,'update','{}','pending']); [$wId,'update','{}','pending']);
@@ -535,39 +534,61 @@ if ($action) {
j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']); j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']);
} elseif ($wType === 'cron' && $wAction === 'run') { } elseif ($wType === 'cron' && $wAction === 'run') {
$scripts = [ $scripts = [
'facts_collector'=>[true, '/home/jarvis.orbishosting.com/api/endpoints/facts_collector.php'], 'facts_collector' =>[true, '/var/www/jarvis/api/endpoints/facts_collector.php'],
'stats_cache' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/stats_cache.php'], 'stats_cache' =>[true, '/var/www/jarvis/api/endpoints/stats_cache.php'],
'calendar_sync' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/calendar_sync.php'], 'calendar_sync' =>[true, '/var/www/jarvis/api/endpoints/calendar_sync.php'],
'kb_intent_generator' =>[true, '/var/www/jarvis/api/endpoints/kb_intent_generator.php'],
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'], 'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
'jarvis_watchdog'=>[false,'/usr/local/bin/jarvis-watchdog.sh'], 'jarvis_watchdog' =>[false,'/usr/local/bin/jarvis-watchdog.sh'],
]; ];
if (isset($scripts[$wId])) { if (isset($scripts[$wId])) {
[$isPhp,$path] = $scripts[$wId]; [$isPhp,$path] = $scripts[$wId];
$cmd = $isPhp $cmd = $isPhp
? '/usr/local/lsws/lsphp85/bin/lsphp '.escapeshellarg($path).' >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 &' ? '/usr/bin/php8.3 '.escapeshellarg($path).' >> /var/log/jarvis/cron.log 2>&1 &'
: escapeshellcmd($path).' >> /home/jarvis.orbishosting.com/logs/deploy.log 2>&1 &'; : escapeshellcmd($path).' >> /var/log/jarvis/deploy.log 2>&1 &';
shell_exec($cmd); shell_exec($cmd);
j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']); j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']);
} else { bad('Unknown cron worker'); } } else { bad('Unknown cron worker'); }
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') { } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') {
shell_exec('bash -c "mkdir -p /home/jarvis.orbishosting.com/logs; systemctl restart jarvis-arc 2>/dev/null || (pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &)"'); shell_exec('systemctl restart jarvis-arc 2>&1');
j(['ok'=>true,'msg'=>'Arc Reactor restarting']); k(['ok'=>true,'msg'=>'Arc Reactor restarting via systemd']);
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') { } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') {
$setupLog = '/home/jarvis.orbishosting.com/logs/arc_reactor.log'; $log = '/var/log/jarvis/arc-setup.log';
$cmd = 'bash -c "'. $cmd = implode(' && ', [
'mkdir -p /opt/jarvis-arc /home/jarvis.orbishosting.com/logs && '. 'mkdir -p /opt/jarvis-arc /var/log/jarvis',
'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py && '. 'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py',
'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt && '. 'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt',
'if [ ! -d /opt/jarvis-arc/venv ]; then python3 -m venv /opt/jarvis-arc/venv; fi && '. '[ ! -f /opt/jarvis-arc/venv/bin/activate ] && python3 -m venv /opt/jarvis-arc/venv || true',
'/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt && '. '/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt',
'pkill -f reactor.py 2>/dev/null; sleep 1 && '. 'cp /var/www/jarvis/deploy/jarvis-arc.service /etc/systemd/system/jarvis-arc.service',
'cd /opt/jarvis-arc && source venv/bin/activate && '. 'systemctl daemon-reload',
'nohup python3 reactor.py >> '.$setupLog.' 2>&1 &'. 'systemctl enable jarvis-arc',
'" >> '.$setupLog.' 2>&1'; 'systemctl restart jarvis-arc',
shell_exec($cmd); ]);
j(['ok'=>true,'msg'=>'Arc Reactor setup started — check log in ~30s then restart']); shell_exec("($cmd) >> " . escapeshellarg($log) . " 2>&1 &");
k(['ok'=>true,'msg'=>'Arc Reactor setup started — check ' . $log]);
} elseif ($wType === 'agent' && $wAction === 'update_status') {
$ag = JarvisDB::single('SELECT version, status FROM registered_agents WHERE agent_id=?', [$wId]);
j(['ok'=>true,'version'=>$ag['version']??null,'status'=>$ag['status']??'unknown']);
} else { bad('Invalid worker action'); } } else { bad('Invalid worker action'); }
break; break;
case 'intent_gen_log':
$logFile = '/var/log/jarvis/cron.log';
$since = max(0, (int)($_GET['since'] ?? 0));
if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); }
$allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$total = count($allLines);
// snapshot=1 just returns the current line count (call BEFORE triggering run)
if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); }
// Return only KB Intent Generator lines from position $since onward
$out = [];
for ($i = $since; $i < $total; $i++) {
if (stripos($allLines[$i], 'KB Intent Generator') !== false) {
$out[] = $allLines[$i];
}
}
j(['lines'=>$out, 'next_line'=>$total]);
case 'arc_status': case 'arc_status':
$ch = curl_init('http://127.0.0.1:7474/status'); $ch = curl_init('http://127.0.0.1:7474/status');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]);
@@ -1442,6 +1463,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button> <button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button> <button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button> <button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
<button class="btn btn-sm" style="border-color:#00d4ff;color:#00d4ff" onclick="runIntentGenerator()">&#9654; RUN GENERATOR</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center"> <div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
@@ -2174,7 +2196,8 @@ const CRON_DEFS = [
{id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'}, {id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'},
{id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'}, {id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'},
{id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true}, {id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true},
{id:'do_server_backup',label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true}, {id:'do_server_backup', label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
{id:'kb_intent_generator', label:'KB Intent Generator', schedule:'Daily 3am', host:'jarvis'},
]; ];
function wBtn(col) { function wBtn(col) {
const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)'; const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)';
@@ -2201,12 +2224,127 @@ function wToast(msg,err=false) {
t.style.opacity='1';t.textContent=msg; t.style.opacity='1';t.textContent=msg;
setTimeout(()=>{t.style.opacity='0';},3000); setTimeout(()=>{t.style.opacity='0';},3000);
} }
async function runIntentGenerator() {
// Open the working popup
const modalHtml = `
<div style="font-family:var(--mono);font-size:0.72rem;line-height:1.8">
<div id="ig-status" style="color:var(--cyan);margin-bottom:8px;font-size:0.65rem;letter-spacing:1px">LAUNCHING...</div>
<div id="ig-log" style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:10px 12px;min-height:160px;max-height:340px;overflow-y:auto;white-space:pre-wrap;font-size:0.62rem;line-height:1.7;color:var(--text-dim)">Waiting for first output...</div>
<div id="ig-stats" style="margin-top:10px;display:flex;gap:20px;font-size:0.6rem;color:var(--text-dim)"></div>
</div>`;
openModal('&#9654; KB INTENT GENERATOR', modalHtml, null, null);
document.getElementById('modalSave').textContent = 'CLOSE';
document.getElementById('modalSave').onclick = () => { _igStop = true; closeModal(); };
const logEl = document.getElementById('ig-log');
const statEl = document.getElementById('ig-status');
const statsEl = document.getElementById('ig-stats');
let _igStop = false;
let lastLine = 0;
let dotted = 0;
// Snapshot current log position BEFORE triggering so we only see new lines
const snap = await api('intent_gen_log', {snapshot:1});
lastLine = snap?.next_line ?? 0;
// Kick off the generator (fire-and-forget in background on server)
const kick = await api('worker_action', {worker_type:'cron', worker_id:'kb_intent_generator', waction:'run'});
if (!kick || !kick.ok) {
statEl.style.color = 'var(--red)';
statEl.textContent = 'LAUNCH FAILED: ' + (kick?.error || 'unknown error');
document.getElementById('modalSave').textContent = 'CLOSE';
return;
}
statEl.textContent = 'RUNNING — polling log every 3s...';
// Poll the cron log for new output
async function pollLog() {
if (_igStop) return;
try {
const res = await api('intent_gen_log', {since: lastLine});
if (res && Array.isArray(res.lines) && res.lines.length) {
lastLine = res.next_line;
const isFirst = logEl.textContent === 'Waiting for first output...';
if (isFirst) logEl.textContent = '';
res.lines.forEach(line => {
const div = document.createElement('div');
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('fail'))
div.style.color = 'var(--red)';
else if (lower.includes('skip') || lower.includes('warn'))
div.style.color = 'var(--yellow)';
else if (lower.includes('insert') || lower.includes('added') || lower.includes('done') || lower.includes('complete') || lower.includes('cleanup'))
div.style.color = 'var(--green)';
else
div.style.color = 'var(--text-dim)';
div.textContent = line;
logEl.appendChild(div);
});
logEl.scrollTop = logEl.scrollHeight;
// Detect completion
const lastLines = res.lines.join(' ').toLowerCase();
if (lastLines.includes('kb intent generator: done') || lastLines.includes('total inserted') || lastLines.includes('cleanup complete')) {
statEl.style.color = 'var(--green)';
statEl.textContent = 'COMPLETE';
// Extract stats from log
const total = (res.lines.join('\n').match(/inserted[:\s]+(\d+)/i) || [])[1];
if (total) statsEl.innerHTML = `<span style="color:var(--green)">+${total} intents added</span>`;
return; // stop polling
}
dotted = 0;
} else {
// No new lines yet — show a waiting dot
dotted++;
if (dotted < 30) { // Stop waiting after ~90s with no output
const waitDiv = document.createElement('div');
waitDiv.style.color = 'rgba(0,212,255,0.3)';
waitDiv.textContent = '· waiting for output' + '.'.repeat(dotted % 4);
// Replace last waiting line instead of appending
const last = logEl.lastChild;
if (last && last.textContent && last.textContent.startsWith('· waiting')) {
logEl.replaceChild(waitDiv, last);
} else {
if (logEl.textContent === 'Waiting for first output...') logEl.textContent = '';
logEl.appendChild(waitDiv);
}
logEl.scrollTop = logEl.scrollHeight;
} else {
statEl.style.color = 'var(--yellow)';
statEl.textContent = 'RUNNING IN BACKGROUND (check cron log for results)';
return;
}
}
} catch(e) {
// network error — keep trying
}
if (!_igStop) setTimeout(pollLog, 3000);
}
setTimeout(pollLog, 2000); // give server 2s head-start
}
async function arcSetup() {
if (!confirm('Run Arc Reactor setup?\n\nThis will:\n• Copy reactor.py from deploy/\n• Install Python packages\n• Install/update systemd service\n• Restart jarvis-arc')) return;
const btn = event.currentTarget;
btn.disabled = true; btn.textContent = '⏳ SETTING UP...';
try {
const d = await api('worker', {type:'daemon', id:'arc_reactor', action:'setup'});
toast(d.msg || (d.ok ? 'Setup started' : 'Setup failed'), d.ok ? 'ok' : 'err');
setTimeout(() => { loadWorkers(); btn.disabled=false; btn.innerHTML='&#9881; SETUP'; }, 5000);
} catch(e) {
toast('Setup failed: ' + e.message, 'err');
btn.disabled=false; btn.innerHTML='&#9881; SETUP';
}
}
async function workerAction(type,id,action) { async function workerAction(type,id,action) {
if (type === 'agent' && action === 'update') { if (type === 'agent' && action === 'update') {
await agentUpdateFlow(id); await agentUpdateFlow(id);
return; return;
} }
const res=await api('worker_action',{worker_type:type,worker_id:id,action}); const res=await api('worker_action',{worker_type:type,worker_id:id,waction:action});
if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);} if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);}
else wToast((res&&res.error)||'Action failed',true); else wToast((res&&res.error)||'Action failed',true);
} }
@@ -2231,7 +2369,7 @@ async function agentUpdateFlow(agentId) {
}; };
// Dispatch command // Dispatch command
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update'}); const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update'});
if (!res || !res.ok) { if (!res || !res.ok) {
log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)'); log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)');
document.getElementById('modalSave').style.display = ''; document.getElementById('modalSave').style.display = '';
@@ -2242,7 +2380,7 @@ async function agentUpdateFlow(agentId) {
log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)'); log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)');
// Poll agent_commands for the result (max 90s) // Poll agent_commands for the result (max 90s)
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update_status'}).catch(()=>null); const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update_status'}).catch(()=>null);
// Actually poll via workers_list for version change // Actually poll via workers_list for version change
const deadline = Date.now() + 90000; const deadline = Date.now() + 90000;
let done = false; let done = false;
@@ -2346,9 +2484,9 @@ async function loadWorkers() {
<td class="ts">jarvis-do :7474</td> <td class="ts">jarvis-do :7474</td>
<td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td> <td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td>
<td class="ts">${rinfo}</td> <td class="ts">${rinfo}</td>
<td style="display:flex;gap:4px"> <td style="white-space:nowrap">
<button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">&#8635; RESTART</button> <button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">&#8635; RESTART</button>
<button onclick="workerAction('daemon','arc_reactor','setup')" style="${wBtn('orange')}">&#9881; SETUP</button> <button onclick="arcSetup()" style="${wBtn('cyan')};margin-left:6px">&#9881; SETUP</button>
</td> </td>
</tr>`; </tr>`;
} }