mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Phase 7: Mission Ops — multi-step automated workflow engine
- DB: missions, mission_steps, mission_runs tables
- reactor.py v7.0.0: handle_run_mission, _execute_mission, mission_trigger_loop (schedule/guardian_event/email_keyword triggers), {{template}} substitution across steps, full CRUD REST endpoints
- arc.php: missions/mission_get/mission_runs/mission_create/mission_update/mission_delete/mission_run/mission_toggle actions
- admin/index.php: Mission Ops tab with visual workflow builder (trigger config, step cards with ↑↓, JSON payload editor, continue-on-failure flag), run history with step-level detail, enable/disable toggle
- index.html: MISSIONS tab with collapsible mission cards, RUN NOW button per mission, live run result feedback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -593,6 +593,71 @@ if ($action) {
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
|
||||
|
||||
// ── MISSION OPS ──────────────────────────────────────────────────────
|
||||
case 'mission_list':
|
||||
$ch = curl_init('http://127.0.0.1:7474/missions');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: []);
|
||||
|
||||
case 'mission_get':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error'=>'not found']);
|
||||
|
||||
case 'mission_runs':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$limit = (int)($_GET['limit'] ?? 20);
|
||||
$ch = curl_init("http://127.0.0.1:7474/missions/{$id}/runs?limit={$limit}");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: []);
|
||||
|
||||
case 'mission_save': // create or update
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$url = $id ? "http://127.0.0.1:7474/missions/{$id}" : 'http://127.0.0.1:7474/missions';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10,
|
||||
CURLOPT_CUSTOMREQUEST => $id ? 'PUT' : 'POST',
|
||||
CURLOPT_POSTFIELDS => file_get_contents('php://input'),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']);
|
||||
|
||||
case 'mission_delete':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['ok'=>true]);
|
||||
|
||||
case 'mission_run':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$ch = curl_init("http://127.0.0.1:7474/missions/{$id}/run");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>120, CURLOPT_POST=>true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['trigger_source'=>'admin']),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable or timeout']);
|
||||
|
||||
case 'mission_toggle':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$enabled = (int)($_GET['enabled'] ?? 0);
|
||||
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT',
|
||||
CURLOPT_POSTFIELDS => json_encode(['enabled'=>$enabled]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['ok'=>true]);
|
||||
|
||||
// ── VISION PROTOCOL ──────────────────────────────────────────────────
|
||||
case 'vision_list':
|
||||
$limit = min((int)($_GET['limit'] ?? 30), 100);
|
||||
@@ -928,6 +993,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
|
||||
<div class="nav-item" data-tab="arc" onclick="nav(this)">⚡ ARC REACTOR</div>
|
||||
<div class="nav-item" data-tab="vision" onclick="nav(this)">◈ VISION PROTOCOL</div>
|
||||
<div class="nav-item" data-tab="guardian" onclick="nav(this)" id="nav-guardian">◈ GUARDIAN MODE</div>
|
||||
<div class="nav-item" data-tab="missions" onclick="nav(this)">◈ MISSION OPS</div>
|
||||
<div class="nav-section">INFO</div>
|
||||
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
|
||||
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
|
||||
@@ -1325,6 +1391,73 @@ select.filter-sel:focus{border-color:var(--cyan)}
|
||||
<div class="tbl-wrap" id="outbox-tbl"><div class="loading">LOADING OUTBOX...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- MISSION OPS -->
|
||||
<div class="tab" id="tab-missions">
|
||||
<div class="page-title">◈ MISSION OPS — AUTOMATED WORKFLOWS</div>
|
||||
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-green" onclick="missionNew()">+ NEW MISSION</button>
|
||||
<button class="btn btn-sm" onclick="loadMissions()">↻ REFRESH</button>
|
||||
<div id="missions-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mission list -->
|
||||
<div id="missions-list"><div class="loading">LOADING MISSIONS...</div></div>
|
||||
|
||||
<!-- Builder panel (hidden until a mission is selected/created) -->
|
||||
<div id="mission-builder" style="display:none;margin-top:20px;border:1px solid var(--border);border-radius:6px;padding:16px;background:rgba(0,212,255,0.02)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<div id="builder-title" style="font-family:var(--mono);font-size:0.75rem;letter-spacing:2px;color:var(--cyan)">◈ MISSION BUILDER</div>
|
||||
<button class="btn btn-xs" onclick="document.getElementById('mission-builder').style.display='none'">✕ CLOSE</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
||||
<div>
|
||||
<div class="lbl">MISSION NAME</div>
|
||||
<input id="mb-name" class="inp" placeholder="e.g. Daily Morning Brief">
|
||||
</div>
|
||||
<div>
|
||||
<div class="lbl">TRIGGER</div>
|
||||
<select id="mb-trigger" class="inp" onchange="missionTriggerChange()">
|
||||
<option value="manual">Manual (run by hand)</option>
|
||||
<option value="schedule">Schedule (every N minutes)</option>
|
||||
<option value="guardian_event">Guardian Event (on alert)</option>
|
||||
<option value="email_keyword">Email Keyword (on triage match)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mb-trigger-config" style="margin-bottom:14px"></div>
|
||||
|
||||
<div>
|
||||
<div class="lbl">DESCRIPTION (optional)</div>
|
||||
<input id="mb-desc" class="inp" placeholder="What does this mission do?">
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<div class="lbl" style="margin:0">STEPS</div>
|
||||
<button class="btn btn-xs btn-green" onclick="missionAddStep()">+ ADD STEP</button>
|
||||
</div>
|
||||
<div id="mb-steps"></div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="mb-mission-id" value="">
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:16px">
|
||||
<button class="btn btn-sm btn-green" onclick="missionSave()">◈ SAVE MISSION</button>
|
||||
<button id="mb-run-btn" class="btn btn-sm" style="display:none" onclick="missionRunFromBuilder()">▶ RUN NOW</button>
|
||||
<button id="mb-del-btn" class="btn btn-sm btn-red" style="display:none" onclick="missionDeleteFromBuilder()">✗ DELETE</button>
|
||||
</div>
|
||||
<div id="mb-status" style="font-family:var(--mono);font-size:0.6rem;color:var(--cyan);margin-top:8px;min-height:14px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Run history panel -->
|
||||
<div id="mission-run-history" style="display:none;margin-top:16px;border:1px solid var(--border);border-radius:6px;padding:14px">
|
||||
<div style="font-family:var(--mono);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:10px">◈ RUN HISTORY</div>
|
||||
<div id="mission-runs-tbl"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /content -->
|
||||
</div><!-- /main -->
|
||||
</div><!-- /app -->
|
||||
@@ -1438,6 +1571,7 @@ function loadTab(tab) {
|
||||
email: ()=>{ loadEmailInbox(); loadEmailActionItems(); },
|
||||
triage: loadTriage,
|
||||
outbox: loadOutbox,
|
||||
missions: loadMissions,
|
||||
vision: loadVision,
|
||||
guardian: loadGuardian,
|
||||
tasks: loadTasks,
|
||||
@@ -2776,6 +2910,340 @@ function outboxCompose() {
|
||||
}, 'DISPATCH');
|
||||
}
|
||||
|
||||
// ── MISSION OPS ──────────────────────────────────────────────────────────────
|
||||
|
||||
const JOB_TYPES = ['ping','echo','shell','llm','research','tool_loop','gmail_triage',
|
||||
'remote_exec','screenshot','vision','sysinfo','sitrep','send_email','compose_email',
|
||||
'schedule_event','meeting_prep','run_mission'];
|
||||
|
||||
let _missionBuilderSteps = [];
|
||||
let _missionBuilderStepIdx = 0;
|
||||
|
||||
async function loadMissions() {
|
||||
const el = document.getElementById('missions-list');
|
||||
if (!el) return;
|
||||
const missions = await api('mission_list');
|
||||
const list = Array.isArray(missions) ? missions : [];
|
||||
document.getElementById('missions-count').textContent = list.length + ' MISSIONS';
|
||||
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<div class="loading">No missions yet. Click + NEW MISSION to create one.</div>';
|
||||
return;
|
||||
}
|
||||
const triggerIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
|
||||
const statusColor = {done:'var(--green)', failed:'var(--red)', running:'var(--orange)'};
|
||||
const rows = list.map(m => {
|
||||
const icon = triggerIcons[m.trigger_type] || '◈';
|
||||
const enabled = m.enabled ? '<span style="color:var(--green)">ENABLED</span>' : '<span style="color:var(--dim)">DISABLED</span>';
|
||||
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleString() : '—';
|
||||
return `<tr>
|
||||
<td style="font-family:var(--mono);font-size:0.7rem">${icon} ${esc(m.name)}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${m.trigger_type.replace('_',' ').toUpperCase()}</td>
|
||||
<td>${enabled}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${m.run_count||0} runs · last ${lastRun}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs btn-green" onclick="missionRunNow(${m.id})">▶ RUN</button>
|
||||
<button class="btn btn-xs" onclick="missionEdit(${m.id})">EDIT</button>
|
||||
<button class="btn btn-xs" onclick="missionViewRuns(${m.id})">HISTORY</button>
|
||||
<button class="btn btn-xs" onclick="missionToggle(${m.id},${m.enabled?0:1})">${m.enabled?'DISABLE':'ENABLE'}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
el.innerHTML = `<table><thead><tr><th>NAME</th><th>TRIGGER</th><th>STATUS</th><th>LAST RUN</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
function missionNew() {
|
||||
_missionBuilderSteps = [];
|
||||
_missionBuilderStepIdx = 0;
|
||||
document.getElementById('mb-mission-id').value = '';
|
||||
document.getElementById('mb-name').value = '';
|
||||
document.getElementById('mb-desc').value = '';
|
||||
document.getElementById('mb-trigger').value = 'manual';
|
||||
document.getElementById('builder-title').textContent = '◈ NEW MISSION';
|
||||
document.getElementById('mb-run-btn').style.display = 'none';
|
||||
document.getElementById('mb-del-btn').style.display = 'none';
|
||||
document.getElementById('mb-status').textContent = '';
|
||||
missionTriggerChange();
|
||||
_renderBuilderSteps();
|
||||
document.getElementById('mission-builder').style.display = 'block';
|
||||
document.getElementById('mission-run-history').style.display = 'none';
|
||||
document.getElementById('mission-builder').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
|
||||
async function missionEdit(id) {
|
||||
const m = await api('mission_get', {id});
|
||||
if (m.error) { toast('Load failed: ' + m.error, 'err'); return; }
|
||||
document.getElementById('mb-mission-id').value = m.id;
|
||||
document.getElementById('mb-name').value = m.name || '';
|
||||
document.getElementById('mb-desc').value = m.description || '';
|
||||
document.getElementById('mb-trigger').value = m.trigger_type || 'manual';
|
||||
document.getElementById('builder-title').textContent = '◈ EDIT MISSION — ' + esc(m.name);
|
||||
document.getElementById('mb-run-btn').style.display = '';
|
||||
document.getElementById('mb-del-btn').style.display = '';
|
||||
document.getElementById('mb-status').textContent = '';
|
||||
missionTriggerChange(m.trigger_config || {});
|
||||
_missionBuilderSteps = (m.steps || []).map(s => ({
|
||||
id: ++_missionBuilderStepIdx,
|
||||
label: s.label || '',
|
||||
job_type: s.job_type || 'ping',
|
||||
payload: typeof s.job_payload === 'string' ? s.job_payload : JSON.stringify(s.job_payload||{}, null, 2),
|
||||
continue_on_failure: s.continue_on_failure ? 1 : 0,
|
||||
}));
|
||||
_renderBuilderSteps();
|
||||
document.getElementById('mission-builder').style.display = 'block';
|
||||
document.getElementById('mission-run-history').style.display = 'none';
|
||||
document.getElementById('mission-builder').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
|
||||
function missionTriggerChange(cfg) {
|
||||
const t = document.getElementById('mb-trigger')?.value;
|
||||
const el = document.getElementById('mb-trigger-config');
|
||||
if (!el) return;
|
||||
cfg = cfg || {};
|
||||
if (t === 'manual') {
|
||||
el.innerHTML = '';
|
||||
} else if (t === 'schedule') {
|
||||
el.innerHTML = `<div class="lbl">INTERVAL (minutes)</div>
|
||||
<input id="mb-tc-interval" class="inp" type="number" min="1" value="${cfg.interval_minutes||60}" style="width:160px">`;
|
||||
} else if (t === 'guardian_event') {
|
||||
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div><div class="lbl">SEVERITY (blank=any)</div>
|
||||
<select id="mb-tc-severity" class="inp">
|
||||
<option value="">Any</option>
|
||||
<option value="critical"${cfg.severity==='critical'?' selected':''}>Critical</option>
|
||||
<option value="warning"${cfg.severity==='warning'?' selected':''}>Warning</option>
|
||||
<option value="info"${cfg.severity==='info'?' selected':''}>Info</option>
|
||||
</select></div>
|
||||
<div><div class="lbl">EVENT TYPE (blank=any)</div>
|
||||
<input id="mb-tc-etype" class="inp" value="${cfg.event_type||''}" placeholder="e.g. cpu_high"></div>
|
||||
</div>`;
|
||||
} else if (t === 'email_keyword') {
|
||||
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div><div class="lbl">KEYWORDS (comma-separated)</div>
|
||||
<input id="mb-tc-keywords" class="inp" value="${(cfg.keywords||[]).join(', ')}" placeholder="urgent, invoice, CEO"></div>
|
||||
<div><div class="lbl">CATEGORY (blank=any)</div>
|
||||
<select id="mb-tc-category" class="inp">
|
||||
<option value="">Any</option>
|
||||
<option value="urgent"${cfg.category==='urgent'?' selected':''}>Urgent</option>
|
||||
<option value="action"${cfg.category==='action'?' selected':''}>Action</option>
|
||||
<option value="reply"${cfg.category==='reply'?' selected':''}>Reply</option>
|
||||
<option value="meeting"${cfg.category==='meeting'?' selected':''}>Meeting</option>
|
||||
</select></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _readTriggerConfig() {
|
||||
const t = document.getElementById('mb-trigger')?.value;
|
||||
if (t === 'schedule') {
|
||||
return {interval_minutes: parseInt(document.getElementById('mb-tc-interval')?.value||60)};
|
||||
} else if (t === 'guardian_event') {
|
||||
return {
|
||||
severity: document.getElementById('mb-tc-severity')?.value || '',
|
||||
event_type: document.getElementById('mb-tc-etype')?.value || '',
|
||||
};
|
||||
} else if (t === 'email_keyword') {
|
||||
const kw = (document.getElementById('mb-tc-keywords')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);
|
||||
return {keywords: kw, category: document.getElementById('mb-tc-category')?.value||''};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function missionAddStep() {
|
||||
_missionBuilderStepIdx++;
|
||||
_missionBuilderSteps.push({id: _missionBuilderStepIdx, label:'', job_type:'ping', payload:'{}', continue_on_failure:0});
|
||||
_renderBuilderSteps();
|
||||
}
|
||||
|
||||
function missionRemoveStep(sid) {
|
||||
_missionBuilderSteps = _missionBuilderSteps.filter(s => s.id !== sid);
|
||||
_renderBuilderSteps();
|
||||
}
|
||||
|
||||
function missionMoveStep(sid, dir) {
|
||||
const idx = _missionBuilderSteps.findIndex(s => s.id === sid);
|
||||
if (idx < 0) return;
|
||||
const newIdx = idx + dir;
|
||||
if (newIdx < 0 || newIdx >= _missionBuilderSteps.length) return;
|
||||
[_missionBuilderSteps[idx], _missionBuilderSteps[newIdx]] = [_missionBuilderSteps[newIdx], _missionBuilderSteps[idx]];
|
||||
_renderBuilderSteps();
|
||||
}
|
||||
|
||||
function _renderBuilderSteps() {
|
||||
const el = document.getElementById('mb-steps');
|
||||
if (!el) return;
|
||||
if (!_missionBuilderSteps.length) {
|
||||
el.innerHTML = '<div style="font-size:0.6rem;color:var(--dim);padding:8px 0">No steps yet. Click + ADD STEP.</div>';
|
||||
return;
|
||||
}
|
||||
const typeOpts = JOB_TYPES.map(t => `<option value="${t}">${t}</option>`).join('');
|
||||
el.innerHTML = _missionBuilderSteps.map((s, i) => `
|
||||
<div id="step-card-${s.id}" style="border:1px solid var(--border);border-radius:4px;padding:10px 12px;margin-bottom:8px;background:rgba(0,212,255,0.02)">
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
||||
<span style="font-family:var(--mono);font-size:0.6rem;color:var(--dim);min-width:24px">0${i+1}</span>
|
||||
<input class="inp" style="flex:2" placeholder="Step label" value="${esc(s.label)}" oninput="_stepUpdate(${s.id},'label',this.value)">
|
||||
<select class="inp" style="flex:2" onchange="_stepUpdate(${s.id},'job_type',this.value)">
|
||||
${JOB_TYPES.map(t=>`<option value="${t}"${s.job_type===t?' selected':''}>${t}</option>`).join('')}
|
||||
</select>
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:0.55rem;color:var(--dim);white-space:nowrap">
|
||||
<input type="checkbox" ${s.continue_on_failure?'checked':''} onchange="_stepUpdate(${s.id},'continue_on_failure',this.checked?1:0)"> CONTINUE IF FAIL
|
||||
</label>
|
||||
<button class="btn btn-xs" onclick="missionMoveStep(${s.id},-1)">↑</button>
|
||||
<button class="btn btn-xs" onclick="missionMoveStep(${s.id},1)">↓</button>
|
||||
<button class="btn btn-xs btn-red" onclick="missionRemoveStep(${s.id})">✗</button>
|
||||
</div>
|
||||
<div class="lbl">PAYLOAD (JSON — use {{step_0.field}} for prior results)</div>
|
||||
<textarea class="inp" rows="3" style="font-family:var(--mono);font-size:0.6rem;resize:vertical" oninput="_stepUpdate(${s.id},'payload',this.value)">${esc(s.payload||'{}')}</textarea>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function _stepUpdate(sid, field, value) {
|
||||
const s = _missionBuilderSteps.find(x => x.id === sid);
|
||||
if (s) s[field] = value;
|
||||
}
|
||||
|
||||
async function missionSave() {
|
||||
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0) || null;
|
||||
const name = document.getElementById('mb-name')?.value.trim();
|
||||
const desc = document.getElementById('mb-desc')?.value.trim();
|
||||
const ttype = document.getElementById('mb-trigger')?.value;
|
||||
const tcfg = _readTriggerConfig();
|
||||
const status = document.getElementById('mb-status');
|
||||
|
||||
if (!name) { if (status) status.textContent = '✗ Mission name is required'; return; }
|
||||
|
||||
const steps = _missionBuilderSteps.map((s, i) => {
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(s.payload || '{}'); } catch(e) { payload = {}; }
|
||||
return {label: s.label, job_type: s.job_type, payload, continue_on_failure: s.continue_on_failure};
|
||||
});
|
||||
|
||||
const body = {name, description: desc, trigger_type: ttype, trigger_config: tcfg, enabled: 1, steps};
|
||||
|
||||
if (status) status.textContent = '◈ SAVING…';
|
||||
const url = id ? `admin?action=mission_save&id=${id}` : 'admin?action=mission_save';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
}).then(r => r.json()).catch(() => ({error: 'request failed'}));
|
||||
|
||||
if (res.ok || res.id) {
|
||||
if (status) status.textContent = '◈ SAVED ✓';
|
||||
if (!id && res.id) document.getElementById('mb-mission-id').value = res.id;
|
||||
document.getElementById('mb-run-btn').style.display = '';
|
||||
document.getElementById('mb-del-btn').style.display = '';
|
||||
toast('Mission saved', 'ok');
|
||||
loadMissions();
|
||||
} else {
|
||||
if (status) status.textContent = '✗ Save failed: ' + (res.error || 'unknown');
|
||||
toast('Save failed: ' + (res.error || ''), 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function missionRunNow(id) {
|
||||
const d = await api('mission_run', {id});
|
||||
if (d.run_id || d.status) {
|
||||
const s = d.status || 'running';
|
||||
const color = s === 'done' ? 'ok' : s === 'failed' ? 'err' : 'ok';
|
||||
toast(`Mission run ${s} — Run #${d.run_id||'?'} (${d.steps||0} steps)`, color);
|
||||
loadMissions();
|
||||
} else {
|
||||
toast('Run failed: ' + (d.error || 'Arc Reactor offline'), 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function missionRunFromBuilder() {
|
||||
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0);
|
||||
if (!id) { toast('Save the mission first', 'err'); return; }
|
||||
const status = document.getElementById('mb-status');
|
||||
if (status) status.textContent = '◈ RUNNING…';
|
||||
await missionRunNow(id);
|
||||
if (status) status.textContent = '◈ Run dispatched — check HISTORY';
|
||||
setTimeout(() => missionViewRuns(id), 1500);
|
||||
}
|
||||
|
||||
async function missionDeleteFromBuilder() {
|
||||
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0);
|
||||
if (!id || !confirm('Delete this mission and all its run history?')) return;
|
||||
const d = await api('mission_delete', {id});
|
||||
if (d.ok) {
|
||||
toast('Mission deleted', 'ok');
|
||||
document.getElementById('mission-builder').style.display = 'none';
|
||||
document.getElementById('mission-run-history').style.display = 'none';
|
||||
loadMissions();
|
||||
} else {
|
||||
toast('Delete failed', 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function missionViewRuns(id) {
|
||||
const el = document.getElementById('mission-runs-tbl');
|
||||
const box = document.getElementById('mission-run-history');
|
||||
if (!el || !box) return;
|
||||
box.style.display = 'block';
|
||||
const runs = await api('mission_runs', {id, limit: 20});
|
||||
const list = Array.isArray(runs) ? runs : [];
|
||||
if (!list.length) { el.innerHTML = '<div class="loading">No runs yet.</div>'; return; }
|
||||
const sColor = {done:'var(--green)', failed:'var(--red)', running:'var(--orange)', cancelled:'var(--dim)'};
|
||||
const rows = list.map(r => {
|
||||
const sc = r.status || 'done';
|
||||
const ts = r.started_at ? new Date(r.started_at+'Z').toLocaleString() : '—';
|
||||
const dur = r.completed_at && r.started_at
|
||||
? Math.round((new Date(r.completed_at) - new Date(r.started_at)) / 1000) + 's'
|
||||
: '—';
|
||||
return `<tr>
|
||||
<td style="font-family:var(--mono);font-size:0.65rem">#${r.id}</td>
|
||||
<td style="color:${sColor[sc]||'var(--text)'};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${esc(r.trigger_source||'manual')}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${ts}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${dur}</td>
|
||||
<td><button class="btn btn-xs" onclick="missionRunDetail(${r.id})">STEPS</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
el.innerHTML = `<table><thead><tr><th>RUN</th><th>STATUS</th><th>TRIGGER</th><th>STARTED</th><th>DURATION</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
box.scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
|
||||
async function missionRunDetail(runId) {
|
||||
// Fetch from DB via a direct approach — get all runs and find this one
|
||||
// We'll just show steps_log from the run using the mission_runs table
|
||||
const res = await fetch(`admin?action=mission_runs&id=0&limit=200&run_id=${runId}`)
|
||||
.then(r => r.json()).catch(() => ({}));
|
||||
// Fallback: fetch all runs for the currently edited mission
|
||||
const mid = parseInt(document.getElementById('mb-mission-id')?.value || 0);
|
||||
const runs = mid ? await api('mission_runs', {id: mid, limit: 50}) : [];
|
||||
const run = Array.isArray(runs) ? runs.find(r => r.id == runId) : null;
|
||||
if (!run) { toast('Run details not available', 'err'); return; }
|
||||
const steps = run.steps_log;
|
||||
const list = Array.isArray(steps) ? steps : (typeof steps === 'string' ? JSON.parse(steps||'[]') : []);
|
||||
const rows = list.map(s => {
|
||||
const sc = s.status || 'done';
|
||||
const sc_color = sc==='done'?'var(--green)':sc==='failed'?'var(--red)':'var(--orange)';
|
||||
const result = s.result ? JSON.stringify(s.result).substring(0,120) : s.error || '—';
|
||||
return `<tr>
|
||||
<td style="font-family:var(--mono);font-size:0.6rem">${s.step+1}</td>
|
||||
<td style="font-size:0.6rem">${esc(s.label||s.job_type)}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${esc(s.job_type)}</td>
|
||||
<td style="color:${sc_color};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
|
||||
<td style="font-size:0.58rem;color:var(--dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(result)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
openModal(`RUN #${runId} STEP LOG`, `
|
||||
<table style="width:100%">
|
||||
<thead><tr><th>#</th><th>LABEL</th><th>TYPE</th><th>STATUS</th><th>RESULT</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`, null, null);
|
||||
}
|
||||
|
||||
async function missionToggle(id, enabled) {
|
||||
const d = await api('mission_toggle', {id, enabled});
|
||||
if (d.ok) loadMissions();
|
||||
else toast('Toggle failed', 'err');
|
||||
}
|
||||
|
||||
// ── PLANNER ─────────────────────────────────────────────────────────────────
|
||||
const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user