Compare commits

23 Commits

Author SHA1 Message Date
myron 80b0dfc583 Document code review + fixes across all 4 DO-hosted business sites (2026-07-06) 2026-07-06 02:58:28 -05:00
myron 30e2bc093c Document WEB HOST agent install and timezone freshness-check fix 2026-07-05 21:56:23 -05:00
myron a29670e93d Fix WEB HOST stats never populating (DO server had no monitoring agent installed) and a timezone bug in freshness checks (America/Chicago default timezone + strtotime() on naive UTC MySQL timestamps caused sites/proxmox/ollama facts to never refresh, and could have suppressed the KB intent generator's scheduled runs) 2026-07-05 21:30:53 -05:00
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
15 changed files with 1209 additions and 193 deletions
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {} return {}
def get_services(cfg: dict) -> list: def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"]) watch = cfg.get("watch_services", [])
statuses = [] statuses = []
for svc in watch: for svc in watch:
try: try:
+1 -1
View File
@@ -39,7 +39,7 @@ foreach ($svcNames as $s) {
// Site health from kb_facts // Site health from kb_facts
$siteLabels = [ $siteLabels = [
"jarvis" => "jarvis.orbishosting.com:1972", "jarvis" => "jarvis.orbishosting.com",
"tomsjavajive" => "tomsjavajive.com", "tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com", "epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com", "parkersling" => "parkerslingshotrentals.com",
+9 -4
View File
@@ -21,13 +21,18 @@ function collect_all(): array {
// Returns true if a fact category has been updated within $secs seconds. // Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh. // Prevents expensive external calls when data is still fresh.
// Comparison is done entirely in SQL (via NOW()) rather than PHP's time()/strtotime()
// — this file's config.php sets date_default_timezone_set('America/Chicago'), which
// makes strtotime() misinterpret MySQL's naive (UTC) datetime strings as being in
// Chicago time, throwing every freshness check off by the UTC offset (previously
// caused "sites" to always look artificially fresh and never actually refresh).
$fresh = function(string $cat, int $secs): bool { $fresh = function(string $cat, int $secs): bool {
$row = JarvisDB::query( $row = JarvisDB::query(
'SELECT updated_at FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1', 'SELECT (updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)) AS is_fresh FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
[$cat] [$secs, $cat]
); );
if (empty($row[0]['updated_at'])) return false; if (empty($row)) return false;
return (time() - strtotime($row[0]['updated_at'])) < $secs; return (bool) $row[0]['is_fresh'];
}; };
+270
View File
@@ -0,0 +1,270 @@
<?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 */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() — this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent 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 && $recentRun && $recentRun['is_recent']) {
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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Dump completed on 2026-06-29 23:15:44 -- Dump completed on 2026-06-29 23:15:44
CREATE TABLE IF NOT EXISTS `email_triage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) NOT NULL,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`date_received` datetime DEFAULT NULL,
`category` varchar(32) NOT NULL DEFAULT 'info',
`priority` int(11) NOT NULL DEFAULT 3,
`summary` text DEFAULT NULL,
`draft_reply` text DEFAULT NULL,
`action_taken` varchar(32) NOT NULL DEFAULT 'none',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_msg` (`msg_id`),
KEY `idx_category` (`category`),
KEY `idx_action` (`action_taken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) DEFAULT NULL,
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`received_at` datetime DEFAULT NULL,
`suggested_title` varchar(255) DEFAULT NULL,
`suggested_date` date DEFAULT NULL,
`task_id` int(11) DEFAULT NULL,
`appointment_id` int(11) DEFAULT NULL,
`dismissed` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_dismissed` (`dismissed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_sent` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`to_email` varchar(255) NOT NULL,
`to_name` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`body` text DEFAULT NULL,
`triage_id` int(11) DEFAULT NULL,
`status` varchar(32) NOT NULL DEFAULT 'sent',
`sent_at` timestamp NULL DEFAULT current_timestamp(),
`error` text DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_account` (`account`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+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_USER = "jarvis_user"
DB_PASS = "J4rv1s_Pr0t0c0l_2026!" DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
DB_NAME = "jarvis_db" DB_NAME = "jarvis_db"
LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log" LOG_FILE = "/var/log/jarvis/arc_reactor.log"
POLL_INTERVAL = 3 POLL_INTERVAL = 3
HEARTBEAT_INTERVAL = 30 HEARTBEAT_INTERVAL = 30
CLAUDE_API_KEY = "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" CLAUDE_API_KEY = "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"
CLAUDE_MODEL = "claude-sonnet-4-6" CLAUDE_MODEL = "claude-sonnet-4-6"
GROQ_API_KEY = "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" GROQ_API_KEY = "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"
GROQ_MODEL = "llama-3.3-70b-versatile" GROQ_MODEL = "llama-3.3-70b-versatile"
OLLAMA_HOST = "http://10.48.200.210:11434" OLLAMA_HOST = "http://10.48.200.210:11434"
OLLAMA_MODEL = "llama3.1:8b" OLLAMA_MODEL = "llama3.1:8b"
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "") # e.g. "llava" or "moondream" -- empty = disabled
GMAIL_USER = "myronblair@gmail.com" GMAIL_USER = "myronblair@gmail.com"
GMAIL_PASS = "demsvdylwweacbcx" GMAIL_PASS = "demsvdylwweacbcx"
@@ -175,15 +176,56 @@ async def _ollama_call(messages: list, system: str = "") -> str:
data = await resp.json() data = await resp.json()
return data.get("response", "") return data.get("response", "")
async def _ollama_vision_call(image_b64: str, prompt: str) -> str:
async with aiohttp.ClientSession() as session: # -- VISION CALL ----------------------------------------------------------
async with session.post( async def _vision_call(image_b64: str, prompt: str) -> tuple:
f"{OLLAMA_HOST}/api/generate", """
json={"model": "llava:7b", "prompt": prompt, "images": [image_b64], "stream": False}, Vision provider cascade: Claude -> Ollama vision model -> graceful fallback.
timeout=aiohttp.ClientTimeout(total=120), Returns (analysis: str, provider: str).
) as resp: To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava").
data = await resp.json() """
return data.get("response", "").strip() # 1. Claude (primary)
if CLAUDE_API_KEY:
try:
import anthropic as _anthropic
_client = _anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
_msg = await _client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
]}],
)
text = _msg.content[0].text if _msg.content else ""
log.info("[VISION] Claude vision OK")
return text, "claude"
except Exception as e:
log.warning(f"[VISION] Claude failed ({e}), trying next provider")
# 2. Ollama vision model (if configured via OLLAMA_VISION_MODEL env var)
if OLLAMA_VISION_MODEL:
try:
async with aiohttp.ClientSession() as _sess:
_payload = {"model": OLLAMA_VISION_MODEL, "prompt": prompt,
"images": [image_b64], "stream": False}
async with _sess.post(f"{OLLAMA_HOST}/api/generate", json=_payload,
timeout=aiohttp.ClientTimeout(total=120)) as _resp:
if _resp.status == 200:
_data = await _resp.json()
text = _data.get("response", "")
log.info(f"[VISION] Ollama vision OK ({OLLAMA_VISION_MODEL})")
return text, f"ollama/{OLLAMA_VISION_MODEL}"
raise RuntimeError(f"Ollama HTTP {_resp.status}")
except Exception as e:
log.warning(f"[VISION] Ollama vision failed ({e})")
# 3. No vision provider available
msg = ("[Vision unavailable] Claude credits depleted and no local vision model configured. "
"To enable: pull a vision model on Ollama (e.g. 'ollama pull llava') then set "
"OLLAMA_VISION_MODEL=llava in the jarvis-arc systemd environment.")
log.warning("[VISION] All vision providers unavailable")
return msg, "none"
async def handle_llm(payload: dict) -> dict: async def handle_llm(payload: dict) -> dict:
message = payload.get("message", "") message = payload.get("message", "")
@@ -668,44 +710,19 @@ async def handle_screenshot(payload: dict) -> dict:
height = result.get("height", 0) height = result.get("height", 0)
file_size = result.get("file_size", 0) file_size = result.get("file_size", 0)
# Run vision analysis if we have an image — llava first, Claude fallback # Run Claude vision analysis if we have an image
analysis = "" analysis = ""
provider_used = "" provider_used = ""
if do_analyze and image_b64: if do_analyze and image_b64:
try: analysis, provider_used = await _vision_call(image_b64, analyze_prompt)
analysis = await _ollama_vision_call(image_b64, analyze_prompt) log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)")
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": analyze_prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
log.info(f"[VISION] Claude fallback analysis complete ({len(analysis)} chars)")
except Exception as e2:
log.warning(f"[VISION] Claude fallback also failed: {e2}")
analysis = f"Vision analysis unavailable: {e2}"
elif do_analyze and not image_b64 and result.get("snapshot_type") == "text": elif do_analyze and not image_b64 and result.get("snapshot_type") == "text":
# Text-only sysinfo snapshot — summarize with Ollama # Text-only sysinfo snapshot — summarize with LLM
try: try:
snap_text = json.dumps(result, indent=2)[:3000] snap_text = json.dumps(result, indent=2)[:3000]
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}" prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
analysis = await llm_call([{"role": "user", "content": prompt}], "ollama") analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
provider_used = "ollama" provider_used = "groq"
except Exception as e: except Exception as e:
analysis = f"Analysis unavailable: {e}" analysis = f"Analysis unavailable: {e}"
@@ -758,41 +775,9 @@ async def handle_vision(payload: dict) -> dict:
if not image_b64: if not image_b64:
raise ValueError("No image data provided") raise ValueError("No image data provided")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname} provider={provider}") log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}")
analysis = "" analysis, provider_used = await _vision_call(image_b64, prompt)
provider_used = provider
if provider == "ollama" or provider == "llava":
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
else:
# Default: llava first, Claude fallback
try:
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
except Exception as e2:
raise RuntimeError(f"Vision analysis failed (llava: {e}, claude: {e2})")
# Update stored screenshot if we have an ID # Update stored screenshot if we have an ID
if screenshot_id: if screenshot_id:
@@ -1053,7 +1038,7 @@ async def guardian_loop() -> None:
"for Myron. Be direct about severity and what action to take. " "for Myron. Be direct about severity and what action to take. "
"No markdown, no headers." "No markdown, no headers."
) )
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "ollama") ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
# Update the most recent guardian event with AI analysis # Update the most recent guardian event with AI analysis
await db_execute( await db_execute(
"""UPDATE guardian_events SET ai_analysis=%s """UPDATE guardian_events SET ai_analysis=%s
@@ -1082,7 +1067,7 @@ async def _guardian_inject_chat(message: str) -> None:
"""Write a proactive JARVIS message into the conversations table so the HUD picks it up.""" """Write a proactive JARVIS message into the conversations table so the HUD picks it up."""
try: try:
await db_execute( await db_execute(
"""INSERT INTO conversations (session_id, role, content, created_at) """INSERT INTO conversations (session_id, role, message, created_at)
VALUES ('guardian', 'assistant', %s, NOW())""", VALUES ('guardian', 'assistant', %s, NOW())""",
(message,) (message,)
) )
@@ -1096,7 +1081,7 @@ async def handle_sitrep(payload: dict) -> dict:
payload: { detail: brief|full, provider: groq } payload: { detail: brief|full, provider: groq }
""" """
detail = payload.get("detail", "full") detail = payload.get("detail", "full")
provider = payload.get("provider", "ollama") provider = payload.get("provider", "groq")
log.info(f"[GUARDIAN] SITREP requested (detail={detail})") log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
@@ -1837,7 +1822,7 @@ Keep the tone confident and action-oriented. Format with clear sections. Under 4
# Store in conversations so HUD can surface it # Store in conversations so HUD can surface it
await db_execute( await db_execute(
"INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())", "INSERT INTO conversations (session_id, role, message, created_at) VALUES ('guardian','assistant',%s,NOW())",
(review_text,) (review_text,)
) )
@@ -2240,31 +2225,6 @@ async def lifespan(app: FastAPI):
log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}") log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}")
await get_pool() await get_pool()
await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,)) await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,))
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_config (
id INT AUTO_INCREMENT PRIMARY KEY,
key_name VARCHAR(64) NOT NULL,
value VARCHAR(255) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_key (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info',
agent_id VARCHAR(64) NOT NULL DEFAULT '',
hostname VARCHAR(128) NOT NULL DEFAULT '',
metric VARCHAR(64) NOT NULL DEFAULT '',
value FLOAT NOT NULL DEFAULT 0,
threshold FLOAT NOT NULL DEFAULT 0,
message TEXT NOT NULL,
ai_analysis TEXT NOT NULL DEFAULT '',
acknowledged TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_severity (severity),
KEY idx_ack (acknowledged),
KEY idx_created (created_at),
KEY idx_agent (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
asyncio.create_task(job_poller()) asyncio.create_task(job_poller())
asyncio.create_task(heartbeat_loop()) asyncio.create_task(heartbeat_loop())
asyncio.create_task(guardian_loop()) asyncio.create_task(guardian_loop())
@@ -2784,13 +2744,13 @@ async def guardian_chat_events(since: str = ""):
"""Return proactive guardian messages injected into conversations.""" """Return proactive guardian messages injected into conversations."""
if since: if since:
rows = await db_fetchall( rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations " "SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC", "WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC",
(since,) (since,)
) )
else: else:
rows = await db_fetchall( rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations " "SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10" "WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10"
) )
return rows or [] return rows or []
@@ -1,5 +1,5 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP ssh: connect to host 10.48.200.90 port 22: Connection timed out
**Last Updated:** 2026-06-18 :** 2026-07-06
**Owner:** Myron Blair — myronblair@outlook.com **Owner:** Myron Blair — myronblair@outlook.com
--- ---
@@ -17,6 +17,7 @@
10. [Backup Systems](#10-backup-systems) 10. [Backup Systems](#10-backup-systems)
11. [SSH Quick Reference](#11-ssh-quick-reference) 11. [SSH Quick Reference](#11-ssh-quick-reference)
12. [Critical Credentials Master List](#12-critical-credentials-master-list) 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] ────────────────────────────────────────────────────────────── [Cloudflare CDN] ──────────────────────────────────────────────────────────────
│ (proxied DNS for public sites) │ (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) └─► [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 113 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
│ ├── VM 118 10.48.200.18 Homebridge │ ├── VM 118 10.48.200.18 Homebridge
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel │ ├── VM 120 10.48.200.110 NovaCPX hosting panel
│ ├── VM 210 10.48.200.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 │ └── CT110 10.48.200.19 WireGuard exit container
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor) ├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
@@ -73,7 +74,7 @@ FortiGate Port Forwards:
| **OS** | Ubuntu 22.04 LTS | | **OS** | Ubuntu 22.04 LTS |
| **Panel** | CyberPanel (OpenLiteSpeed) | | **Panel** | CyberPanel (OpenLiteSpeed) |
| **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` | | **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` |
| **Purpose** | All public websites (6 sites) — webhook deploy for websites | | **Purpose** | All public websites (7 sites) — webhook deploy for websites |
**Key Paths:** **Key Paths:**
- All sites: `/home/<domain>/public_html/` - 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 | | Field | Value |
|-------|-------| |-------|-------|
| **IP** | 10.48.200.210 | | **IP** | 10.48.200.210 |
| **OS** | Ubuntu (cloud image) | | **OS** | Ubuntu (cloud image) |
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) | | **SSH** | `ssh root@10.48.200.210` via PVE1 hop — password: `Joker1974!!!` |
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat | | **Purpose** | Local AI inference — chat (llama3.1:8b) + vision (llava:7b) |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) | | **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` | | **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
| **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). **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). 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. **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) ### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
| Field | Value | | 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)` | | **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
| **GitHub** | `myronblair/jarvis` | | **GitHub** | `myronblair/jarvis` |
| **Login** | `myron / Joker1974!!!` | | **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 ### tomsjavajive.com — Tom's Java Jive
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
@@ -466,13 +488,53 @@ See Section 7 for full JARVIS details.
--- ---
### Code review pass — all 4 DO-hosted business sites (2026-07-06)
Full security/correctness review of tomsjavajive.com, tomtomgames.com, parkerslingshotrentals.com, epictravelexpeditions.com (orbishosting.com apex and orbis.orbishosting.com excluded — not live/do-not-touch per earlier note). 10 findings, all fixed, tested, committed, and pushed to each site's `main` branch same day.
**Critical (live exploitable, now fixed):**
- **tomsjavajive.com `api/orders.php`** had a literal `// Admin check would go here` comment — anyone who knew/found an `order_id` could silently change any order's status (cancelled/delivered/refunded) or overwrite tracking numbers, and read another customer's full order (name, email, address, items), with zero auth. Fixed: `update_status` now requires `AdminAuth::isLoggedIn()`; the `GET ?id=` lookup now requires admin or the order's own customer.
- **parkerslingshotrentals.com `uploads/`** — customer driver's license and insurance-card photos were directly downloadable with no login at all (the `Order deny,allow`/`Require all denied` pattern in `uploads/.htaccess` doesn't work on this OpenLiteSpeed setup, same class of gotcha as the `.git` exposure found earlier). Verified live via a throwaway test file before fixing. Fixed with the working `RewriteRule .* - [F,L]` pattern in both the nested `uploads/.htaccess` and the root `.htaccess`**required an actual `systemctl restart lshttpd`** to take effect (touching `/usr/local/lsws/cgid` alone, which only prevents the cron's own restart trigger, was NOT sufficient for a *new* rewrite rule to be picked up — worth remembering for future `.htaccess` changes on this server).
**High (fixed):**
- **tomsjavajive.com `admin/orders.php`** — the exact "same named PDO param reused twice" bug that already bit `awardPoints()` had recurred in the order search box (`:search` bound once, referenced 3 times), causing a fatal `SQLSTATE[HY093]` on every admin search. Fixed with distinct `:search1`/`:search2`/`:search3`.
- **tomtomgames.com `admin/index.php`** — stored XSS: `renderGamerOverview()` inserted username/alias/email into `innerHTML` without the `escHtmlA()` helper used correctly everywhere else in the same file. Alias has no character restriction, so a malicious alias could execute script in an admin's session the moment they open that user's profile. Fixed.
**Medium (fixed):**
- **tomtomgames.com `includes/square.php`** (untracked by git — lives outside `public_html`, fix deployed to the server only) — `charge()`/`refund()` generated a fresh `uniqid()` idempotency key on every call, defeating Square's duplicate-protection entirely. Fixed: keyed off `md5(source_id)` for charges and `md5(payment_id . amount)` for refunds, so retries/double-clicks are recognized as duplicates.
- **parkerslingshotrentals.com `contact.php`** — booking availability check + insert had no locking, allowing a double-booking race under concurrent submissions; the deposit-hold idempotency key was suffixed with `time()` (changes every second, so retries aren't deduped). Fixed: wrapped the check+insert in a MySQL `GET_LOCK`/`RELEASE_LOCK` pair scoped to the requested date range, and made the idempotency key stable (`{ref}-dep`, no time suffix).
- **epictravelexpeditions.com `api/config.php`** — DB credentials, JWT secret, admin password hash, and mail API key sat in plaintext inside the webroot (protected only by an `.htaccess` rewrite rule, unlike every sibling site where secrets already live outside `public_html`). Relocated to `/home/epictravelexpeditions.com/api-secrets.php` (was already gitignored, so no git history exposure). `.git` itself was already correctly relocated to `git-data/` (just a 49-byte pointer file in the webroot, blocked by `.htaccess`) — no action needed there.
**Low (fixed):**
- **tomtomgames.com `api/purchase.php`**`logActivity()` referenced undefined `$paymentMethod`/`$amountDollars` (should be `$method`/`$priceCents`) in two places, producing PHP warnings and blank values in the purchase audit log for every transaction. Fixed.
- **epictravelexpeditions.com `api/api/testimonials.php`** — the public image-upload endpoint (no login required, by design) had no rate limiting, allowing storage/bandwidth abuse via scripted repeat uploads. Added a `upload_rate_limits` table + per-IP cap (5 uploads/hour).
---
## 7. JARVIS AI SYSTEM ## 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) **Files:** `/var/www/jarvis/` on JARVIS VM (PVE1 VM 211 — 10.48.200.211, 8 cores, 16GB RAM)
**DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!` **DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
**Login:** `myron / Joker1974!!!` **Login:** `myron / Joker1974!!!`
**Admin portal:** 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`.
### Functional bugs fixed (2026-07-06)
- **"WEB HOST" card on the front dashboard always showed `--%`/offline.** Root cause: the DO server (165.22.1.228) never had the JARVIS monitoring agent installed — every other host in the fleet had one, this one didn't. Installed it with `curl -sk http://10.48.200.211/install-agent.sh | bash -s jarvis-do linux` (hostname arg `jarvis-do` + the DO server's actual machine hostname `orbis` produces the expected `agent_id=jarvis-do_orbis` that `do_server.php` queries for). Also found and fixed two secondary issues hit along the way: the agent's config pointed at a **dead/stale Tailscale peer** (`jarvis-211`, 100.77.178.42, offline 9+ days) instead of the current active one (`jarvis-211-1`, 100.78.153.71) — likely left over from a VM Tailscale re-auth at some point; and a **stale cached API key** in `/var/lib/jarvis-agent/state.json` from a registration attempt that never actually completed server-side, which had to be deleted to force a clean re-registration. Verified live: `do_server` field in `/api/do` now returns real `cpu`/`mem`/`disk`/`online:true` instead of an empty array.
- **"WEBSITES" list (part of the same JARVIS SERVER panel) was always empty**, and the KB intent generator's 4-hour "don't run again too soon" guard was potentially never actually throttling correctly. Root cause, found while investigating the above: `api/config.php` sets `date_default_timezone_set('America/Chicago')`, and several places compute "how old is this DB timestamp" via PHP's `time() - strtotime($mysqlDatetimeString)`. Since MySQL's `NOW()`/stored datetimes are naive UTC strings, `strtotime()` under a non-UTC default timezone misinterprets them as being in Chicago time, which throws every such comparison off by the UTC offset (5-6 hours) — in this case making `facts_collector.php`'s freshness gate for the `sites` (and incidentally `proxmox`/`ollama`) categories always look artificially fresh, so the site-health checks that populate the WEBSITES list stopped actually running. Fixed in `facts_collector.php`'s `$fresh()` helper and `kb_intent_generator.php`'s run-guard by moving the elapsed-time comparison entirely into SQL (`updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)`), which sidesteps PHP timezone handling altogether. Also fixed a leftover cosmetic label (`do_server.php`) still showing `jarvis.orbishosting.com:1972` from before the JARVIS port fix.
- **Note for future work**: the `time() - strtotime($dbTimestamp)` anti-pattern appears in a couple of other files (`chat.php`, `email.php`, `planner.php`) but only for *display formatting* of dates, not elapsed-time threshold checks — lower priority, not fixed in this pass, but worth a look if any displayed timestamps look off by a few hours.
### Architecture (end-to-end) ### Architecture (end-to-end)
@@ -484,12 +546,49 @@ Voice (browser mic)
→ /api/chat.php (4-tier AI) → /api/chat.php (4-tier AI)
Tier 0.7: KB intents / planner (tasks, appointments) Tier 0.7: KB intents / planner (tasks, appointments)
Tier 1: Knowledge Base (MySQL) Tier 1: Knowledge Base (MySQL)
Tier 1.5: Ollama (10.48.200.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 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback) Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker → ElevenLabs TTS → browser speaker
``` ```
### 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 ### Deploy Pipeline
``` ```
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified) Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
@@ -571,6 +670,7 @@ fs_cli -x "reloadacl" # reload ACL (safe)
- WAN IP: 97.154.109.245 (dynamic) - WAN IP: 97.154.109.245 (dynamic)
- DDNS: `orbisne.fortiddns.com` (FortiGate auto-updates on IP change) - 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 - 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:** **Port Forwards:**
| External Port | Internal Destination | Purpose | | External Port | Internal Destination | Purpose |
@@ -594,6 +694,14 @@ fs_cli -x "reloadacl" # reload ACL (safe)
## 10. BACKUP SYSTEMS ## 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 ### DO Server Backup
- **Repo:** `myronblair/do-server-config` - **Repo:** `myronblair/do-server-config`
- **Schedule:** Weekly, Sunday 4am - **Schedule:** Weekly, Sunday 4am
@@ -613,8 +721,22 @@ fs_cli -x "reloadacl" # reload ACL (safe)
- **Repo:** `myronblair/fusionpbx-config` - **Repo:** `myronblair/fusionpbx-config`
- **Schedule:** Weekly, Sunday 5am - **Schedule:** Weekly, Sunday 5am
- **Launcher:** `/usr/local/bin/fusionpbx-backup` - **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` - **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 +802,9 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
| phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` | | phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` |
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` | | Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` | | Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
| JARVIS | http://jarvis.orbishosting.com:1972 | myron | `Joker1974!!!` | | JARVIS | http://jarvis.orbishosting.com | myron | `Joker1974!!!` |
| JARVIS Admin | http://jarvis.orbishosting.com:1972/admin | 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` | | FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) | | Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` | | NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
@@ -703,13 +826,14 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
| Epic Travel | `epic_travel_db` | (see config.php) | (see config.php) | | Epic Travel | `epic_travel_db` | (see config.php) | (see config.php) |
| Epic/Parker Slingshot | `epic_parkersling` | `epic_parkersling` | `Joker1974!!!` | | Epic/Parker Slingshot | `epic_parkersling` | `epic_parkersling` | `Joker1974!!!` |
| NovaCPX | SQLite: `/var/lib/novacpx/panel.db` | — | — | | NovaCPX | SQLite: `/var/lib/novacpx/panel.db` | — | — |
| ChuckCo Time Keeper | `workt_track_db` | `workt_track_user` | `ZWCNMRP2N5NVPsghmve5aRS9` |
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` | | FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` | | MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
### API Keys ### API Keys
| Service | Key | | 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` | | JARVIS Agent Registration | `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518` |
| Proxmox API Token | `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b` | | Proxmox API Token | `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b` |
| HA Long-lived Token | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE` | | HA Long-lived Token | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE` |
@@ -735,4 +859,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.* *This document contains sensitive credentials. Store securely and do not share.*
+588 -45
View File
@@ -242,9 +242,7 @@ if ($action) {
$search = strtolower(trim($_GET['search'] ?? '')); $search = strtolower(trim($_GET['search'] ?? ''));
$skipDomains = ['sensor','binary_sensor','button','update','select','number', $skipDomains = ['sensor','binary_sensor','button','update','select','number',
'device_tracker','event','image','person','zone','tts','conversation', 'device_tracker','event','image','person','zone','tts','conversation',
'assist_satellite','input_button','media_player','scene','water_heater', 'assist_satellite','input_button'];
'alarm_control_panel','automation','script','calendar','notify',
'weather','camera','siren','remote','todo','lawn_mower'];
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
'_siren_on','_email_on','_manual_record','_infrared_', '_siren_on','_email_on','_manual_record','_infrared_',
'do_not_disturb','matter_server','zerotier','mariadb', 'do_not_disturb','matter_server','zerotier','mariadb',
@@ -432,7 +430,7 @@ if ($action) {
case 'cal_feeds_list': 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': case 'cal_feed_save':
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
@@ -489,25 +487,26 @@ if ($action) {
$arcCounts = []; $arcCounts = [];
foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt']; foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt'];
$cronLast = []; $cronLast = [];
$cronLog = '/home/jarvis.orbishosting.com/logs/cron.log'; $cronLog = '/var/log/jarvis/cron.log';
if (file_exists($cronLog)) { if (file_exists($cronLog)) {
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar' " . escapeshellarg($cronLog) . " | tail -60"))); $lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar\\|intent' " . escapeshellarg($cronLog) . " | tail -60")));
foreach ($lines as $line) { foreach ($lines as $line) {
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1]; if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1]; if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1]; if (preg_match('/^\\[(\\d{2}{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
if (preg_match('/^\\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\\].*intent/i', $line, $m)) $cronLast['kb_intent_generator'] = $m[1];
} }
} }
if (empty($cronLast['stats_cache'])) { if (empty($cronLast['stats_cache'])) {
$row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")'); $row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")');
if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t']; if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t'];
} }
$deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log'; $deployLog = '/var/log/jarvis/deploy.log';
if (file_exists($deployLog)) { if (file_exists($deployLog)) {
$last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1"); $last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1");
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1]; if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1];
} }
$wdLog = '/home/jarvis.orbishosting.com/logs/watchdog.log'; $wdLog = '/var/log/jarvis/watchdog.log';
if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog)); if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog));
$bkLog = '/var/backups/jarvis/backup.log'; $bkLog = '/var/backups/jarvis/backup.log';
if (file_exists($bkLog)) { if (file_exists($bkLog)) {
@@ -520,9 +519,9 @@ if ($action) {
break; break;
case 'worker_action': case 'worker_action':
$wType = $data['worker_type'] ?? ''; $wType = $_REQUEST['worker_type'] ?? '';
$wId = $data['worker_id'] ?? ''; $wId = $_REQUEST['worker_id'] ?? '';
$wAction = $data['action'] ?? ''; $wAction = $_REQUEST['waction'] ?? $_REQUEST['action'] ?? '';
if ($wType === 'agent' && $wAction === 'update') { if ($wType === 'agent' && $wAction === 'update') {
JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)', JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)',
[$wId,'update','{}','pending']); [$wId,'update','{}','pending']);
@@ -535,39 +534,153 @@ if ($action) {
j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']); j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']);
} elseif ($wType === 'cron' && $wAction === 'run') { } elseif ($wType === 'cron' && $wAction === 'run') {
$scripts = [ $scripts = [
'facts_collector'=>[true, '/home/jarvis.orbishosting.com/api/endpoints/facts_collector.php'], 'facts_collector' =>[true, '/var/www/jarvis/api/endpoints/facts_collector.php'],
'stats_cache' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/stats_cache.php'], 'stats_cache' =>[true, '/var/www/jarvis/api/endpoints/stats_cache.php'],
'calendar_sync' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/calendar_sync.php'], 'calendar_sync' =>[true, '/var/www/jarvis/api/endpoints/calendar_sync.php'],
'kb_intent_generator' =>[true, '/var/www/jarvis/api/endpoints/kb_intent_generator.php'],
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'], 'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
'jarvis_watchdog'=>[false,'/usr/local/bin/jarvis-watchdog.sh'], 'jarvis_watchdog' =>[false,'/usr/local/bin/jarvis-watchdog.sh'],
]; ];
if (isset($scripts[$wId])) { if (isset($scripts[$wId])) {
[$isPhp,$path] = $scripts[$wId]; [$isPhp,$path] = $scripts[$wId];
$env = ($wId === 'kb_intent_generator') ? 'JARVIS_FORCE_RUN=1 ' : '';
$cmd = $isPhp $cmd = $isPhp
? '/usr/local/lsws/lsphp85/bin/lsphp '.escapeshellarg($path).' >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 &' ? $env.'/usr/bin/php8.3 '.escapeshellarg($path).' >> /var/log/jarvis/cron.log 2>&1 &'
: escapeshellcmd($path).' >> /home/jarvis.orbishosting.com/logs/deploy.log 2>&1 &'; : escapeshellcmd($path).' >> /var/log/jarvis/deploy.log 2>&1 &';
shell_exec($cmd); shell_exec($cmd);
j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']); j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']);
} else { bad('Unknown cron worker'); } } else { bad('Unknown cron worker'); }
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') { } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') {
shell_exec('bash -c "mkdir -p /home/jarvis.orbishosting.com/logs; systemctl restart jarvis-arc 2>/dev/null || (pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &)"'); shell_exec('systemctl restart jarvis-arc 2>&1');
j(['ok'=>true,'msg'=>'Arc Reactor restarting']); j(['ok'=>true,'msg'=>'Arc Reactor restarting via systemd']);
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') { } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') {
$setupLog = '/home/jarvis.orbishosting.com/logs/arc_reactor.log'; $log = '/var/log/jarvis/arc-setup.log';
$cmd = 'bash -c "'. $cmd = implode(' && ', [
'mkdir -p /opt/jarvis-arc /home/jarvis.orbishosting.com/logs && '. 'mkdir -p /opt/jarvis-arc /var/log/jarvis',
'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py && '. 'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py',
'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt && '. 'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt',
'if [ ! -d /opt/jarvis-arc/venv ]; then python3 -m venv /opt/jarvis-arc/venv; fi && '. '[ ! -f /opt/jarvis-arc/venv/bin/activate ] && python3 -m venv /opt/jarvis-arc/venv || true',
'/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt && '. '/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt',
'pkill -f reactor.py 2>/dev/null; sleep 1 && '. 'cp /var/www/jarvis/deploy/jarvis-arc.service /etc/systemd/system/jarvis-arc.service',
'cd /opt/jarvis-arc && source venv/bin/activate && '. 'systemctl daemon-reload',
'nohup python3 reactor.py >> '.$setupLog.' 2>&1 &'. 'systemctl enable jarvis-arc',
'" >> '.$setupLog.' 2>&1'; 'systemctl restart jarvis-arc',
shell_exec($cmd); ]);
j(['ok'=>true,'msg'=>'Arc Reactor setup started — check log in ~30s then restart']); shell_exec("($cmd) >> " . escapeshellarg($log) . " 2>&1 &");
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'); } } else { bad('Invalid worker action'); }
break; 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': case 'arc_status':
$ch = curl_init('http://127.0.0.1:7474/status'); $ch = curl_init('http://127.0.0.1:7474/status');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]);
@@ -1142,6 +1255,17 @@ if ($action) {
readfile($path); readfile($path);
exit; 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'); 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-green" onclick="intentModal()">+ ADD INTENT</button>
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button> <button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button> <button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
<button class="btn btn-sm" style="border-color:#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> </div>
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center"> <div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
@@ -1529,7 +1655,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="card" style="padding:24px;margin:20px 0"> <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="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> <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"> 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 DOWNLOAD INFRASTRUCTURE-REFERENCE.MD
</a> </a>
@@ -2056,6 +2182,12 @@ let _alertFilter = 'active';
let _modalCb = null; let _modalCb = null;
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } 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 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 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'; } 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'; }
@@ -2174,7 +2306,8 @@ const CRON_DEFS = [
{id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'}, {id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'},
{id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'}, {id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'},
{id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true}, {id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true},
{id:'do_server_backup',label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true}, {id:'do_server_backup', label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
{id:'kb_intent_generator', label:'KB Intent Generator', schedule:'Daily 3am', host:'jarvis'},
]; ];
function wBtn(col) { function wBtn(col) {
const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)'; const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)';
@@ -2201,12 +2334,422 @@ function wToast(msg,err=false) {
t.style.opacity='1';t.textContent=msg; t.style.opacity='1';t.textContent=msg;
setTimeout(()=>{t.style.opacity='0';},3000); setTimeout(()=>{t.style.opacity='0';},3000);
} }
async function runIntentGenerator() {
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) { async function workerAction(type,id,action) {
if (type === 'agent' && action === 'update') { if (type === 'agent' && action === 'update') {
await agentUpdateFlow(id); await agentUpdateFlow(id);
return; return;
} }
const res=await api('worker_action',{worker_type:type,worker_id:id,action}); const res=await api('worker_action',{worker_type:type,worker_id:id,waction:action});
if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);} if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);}
else wToast((res&&res.error)||'Action failed',true); else wToast((res&&res.error)||'Action failed',true);
} }
@@ -2231,7 +2774,7 @@ async function agentUpdateFlow(agentId) {
}; };
// Dispatch command // Dispatch command
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update'}); const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update'});
if (!res || !res.ok) { if (!res || !res.ok) {
log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)'); log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)');
document.getElementById('modalSave').style.display = ''; document.getElementById('modalSave').style.display = '';
@@ -2242,7 +2785,7 @@ async function agentUpdateFlow(agentId) {
log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)'); log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)');
// Poll agent_commands for the result (max 90s) // Poll agent_commands for the result (max 90s)
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update_status'}).catch(()=>null); const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update_status'}).catch(()=>null);
// Actually poll via workers_list for version change // Actually poll via workers_list for version change
const deadline = Date.now() + 90000; const deadline = Date.now() + 90000;
let done = false; let done = false;
@@ -2346,9 +2889,9 @@ async function loadWorkers() {
<td class="ts">jarvis-do :7474</td> <td class="ts">jarvis-do :7474</td>
<td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td> <td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td>
<td class="ts">${rinfo}</td> <td class="ts">${rinfo}</td>
<td style="display:flex;gap:4px"> <td style="white-space:nowrap">
<button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">&#8635; RESTART</button> <button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">&#8635; RESTART</button>
<button onclick="workerAction('daemon','arc_reactor','setup')" style="${wBtn('orange')}">&#9881; SETUP</button> <button onclick="arcSetup()" style="${wBtn('cyan')};margin-left:6px">&#9881; SETUP</button>
</td> </td>
</tr>`; </tr>`;
} }
@@ -2552,7 +3095,7 @@ function renderNetwork() {
<td class="ts">${ago(d.last_seen)}</td> <td class="ts">${ago(d.last_seen)}</td>
<td><div class="actions-col"> <td><div class="actions-col">
<button class="btn btn-xs" onclick="pingDev('${esc(d.ip)}',this)">PING</button> <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> <button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
</div></td>`; </div></td>`;
}, null, null); }, null, null);
@@ -2631,7 +3174,7 @@ async function loadAlerts() {
<td class="ts">${ts(a.created_at)}</td> <td class="ts">${ts(a.created_at)}</td>
<td><div class="actions-col"> <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>`:''} ${!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> <button class="btn btn-xs btn-red" onclick="apiPost('alerts_delete',{id:${a.id}},()=>{toast('Deleted','ok');loadAlerts()})">DEL</button>
</div></td>`, null, null); </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>${i.active?'<span class="badge badge-green">ON</span>':'<span class="badge badge-dim">OFF</span>'}</td>
<td><div class="actions-col"> <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" 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> <button class="btn btn-xs btn-red" onclick="apiPost('intents_delete',{id:${i.id}},()=>{toast('Deleted','ok');loadIntents()})">DEL</button>
</div></td>`, null, null); </div></td>`, null, null);
} }
@@ -2973,7 +3516,7 @@ async function loadNews() {
<div style="font-size:0.75rem">${esc(c.title)}</div> <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>`:''} ${c.url?`<div style="font-size:0.6rem;color:var(--dim)">${esc(c.url)}</div>`:''}
</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> <button class="btn btn-xs btn-red" onclick="apiPost('news_custom_delete',{id:${c.id}},()=>{toast('Deleted','ok');loadNews()})">DEL</button>
</div>`).join(''); </div>`).join('');
} }
@@ -4358,7 +4901,7 @@ async function loadCalFeeds() {
<td>${ts(f.last_sync)}</td> <td>${ts(f.last_sync)}</td>
<td>${f.last_count||0}</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>${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> <button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
</tr>`).join('')}</tbody></table>`; </tr>`).join('')}</tbody></table>`;
} }
+1 -1
View File
@@ -57,7 +57,7 @@ else
"poll_interval": 30, "poll_interval": 30,
"heartbeat_every": 10, "heartbeat_every": 10,
"update_check_hours": 24, "update_check_hours": 24,
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"] "watch_services": []
} }
JSONEOF JSONEOF
chmod 600 "$CONFIG_DIR/config.json" chmod 600 "$CONFIG_DIR/config.json"
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {} return {}
def get_services(cfg: dict) -> list: def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"]) watch = cfg.get("watch_services", [])
statuses = [] statuses = []
for svc in watch: for svc in watch:
try: try:
+6 -1
View File
@@ -27,7 +27,12 @@ if (!$_skipSession) {
} }
header('Content-Type: application/json'); 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-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token'); header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
+20 -9
View File
@@ -1,4 +1,14 @@
// ── GLOBALS ────────────────────────────────────────────────────────── // ── 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 sessionToken = '';
let sessionUser = ''; let sessionUser = '';
let sessionId = 'session_' + Date.now(); let sessionId = 'session_' + Date.now();
@@ -596,15 +606,15 @@ function renderNetworkStatus(n) {
agent_id: d.agent_id, hostname: d.name}; agent_id: d.agent_id, hostname: d.name};
const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : ''; const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : '';
const badge = d.source === 'agent' 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 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)') + ';' : ''; 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-status ${alive?'on':'off'}" style="flex-shrink:0"></div>
<div class="device-info" style="flex:1;min-width:0"> <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-name" style="display:flex;align-items:center">${escHtml(d.name||d.ip)}${badge}</div>
<div class="device-ip">${d.ip||''}${lat}</div> <div class="device-ip">${escHtml(d.ip||'')}${lat}</div>
</div>${del} </div>${del}
</div>`; </div>`;
} }
@@ -684,7 +694,7 @@ async function loadProxmox() {
type_label:vm.type||'qemu', uptime:vm.uptime||0}; 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"> return `<div class="vm-card" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this VM">
<div class="vm-header"> <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> <span style="color:${statusColor};font-size:0.65rem"> ${(vm.status||'').toUpperCase()}</span>
</div> </div>
<div class="vm-metrics"> <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); const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30);
_panelCtx[ctxKey] = {type:'news', label:a.title, _panelCtx[ctxKey] = {type:'news', label:a.title,
title:a.title, source:a.source, pub:a.pub||'', category:cat}; 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"> 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-source">${escHtml(a.source)}</div>
<div class="news-title">${a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title}</div> <div class="news-title">${escHtml(titleDisplay)}</div>
${a.pub ? '<div class="news-time">' + a.pub + '</div>' : ''} ${a.pub ? '<div class="news-time">' + escHtml(a.pub) + '</div>' : ''}
</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)'}"> 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="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> <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-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)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</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> <span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
</div> </div>
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px"> ${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>` : ''} ${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
</div> </div>
${alive ? `<div style="display:flex;gap:5px;margin-top:6px"> ${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();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('${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();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>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
+1 -1
View File
@@ -57,7 +57,7 @@ else
"poll_interval": 30, "poll_interval": 30,
"heartbeat_every": 10, "heartbeat_every": 10,
"update_check_hours": 24, "update_check_hours": 24,
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"] "watch_services": []
} }
JSONEOF JSONEOF
chmod 600 "$CONFIG_DIR/config.json" chmod 600 "$CONFIG_DIR/config.json"