mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
refactor: Phase 3 — split jarvis-protocols.js into 3 panel files
A single SyntaxError in the 1668-line monolith kills every panel
(proven by the apostrophe bug on 2026-06-17). Split into:
panels/jarvis-arc.js (608 lines) — Arc Reactor, Intel, Comms, Guardian
panels/jarvis-agents.js (715 lines) — Missions, Directives, Memory,
Clearance, Agents tab, Sites, Vision
panels/jarvis-assistant.js (345 lines) — Chat History, Suggestions,
Mobile, Command Palette, Topo map
A parse error in any one file now fails only that group of panels.
escHtml() stays in jarvis-arc.js (loads first) and remains global.
All other dependencies (api, speak, addMessage) come from jarvis-app.js.
Version param bumped to ?v=20260617b to force Cloudflare cache miss.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
|
||||
let _missionsOpenCards = new Set();
|
||||
|
||||
async function loadMissionsHud() {
|
||||
const el = document.getElementById('missions-hud');
|
||||
if (!el) return;
|
||||
try {
|
||||
const missions = await api('arc?action=missions');
|
||||
const list = Array.isArray(missions) ? missions : [];
|
||||
|
||||
let html = '<button class="mission-new-btn" onclick="window.open(\'/admin#missions\',\'_blank\')">◈ MANAGE MISSIONS IN ADMIN</button>';
|
||||
|
||||
if (!list.length) {
|
||||
html += '<div class="comms-empty">◈ NO MISSIONS<br><span style="opacity:0.5">Create workflows in Admin → Mission Ops</span></div>';
|
||||
el.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
|
||||
for (const m of list) {
|
||||
const isOpen = _missionsOpenCards.has(m.id);
|
||||
const icon = trigIcons[m.trigger_type] || '◈';
|
||||
const enabled = m.enabled;
|
||||
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
|
||||
html += `<div class="mission-card${isOpen?' open':''}" id="mission-card-${m.id}">
|
||||
<div class="mission-card-head" onclick="toggleMissionCard(${m.id})">
|
||||
<span style="opacity:${enabled?1:0.35}">${icon}</span>
|
||||
<span class="mission-card-name" style="opacity:${enabled?1:0.45}">${escHtml(m.name)}</span>
|
||||
<span class="mission-card-trigger">${m.trigger_type.replace('_',' ').toUpperCase()}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${m.run_count||0} runs</span>
|
||||
</div>
|
||||
<div class="mission-card-body">
|
||||
${m.description ? `<div style="font-size:0.58rem;color:var(--text-dim);margin:6px 0">${escHtml(m.description)}</div>` : ''}
|
||||
<div style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);margin:4px 0">Last run: ${lastRun} · ${m.run_count||0} total runs</div>
|
||||
<div class="mission-run-bar">
|
||||
<button class="mission-run-btn" id="mission-run-btn-${m.id}" onclick="hudRunMission(${m.id})"${!enabled?' disabled title="Mission disabled"':''}>▶ RUN NOW</button>
|
||||
</div>
|
||||
<div id="mission-run-result-${m.id}" style="font-family:var(--font-mono);font-size:0.52rem;margin-top:6px;min-height:12px"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
if (el) el.innerHTML = '<div class="comms-empty">MISSIONS OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMissionCard(id) {
|
||||
const card = document.getElementById('mission-card-' + id);
|
||||
if (!card) return;
|
||||
if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
|
||||
else _missionsOpenCards.add(id);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
async function hudRunMission(id) {
|
||||
const btn = document.getElementById('mission-run-btn-' + id);
|
||||
const res = document.getElementById('mission-run-result-' + id);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
|
||||
if (res) res.textContent = '';
|
||||
try {
|
||||
const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
|
||||
const s = data.status || 'done';
|
||||
const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
|
||||
if (res) res.style.color = color;
|
||||
if (res) res.textContent = `◈ ${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
|
||||
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
|
||||
setTimeout(loadMissionsHud, 2000);
|
||||
} catch(e) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
|
||||
if (res) res.textContent = '✗ Run failed';
|
||||
}
|
||||
}
|
||||
|
||||
// ── DIRECTIVES HUD ────────────────────────────────────────────────────────────
|
||||
let _dirOpenCards = new Set();
|
||||
|
||||
async function loadDirectivesHud() {
|
||||
const el = document.getElementById('directives-hud');
|
||||
if (!el) return;
|
||||
try {
|
||||
const d = await api('directives/list?status=active');
|
||||
const list = (d.directives || []);
|
||||
|
||||
let html = '<button class="dir-admin-btn" onclick="window.open(\'/admin#directives\',\'_blank\')">◈ MANAGE IN ADMIN</button>';
|
||||
|
||||
if (!list.length) {
|
||||
html += '<div class="comms-empty">◈ NO ACTIVE DIRECTIVES<br><span style="opacity:0.5">Create objectives in Admin → Directives</span></div>';
|
||||
el.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'};
|
||||
for (const dir of list) {
|
||||
const pct = Math.min(100, Math.round(dir.progress || 0));
|
||||
const isOpen = _dirOpenCards.has(dir.id);
|
||||
const color = catColors[dir.category] || 'var(--cyan)';
|
||||
const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644';
|
||||
const daysLeft = dir.target_date
|
||||
? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null;
|
||||
const dueTxt = daysLeft !== null
|
||||
? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`)
|
||||
: '';
|
||||
const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)';
|
||||
|
||||
html += `<div class="dir-card${isOpen?' open':''}" id="dir-card-${dir.id}">
|
||||
<div class="dir-card-head" onclick="toggleDirCard(${dir.id})">
|
||||
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${color};flex-shrink:0">${dir.category.toUpperCase()}</span>
|
||||
<span class="dir-card-title" style="color:${color}">${escHtml(dir.title)}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${fillColor};flex-shrink:0">${pct}%</span>
|
||||
${dueTxt ? `<span style="font-family:var(--font-mono);font-size:0.48rem;color:${dueColor};flex-shrink:0">${dueTxt}</span>` : ''}
|
||||
</div>
|
||||
<div class="dir-card-body">
|
||||
<div class="dir-progress-bar"><div class="dir-progress-fill" style="width:${pct}%;background:${fillColor}"></div></div>
|
||||
<div style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim);margin-bottom:6px">${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS</div>
|
||||
<button onclick="hudDirectiveReview(${dir.id})" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.2);border-radius:3px;padding:3px 8px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ AI REVIEW</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
if (el) el.innerHTML = '<div class="comms-empty">DIRECTIVES OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDirCard(id) {
|
||||
const card = document.getElementById('dir-card-' + id);
|
||||
if (!card) return;
|
||||
if (_dirOpenCards.has(id)) _dirOpenCards.delete(id);
|
||||
else _dirOpenCards.add(id);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
async function hudDirectiveReview(id) {
|
||||
const res = await api('arc?action=job_create', 'POST', {
|
||||
type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6,
|
||||
});
|
||||
if (res.job_id) {
|
||||
addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`);
|
||||
speak(`Directive review underway. I'll brief you on your progress in a moment.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── MEMORY CORE — bottom bar count ────────────────────────────────────────────
|
||||
async function updateMemoryCount() {
|
||||
try {
|
||||
const stats = await api('memory?action=stats');
|
||||
const el = document.getElementById('bb-memory-count');
|
||||
const dot = document.getElementById('bb-memory-dot');
|
||||
if (el && stats) {
|
||||
const total = stats.total || 0;
|
||||
el.textContent = total + ' FACTS';
|
||||
if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
|
||||
const _clrOpenCards = new Set();
|
||||
|
||||
async function updateClearanceBanner() {
|
||||
try {
|
||||
const pending = await api('arc?action=clearance_pending');
|
||||
const list = Array.isArray(pending) ? pending : [];
|
||||
const count = list.length;
|
||||
const banner = document.getElementById('clearance-banner');
|
||||
const badge = document.getElementById('clr-tab-badge');
|
||||
const bcount = document.getElementById('clr-banner-count');
|
||||
if (banner) {
|
||||
if (count > 0) {
|
||||
banner.classList.add('active');
|
||||
if (bcount) bcount.textContent = count;
|
||||
} else {
|
||||
banner.classList.remove('active');
|
||||
}
|
||||
}
|
||||
if (badge) {
|
||||
if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
|
||||
else badge.style.display = 'none';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadClearanceHud() {
|
||||
const el = document.getElementById('clearance-hud');
|
||||
if (!el) return;
|
||||
try {
|
||||
const [pendingRes, rulesRes, historyRes] = await Promise.all([
|
||||
api('arc?action=clearance_pending'),
|
||||
api('arc?action=clearance_rules'),
|
||||
api('arc?action=clearance_history&limit=20')
|
||||
]);
|
||||
const pending = Array.isArray(pendingRes) ? pendingRes : [];
|
||||
const rules = Array.isArray(rulesRes) ? rulesRes : [];
|
||||
const history = Array.isArray(historyRes) ? historyRes : [];
|
||||
|
||||
let html = '<button class="clr-admin-btn" onclick="window.open(\'/admin#clearance\',\'_blank\')">◈ MANAGE CLEARANCE RULES IN ADMIN</button>';
|
||||
|
||||
// Pending requests
|
||||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:#ff6680;margin:8px 0 4px">PENDING AUTHORIZATION (${pending.length})</div>`;
|
||||
if (!pending.length) {
|
||||
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">◈ NO PENDING CLEARANCE REQUESTS</div>';
|
||||
} else {
|
||||
for (const cr of pending) {
|
||||
const isOpen = _clrOpenCards.has(cr.id);
|
||||
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
|
||||
const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
|
||||
const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
|
||||
html += `<div class="clr-card${isOpen?' open':''}" id="clr-card-${cr.id}">
|
||||
<div class="clr-card-head" onclick="toggleClrCard(${cr.id})">
|
||||
<span class="clr-card-type">${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
|
||||
<span class="clr-card-risk ${cr.risk_level}">${cr.risk_level.toUpperCase()}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">#${cr.id}</span>
|
||||
</div>
|
||||
<div class="clr-card-body">
|
||||
<div class="clr-card-desc">${escHtml(cr.description || 'No description')}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:4px">
|
||||
Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
|
||||
</div>
|
||||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:6px;word-break:break-all">
|
||||
Payload: ${escHtml(JSON.stringify(pl))}
|
||||
</div>
|
||||
<div class="clr-action-bar">
|
||||
<button class="clr-approve-btn" onclick="hudClearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
|
||||
<button class="clr-deny-btn" onclick="hudClearanceDecide(${cr.id},'deny')">✕ DENY</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Rules
|
||||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:12px 0 4px">CLEARANCE RULES</div>`;
|
||||
if (!rules.length) {
|
||||
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">No rules configured</div>';
|
||||
} else {
|
||||
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px;margin-bottom:8px">';
|
||||
for (const r of rules) {
|
||||
const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
|
||||
const enLabel = r.enabled ? 'ON' : 'OFF';
|
||||
const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
|
||||
const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
|
||||
html += `<div class="clr-rule-row">
|
||||
<span class="clr-rule-type">${r.job_type.replace(/_/g,' ').toUpperCase()}</span>
|
||||
<span class="clr-card-risk ${r.risk_level}" style="font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px;border:1px solid">${r.risk_level.toUpperCase()}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${reqLabel}${autoTxt}</span>
|
||||
<button class="clr-rule-toggle ${enClass}" onclick="hudClearanceRuleToggle(${r.id},${r.enabled?0:1})">${enLabel}</button>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Recent history
|
||||
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">RECENT HISTORY</div>`;
|
||||
const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
|
||||
if (!recentDecided.length) {
|
||||
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3)">No history yet</div>';
|
||||
} else {
|
||||
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px">';
|
||||
for (const h of recentDecided) {
|
||||
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
|
||||
html += `<div class="clr-history-row">
|
||||
<span class="clr-status-${h.status}">◈</span>
|
||||
<span style="flex:1">${h.job_type.replace(/_/g,' ').toUpperCase()}</span>
|
||||
<span class="clr-status-${h.status}">${h.status.toUpperCase()}</span>
|
||||
<span style="color:rgba(255,255,255,0.3)">${ts}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
await updateClearanceBanner();
|
||||
} catch(e) {
|
||||
if (el) el.innerHTML = '<div class="comms-empty">CLEARANCE SYSTEM OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleClrCard(id) {
|
||||
const card = document.getElementById('clr-card-' + id);
|
||||
if (!card) return;
|
||||
if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
|
||||
else _clrOpenCards.add(id);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
async function hudClearanceDecide(id, action) {
|
||||
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
|
||||
if (!confirm(`${label} clearance request #${id}?`)) return;
|
||||
const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
|
||||
try {
|
||||
const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
|
||||
const msg = action === 'approve'
|
||||
? `◈ Clearance #${id} authorized. Job dispatched.`
|
||||
: `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
|
||||
addMessage('jarvis', msg);
|
||||
speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
|
||||
await loadClearanceHud();
|
||||
} catch(e) {
|
||||
addMessage('system', 'Clearance action failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function hudClearanceRuleToggle(id, newEnabled) {
|
||||
try {
|
||||
await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
|
||||
await loadClearanceHud();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const [listData, metricsData] = await Promise.all([
|
||||
api('agent/list'),
|
||||
api('agent/status')
|
||||
]);
|
||||
const agents = listData.agents || [];
|
||||
const metrics = metricsData.metrics || {};
|
||||
// Fetch sparkline data (non-blocking)
|
||||
api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
|
||||
renderAgentsTab(agents, metrics);
|
||||
}
|
||||
|
||||
async function addNetworkDevice() {
|
||||
const ip = prompt('IP address (e.g. 10.48.200.43):');
|
||||
if (!ip) return;
|
||||
const name = prompt('Device name (e.g. Yealink Phone):');
|
||||
if (!name) return;
|
||||
const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device';
|
||||
const r = await api('network/add', 'POST', {ip, alias: name, type});
|
||||
if (r.error) { alert('Error: ' + r.error); return; }
|
||||
loadNetwork();
|
||||
}
|
||||
|
||||
async function deleteNetworkDevice(ip, evt) {
|
||||
evt.stopPropagation();
|
||||
if (!confirm('Remove ' + ip + ' from the network list?')) return;
|
||||
const r = await api('network/delete', 'POST', {ip});
|
||||
if (r.error) { alert('Error: ' + r.error); return; }
|
||||
loadNetwork();
|
||||
}
|
||||
|
||||
let _agentSparkData = {};
|
||||
function sparkline(points, width=80, height=20, color='var(--cyan)') {
|
||||
if (!points || points.length < 2) return '';
|
||||
const max = Math.max(...points, 1);
|
||||
const min = Math.min(...points);
|
||||
const range = max - min || 1;
|
||||
const step = width / (points.length - 1);
|
||||
const pts = points.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - ((v - min) / range) * (height - 2) - 1;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
return `<svg width="${width}" height="${height}" style="overflow:visible;display:block">
|
||||
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>
|
||||
<circle cx="${((points.length-1)*step).toFixed(1)}" cy="${(height - ((points[points.length-1]-min)/range)*(height-2)-1).toFixed(1)}" r="2" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderAgentsTab(agents, metrics) {
|
||||
const el = document.getElementById('agents-list');
|
||||
if (!el) return;
|
||||
if (!agents.length) {
|
||||
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;margin-top:20px">NO AGENTS REGISTERED</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = agents.map(ag => {
|
||||
const m = metrics[ag.agent_id] || {};
|
||||
const sys = m.system || {};
|
||||
const alive = ag.status === 'online';
|
||||
const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--';
|
||||
const mem = sys.memory ? Math.round(sys.memory.percent) : '--';
|
||||
const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--';
|
||||
const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--';
|
||||
const disks = sys.disk || [];
|
||||
const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null;
|
||||
const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE');
|
||||
const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--';
|
||||
|
||||
const gauge = (val, unit='%', warn=80, crit=90) => {
|
||||
const v = typeof val === 'number' ? val : parseInt(val);
|
||||
if (isNaN(v)) return `<span style="color:var(--text-dim)">--</span>`;
|
||||
const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)';
|
||||
return `<div style="display:flex;align-items:center;gap:4px">
|
||||
<div style="width:50px;height:5px;background:rgba(255,255,255,0.1);border-radius:3px;flex-shrink:0">
|
||||
<div style="width:${Math.min(v,100)}%;height:100%;background:${col};border-radius:3px;transition:width 0.5s"></div>
|
||||
</div>
|
||||
<span style="color:${col};font-size:0.65rem">${v}${unit}</span>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true)
|
||||
.map(s => `<span style="color:${s.status==='active'?'var(--green)':'var(--red)'};font-size:0.58rem;margin-right:6px">${s.service}: ${s.status}</span>`)
|
||||
.join('');
|
||||
|
||||
const ctxKey = 'agent_' + ag.agent_id;
|
||||
_panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id,
|
||||
hostname: ag.hostname, status: ag.status, cpu, mem};
|
||||
|
||||
return `<div class="alert-item ${alive ? '' : 'critical'}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')"
|
||||
style="flex-direction:column;align-items:stretch;border-left:3px solid ${alive ? 'var(--green)' : 'var(--red)'}">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:${alive ? 'var(--green)' : 'var(--red)'};box-shadow:${alive ? '0 0 6px var(--green)' : 'none'};flex-shrink:0"></div>
|
||||
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${ag.hostname}</span>
|
||||
<span style="font-size:0.58rem;color:var(--text-dim)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</span>
|
||||
<span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px">
|
||||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
|
||||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
|
||||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px">
|
||||
<div>
|
||||
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">CPU 2H</div>
|
||||
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">MEM 2H</div>
|
||||
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
|
||||
${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
|
||||
</div>
|
||||
${alive ? `<div style="display:flex;gap:5px;margin-top:6px">
|
||||
<button onclick="event.stopPropagation();agentScreenshot('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SCREENSHOT</button>
|
||||
<button onclick="event.stopPropagation();agentSysinfo('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">⚡ SYSINFO</button>
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openAgentModal() {
|
||||
const os = detectOS();
|
||||
const title = document.getElementById('agentModalTitle');
|
||||
const content = document.getElementById('agentModalContent');
|
||||
const modal = document.getElementById('agentModal');
|
||||
const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
|
||||
const baseUrl = 'https://jarvis.orbishosting.com/agent';
|
||||
const jUrl = 'https://jarvis.orbishosting.com';
|
||||
|
||||
if (os === 'tablet') {
|
||||
title.textContent = '● JARVIS — TABLET / MOBILE';
|
||||
content.innerHTML =
|
||||
'<div style="color:var(--cyan);font-size:0.75rem;margin-bottom:12px">✓ You\'re viewing JARVIS on a tablet or mobile device.</div>' +
|
||||
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.6">The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).<br><br>' +
|
||||
'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.</div>';
|
||||
} else if (_agentOnline) {
|
||||
title.textContent = '● AGENT CONNECTED';
|
||||
content.innerHTML =
|
||||
'<div style="color:var(--green);font-size:0.75rem;margin-bottom:12px">✓ JARVIS Agent is active on this machine.</div>' +
|
||||
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.8">' +
|
||||
'<b style="color:var(--text)">Host:</b> ' + (_myAgent?.hostname||'—') + '<br>' +
|
||||
'<b style="color:var(--text)">IP:</b> ' + (_myAgent?.ip_address||'—') + '<br>' +
|
||||
'<b style="color:var(--text)">Type:</b> ' + (_myAgent?.agent_type||'—').toUpperCase() + '<br>' +
|
||||
'<b style="color:var(--text)">Reporting:</b> CPU · Memory · Disk · Services · Uptime</div>';
|
||||
} else {
|
||||
const inst = {
|
||||
windows: {
|
||||
label:'Windows',
|
||||
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
|
||||
dl: baseUrl+'/install-windows.ps1',
|
||||
note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
|
||||
},
|
||||
mac: {
|
||||
label:'macOS',
|
||||
cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
|
||||
dl: baseUrl+'/install-mac.sh',
|
||||
note:'Run in Terminal. Installs as a launchd background service.'
|
||||
},
|
||||
linux: {
|
||||
label:'Linux',
|
||||
cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
|
||||
dl: baseUrl+'/install.sh',
|
||||
note:'Run in terminal. Installs as a systemd service.'
|
||||
},
|
||||
unknown: {
|
||||
label:'Your System',
|
||||
cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
|
||||
dl: 'https://jarvis.orbishosting.com/agent/',
|
||||
note:'Choose your platform installer from the JARVIS agent directory.'
|
||||
}
|
||||
};
|
||||
const i = inst[os] || inst.unknown;
|
||||
const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase();
|
||||
title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM');
|
||||
content.innerHTML =
|
||||
'<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:1px;margin-bottom:8px">DETECTED: ' + osBadge + '</div>' +
|
||||
'<div style="color:var(--text-dim);font-size:0.65rem;margin-bottom:12px">'+i.note+'</div>' +
|
||||
'<pre id="agentCmdPre">'+i.cmd+'</pre>' +
|
||||
'<a class="agent-dl-btn" href="'+i.dl+'" target="_blank">↓ DOWNLOAD INSTALLER</a>' +
|
||||
'<div style="color:var(--text-dim);font-size:0.6rem;margin-top:16px;opacity:0.7">After install, the AGENT indicator turns green within 30 seconds.</div>';
|
||||
}
|
||||
modal.classList.add('open');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target === document.getElementById('agentModal'))
|
||||
document.getElementById('agentModal').classList.remove('open');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// ── SITES MANAGER ────────────────────────────────────────────────────
|
||||
let sitesData = {};
|
||||
|
||||
function openSitesModal() {
|
||||
document.getElementById('sitesModal').style.display = 'flex';
|
||||
loadSites();
|
||||
}
|
||||
function closeSitesModal() {
|
||||
document.getElementById('sitesModal').style.display = 'none';
|
||||
}
|
||||
// Close on backdrop click
|
||||
document.getElementById('sitesModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeSitesModal();
|
||||
});
|
||||
|
||||
async function loadSites() {
|
||||
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem;letter-spacing:2px">LOADING SITE SETTINGS...</div>';
|
||||
const res = await api('sites');
|
||||
if (!res.success) {
|
||||
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:#f44;font-size:0.65rem">FAILED TO LOAD SETTINGS</div>';
|
||||
return;
|
||||
}
|
||||
sitesData = res.sites;
|
||||
// Pre-fill global key from first site
|
||||
const firstKey = Object.values(res.sites)[0]?.api_key || '';
|
||||
document.getElementById('global-api-key').value = firstKey;
|
||||
renderSiteCards();
|
||||
}
|
||||
|
||||
function renderSiteCards() {
|
||||
const grid = document.getElementById('sites-grid');
|
||||
let html = '';
|
||||
for (const [id, s] of Object.entries(sitesData)) {
|
||||
html += `
|
||||
<div style="background:rgba(0,212,255,0.02);border:1px solid rgba(0,212,255,0.12);padding:16px">
|
||||
<div style="margin-bottom:12px">
|
||||
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:2px">${s.name.toUpperCase()}</div>
|
||||
<div style="color:var(--text-dim);font-size:0.58rem">${s.url}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM EMAIL</div>
|
||||
<input id="${id}-from_email" type="text" value="${s.from_email || ''}"
|
||||
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM NAME</div>
|
||||
<input id="${id}-from_name" type="text" value="${s.from_name || ''}"
|
||||
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">ADMIN NOTIFICATION EMAIL</div>
|
||||
<input id="${id}-admin_email" type="text" value="${s.admin_email || ''}"
|
||||
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<button onclick="saveSite('${id}')"
|
||||
style="background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-mono);font-size:0.58rem;letter-spacing:2px;padding:6px 16px;cursor:pointer">
|
||||
SAVE
|
||||
</button>
|
||||
<span id="${id}-status" style="font-size:0.58rem;color:var(--text-dim)"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
async function pushApiKey() {
|
||||
const key = document.getElementById('global-api-key').value.trim();
|
||||
const status = document.getElementById('push-status');
|
||||
if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; }
|
||||
status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...';
|
||||
const res = await api('sites', 'POST', {action:'push_key', api_key:key});
|
||||
if (res.success) {
|
||||
const ok = Object.values(res.results).filter(Boolean).length;
|
||||
const total = Object.keys(res.results).length;
|
||||
status.style.color = ok === total ? 'var(--cyan)' : '#fa0';
|
||||
status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`;
|
||||
for (const id of Object.keys(sitesData)) sitesData[id].api_key = key;
|
||||
} else {
|
||||
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSite(id) {
|
||||
const status = document.getElementById(id + '-status');
|
||||
status.style.color='var(--text-dim)'; status.textContent='SAVING...';
|
||||
const res = await api('sites', 'POST', {
|
||||
action: 'save',
|
||||
site: id,
|
||||
from_email: document.getElementById(id+'-from_email').value.trim(),
|
||||
from_name: document.getElementById(id+'-from_name').value.trim(),
|
||||
admin_email: document.getElementById(id+'-admin_email').value.trim(),
|
||||
});
|
||||
if (res.success) {
|
||||
status.style.color='var(--cyan)'; status.textContent='✓ SAVED';
|
||||
setTimeout(() => { status.textContent=''; }, 3000);
|
||||
} else {
|
||||
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
// ── VISION PROTOCOL — screenshot lightbox ────────────────────────────────────
|
||||
function openVisionLightbox(title) {
|
||||
const lb = document.getElementById('vision-lightbox');
|
||||
document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL';
|
||||
document.getElementById('vision-lb-img').style.display = 'none';
|
||||
document.getElementById('vision-lb-img').src = '';
|
||||
document.getElementById('vision-lb-analysis').textContent = '';
|
||||
document.getElementById('vision-lb-spinner').style.display = 'block';
|
||||
lb.classList.add('open');
|
||||
}
|
||||
|
||||
function closeVisionLightbox() {
|
||||
document.getElementById('vision-lightbox').classList.remove('open');
|
||||
}
|
||||
|
||||
async function agentScreenshot(hostname) {
|
||||
openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase());
|
||||
const arcRes = await api('arc?action=job_create', 'POST', {
|
||||
type: 'screenshot',
|
||||
payload: {agent: hostname, analyze: true},
|
||||
priority: 8,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!arcRes || !arcRes.job_id) {
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for result
|
||||
const jobId = arcRes.job_id;
|
||||
let tries = 0;
|
||||
const poll = async () => {
|
||||
tries++;
|
||||
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
|
||||
if (job && job.status === 'done') {
|
||||
const r = job.result || {};
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
if (r.has_image && r.screenshot_id) {
|
||||
// Fetch full screenshot with image
|
||||
const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null);
|
||||
if (full && full.image_b64) {
|
||||
const img = document.getElementById('vision-lb-img');
|
||||
img.src = 'data:image/png;base64,' + full.image_b64;
|
||||
img.style.display = 'block';
|
||||
}
|
||||
}
|
||||
document.getElementById('vision-lb-analysis').textContent =
|
||||
r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2));
|
||||
} else if (job && job.status === 'failed') {
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error');
|
||||
} else if (tries < 30) {
|
||||
setTimeout(poll, 2000);
|
||||
} else {
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.';
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
|
||||
async function agentSysinfo(hostname) {
|
||||
openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase());
|
||||
const arcRes = await api('arc?action=job_create', 'POST', {
|
||||
type: 'sysinfo',
|
||||
payload: {agent: hostname, analyze: true},
|
||||
priority: 7,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!arcRes || !arcRes.job_id) {
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.';
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = arcRes.job_id;
|
||||
let tries = 0;
|
||||
const poll = async () => {
|
||||
tries++;
|
||||
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
|
||||
if (job && job.status === 'done') {
|
||||
const r = job.result || {};
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
const snap = r.snapshot || {};
|
||||
const snapText = Object.entries(snap)
|
||||
.filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k))
|
||||
.map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`)
|
||||
.join('\n');
|
||||
document.getElementById('vision-lb-analysis').textContent =
|
||||
(r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2));
|
||||
} else if (job && job.status === 'failed') {
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error');
|
||||
} else if (tries < 20) {
|
||||
setTimeout(poll, 2000);
|
||||
} else {
|
||||
document.getElementById('vision-lb-spinner').style.display = 'none';
|
||||
document.getElementById('vision-lb-analysis').textContent = 'Timed out.';
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeVisionLightbox();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
// ── ARC REACTOR STATUS ────────────────────────────────────────────────
|
||||
let _arcOnline = false;
|
||||
let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
|
||||
|
||||
async function checkArcStatus() {
|
||||
const dot = document.getElementById('bb-arc-dot');
|
||||
const sta = document.getElementById('bb-arc-status');
|
||||
if (!dot || !sta) return;
|
||||
try {
|
||||
const d = await api('arc?action=status');
|
||||
if (d && d.online) {
|
||||
_arcOnline = true;
|
||||
dot.className = 'bb-dot online';
|
||||
const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
|
||||
sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
|
||||
_arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
|
||||
done: d.jobs_done||0, failed: d.jobs_failed||0 };
|
||||
} else {
|
||||
_arcOnline = false;
|
||||
dot.className = 'bb-dot offline';
|
||||
sta.textContent = 'OFFLINE';
|
||||
}
|
||||
} catch(e) {
|
||||
_arcOnline = false;
|
||||
dot.className = 'bb-dot offline';
|
||||
sta.textContent = 'OFFLINE';
|
||||
}
|
||||
}
|
||||
|
||||
// Submit a job to the Arc Reactor and return job_id
|
||||
async function arcSubmitJob(type, payload, priority) {
|
||||
payload = payload || {};
|
||||
priority = priority || 5;
|
||||
const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
|
||||
return d.job_id || null;
|
||||
}
|
||||
|
||||
// Poll a job until done or failed (max 120s), calling onProgress each tick
|
||||
async function arcWaitJob(jobId, onProgress) {
|
||||
var start = Date.now();
|
||||
while (Date.now() - start < 120000) {
|
||||
const d = await api('arc?action=job_get&id=' + jobId);
|
||||
if (onProgress) onProgress(d);
|
||||
if (d.status === 'done') return d;
|
||||
if (d.status === 'failed') throw new Error(d.error || 'Job failed');
|
||||
await new Promise(function(r){ setTimeout(r, 1500); });
|
||||
}
|
||||
throw new Error('Arc Reactor job timed out');
|
||||
}
|
||||
|
||||
|
||||
// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
|
||||
let _intelPollTimer = null;
|
||||
let _intelActiveJobs = new Set();
|
||||
let _intelLastLoad = 0;
|
||||
|
||||
async function loadIntel() {
|
||||
const el = document.getElementById('intel-list');
|
||||
if (!el) return;
|
||||
_intelLastLoad = Date.now();
|
||||
|
||||
try {
|
||||
// Fetch recent research + tool_loop jobs
|
||||
const [resJobs, toolJobs] = await Promise.all([
|
||||
api('arc?action=jobs&status=&limit=20').catch(() => []),
|
||||
Promise.resolve([]),
|
||||
]);
|
||||
const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
|
||||
|
||||
if (!jobs.length) {
|
||||
el.innerHTML = '<div class="intel-empty">◈ NO INTEL JOBS<br><span style="opacity:0.5">Say "research [topic]" to activate</span></div>';
|
||||
stopIntelPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active jobs
|
||||
const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running');
|
||||
if (hasActive) startIntelPolling(); else stopIntelPolling();
|
||||
|
||||
let html = '<button class="intel-new-btn" onclick="intelPrompt()">⚡ NEW RESEARCH</button>';
|
||||
for (const job of jobs) {
|
||||
const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running';
|
||||
const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running';
|
||||
const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase();
|
||||
const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM';
|
||||
|
||||
// Get result details if done
|
||||
let bodyHtml = '';
|
||||
if (job.status === 'done' && job.result) {
|
||||
let r = job.result;
|
||||
if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} }
|
||||
if (typeof r === 'object') {
|
||||
const synthesis = (r.synthesis || r.result || r.response || '').trim();
|
||||
const sources = r.sources || [];
|
||||
const query = r.query || r.task || '';
|
||||
const provider = r.provider || '';
|
||||
|
||||
bodyHtml = `<div class="intel-card-body">`;
|
||||
if (provider) bodyHtml += `<div style="font-size:0.55rem;color:var(--text-dim);margin:6px 0 2px;font-family:var(--font-mono)">PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}</div>`;
|
||||
if (synthesis) bodyHtml += `<div class="synthesis">${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}</div>`;
|
||||
if (sources.length) {
|
||||
bodyHtml += '<div class="intel-sources"><div style="font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin-bottom:4px;font-family:var(--font-display)">SOURCES</div>';
|
||||
sources.slice(0,5).forEach((s,i) => {
|
||||
const title = escHtml((s.title||s.url||'').substring(0,60));
|
||||
const url = escHtml(s.url||'');
|
||||
bodyHtml += `<div class="intel-source">${i+1}. <a href="${url}" target="_blank" rel="noopener">${title||url}</a></div>`;
|
||||
});
|
||||
bodyHtml += '</div>';
|
||||
}
|
||||
bodyHtml += '</div>';
|
||||
}
|
||||
} else if (job.status === 'running' || job.status === 'queued') {
|
||||
const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...';
|
||||
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--text-dim);padding:8px 0;font-family:var(--font-mono)">${typeMsg}</div></div>`;
|
||||
} else if (job.status === 'failed' && job.error) {
|
||||
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--red);padding:8px 0;font-family:var(--font-mono)">${escHtml(job.error.substring(0,200))}</div></div>`;
|
||||
}
|
||||
|
||||
const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : '';
|
||||
const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : '';
|
||||
|
||||
html += `<div class="intel-card${(isOpen && bodyHtml) ? ' open':''}" id="intel-card-${job.id}">
|
||||
<div class="intel-card-head" onclick="toggleIntelCard(${job.id})">
|
||||
<span style="font-size:0.55rem;color:var(--text-dim);font-family:var(--font-mono);flex-shrink:0">${typeLabel}</span>
|
||||
<span class="intel-card-query">#${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))}</span>
|
||||
<span style="font-size:0.55rem;color:var(--text-dim);flex-shrink:0;font-family:var(--font-mono)">${ts}</span>
|
||||
<span class="intel-card-status ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
${bodyHtml}
|
||||
</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
} catch(e) {
|
||||
if (el) el.innerHTML = '<div class="intel-empty">INTEL OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIntelCard(id) {
|
||||
const card = document.getElementById('intel-card-' + id);
|
||||
if (!card) return;
|
||||
if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id);
|
||||
else _intelActiveJobs.add(id);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
function startIntelPolling() {
|
||||
if (_intelPollTimer) return;
|
||||
_intelPollTimer = setInterval(() => {
|
||||
if (document.getElementById('tab-intel')?.classList.contains('active')) {
|
||||
loadIntel();
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function stopIntelPolling() {
|
||||
if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; }
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function intelPrompt() {
|
||||
const input = document.getElementById('textInput');
|
||||
if (input) { input.value = 'research '; input.focus(); }
|
||||
}
|
||||
|
||||
// Called when arc_job is returned from chat response
|
||||
function onArcJobStarted(jobId, jobType) {
|
||||
const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
|
||||
if (commsTypes.includes(jobType)) {
|
||||
const commsBtn = document.getElementById('tab-btn-comms');
|
||||
if (commsBtn) commsBtn.click();
|
||||
startCommsPolling();
|
||||
} else {
|
||||
_intelActiveJobs.add(jobId);
|
||||
const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]');
|
||||
if (intelTab) intelTab.click();
|
||||
startIntelPolling();
|
||||
}
|
||||
}
|
||||
|
||||
// ── COMMS PROTOCOL — email triage HUD ────────────────────────────────────
|
||||
let _commsPollTimer = null;
|
||||
let _commsFilter = 'priority';
|
||||
let _commsOpenCards = new Set();
|
||||
|
||||
async function loadComms() {
|
||||
const el = document.getElementById('comms-list');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter);
|
||||
const items = Array.isArray(res) ? res : (res.items || []);
|
||||
|
||||
if (!items.length) {
|
||||
el.innerHTML = '<button class="comms-triage-btn" onclick="commsTriageNow()">◈ TRIAGE INBOX NOW</button>'
|
||||
+ '<div class="comms-empty">◈ NO TRIAGE DATA<br><span style="opacity:0.5">Say "check my email" to activate</span></div>';
|
||||
stopCommsPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
|
||||
const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'ℹ', promo:'📢', spam:'🗑'};
|
||||
|
||||
let html = '<div style="display:flex;gap:5px;margin-bottom:5px">';
|
||||
html += '<button class="comms-triage-btn" style="flex:3;margin-bottom:0" onclick="commsTriageNow()">◈ TRIAGE INBOX</button>';
|
||||
html += '<button class="comms-compose-btn" style="flex:2;margin-bottom:0" onclick="commsShowCompose()">+ COMPOSE</button>';
|
||||
html += '</div>';
|
||||
html += '<div class="comms-header-bar">';
|
||||
for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) {
|
||||
html += `<div class="comms-filter-btn${_commsFilter===f?' active':''}" onclick="commsSetFilter('${f}')">${label}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
for (const item of items) {
|
||||
const cat = item.category || 'info';
|
||||
const icon = catIcons[cat] || '◈';
|
||||
const prio = item.priority || 0;
|
||||
const isOpen = _commsOpenCards.has(item.id);
|
||||
const hasReply = item.draft_reply && item.draft_reply.trim().length > 5;
|
||||
|
||||
html += `<div class="comms-card${isOpen?' open':''}" id="comms-card-${item.id}">
|
||||
<div class="comms-card-head" onclick="toggleCommsCard(${item.id})">
|
||||
<span class="comms-card-cat ${cat}">${icon} ${cat.toUpperCase()}</span>
|
||||
<span class="comms-card-subject">${escHtml((item.subject||'(no subject)').substring(0,60))}</span>
|
||||
<span class="comms-prio">${prio}/10</span>
|
||||
</div>
|
||||
<div class="comms-card-body">
|
||||
<div class="comms-card-from">FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}</div>
|
||||
<div class="comms-card-summary">${escHtml(item.summary||'')}</div>
|
||||
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft" id="comms-draft-${item.id}">${escHtml(item.draft_reply)}</div>` : ''}
|
||||
<div style="display:flex;gap:5px;margin-top:8px">
|
||||
${hasReply ? `<button class="comms-send-btn" id="comms-send-${item.id}" onclick="commsSendReply(${item.id})">◈ SEND REPLY</button>` : ''}
|
||||
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY</button>` : ''}
|
||||
<button onclick="commsDismiss(${item.id})" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">DISMISS</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
} catch(e) {
|
||||
if (el) el.innerHTML = '<div class="comms-empty">COMMS OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCommsCard(id) {
|
||||
const card = document.getElementById('comms-card-' + id);
|
||||
if (!card) return;
|
||||
if (_commsOpenCards.has(id)) _commsOpenCards.delete(id);
|
||||
else _commsOpenCards.add(id);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
function commsSetFilter(f) {
|
||||
_commsFilter = f;
|
||||
loadComms();
|
||||
}
|
||||
|
||||
async function commsDismiss(id) {
|
||||
await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {});
|
||||
loadComms();
|
||||
}
|
||||
|
||||
async function commsCopyReply(id) {
|
||||
const draft = document.querySelector(`#comms-draft-${id}`);
|
||||
if (draft) {
|
||||
navigator.clipboard.writeText(draft.innerText).catch(() => {});
|
||||
const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
|
||||
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
|
||||
}
|
||||
}
|
||||
|
||||
async function commsSendReply(id) {
|
||||
const btn = document.getElementById('comms-send-' + id);
|
||||
const draft = document.getElementById('comms-draft-' + id);
|
||||
if (!btn || !draft) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '◈ SENDING…';
|
||||
try {
|
||||
const res = await api('arc', 'POST', {
|
||||
action: 'job_create',
|
||||
type: 'send_email',
|
||||
payload: { triage_id: id, content: draft.innerText },
|
||||
priority: 8,
|
||||
});
|
||||
if (res.job_id) {
|
||||
btn.textContent = '◈ SENT ✓';
|
||||
btn.style.color = '#00ff88';
|
||||
setTimeout(() => loadComms(), 3000);
|
||||
loadCommsOutbox();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '◈ SEND REPLY';
|
||||
alert('Send failed: ' + (res.error || 'unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '◈ SEND REPLY';
|
||||
}
|
||||
}
|
||||
|
||||
function commsShowCompose() {
|
||||
const existing = document.getElementById('comms-compose-modal');
|
||||
if (existing) existing.remove();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'comms-compose-modal';
|
||||
modal.id = 'comms-compose-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="comms-compose-inner">
|
||||
<div class="comms-compose-title">◈ COMPOSE MESSAGE</div>
|
||||
<select id="cc-account" class="comms-compose-field" style="cursor:pointer">
|
||||
<option value="gmail">Gmail</option>
|
||||
<option value="icloud">iCloud</option>
|
||||
</select>
|
||||
<input id="cc-to" class="comms-compose-field" placeholder="To: email address" type="email">
|
||||
<input id="cc-subject" class="comms-compose-field" placeholder="Subject">
|
||||
<textarea id="cc-instructions" class="comms-compose-field" rows="4" placeholder="Describe what to say (AI will draft it)"></textarea>
|
||||
<div id="cc-preview" style="display:none">
|
||||
<div class="comms-draft-label">DRAFTED MESSAGE</div>
|
||||
<div class="comms-draft" id="cc-preview-body" style="max-height:200px"></div>
|
||||
</div>
|
||||
<div class="comms-compose-actions">
|
||||
<button class="comms-send-btn" style="flex:1" onclick="commsComposeDraft()">◈ DRAFT</button>
|
||||
<button class="comms-send-btn" style="flex:1;display:none" id="cc-send-btn" onclick="commsComposeAndSend()">◈ SEND NOW</button>
|
||||
<button onclick="document.getElementById('comms-compose-modal').remove()" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">CANCEL</button>
|
||||
</div>
|
||||
<div id="cc-status" style="font-family:var(--font-mono);font-size:0.55rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
|
||||
}
|
||||
|
||||
let _ccDraftedBody = '';
|
||||
|
||||
async function commsComposeDraft() {
|
||||
const to = document.getElementById('cc-to')?.value.trim();
|
||||
const subject = document.getElementById('cc-subject')?.value.trim();
|
||||
const instructions = document.getElementById('cc-instructions')?.value.trim();
|
||||
const account = document.getElementById('cc-account')?.value;
|
||||
const status = document.getElementById('cc-status');
|
||||
if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
|
||||
if (status) status.textContent = '◈ DRAFTING…';
|
||||
try {
|
||||
const res = await api('arc', 'POST', {
|
||||
action: 'job_create', type: 'compose_email',
|
||||
payload: { recipient: to, subject, instructions, account, auto_send: false },
|
||||
priority: 7,
|
||||
});
|
||||
if (!res.job_id) throw new Error(res.error || 'No job');
|
||||
// poll for result
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
const job = await api('arc?action=job_get&id=' + res.job_id);
|
||||
if (job.status === 'done' && job.result?.drafted_body) {
|
||||
_ccDraftedBody = job.result.drafted_body;
|
||||
document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
|
||||
document.getElementById('cc-preview').style.display = 'block';
|
||||
document.getElementById('cc-send-btn').style.display = '';
|
||||
if (status) status.textContent = '◈ DRAFT READY — Review and send';
|
||||
} else if (job.status === 'failed') {
|
||||
if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
|
||||
} else if (attempts++ < 20) {
|
||||
setTimeout(poll, 1500);
|
||||
} else {
|
||||
if (status) status.textContent = '◈ Job still running — check INTEL tab';
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
} catch(e) {
|
||||
if (status) status.textContent = '✗ Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function commsComposeAndSend() {
|
||||
const to = document.getElementById('cc-to')?.value.trim();
|
||||
const subject = document.getElementById('cc-subject')?.value.trim();
|
||||
const account = document.getElementById('cc-account')?.value;
|
||||
const status = document.getElementById('cc-status');
|
||||
const btn = document.getElementById('cc-send-btn');
|
||||
if (!to || !_ccDraftedBody) return;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
|
||||
if (status) status.textContent = '◈ TRANSMITTING…';
|
||||
try {
|
||||
const res = await api('arc', 'POST', {
|
||||
action: 'job_create', type: 'send_email',
|
||||
payload: { to_email: to, subject, body: _ccDraftedBody, account },
|
||||
priority: 9,
|
||||
});
|
||||
if (res.job_id) {
|
||||
if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
|
||||
setTimeout(() => {
|
||||
document.getElementById('comms-compose-modal')?.remove();
|
||||
loadCommsOutbox();
|
||||
}, 1500);
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||||
if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
|
||||
}
|
||||
} catch(e) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||||
if (status) status.textContent = '✗ Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommsOutbox() {
|
||||
const el = document.getElementById('comms-outbox');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await api('arc?action=comms_sent&limit=20');
|
||||
const sent = Array.isArray(data) ? data : (data.sent || []);
|
||||
if (!sent.length) {
|
||||
el.innerHTML = '<div class="comms-empty" style="padding:10px">No sent messages yet</div>';
|
||||
return;
|
||||
}
|
||||
const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
|
||||
let html = '';
|
||||
for (const m of sent) {
|
||||
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
|
||||
const sc = m.status || 'sent';
|
||||
html += `<div class="comms-outbox-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="comms-outbox-to">TO: ${escHtml((m.to_email||'').substring(0,40))}</div>
|
||||
<span class="comms-outbox-status ${sc}">${sc.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="comms-outbox-subj">${escHtml((m.subject||'(no subject)').substring(0,60))}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${ts} · ${m.account||'gmail'}</div>
|
||||
</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="comms-empty" style="padding:10px">OUTBOX OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function commsTriageNow() {
|
||||
const input = document.getElementById('textInput');
|
||||
if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||||
}
|
||||
|
||||
function startCommsPolling() {
|
||||
if (_commsPollTimer) return;
|
||||
_commsPollTimer = setInterval(() => {
|
||||
if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
function stopCommsPolling() {
|
||||
if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; }
|
||||
}
|
||||
|
||||
// ── GUARDIAN MODE ─────────────────────────────────────────────────────────────
|
||||
let _guardianPollTimer = null;
|
||||
let _guardianChatTimer = null;
|
||||
let _guardianLastChat = '';
|
||||
let _guardianUnread = 0;
|
||||
|
||||
async function loadGuardian() {
|
||||
const el = document.getElementById('guardian-list');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const [statusData, eventsData] = await Promise.all([
|
||||
api('arc?action=guardian_status').catch(() => ({})),
|
||||
api('arc?action=guardian_events&limit=40').catch(() => []),
|
||||
]);
|
||||
|
||||
const events = Array.isArray(eventsData) ? eventsData : [];
|
||||
const status = statusData || {};
|
||||
const counts = status.counts || {};
|
||||
const unread = parseInt(counts.unread || 0);
|
||||
const critU = parseInt(counts.critical_unread || 0);
|
||||
|
||||
_guardianUnread = unread;
|
||||
_updateGuardianBadge(unread, critU);
|
||||
if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('JARVIS ALERT', {
|
||||
body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.',
|
||||
icon: '/favicon.ico',
|
||||
});
|
||||
}
|
||||
|
||||
const lastScan = status.last_scan
|
||||
? new Date(status.last_scan + 'Z').toLocaleTimeString()
|
||||
: '—';
|
||||
|
||||
let html = `<div style="padding:6px 10px 4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--cyan)">◈ GUARDIAN MODE</span>
|
||||
<span style="font-family:var(--font-mono);font-size:0.5rem;color:${status.enabled?'var(--green)':'var(--red)'}">
|
||||
${status.enabled ? '● ACTIVE' : '○ INACTIVE'}
|
||||
</span>
|
||||
<span style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim)">SCAN: ${lastScan}</span>
|
||||
${unread ? `<button onclick="guardianAckAll()" class="guardian-ack-btn" style="margin-left:auto">ACK ALL (${unread})</button>` : '<span style="margin-left:auto"></span>'}
|
||||
<button onclick="guardianSitrep()" style="background:rgba(0,212,255,0.08);border:1px solid var(--panel-border);color:var(--cyan);padding:3px 7px;border-radius:3px;font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SITREP</button>
|
||||
</div>`;
|
||||
|
||||
if (!events.length) {
|
||||
html += '<div style="text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px">◈ ALL CLEAR<br><span style="opacity:0.5">Guardian is watching...</span></div>';
|
||||
} else {
|
||||
for (const ev of events) {
|
||||
const sev = ev.severity || 'info';
|
||||
const acked = ev.acknowledged;
|
||||
const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : '';
|
||||
const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',
|
||||
mem_high:'⚡',disk_high:'💾',service_down:'✗',
|
||||
service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈';
|
||||
html += `<div class="guardian-event ${sev}${acked?' acked':''}" id="gev-${ev.id}">
|
||||
<span class="guardian-sev ${sev}">${sev.toUpperCase()}</span>
|
||||
<div style="flex:1">
|
||||
<div class="guardian-msg">${typeIco} ${escHtml(ev.message||'')}</div>
|
||||
${ev.ai_analysis ? `<div class="guardian-ai">${escHtml(ev.ai_analysis.substring(0,200))}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
|
||||
<span class="guardian-time">${ts}</span>
|
||||
${!acked ? `<button class="guardian-ack-btn" onclick="guardianAck(${ev.id})">ACK</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
el.innerHTML = html;
|
||||
startGuardianPolling();
|
||||
|
||||
} catch(e) {
|
||||
if (el) el.innerHTML = '<div style="text-align:center;padding:20px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim)">GUARDIAN OFFLINE</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _updateGuardianBadge(unread, critical) {
|
||||
const dot = document.getElementById('bb-guardian-dot');
|
||||
const badge = document.getElementById('bb-guardian-badge');
|
||||
const status = document.getElementById('bb-guardian-status');
|
||||
if (!dot) return;
|
||||
dot.className = 'bb-dot';
|
||||
if (critical > 0) {
|
||||
dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)';
|
||||
} else if (unread > 0) {
|
||||
dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623';
|
||||
} else {
|
||||
dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)';
|
||||
}
|
||||
if (unread > 0) {
|
||||
badge.textContent = unread; badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function guardianAck(id) {
|
||||
await api('arc?action=guardian_ack&id=' + id).catch(() => {});
|
||||
const ev = document.getElementById('gev-' + id);
|
||||
if (ev) ev.classList.add('acked');
|
||||
_guardianUnread = Math.max(0, _guardianUnread - 1);
|
||||
_updateGuardianBadge(_guardianUnread, 0);
|
||||
}
|
||||
|
||||
async function guardianAckAll() {
|
||||
await api('arc?action=guardian_ack').catch(() => {});
|
||||
loadGuardian();
|
||||
}
|
||||
|
||||
function guardianSitrep() {
|
||||
const input = document.getElementById('textInput');
|
||||
if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||||
}
|
||||
|
||||
function switchGuardianTab() {
|
||||
const btn = document.getElementById('tab-btn-guardian');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
|
||||
function startGuardianPolling() {
|
||||
if (_guardianPollTimer) return;
|
||||
_guardianPollTimer = setInterval(() => {
|
||||
if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian();
|
||||
else _refreshGuardianBadge();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
async function _refreshGuardianBadge() {
|
||||
const s = await api('arc?action=guardian_status').catch(() => null);
|
||||
if (!s) return;
|
||||
const counts = s.counts || {};
|
||||
_updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0));
|
||||
}
|
||||
|
||||
// Proactive chat polling — checks for guardian-injected messages every 30s
|
||||
let _proactiveChatLastId = 0;
|
||||
async function _pollProactiveChat() {
|
||||
try {
|
||||
const rows = await api('arc?action=guardian_chat').catch(() => []);
|
||||
if (!Array.isArray(rows)) return;
|
||||
for (const row of rows) {
|
||||
if (row.id > _proactiveChatLastId) {
|
||||
_proactiveChatLastId = row.id;
|
||||
// Don't spam on first load — only show messages from last 5 min
|
||||
const age = Date.now() - new Date(row.created_at + 'Z').getTime();
|
||||
if (age < 300000) {
|
||||
addMessage('jarvis', row.message);
|
||||
speak(row.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
|
||||
function openSearchModal() {
|
||||
document.getElementById('searchModal').style.display = 'flex';
|
||||
document.getElementById('searchInput').focus();
|
||||
}
|
||||
function closeSearchModal() {
|
||||
document.getElementById('searchModal').style.display = 'none';
|
||||
document.getElementById('searchResults').innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>';
|
||||
document.getElementById('searchInput').value = '';
|
||||
}
|
||||
async function runSearch() {
|
||||
const q = document.getElementById('searchInput').value.trim();
|
||||
if (!q) return;
|
||||
const el = document.getElementById('searchResults');
|
||||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Searching...</div>';
|
||||
try {
|
||||
const d = await api('history?q=' + encodeURIComponent(q));
|
||||
if (!d.results || !d.results.length) {
|
||||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">No results for "' + q + '"</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = d.results.map(r => {
|
||||
const role = r.role === 'user' ? '👤' : '🤖';
|
||||
const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
|
||||
return `<div style="background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:10px 12px">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||||
<span style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:var(--cyan)">${role} ${r.role.toUpperCase()}</span>
|
||||
<span style="font-size:0.52rem;color:var(--text-dim)">${ts}</span>
|
||||
</div>
|
||||
<div style="font-size:0.68rem;color:var(--text-primary);line-height:1.4">${snippet.replace(/</g,'<')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div style="color:var(--red);font-size:0.65rem;text-align:center;padding:20px">Search failed</div>';
|
||||
}
|
||||
}
|
||||
document.getElementById('searchModal')?.addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('searchModal')) closeSearchModal();
|
||||
});
|
||||
|
||||
// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
|
||||
const _shownSuggestions = new Set();
|
||||
async function checkSuggestions() {
|
||||
const d = await api('suggestions').catch(() => null);
|
||||
if (!d || !d.suggestions || !d.suggestions.length) return;
|
||||
for (const s of d.suggestions) {
|
||||
const key = s.intent + ':' + d.hour + ':' + d.dow;
|
||||
if (_shownSuggestions.has(key)) continue;
|
||||
_shownSuggestions.add(key);
|
||||
// Show as a soft suggestion chip in chat
|
||||
const log = document.getElementById('chatLog');
|
||||
const chip = document.createElement('div');
|
||||
chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
|
||||
chip.innerHTML = `<button onclick="sendSuggestion('${s.intent}',this)" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.25);border-radius:12px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:1px;padding:4px 12px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.background='rgba(0,212,255,0.12)'" onmouseout="this.style.background='rgba(0,212,255,0.06)'">◈ ${s.prompt}</button>`;
|
||||
log.appendChild(chip);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
break; // show max one suggestion at a time
|
||||
}
|
||||
}
|
||||
|
||||
function sendSuggestion(intent, btn) {
|
||||
btn.closest('div').remove();
|
||||
const prompts = {
|
||||
'network_scan': 'run a network scan',
|
||||
'jellyfin_now_playing': 'what is playing on Jellyfin',
|
||||
'ha_scene': 'what scenes are available',
|
||||
'planner:briefing': 'daily briefing',
|
||||
'vm_suggestions': 'VM resource suggestions',
|
||||
'focus_mode': 'focus mode',
|
||||
};
|
||||
const msg = prompts[intent] || intent.replace(/_/g,' ');
|
||||
document.getElementById('textInput').value = msg;
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
|
||||
function mobSwitch(which) {
|
||||
if (window.innerWidth > 900) return;
|
||||
const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
|
||||
Object.entries(panels).forEach(([k, id]) => {
|
||||
document.getElementById(id)?.classList.toggle('mob-active', k === which);
|
||||
});
|
||||
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('mob-btn-' + which)?.classList.add('active');
|
||||
if (which === 'right') loadNews();
|
||||
}
|
||||
function initMobile() {
|
||||
if (window.innerWidth > 900) return;
|
||||
['leftPanel','centerPanel','rightPanel'].forEach(id =>
|
||||
document.getElementById(id)?.classList.remove('mob-active'));
|
||||
document.getElementById('leftPanel')?.classList.add('mob-active');
|
||||
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('mob-btn-left')?.classList.add('active');
|
||||
}
|
||||
window.addEventListener('resize', initMobile);
|
||||
|
||||
// ── COMMAND PALETTE (Ctrl+K) ──────────────────────────────────────────────
|
||||
const _PALETTE_COMMANDS = [
|
||||
{ label: 'Run a network scan', q: 'run a network scan', group: 'Network' },
|
||||
{ label: 'Show online devices', q: 'who is online on the network', group: 'Network' },
|
||||
{ label: 'Proxmox status', q: 'proxmox status', group: 'Network' },
|
||||
{ label: 'Check agent status', q: 'check all agents', group: 'Agents' },
|
||||
{ label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' },
|
||||
{ label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' },
|
||||
{ label: 'Daily briefing', q: 'daily briefing', group: 'Planner' },
|
||||
{ label: 'My tasks today', q: 'my tasks today', group: 'Planner' },
|
||||
{ label: 'My calendar', q: 'my calendar', group: 'Planner' },
|
||||
{ label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' },
|
||||
{ label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' },
|
||||
{ label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' },
|
||||
{ label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' },
|
||||
{ label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'},
|
||||
{ label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'},
|
||||
{ label: 'Focus mode', q: 'focus mode', group: 'UI' },
|
||||
{ label: 'Show all panels', q: 'show all panels', group: 'UI' },
|
||||
{ label: 'Check alerts', q: 'check alerts', group: 'System' },
|
||||
{ label: 'Site health', q: 'site health', group: 'System' },
|
||||
{ label: 'System status', q: 'system status', group: 'System' },
|
||||
{ label: 'Check inbox', q: 'check inbox', group: 'Comms' },
|
||||
{ label: 'Search history…', q: '', group: 'Chat', search: true },
|
||||
];
|
||||
|
||||
let _paletteOpen = false;
|
||||
|
||||
function openPalette() {
|
||||
if (_paletteOpen) return;
|
||||
_paletteOpen = true;
|
||||
const ov = document.getElementById('cmdPalette');
|
||||
if (!ov) return;
|
||||
ov.style.display = 'flex';
|
||||
const inp = document.getElementById('cmdPaletteInput');
|
||||
inp.value = '';
|
||||
renderPaletteItems('');
|
||||
requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); });
|
||||
}
|
||||
|
||||
function closePalette() {
|
||||
if (!_paletteOpen) return;
|
||||
_paletteOpen = false;
|
||||
const ov = document.getElementById('cmdPalette');
|
||||
if (!ov) return;
|
||||
ov.classList.remove('open');
|
||||
setTimeout(() => { ov.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
function renderPaletteItems(q) {
|
||||
const list = document.getElementById('cmdPaletteList');
|
||||
if (!list) return;
|
||||
const low = q.toLowerCase().trim();
|
||||
const filtered = low
|
||||
? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low))
|
||||
: _PALETTE_COMMANDS;
|
||||
|
||||
let currentGroup = null;
|
||||
list.innerHTML = '';
|
||||
filtered.forEach((cmd, i) => {
|
||||
if (cmd.group !== currentGroup) {
|
||||
currentGroup = cmd.group;
|
||||
const g = document.createElement('div');
|
||||
g.className = 'cp-group';
|
||||
g.textContent = cmd.group;
|
||||
list.appendChild(g);
|
||||
}
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cp-item' + (i === 0 ? ' cp-active' : '');
|
||||
row.dataset.q = cmd.q;
|
||||
row.dataset.search = cmd.search ? '1' : '';
|
||||
const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'),
|
||||
m => `<mark>${m}</mark>`);
|
||||
row.innerHTML = `<span class="cp-icon">◈</span><span class="cp-label">${lbl}</span><kbd class="cp-kbd">↵</kbd>`;
|
||||
row.addEventListener('click', () => firePaletteItem(row));
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function movePaletteSelection(dir) {
|
||||
const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item'));
|
||||
if (!items.length) return;
|
||||
const cur = items.findIndex(el => el.classList.contains('cp-active'));
|
||||
const next = (cur + dir + items.length) % items.length;
|
||||
items.forEach(el => el.classList.remove('cp-active'));
|
||||
items[next].classList.add('cp-active');
|
||||
items[next].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function firePaletteItem(el) {
|
||||
if (!el) {
|
||||
const active = document.querySelector('#cmdPaletteList .cp-active');
|
||||
if (!active) return;
|
||||
el = active;
|
||||
}
|
||||
const q = el.dataset.q;
|
||||
const isSearch = el.dataset.search === '1';
|
||||
closePalette();
|
||||
if (isSearch) {
|
||||
if (typeof openSearchModal === 'function') openSearchModal();
|
||||
return;
|
||||
}
|
||||
if (q) {
|
||||
document.getElementById('textInput').value = q;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard events
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
_paletteOpen ? closePalette() : openPalette();
|
||||
return;
|
||||
}
|
||||
if (!_paletteOpen) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); }
|
||||
});
|
||||
|
||||
// Filter on type
|
||||
document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
|
||||
renderPaletteItems(e.target.value);
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
document.getElementById('cmdPalette')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'cmdPalette') closePalette();
|
||||
});
|
||||
|
||||
// ── AGENT TOPOLOGY MAP ─────────────────────────────────────────────────────────────
|
||||
let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = [];
|
||||
|
||||
function toggleAgentTopo() {
|
||||
_agentTopoMode = !_agentTopoMode;
|
||||
const btn = document.getElementById('agent-topo-btn');
|
||||
const list = document.getElementById('agents-list');
|
||||
const cvs = document.getElementById('agentTopoCanvas');
|
||||
if (!btn || !list || !cvs) return;
|
||||
btn.classList.toggle('active', _agentTopoMode);
|
||||
if (_agentTopoMode) {
|
||||
list.style.display = 'none'; cvs.style.display = 'block';
|
||||
_buildAgentTopoData(); _drawAgentTopo();
|
||||
} else {
|
||||
list.style.display = 'block'; cvs.style.display = 'none';
|
||||
if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function _buildAgentTopoData() {
|
||||
// Build node list from rendered agent cards
|
||||
_agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}];
|
||||
document.querySelectorAll('.agent-card').forEach(el => {
|
||||
const nameEl = el.querySelector('.agent-name, [class*="name"]');
|
||||
if (!nameEl) return;
|
||||
const name = nameEl.textContent.trim();
|
||||
const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online');
|
||||
const lname = name.toLowerCase();
|
||||
let type = 'linux';
|
||||
if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox';
|
||||
else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant';
|
||||
else if (lname.includes('windows') || lname.includes('mini')) type = 'windows';
|
||||
_agentTopoData.push({id:name, label:name.substring(0,12), online, type});
|
||||
});
|
||||
// Fallback: use last known registered agent list if cards not rendered
|
||||
if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') {
|
||||
(_lastAgents || []).forEach(a => {
|
||||
_agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _drawAgentTopo() {
|
||||
const cvs = document.getElementById('agentTopoCanvas');
|
||||
if (!cvs || !_agentTopoMode) return;
|
||||
const ctx = cvs.getContext('2d');
|
||||
const rect = cvs.getBoundingClientRect();
|
||||
const W = rect.width || 280, H = rect.height || 260;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
cvs.width = W * dpr; cvs.height = H * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68};
|
||||
const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'};
|
||||
// Assign positions
|
||||
const byType = {};
|
||||
_agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); });
|
||||
_agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2;
|
||||
Object.entries(byType).forEach(([tp, nodes]) => {
|
||||
const rf = typeRing[tp] || 0.68;
|
||||
const r = Math.min(W, H) / 2 * rf;
|
||||
nodes.forEach((n, i) => {
|
||||
const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2;
|
||||
n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r;
|
||||
});
|
||||
});
|
||||
let t = 0;
|
||||
function frame() {
|
||||
if (!_agentTopoMode) return;
|
||||
t += 0.007; ctx.clearRect(0, 0, W, H);
|
||||
// Orbit rings
|
||||
[0.28, 0.48, 0.68].forEach(rf => {
|
||||
ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2);
|
||||
ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke();
|
||||
});
|
||||
// Edges
|
||||
_agentTopoData.slice(1).forEach(n => {
|
||||
if (!n.x) return;
|
||||
const col = typeColor[n.type] || '0,190,255';
|
||||
ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y);
|
||||
ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)';
|
||||
ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke();
|
||||
});
|
||||
// Particles
|
||||
_agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => {
|
||||
const p = ((t*0.35+i*0.41)%1);
|
||||
const col = typeColor[n.type]||'0,190,255';
|
||||
const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p;
|
||||
ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2);
|
||||
ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill();
|
||||
});
|
||||
// Nodes
|
||||
_agentTopoData.forEach((n,i) => {
|
||||
if (!n.x) return;
|
||||
const col = typeColor[n.type]||'0,190,255';
|
||||
const nr = n.type==='hub' ? 13 : 7;
|
||||
const pulse = Math.sin(t+i*0.9)*0.25+0.75;
|
||||
if (n.online||n.type==='hub') {
|
||||
const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5);
|
||||
g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')');
|
||||
g.addColorStop(1,'transparent');
|
||||
ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2);
|
||||
ctx.fillStyle=g; ctx.fill();
|
||||
}
|
||||
ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2);
|
||||
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke();
|
||||
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)';
|
||||
ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace';
|
||||
ctx.textAlign='center';
|
||||
ctx.fillText(n.label, n.x, n.y+nr+9);
|
||||
});
|
||||
_agentTopoRaf = requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
}
|
||||
@@ -418,10 +418,12 @@
|
||||
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px"></video>
|
||||
<script data-cfasync="false" src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
|
||||
|
||||
<script data-cfasync="false" src="assets/js/jarvis-effects.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-protocols.js?v=20260617"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-effects.js?v=20260617b"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-overlays.js?v=20260617b"></script>
|
||||
<script data-cfasync="false" src="assets/js/jarvis-app.js?v=20260617b"></script>
|
||||
<script data-cfasync="false" src="assets/js/panels/jarvis-arc.js?v=20260617b"></script>
|
||||
<script data-cfasync="false" src="assets/js/panels/jarvis-agents.js?v=20260617b"></script>
|
||||
<script data-cfasync="false" src="assets/js/panels/jarvis-assistant.js?v=20260617b"></script>
|
||||
|
||||
<!-- VISION LIGHTBOX -->
|
||||
<div id="vision-lightbox">
|
||||
|
||||
Reference in New Issue
Block a user