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:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user