mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(web): add Missions tab to gateway UI
Add a full Missions page to the web gateway with list view, detail view, and action buttons (Fire, Pause, Resume). Backend: add /api/engine/missions/summary endpoint returning counts by status (active/paused/completed/failed). Frontend: - New "Missions" tab between Jobs and Routines - Summary cards showing mission counts by status - Table with name, goal, cadence type, thread count, status, actions - Detail view with goal, cadence, current focus, success criteria, approach history, spawned thread list, and action buttons - Fire/Pause/Resume actions with toast notifications - i18n support (English + Chinese) - CSS following the existing routines/jobs patterns Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -94,6 +94,29 @@ pub async fn engine_missions_handler(
|
||||
Ok(Json(EngineMissionListResponse { missions }))
|
||||
}
|
||||
|
||||
pub async fn engine_missions_summary_handler(
|
||||
State(_state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
) -> Result<Json<EngineMissionSummaryResponse>, (StatusCode, String)> {
|
||||
let missions = crate::bridge::list_engine_missions(None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = missions.len() as u64;
|
||||
let active = missions.iter().filter(|m| m.status == "Active").count() as u64;
|
||||
let paused = missions.iter().filter(|m| m.status == "Paused").count() as u64;
|
||||
let completed = missions.iter().filter(|m| m.status == "Completed").count() as u64;
|
||||
let failed = missions.iter().filter(|m| m.status == "Failed").count() as u64;
|
||||
|
||||
Ok(Json(EngineMissionSummaryResponse {
|
||||
total,
|
||||
active,
|
||||
paused,
|
||||
completed,
|
||||
failed,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn engine_mission_detail_handler(
|
||||
State(_state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
|
||||
@@ -35,9 +35,9 @@ use crate::channels::web::auth::{
|
||||
};
|
||||
use crate::channels::web::handlers::engine::{
|
||||
engine_mission_detail_handler, engine_mission_fire_handler, engine_mission_pause_handler,
|
||||
engine_mission_resume_handler, engine_missions_handler, engine_project_detail_handler,
|
||||
engine_projects_handler, engine_thread_detail_handler, engine_thread_events_handler,
|
||||
engine_thread_steps_handler, engine_threads_handler,
|
||||
engine_mission_resume_handler, engine_missions_handler, engine_missions_summary_handler,
|
||||
engine_project_detail_handler, engine_projects_handler, engine_thread_detail_handler,
|
||||
engine_thread_events_handler, engine_thread_steps_handler, engine_threads_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::jobs::{
|
||||
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
||||
@@ -517,6 +517,10 @@ pub async fn start_server(
|
||||
get(engine_project_detail_handler),
|
||||
)
|
||||
.route("/api/engine/missions", get(engine_missions_handler))
|
||||
.route(
|
||||
"/api/engine/missions/summary",
|
||||
get(engine_missions_summary_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/engine/missions/{id}",
|
||||
get(engine_mission_detail_handler),
|
||||
|
||||
@@ -2212,6 +2212,7 @@ function switchTab(tab) {
|
||||
|
||||
if (tab === 'memory') loadMemoryTree();
|
||||
if (tab === 'jobs') loadJobs();
|
||||
if (tab === 'missions') loadMissions();
|
||||
if (tab === 'routines') loadRoutines();
|
||||
if (tab === 'logs') applyLogFilters();
|
||||
if (tab === 'settings') {
|
||||
@@ -4317,6 +4318,184 @@ function deleteRoutine(id, name) {
|
||||
.catch((err) => showToast('Delete failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
// ── Missions ──────────────────────────────────────────────
|
||||
|
||||
let currentMissionId = null;
|
||||
|
||||
function loadMissions() {
|
||||
currentMissionId = null;
|
||||
const detail = document.getElementById('mission-detail');
|
||||
if (detail) detail.style.display = 'none';
|
||||
const table = document.getElementById('missions-table');
|
||||
if (table) table.style.display = '';
|
||||
|
||||
Promise.all([
|
||||
apiFetch('/api/engine/missions/summary'),
|
||||
apiFetch('/api/engine/missions'),
|
||||
]).then(([summary, listData]) => {
|
||||
renderMissionsSummary(summary);
|
||||
renderMissionsList(listData.missions);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function renderMissionsSummary(s) {
|
||||
document.getElementById('missions-summary').innerHTML = ''
|
||||
+ summaryCard(I18n.t('missions.summary.total'), s.total, '')
|
||||
+ summaryCard(I18n.t('missions.summary.active'), s.active, 'active')
|
||||
+ summaryCard(I18n.t('missions.summary.paused'), s.paused, '')
|
||||
+ summaryCard(I18n.t('missions.summary.completed'), s.completed, 'completed')
|
||||
+ summaryCard(I18n.t('missions.summary.failed'), s.failed, 'failed');
|
||||
}
|
||||
|
||||
function renderMissionsList(missions) {
|
||||
const tbody = document.getElementById('missions-tbody');
|
||||
const empty = document.getElementById('missions-empty');
|
||||
|
||||
if (!missions || missions.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = missions.map((m) => {
|
||||
const statusClass = m.status === 'Active' ? 'in_progress'
|
||||
: m.status === 'Completed' ? 'completed'
|
||||
: m.status === 'Paused' ? 'pending'
|
||||
: 'failed';
|
||||
|
||||
return '<tr class="mission-row" data-action="open-mission" data-id="' + escapeHtml(m.id) + '">'
|
||||
+ '<td>' + escapeHtml(m.name) + '</td>'
|
||||
+ '<td class="truncate">' + escapeHtml(m.goal) + '</td>'
|
||||
+ '<td>' + escapeHtml(m.cadence_type) + '</td>'
|
||||
+ '<td>' + m.thread_count + '</td>'
|
||||
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(m.status) + '</span></td>'
|
||||
+ '<td>'
|
||||
+ (m.status === 'Active' ? '<button class="btn-cancel" data-action="pause-mission" data-id="' + escapeHtml(m.id) + '">Pause</button> ' : '')
|
||||
+ (m.status === 'Paused' ? '<button class="btn-restart" data-action="resume-mission" data-id="' + escapeHtml(m.id) + '">Resume</button> ' : '')
|
||||
+ '<button class="btn-restart" data-action="fire-mission" data-id="' + escapeHtml(m.id) + '">Fire</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openMissionDetail(id) {
|
||||
currentMissionId = id;
|
||||
apiFetch('/api/engine/missions/' + id).then((data) => {
|
||||
renderMissionDetail(data.mission);
|
||||
}).catch((err) => {
|
||||
showToast('Failed to load mission: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function closeMissionDetail() {
|
||||
currentMissionId = null;
|
||||
loadMissions();
|
||||
}
|
||||
|
||||
function renderMissionDetail(m) {
|
||||
const table = document.getElementById('missions-table');
|
||||
if (table) table.style.display = 'none';
|
||||
document.getElementById('missions-empty').style.display = 'none';
|
||||
|
||||
const detail = document.getElementById('mission-detail');
|
||||
detail.style.display = 'block';
|
||||
|
||||
const statusClass = m.status === 'Active' ? 'in_progress'
|
||||
: m.status === 'Completed' ? 'completed'
|
||||
: m.status === 'Paused' ? 'pending'
|
||||
: 'failed';
|
||||
|
||||
let html = '<div class="job-detail-header">'
|
||||
+ '<button class="btn-back" data-action="close-mission-detail">← Back</button>'
|
||||
+ '<h2>' + escapeHtml(m.name) + '</h2>'
|
||||
+ '<span class="badge ' + statusClass + '">' + escapeHtml(m.status) + '</span>'
|
||||
+ '</div>';
|
||||
|
||||
html += '<div class="job-meta-grid">'
|
||||
+ metaItem('Goal', m.goal)
|
||||
+ metaItem('Cadence', m.cadence_type)
|
||||
+ metaItem('Status', m.status)
|
||||
+ metaItem('Threads Today', m.threads_today + ' / ' + (m.max_threads_per_day || '∞'))
|
||||
+ metaItem('Total Threads', m.thread_count)
|
||||
+ metaItem('Created', formatDate(m.created_at))
|
||||
+ metaItem('Next Fire', m.next_fire_at ? formatDate(m.next_fire_at) : 'N/A')
|
||||
+ '</div>';
|
||||
|
||||
if (m.current_focus) {
|
||||
html += '<div class="job-description"><h3>Current Focus</h3>'
|
||||
+ '<div class="job-description-body">' + escapeHtml(m.current_focus) + '</div></div>';
|
||||
}
|
||||
|
||||
if (m.success_criteria) {
|
||||
html += '<div class="job-description"><h3>Success Criteria</h3>'
|
||||
+ '<div class="job-description-body">' + escapeHtml(m.success_criteria) + '</div></div>';
|
||||
}
|
||||
|
||||
if (m.approach_history && m.approach_history.length > 0) {
|
||||
html += '<div class="job-description"><h3>Approach History</h3><ul>';
|
||||
m.approach_history.forEach((a) => {
|
||||
html += '<li>' + escapeHtml(a) + '</li>';
|
||||
});
|
||||
html += '</ul></div>';
|
||||
}
|
||||
|
||||
if (m.thread_ids && m.thread_ids.length > 0) {
|
||||
html += '<div class="job-description"><h3>Spawned Threads</h3><ul>';
|
||||
m.thread_ids.forEach((tid) => {
|
||||
html += '<li><code>' + escapeHtml(tid) + '</code></li>';
|
||||
});
|
||||
html += '</ul></div>';
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
html += '<div style="margin-top:16px;">';
|
||||
if (m.status === 'Active') {
|
||||
html += '<button class="btn-cancel" data-action="pause-mission" data-id="' + escapeHtml(m.id) + '">Pause</button> ';
|
||||
}
|
||||
if (m.status === 'Paused') {
|
||||
html += '<button class="btn-restart" data-action="resume-mission" data-id="' + escapeHtml(m.id) + '">Resume</button> ';
|
||||
}
|
||||
html += '<button class="btn-restart" data-action="fire-mission" data-id="' + escapeHtml(m.id) + '">Fire Now</button>';
|
||||
html += '</div>';
|
||||
|
||||
detail.innerHTML = html;
|
||||
}
|
||||
|
||||
function fireMission(id) {
|
||||
apiFetch('/api/engine/missions/' + id + '/fire', { method: 'POST' })
|
||||
.then((data) => {
|
||||
if (data.fired) {
|
||||
showToast('Mission fired — thread ' + data.thread_id, 'success');
|
||||
} else {
|
||||
showToast('Mission not fired (terminal or budget exhausted)', 'warning');
|
||||
}
|
||||
if (currentMissionId === id) openMissionDetail(id);
|
||||
else loadMissions();
|
||||
})
|
||||
.catch((err) => showToast('Fire failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function pauseMission(id) {
|
||||
apiFetch('/api/engine/missions/' + id + '/pause', { method: 'POST' })
|
||||
.then(() => {
|
||||
showToast('Mission paused', 'success');
|
||||
if (currentMissionId === id) openMissionDetail(id);
|
||||
else loadMissions();
|
||||
})
|
||||
.catch((err) => showToast('Pause failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function resumeMission(id) {
|
||||
apiFetch('/api/engine/missions/' + id + '/resume', { method: 'POST' })
|
||||
.then(() => {
|
||||
showToast('Mission resumed', 'success');
|
||||
if (currentMissionId === id) openMissionDetail(id);
|
||||
else loadMissions();
|
||||
})
|
||||
.catch((err) => showToast('Resume failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString) {
|
||||
if (!isoString) return '-';
|
||||
const d = new Date(isoString);
|
||||
@@ -6018,6 +6197,24 @@ document.addEventListener('click', function(e) {
|
||||
case 'close-routine-detail':
|
||||
closeRoutineDetail();
|
||||
break;
|
||||
case 'open-mission':
|
||||
openMissionDetail(el.dataset.id);
|
||||
break;
|
||||
case 'close-mission-detail':
|
||||
closeMissionDetail();
|
||||
break;
|
||||
case 'fire-mission':
|
||||
e.stopPropagation();
|
||||
fireMission(el.dataset.id);
|
||||
break;
|
||||
case 'pause-mission':
|
||||
e.stopPropagation();
|
||||
pauseMission(el.dataset.id);
|
||||
break;
|
||||
case 'resume-mission':
|
||||
e.stopPropagation();
|
||||
resumeMission(el.dataset.id);
|
||||
break;
|
||||
case 'view-run-job':
|
||||
e.preventDefault();
|
||||
switchTab('jobs');
|
||||
|
||||
@@ -34,6 +34,7 @@ I18n.register('en', {
|
||||
'tab.chat': 'Chat',
|
||||
'tab.memory': 'Memory',
|
||||
'tab.jobs': 'Jobs',
|
||||
'tab.missions': 'Missions',
|
||||
'tab.routines': 'Routines',
|
||||
'tab.settings': 'Settings',
|
||||
'tab.extensions': 'Extensions',
|
||||
@@ -111,6 +112,20 @@ I18n.register('en', {
|
||||
'jobs.viewJob': 'View Job',
|
||||
'jobs.browse': 'Browse',
|
||||
|
||||
// Missions Tab
|
||||
'missions.name': 'Name',
|
||||
'missions.goal': 'Goal',
|
||||
'missions.cadence': 'Cadence',
|
||||
'missions.threads': 'Threads',
|
||||
'missions.status': 'Status',
|
||||
'missions.actions': 'Actions',
|
||||
'missions.noConfigured': 'No missions found. Ask the assistant to create one.',
|
||||
'missions.summary.total': 'Total',
|
||||
'missions.summary.active': 'Active',
|
||||
'missions.summary.paused': 'Paused',
|
||||
'missions.summary.completed': 'Completed',
|
||||
'missions.summary.failed': 'Failed',
|
||||
|
||||
// Routines Tab
|
||||
'routines.summary': 'Routines Summary',
|
||||
'routines.name': 'Name',
|
||||
|
||||
@@ -34,6 +34,7 @@ I18n.register('zh-CN', {
|
||||
'tab.chat': '聊天',
|
||||
'tab.memory': '记忆',
|
||||
'tab.jobs': '任务',
|
||||
'tab.missions': '使命',
|
||||
'tab.routines': '定时任务',
|
||||
'tab.settings': '设置',
|
||||
'tab.extensions': '扩展',
|
||||
@@ -111,6 +112,20 @@ I18n.register('zh-CN', {
|
||||
'jobs.viewJob': '查看任务',
|
||||
'jobs.browse': '浏览',
|
||||
|
||||
// 使命标签页
|
||||
'missions.name': '名称',
|
||||
'missions.goal': '目标',
|
||||
'missions.cadence': '节奏',
|
||||
'missions.threads': '线程',
|
||||
'missions.status': '状态',
|
||||
'missions.actions': '操作',
|
||||
'missions.noConfigured': '暂无使命。请让助手创建一个。',
|
||||
'missions.summary.total': '总计',
|
||||
'missions.summary.active': '活跃',
|
||||
'missions.summary.paused': '暂停',
|
||||
'missions.summary.completed': '已完成',
|
||||
'missions.summary.failed': '失败',
|
||||
|
||||
// 定时任务标签页
|
||||
'routines.summary': '定时任务摘要',
|
||||
'routines.name': '名称',
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
|
||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||
<button data-tab="missions" data-i18n="tab.missions">Missions</button>
|
||||
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
||||
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
|
||||
<div class="spacer"></div>
|
||||
@@ -257,6 +258,30 @@
|
||||
</div>
|
||||
|
||||
<!-- Routines Tab -->
|
||||
<!-- Missions Tab -->
|
||||
<div class="tab-panel" id="tab-missions">
|
||||
<div class="missions-container">
|
||||
<div class="missions-summary" id="missions-summary"></div>
|
||||
<table class="missions-table" id="missions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="missions.name">Name</th>
|
||||
<th data-i18n="missions.goal">Goal</th>
|
||||
<th data-i18n="missions.cadence">Cadence</th>
|
||||
<th data-i18n="missions.threads">Threads</th>
|
||||
<th data-i18n="missions.status">Status</th>
|
||||
<th data-i18n="missions.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missions-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="missions-empty" style="display:none">
|
||||
<span data-i18n="missions.noConfigured">No missions found. Ask the assistant to create one.</span>
|
||||
</div>
|
||||
<div class="mission-detail" id="mission-detail" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-routines">
|
||||
<div class="routines-container">
|
||||
<div class="routines-summary" id="routines-summary"></div>
|
||||
|
||||
@@ -2578,6 +2578,63 @@ body {
|
||||
}
|
||||
|
||||
/* Routines Tab */
|
||||
/* ── Missions ──────────────────────────────────── */
|
||||
|
||||
.missions-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.missions-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.missions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.missions-table th,
|
||||
.missions-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.missions-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.missions-table tr:hover td {
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.mission-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mission-detail {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Routines ──────────────────────────────────── */
|
||||
|
||||
.routines-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -857,6 +857,15 @@ pub struct EngineMissionListResponse {
|
||||
pub missions: Vec<crate::bridge::EngineMissionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EngineMissionSummaryResponse {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
pub paused: u64,
|
||||
pub completed: u64,
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EngineMissionDetailResponse {
|
||||
pub mission: crate::bridge::EngineMissionDetail,
|
||||
|
||||
Reference in New Issue
Block a user