mirror of
https://github.com/myronblair/jarvis
synced 2026-07-29 09:12:36 -05:00
Compare commits
20 Commits
main
...
10350cbcd8
| Author | SHA1 | Date | |
|---|---|---|---|
| 10350cbcd8 | |||
| 24bc876c1d | |||
| cfb5b2a3f9 | |||
| 0b1a19d9de | |||
| 66a5443f22 | |||
| e5536b077c | |||
| a292411d52 | |||
| 26b501b600 | |||
| 7327104612 | |||
| a13f750846 | |||
| 3c8dd8e206 | |||
| 554488aefa | |||
| e99c3aa171 | |||
| 2528b37ea1 | |||
| 46dabe3c31 | |||
| d6a1fc9456 | |||
| 2f74b98bbc | |||
| ed15ff12dd | |||
| 3f18cec739 | |||
| 8911645c20 |
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* JARVIS KB Intent Generator
|
||||||
|
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
|
||||||
|
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
|
||||||
|
*
|
||||||
|
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
|
||||||
|
* Schedule: Daily – 3 am
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../lib/db.php';
|
||||||
|
|
||||||
|
/* ── helpers ── */
|
||||||
|
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
|
||||||
|
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
|
||||||
|
|
||||||
|
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
|
||||||
|
for ($attempt = 0; $attempt <= $retries; $attempt++) {
|
||||||
|
if ($attempt > 0) {
|
||||||
|
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
|
||||||
|
sleep(25);
|
||||||
|
}
|
||||||
|
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: Bearer ' . GROQ_API_KEY,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode([
|
||||||
|
'model' => 'llama-3.3-70b-versatile',
|
||||||
|
'max_tokens' => $max,
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'system', 'content' => $system],
|
||||||
|
['role' => 'user', 'content' => $user],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
$info = curl_getinfo($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err || !$raw) continue;
|
||||||
|
|
||||||
|
// Check for rate limit response (429)
|
||||||
|
if (($info['http_code'] ?? 0) === 429) continue;
|
||||||
|
|
||||||
|
$d = json_decode($raw, true);
|
||||||
|
$content = $d['choices'][0]['message']['content'] ?? null;
|
||||||
|
if ($content !== null) return $content;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── normalize a pattern to valid PHP PCRE ── */
|
||||||
|
function normalize_pattern(string $pat): string {
|
||||||
|
$pat = trim($pat);
|
||||||
|
// If pattern already has PCRE delimiters, leave it alone
|
||||||
|
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
|
||||||
|
// Strip any (?i) inline flag — we'll add /i at the delimiter level
|
||||||
|
$pat = preg_replace('/^\(\?i\)/', '', $pat);
|
||||||
|
// Escape forward slashes inside the pattern
|
||||||
|
$pat = str_replace('/', '\\/', $pat);
|
||||||
|
return '/' . $pat . '/i';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── run guard: skip if ran within last 4 hours ── */
|
||||||
|
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
|
||||||
|
$lastRun = JarvisDB::single(
|
||||||
|
"SELECT updated_at FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
|
||||||
|
);
|
||||||
|
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
|
||||||
|
if (!$forceRun && $lastRun && (time() - strtotime($lastRun['updated_at'])) < 14400) {
|
||||||
|
log_line('Skipping – ran within last 4 hours. Use --force to override.');
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
|
||||||
|
|
||||||
|
log_line('Starting daily KB intent generation run.');
|
||||||
|
|
||||||
|
/* ── load active topics from database ── */
|
||||||
|
$BATCHES = JarvisDB::query(
|
||||||
|
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
|
||||||
|
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
|
||||||
|
);
|
||||||
|
if (empty($BATCHES)) {
|
||||||
|
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
|
||||||
|
|
||||||
|
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
|
||||||
|
define('BATCH_SIZE', 25);
|
||||||
|
$totalTopics = count($BATCHES);
|
||||||
|
$offsetRow = JarvisDB::single(
|
||||||
|
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
|
||||||
|
);
|
||||||
|
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
|
||||||
|
if ($batchOffset >= $totalTopics) $batchOffset = 0;
|
||||||
|
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
|
||||||
|
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
|
||||||
|
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
|
||||||
|
|
||||||
|
$runBatches = [];
|
||||||
|
for ($i = 0; $i < BATCH_SIZE; $i++) {
|
||||||
|
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
|
||||||
|
}
|
||||||
|
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
|
||||||
|
log_line("Rotation: topics " . ($batchOffset + 1) . "–" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
|
||||||
|
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
|
||||||
|
|
||||||
|
/* ── main generation loop ── */
|
||||||
|
$totalBatches = count($runBatches);
|
||||||
|
foreach ($runBatches as $idx => $batch) {
|
||||||
|
$num = $idx + 1;
|
||||||
|
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
|
||||||
|
|
||||||
|
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
|
||||||
|
. "Subtopics to cover: {$batch['desc']}.\n"
|
||||||
|
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
|
||||||
|
. "Category string to use: \"{$batch['category']}\".";
|
||||||
|
|
||||||
|
$raw = groq($SYSTEM, $user, 5000);
|
||||||
|
if ($raw === null) {
|
||||||
|
log_line(" ✗ API call failed after retries – skipping batch.");
|
||||||
|
$errors += 20;
|
||||||
|
sleep(8);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip markdown code fences if model added them
|
||||||
|
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
|
||||||
|
$raw = preg_replace('/^```\s*/m', '', $raw);
|
||||||
|
$raw = trim($raw);
|
||||||
|
|
||||||
|
// Extract JSON array; fall back to partial recovery for truncated responses
|
||||||
|
$items = null;
|
||||||
|
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
|
||||||
|
$items = json_decode($m[0], true);
|
||||||
|
if (!is_array($items)) {
|
||||||
|
log_line(" ✗ JSON parse failed – skipping batch.");
|
||||||
|
$errors += 20; sleep(8); continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$start = strpos($raw, '[');
|
||||||
|
if ($start !== false) {
|
||||||
|
$partial = substr($raw, $start);
|
||||||
|
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
|
||||||
|
$recovered = '[' . implode(',', $objs[0]) . ']';
|
||||||
|
$items = json_decode($recovered, true);
|
||||||
|
if (is_array($items) && count($items) > 0)
|
||||||
|
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($items) || count($items) === 0) {
|
||||||
|
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
|
||||||
|
$errors += 20; sleep(8); continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchInserted = 0;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (!is_array($item)) continue;
|
||||||
|
safe_insert($item, $batch['category']);
|
||||||
|
$batchInserted++;
|
||||||
|
}
|
||||||
|
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
|
||||||
|
|
||||||
|
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
|
||||||
|
if ($num < $totalBatches) sleep(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
|
||||||
|
|
||||||
|
/* ── cleanup phase ── */
|
||||||
|
log_line('Starting cleanup phase...');
|
||||||
|
|
||||||
|
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
|
||||||
|
$dups = JarvisDB::query(
|
||||||
|
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
|
||||||
|
);
|
||||||
|
$dupsPruned = 0;
|
||||||
|
foreach ($dups as $dup) {
|
||||||
|
$rows = JarvisDB::query(
|
||||||
|
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
|
||||||
|
[$dup['intent_name']]
|
||||||
|
);
|
||||||
|
array_shift($rows);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
|
||||||
|
$dupsPruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
|
||||||
|
|
||||||
|
// 2. Remove intents with very short responses (< 40 chars)
|
||||||
|
$shortPruned = JarvisDB::execute(
|
||||||
|
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
|
||||||
|
);
|
||||||
|
log_line(" Short-response rows pruned: {$shortPruned}");
|
||||||
|
|
||||||
|
// 3. Trim whitespace on all generated intents
|
||||||
|
JarvisDB::execute(
|
||||||
|
"UPDATE kb_intents SET
|
||||||
|
intent_name = TRIM(intent_name),
|
||||||
|
pattern = TRIM(pattern),
|
||||||
|
response_template = TRIM(response_template),
|
||||||
|
fact_category = TRIM(fact_category)
|
||||||
|
WHERE priority = 5"
|
||||||
|
);
|
||||||
|
log_line(' Whitespace trimmed on all generated intents.');
|
||||||
|
|
||||||
|
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
|
||||||
|
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
|
||||||
|
$badPattern = 0;
|
||||||
|
$fixedPattern = 0;
|
||||||
|
foreach ($all as $row) {
|
||||||
|
$pat = normalize_pattern($row['pattern']);
|
||||||
|
if ($pat !== $row['pattern']) {
|
||||||
|
// Update to normalized form
|
||||||
|
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
|
||||||
|
$fixedPattern++;
|
||||||
|
} elseif (@preg_match($pat, '') === false) {
|
||||||
|
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
|
||||||
|
$badPattern++;
|
||||||
|
} else {
|
||||||
|
// Valid pattern — make sure it's active
|
||||||
|
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
|
||||||
|
|
||||||
|
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
|
||||||
|
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
|
||||||
|
log_line(" Re-activated previously inactive intents: {$reactivated}");
|
||||||
|
|
||||||
|
// 6. Final stats
|
||||||
|
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
|
||||||
|
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
|
||||||
|
|
||||||
|
/* ── record last-run timestamp ── */
|
||||||
|
JarvisDB::execute(
|
||||||
|
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||||
|
VALUES ('kb_generator', 'last_run', NOW(), 'local')
|
||||||
|
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
JarvisDB::execute(
|
||||||
|
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||||
|
VALUES ('kb_generator', 'batch_offset', ?, 'local')
|
||||||
|
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||||
|
[$nextOffset]
|
||||||
|
);
|
||||||
|
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
|
||||||
|
JarvisDB::execute(
|
||||||
|
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||||
|
VALUES ('kb_generator', 'last_inserted', ?, 'local')
|
||||||
|
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||||
|
[$inserted]
|
||||||
|
);
|
||||||
|
|
||||||
|
log_line('Done.');
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
|
||||||
**Last Updated:** 2026-06-18
|
**Last Updated:** 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 (Fri–Thu) 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 |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
@@ -468,11 +490,24 @@ See Section 7 for full JARVIS details.
|
|||||||
|
|
||||||
## 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`.
|
||||||
|
|
||||||
### Architecture (end-to-end)
|
### Architecture (end-to-end)
|
||||||
|
|
||||||
@@ -484,12 +519,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 +643,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 +667,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 +694,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 +775,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 +799,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 +832,16 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 13. GIT & REPOSITORY MANAGEMENT
|
||||||
|
|
||||||
|
**GitHub** (`myronblair`, 26 private repos as of 2026-07-05) is the permanent/source-of-truth storage. **Gitea** (`gitea.orbishosting.com`, hosted on the Synology NAS, login `myron / Joker1974!!!`) mirrors it one-way — GitHub → Gitea only, via pull-mirrors triggered by `POST /api/v1/repos/<owner>/<repo>/mirror-sync`. Never push directly to a Gitea remote.
|
||||||
|
|
||||||
|
**5 repos are Gitea-only by design, never pushed to GitHub** (deliberately kept off a third-party cloud service since they hold real credentials): `fortigate-config`, `infra-private`, `jarvis-secrets`, `msp360-config`, `proxmox-secrets`.
|
||||||
|
|
||||||
|
**Local clones** live on the admin Windows machine at `C:\Users\myron\repos\` (currently `chucko`, `web-dashboard` — more cloned there as needed). Periodic maintenance: `git gc --aggressive --prune=now` to keep loose objects packed.
|
||||||
|
|
||||||
|
**Per-site deploy pattern (the 7 DO-hosted sites + jarvis + web-dashboard):** `.git` metadata is relocated outside the public webroot (e.g. `/home/<site>/git-data` with a `gitdir:` pointer file left in `public_html/.git`) so `.git` itself is never web-accessible, while `git` commands run normally from inside `public_html`. Most sites deploy via a GitHub webhook → queue file → cron puller (~1 min); `chucko` (ChuckCo Time Keeper) currently has no auto-deploy hook — pushes are manual (SSH in, `git pull`/`push` directly).
|
||||||
|
|
||||||
|
**Known repo-hygiene issue:** `fusionpbx-config` bloated to ~166-196MB from repeatedly committing a large DB dump straight into history — see Section 10.
|
||||||
|
|
||||||
*This document contains sensitive credentials. Store securely and do not share.*
|
*This document contains sensitive credentials. Store securely and do not share.*
|
||||||
|
|||||||
+589
-46
@@ -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'],
|
||||||
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
|
'kb_intent_generator' =>[true, '/var/www/jarvis/api/endpoints/kb_intent_generator.php'],
|
||||||
'jarvis_watchdog'=>[false,'/usr/local/bin/jarvis-watchdog.sh'],
|
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.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()">⚙ MANAGE TOPICS</button>
|
||||||
|
<button class="btn btn-sm" style="border-color:#00d4ff;color:#00d4ff" onclick="runIntentGenerator()">▶ 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
// 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 '/" 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('▶ 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)">✓ 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)">⚠ ${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,'<')}</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()">← 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('⚙ 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> | ` +
|
||||||
|
`<span style="color:var(--green)">${activeCount} active</span> | ` +
|
||||||
|
`<span style="color:var(--yellow)">${total-activeCount} disabled</span>` +
|
||||||
|
(rows.length < total ? ` | <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("⚙ 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')}">↻ RESTART</button>
|
<button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">↻ RESTART</button>
|
||||||
<button onclick="workerAction('daemon','arc_reactor','setup')" style="${wBtn('orange')}">⚙ SETUP</button>
|
<button onclick="arcSetup()" style="${wBtn('cyan')};margin-left:6px">⚙ 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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
// ── GLOBALS ──────────────────────────────────────────────────────────
|
// ── GLOBALS ──────────────────────────────────────────────────────────
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
// 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('');
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user