feat: add schedule controls to Orbit console
This commit is contained in:
@@ -38,7 +38,9 @@
|
||||
|
||||
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。
|
||||
|
||||
控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情、计划控制和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。
|
||||
控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。
|
||||
|
||||
定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。
|
||||
|
||||
## 2. 持续推进方式
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ http://127.0.0.1:8080/console/
|
||||
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
||||
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
||||
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
||||
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
|
||||
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
|
||||
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
||||
|
||||
@@ -30,4 +31,4 @@ http://127.0.0.1:8080/console/
|
||||
|
||||
控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
||||
|
||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划控制和交付重试等更高风险操作。
|
||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划版本发布和交付重试等更高风险操作。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(() => {
|
||||
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '' };
|
||||
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
|
||||
@@ -69,7 +69,11 @@
|
||||
function renderSchedules(items) {
|
||||
text('metric-schedules', String(items.length));
|
||||
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>需要 schedules:read 权限</small></div></div>'; return; }
|
||||
el.innerHTML = items.slice(0, 4).map(item => `<div class="list-row"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.enabled === false ? '已暂停' : '运行中')}</div></div>`).join('');
|
||||
el.innerHTML = items.slice(0, 4).map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
|
||||
el.querySelectorAll('.schedule-row').forEach(row => {
|
||||
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
|
||||
});
|
||||
}
|
||||
function renderWorkflows(items) {
|
||||
text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
|
||||
@@ -155,6 +159,41 @@
|
||||
catch (error) { $('action-error').textContent = error.message || '操作失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
function showScheduleDrawer() { $('schedule-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||
function closeScheduleDrawer() { $('schedule-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeScheduleAction(); }
|
||||
function closeScheduleAction() { $('schedule-action-form').hidden = true; $('schedule-action-error').textContent = ''; }
|
||||
function scheduleRuleLabel(rule) { if (!rule) return '—'; return rule.kind === 'CRON' ? `Cron · ${rule.cron || '—'}` : `固定间隔 · ${rule.interval_ms || '—'} ms`; }
|
||||
function renderScheduleOccurrences(items, error) {
|
||||
text('schedule-occurrences-count', error ? '权限不足' : `${items.length} 条`);
|
||||
renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${String(item.decision || '').toLowerCase()}"></span><div><b>${escapeHTML(item.decision || 'UNKNOWN')} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(item.reason || '—')} · 版本 ${escapeHTML(item.schedule_version || '—')}</small></div><em>${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}</em></div>`);
|
||||
}
|
||||
function renderScheduleDetail(schedule) {
|
||||
state.selectedSchedule = schedule;
|
||||
text('schedule-detail-title', schedule.name || '定时计划'); text('schedule-detail-id', schedule.id || '—'); text('schedule-detail-state', schedule.paused ? '已暂停' : '运行中'); $('schedule-detail-state').className = `state-pill ${schedule.paused ? 'waiting' : 'succeeded'}`;
|
||||
text('schedule-detail-updated', `最后更新 ${formatTime(schedule.updated_at)}`); text('schedule-detail-name', schedule.name || '—'); text('schedule-detail-queue', shortID(schedule.queue_id)); text('schedule-detail-revision', schedule.revision || '—'); text('schedule-detail-version', schedule.current_version || '—'); text('schedule-detail-next', formatTime(schedule.next_fire_at));
|
||||
const definition = schedule.current?.definition || {}; const rule = definition.rule || {}; text('schedule-detail-timezone', rule.timezone || '—'); text('schedule-detail-rule-kind', rule.kind || '—'); $('schedule-detail-rule').innerHTML = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(definition.policy?.misfire || '—')} · 重叠 ${escapeHTML(definition.policy?.overlap || '—')} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}</small>`;
|
||||
$('schedule-actions').hidden = false; text('schedule-control-button', schedule.paused ? '恢复计划' : '暂停计划'); text('schedule-action-note', schedule.paused ? '恢复会按冻结的漏跑策略继续处理暂停期间的触发点。' : '暂停只停止未来触发,已经创建的任务继续执行。');
|
||||
}
|
||||
async function openScheduleDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 schedules:read 权限的凭据'); return; }
|
||||
showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
||||
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; }
|
||||
renderScheduleDetail(extractData(scheduleResult.value)); renderScheduleOccurrences(occurrencesResult.status === 'fulfilled' ? extractItems(occurrencesResult.value) : [], occurrencesResult.status === 'rejected');
|
||||
}
|
||||
function openScheduleAction() {
|
||||
if (!state.selectedSchedule) return;
|
||||
const paused = state.selectedSchedule.paused; $('schedule-action-form').hidden = false; $('schedule-action-error').textContent = ''; $('schedule-action-reason').value = ''; text('schedule-action-title', paused ? '恢复计划' : '暂停计划'); text('schedule-action-description', paused ? '恢复后会按计划冻结的漏跑和重叠策略继续处理。' : '暂停只停止未来触发,不取消已经创建的任务。'); $('schedule-action-reason').focus();
|
||||
}
|
||||
async function submitScheduleAction(event) {
|
||||
event.preventDefault(); if (!state.selectedSchedule) return;
|
||||
const reason = $('schedule-action-reason').value.trim(); if (!reason) { $('schedule-action-error').textContent = '请填写操作原因。'; return; }
|
||||
const paused = state.selectedSchedule.paused; const button = $('schedule-action-submit'); button.disabled = true; $('schedule-action-error').textContent = '';
|
||||
try { await request(`/v1/schedules/${encodeURIComponent(state.selectedSchedule.id)}:${paused ? 'resume' : 'pause'}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(paused ? 'schedule-resume' : 'schedule-pause') }, body: JSON.stringify({ expected_revision: String(state.selectedSchedule.revision), reason }) }); showToast(paused ? '计划已恢复' : '计划已暂停'); closeScheduleAction(); await refresh(); await openScheduleDetail(state.selectedSchedule.id); }
|
||||
catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
async function refresh() {
|
||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||
try {
|
||||
@@ -178,7 +217,7 @@
|
||||
sessionStorage.setItem('orbit.console.queue', state.queue); refresh();
|
||||
}
|
||||
$('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', () => $('connection-panel').scrollIntoView({ behavior: 'smooth', block: 'center' })); $('refresh-button').addEventListener('click', refresh);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); } });
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); } });
|
||||
$('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; });
|
||||
$('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); $('token').value = credential.token || ''; $('queue-id').value = credential.queue_id || ''; showToast('凭据已读取,只保留在当前页面内'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
|
||||
document.querySelectorAll('[data-scroll]').forEach(button => button.addEventListener('click', () => { const target = $(button.dataset.scroll); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.scroll === button.dataset.scroll)); }));
|
||||
|
||||
@@ -131,6 +131,34 @@
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
|
||||
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
||||
</div>
|
||||
<div class="drawer-status-line"><span class="state-pill" id="schedule-detail-state">—</span><span id="schedule-detail-updated">—</span></div>
|
||||
<div class="detail-grid">
|
||||
<div><span>计划名称</span><b id="schedule-detail-name">—</b></div>
|
||||
<div><span>队列</span><b id="schedule-detail-queue">—</b></div>
|
||||
<div><span>控制修订</span><b id="schedule-detail-revision">—</b></div>
|
||||
<div><span>当前版本</span><b id="schedule-detail-version">—</b></div>
|
||||
<div><span>下一次触发</span><b id="schedule-detail-next">—</b></div>
|
||||
<div><span>时区</span><b id="schedule-detail-timezone">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>当前版本规则</span><small id="schedule-detail-rule-kind">—</small></div><div class="schedule-rule-card" id="schedule-detail-rule">等待计划数据…</div></div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>最近触发记录</span><small id="schedule-occurrences-count">加载中</small></div><div class="detail-list" id="schedule-occurrences"><div class="detail-loading">正在读取触发记录…</div></div></div>
|
||||
<div class="drawer-actions" id="schedule-actions" hidden><button class="quiet-button" id="schedule-control-button" type="button">暂停计划</button></div>
|
||||
<p class="drawer-note" id="schedule-action-note">计划控制需要 schedules:manage 权限;暂停只停止未来触发,已经创建的任务继续执行。</p>
|
||||
<form class="action-form" id="schedule-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">SCHEDULE CONTROL</span><h3 id="schedule-action-title">暂停计划</h3></div>
|
||||
<p id="schedule-action-description">暂停后不会再创建新的任务。</p>
|
||||
<label class="field-label" for="schedule-action-reason">操作原因</label><textarea id="schedule-action-reason" rows="3" maxlength="1024" placeholder="说明本次计划控制的原因"></textarea>
|
||||
<div class="action-form-buttons"><button class="quiet-button" id="schedule-action-cancel" type="button">返回</button><button class="primary-button compact" id="schedule-action-submit" type="submit">提交控制 →</button></div>
|
||||
<div class="action-error" id="schedule-action-error" role="alert"></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user