Compare commits

20 Commits

Author SHA1 Message Date
myron 10350cbcd8 Update infrastructure reference: document 2026-07-06 security review and fixes 2026-07-05 20:13:21 -05:00
myron 24bc876c1d Fix code review findings: unauthenticated infra doc + backup file exposure, onclick-attribute XSS breakouts (admin panel and front-end), plaintext calendar passwords, k()->j() typo, wildcard CORS, session cookie hardening (HttpOnly/SameSite) 2026-07-05 20:09:52 -05:00
myron cfb5b2a3f9 Document ChuckCo Time Keeper code review fixes and admin login rate limiting 2026-07-05 19:17:14 -05:00
myron 0b1a19d9de Update infrastructure reference: fix stale JARVIS port, rotate GitHub PAT reference, add ChuckCo Time Keeper site, FortiGate DNS change, git/repo management section 2026-07-05 18:58:56 -05:00
myron 66a5443f22 Document Homebridge backup switch from MSP360 to Proxmox vzdump 2026-07-04 12:21:53 -05:00
myron e5536b077c Document MSP360 backup status dashboard integration 2026-07-03 08:03:54 -05:00
myron a292411d52 Stop hardcoding a generic watch_services list on every agent install
The installer wrote the same service list (ollama, homeassistant, mysql,
mariadb, nginx, apache2, docker) onto every host regardless of what it
actually runs, causing false "Service Down" alerts on hosts that never
installed those services. Default is now empty; watch_services should be
set per-host to match what's actually running there.
2026-07-02 20:28:42 -05:00
myron 26b501b600 fix: address 8 code-review findings in KB topic manager
- Fix 1 (ORDER BY): use table alias t.id to force integer PK ordering;
  topic_id alias was causing alphabetical sort, breaking rotation offsets
- Fix 2 (PDOException): wrap INSERT/UPDATE in try/catch in kb_topic_save;
  duplicate topic_id now returns clean JSON error instead of HTTP 500
- Fix 3 (XSS/onclick): DEL button passes only t.id; tmDelete looks up
  name from _topicsData, removing JSON.stringify from onclick attribute
- Fix 4 (validation): topic_id must contain at least one [a-z0-9] after
  sanitization; "@@@" to "___" no longer passes required-field check
- Fix 5 (stale logs): guard comment and log messages updated from 20h to
  4h to match the actual 14400s threshold
- Fix 6 (dedup): tmEsc() replaced with existing esc() throughout; removed
  duplicate function definition
- Fix 7 (toggle rollback): tmToggle uses inline fetch to revert el.checked
  on API failure instead of leaving the UI in wrong state
- Fix 8 (confirm): tmDelete uses openModal confirmation instead of
  confirm() dialog, consistent with project live-popup convention

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 10:26:47 -05:00
myron 7327104612 feat: DB-driven KB topic manager — admin UI + migration
- Create kb_generator_topics table (140 topics migrated from hardcoded array)
- kb_intent_generator.php now loads active topics from DB instead of $BATCHES array
- Admin panel: MANAGE TOPICS button opens full topic manager modal
  - Browse all 140 topics with search, category, and active/disabled filters
  - Inline active toggle per topic (checkbox)
  - Add new topic form (topic_id, category, name, description)
  - Edit existing topics (all fields)
  - Delete topics with confirmation
  - Run count display
- Backend API cases: kb_topics, kb_topic_save, kb_topic_delete, kb_topic_toggle
- Topics can now be added/managed without any code changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 07:04:20 -05:00
myron a13f750846 feat(kb-intent): expand to 140 topics, 109 KB topic library
Rich topic library across mathematics (12), sciences (13), history (10),
government/economics, literature, life skills (13), technology (5),
Texas/local (4), national US (3), world affairs (5), human sexuality (2),
astronomy (3), space (2), culture/arts (8), sports (8), food/drink (4),
home/DIY (3), wellness (4), tech continued (3), national continued (2),
world continued (2), medicine (3), math continued (2), communication (2).

Each topic has detailed multi-subtopic descriptions (~200 chars each)
vs prior 8-word descriptions — significantly richer Groq prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 06:50:12 -05:00
myron 3c8dd8e206 feat(kb-intent): 122-topic rotation engine, every-6h cron
- Expanded $BATCHES from 25 to 122 topics across: life skills, personal
  finance, Texas/local, US national, world geopolitics, human sexuality
  (educational), deep astronomy (15 topics), and space exploration (15 topics)
- Rotation engine: 25 topics per run cycling through all 122 in order;
  wraps to topic 1 on cycle complete (~30h full cycle at 6h interval)
- batch_offset tracked in kb_facts for cross-run persistence
- Cron changed from 0 3 * * * to 0 */6 * * * (every 6 hours)
- 20h skip guard reduced to 4h so 6h cron isn't blocked
- max_tokens bumped 3500 → 5000 to prevent JSON truncation errors
- JSON extraction: partial recovery + raw snippet logged on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:53:57 -05:00
myron 554488aefa fix(kb-intent): reduce batch size 40→20 intents, raise max_tokens to 5000
Token budget was too low for 40 intents — responses were truncated mid-JSON,
causing "No JSON array found" on ~88% of batches. Fixes 880 errors/run.
Also adds partial-JSON truncation recovery and raw response debug logging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:44:22 -05:00
myron e99c3aa171 feat(admin): KB intent generator — force run, background on close, history panel
- Always runs when triggered from admin (JARVIS_FORCE_RUN=1 bypasses 20h guard)
- Closing popup stops UI polling but server job continues; toast confirms background run
- History panel at top of popup shows last success time/count + failures from last 7 days
- New intent_gen_history backend case parses cron.log for status summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:39:41 -05:00
myron 2528b37ea1 docs: update infrastructure reference for July 2026 session
- VM 210: correct SSH path, add llava:7b vision model
- Add Arc Reactor section (service, logs, SETUP button, vision cascade)
- JARVIS AI tiers: llama3.2 → llama3.1:8b, add vision tier
- Backup Systems: add JARVIS database backup (path, format, retention)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:32:31 -05:00
myron 46dabe3c31 feat(admin): Arc Reactor SETUP button — live log popup
- Replace arcSetup() confirm/toast with openModal() live popup
- Add arc_setup_log PHP backend (polls /var/log/jarvis/arc-setup.log)
- Color-coded line output: green=ok/done/active, red=error/fail, yellow=warn
- Auto-detects completion keywords and updates status header
- CLOSE button stops polling and refreshes worker table

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