Files
orbit/internal/transport/httpserver/console/app.js

449 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const state = { token: '', queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
const $ = (id) => document.getElementById(id);
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
$('api-url').value = location.origin;
$('queue-id').value = sessionQueue;
function showToast(message) {
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
}
function formatTime(value) {
if (!value) return '—';
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
}
function shortID(id) { return id && id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : (id || '—'); }
function stateClass(value) { return String(value || '').toLowerCase(); }
function extractItems(payload) { return payload?.data?.items || payload?.items || []; }
function extractData(payload) { return payload?.data ?? payload ?? {}; }
function currentView() {
const raw = location.hash.replace(/^#\/?/, '').split('/')[0];
return pageNames[raw] ? raw : 'overview';
}
function setView(view, updateHash = true) {
const next = pageNames[view] ? view : 'overview';
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next));
text('page-title', pageNames[next]);
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function request(path, options = {}) {
const headers = { Accept: 'application/json', ...(options.headers || {}) };
if (state.token) headers.Authorization = `Bearer ${state.token}`;
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers });
const raw = await response.text(); let body = {};
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
return body;
}
async function loadConsoleConfig() {
try {
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) return;
state.consoleConfig = await response.json();
const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim();
if (!state.queue && defaultQueue) {
state.queue = defaultQueue;
$('queue-id').value = defaultQueue;
sessionStorage.setItem('orbit.console.queue', defaultQueue);
text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。');
}
} catch {
// 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。
}
}
function setConnection(connected, detail = '') {
state.connected = connected;
const authenticated = connected && Boolean(state.token && state.queue);
$('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接');
$('connection-state').classList.toggle('ready', connected);
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
$('side-endpoint').textContent = connected ? state.base : 'API 未连接';
$('side-progress').style.width = connected ? '100%' : '0%';
document.querySelector('.status-dot').classList.toggle('ready', connected);
document.querySelector('.live-chip').classList.toggle('ready', connected);
$('live-label').textContent = connected ? '环境在线' : '本地环境';
if (detail) showToast(detail);
}
async function loadHealth() {
try {
const [live, version] = await Promise.all([request('/health/live'), request('/version')]);
text('metric-health', live?.status === 'alive' ? '在线' : '异常');
text('metric-health-sub', `Go ${version?.go_version || '—'} · API v${version?.http_protocol_version || '—'}`);
text('side-status', '环境已连接');
return version;
} catch (error) {
text('metric-health', '离线'); text('metric-health-sub', error.message); setConnection(false); throw error;
}
}
function renderTasks(items) {
text('task-count', `${items.length} 项`); text('metric-tasks', String(items.length));
const counts = items.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {});
const total = Math.max(items.length, 1); const width = (name) => `${(counts[name] || 0) / total * 100}%`;
$('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`;
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
const body = $('task-table');
if (!items.length) { body.innerHTML = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
body.innerHTML = items.slice(0, 8).map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
body.querySelectorAll('.task-row').forEach(row => {
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
});
}
function renderSchedules(items) {
text('schedule-page-hint', `${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.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 ? '最近实例' : '当前没有实例');
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
el.querySelectorAll('.workflow-row').forEach(row => {
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
});
}
function renderSubscriptions(items) {
text('metric-subscriptions', String(items.length));
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-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(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
el.querySelectorAll('.subscription-row').forEach(row => {
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
});
}
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function formatJSON(value) {
if (value === undefined || value === null || value === '') return '';
try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2); } catch { return String(value); }
}
function operationKey(prefix) {
const id = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `console-${prefix}-${id}`.slice(0, 128);
}
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
function renderDetailList(id, items, emptyText, renderItem) {
const el = $(id);
if (!items.length) { el.innerHTML = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
el.innerHTML = items.map(renderItem).join('');
}
function renderTaskDetail(task) {
state.selectedTask = task;
text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN');
$('detail-state').className = `state-pill ${stateClass(task.state)}`;
text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`);
text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '待核查' : '已确认');
text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at));
const resultBlock = $('detail-result-block'); resultBlock.hidden = !task.result; if (task.result) $('detail-result').textContent = formatJSON(task.result);
const canCancel = !terminalState(task.state) && !task.cancel_requested_at;
const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain;
$('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry;
text('detail-action-note', task.state === 'NEEDS_REVIEW' ? '此任务需要先完成带证据的人工核查,确认旧执行已经结清后再决定最终状态。' : '写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。');
}
function renderAttempts(items, error) {
text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
}
function renderOperations(items, error) {
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.action || '操作')}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
}
async function openTaskDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; }
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); return; }
const task = extractData(taskResult.value); renderTaskDetail(task);
renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected');
renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected');
}
function closeActionForm() { state.actionMode = ''; $('task-action-form').hidden = true; $('action-error').textContent = ''; }
function openActionForm(mode) {
if (!state.selectedTask) return;
state.actionMode = mode; $('task-action-form').hidden = false; $('action-error').textContent = ''; $('action-reason').value = ''; $('action-evidence').value = `evidence:console-${Date.now()}`; $('action-safe').checked = false;
const retry = mode === 'retry'; text('action-kicker', retry ? 'TASK RECOVERY' : 'TASK CONTROL'); text('action-title', retry ? '安全重试任务' : '取消任务'); text('action-description', retry ? '只有确认旧执行不会继续产生外部效果时,才能创建新的关联任务。' : '取消会记录持久化取消意图;运行中的处理器仍需上报结果或等待恢复。');
$('action-evidence-wrap').hidden = !retry; $('action-timeout-wrap').hidden = !retry; $('action-safe-wrap').hidden = !retry; $('action-submit').textContent = retry ? '创建重试任务 →' : '提交取消 →'; $('action-reason').focus();
}
async function submitAction(event) {
event.preventDefault(); if (!state.selectedTask || !state.actionMode) return;
const reason = $('action-reason').value.trim(); if (!reason) { $('action-error').textContent = '请填写操作原因。'; return; }
const task = state.selectedTask; const retry = state.actionMode === 'retry'; const body = { expected_revision: String(task.revision), reason };
const path = `/v1/tasks/${encodeURIComponent(task.id)}:${retry ? 'retry' : 'cancel'}`;
if (retry) {
const timeout = Number($('action-timeout').value); if (!$('action-safe').checked) { $('action-error').textContent = '请先确认安全重试声明。'; return; }
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2592000000) { $('action-error').textContent = '任务总时限必须是 1 到 2592000000 毫秒。'; return; }
const evidence = $('action-evidence').value.trim(); if (!evidence) { $('action-error').textContent = '请填写证据引用。'; return; }
Object.assign(body, { evidence_ref: evidence, safe_to_retry: true, task_timeout_ms: timeout });
}
const button = $('action-submit'); button.disabled = true; $('action-error').textContent = '';
try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(retry ? 'retry' : 'cancel') }, body: JSON.stringify(body) }); showToast(retry ? '已创建关联重试任务' : '取消意图已提交'); closeActionForm(); await refresh(); await openTaskDetail(task.id); if (response?.data?.task?.id && retry) showToast(`重试任务:${shortID(response.data.task.id)}`); }
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; }
}
function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeWorkflowAction() { state.workflowActionMode = ''; $('workflow-action-form').hidden = true; $('workflow-action-error').textContent = ''; }
function closeWorkflowRecovery() { state.workflowRecoveryMode = ''; state.workflowRecoveryContext = null; $('workflow-recovery-form').hidden = true; $('workflow-recovery-error').textContent = ''; }
function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeWorkflowAction(); closeWorkflowRecovery(); }
function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeSubscriptionAction() { state.subscriptionActionMode = ''; $('subscription-action-form').hidden = true; $('subscription-action-error').textContent = ''; }
function closeSubscriptionRetry() { state.selectedDelivery = null; $('subscription-retry-form').hidden = true; $('subscription-retry-error').textContent = ''; }
function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeSubscriptionAction(); closeSubscriptionRetry(); }
function openWorkflowAction(mode) {
if (!state.selectedWorkflow) return;
state.workflowActionMode = mode; $('workflow-action-form').hidden = false; $('workflow-action-error').textContent = ''; $('workflow-action-reason').value = '';
const labels = { PAUSE: ['暂停流程', '暂停会停止推进新步骤,但已经发出的任务和结果仍会被收集。'], RESUME: ['恢复流程', '恢复只解除暂停,不清除人工核查、取消意图或延长原截止时间。'], CANCEL: ['取消流程', '取消会保存持久意图并停止后继;在途工作仍需结清,未知效果会进入核查。'] };
text('workflow-action-title', labels[mode][0]); text('workflow-action-description', labels[mode][1]); text('workflow-action-submit', mode === 'CANCEL' ? '提交取消 →' : '提交控制 →'); $('workflow-action-reason').focus();
}
async function submitWorkflowAction(event) {
event.preventDefault(); if (!state.selectedWorkflow || !state.workflowActionMode) return;
const reason = $('workflow-action-reason').value.trim(); if (!reason) { $('workflow-action-error').textContent = '请填写操作原因。'; return; }
const workflow = state.selectedWorkflow; const button = $('workflow-action-submit'); button.disabled = true; $('workflow-action-error').textContent = '';
try { await request(`/v1/workflows/${encodeURIComponent(workflow.id)}/control`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(`workflow-${state.workflowActionMode.toLowerCase()}`) }, body: JSON.stringify({ operation: state.workflowActionMode, expected_control_revision: String(workflow.control_revision), reason }) }); showToast(state.workflowActionMode === 'CANCEL' ? '流程取消意图已提交' : (state.workflowActionMode === 'PAUSE' ? '流程已暂停' : '流程已恢复')); closeWorkflowAction(); await refresh(); await openWorkflowDetail(workflow.id); }
catch (error) { $('workflow-action-error').textContent = error.message || '流程控制失败'; }
finally { button.disabled = false; }
}
const workflowRecoveryLabels = {
'task-review': ['核查任务结果', '先核实任务执行已经结清,再记录这次执行的最终结论。'],
'step-resolve': ['确认步骤结论', '确认当前步骤的逻辑结果;步骤结果不会自动解除流程核查。'],
'continue': ['解除流程核查', '确认全部核查事项已处理,再允许流程继续推进。'],
'step-retry': ['安全重试步骤', '仅在确认旧执行不会继续产生外部效果时,创建新的步骤激活。'],
'poll-continue': ['接续 PENDING 查询', '确认权威结果仍为 PENDING,沿原查询间隔继续,不重复查询同一轮。'],
'wait-rearm': ['重建等待代次', '确认当前等待事实后重建 SIGNAL 或 TIMER 等待代次,原截止时间不延长。'],
};
function recoveryStepState(step) { return String(step?.state || '').toUpperCase(); }
function renderWorkflowRecovery(workflow, payload, error) {
const block = $('workflow-recovery-block'); const actions = $('workflow-recovery-actions');
block.hidden = workflow?.state !== 'NEEDS_REVIEW'; actions.innerHTML = '';
if (block.hidden) return;
if (error) { text('workflow-recovery-hint', '步骤读取失败'); actions.innerHTML = '<div class="detail-empty">当前无法读取步骤,暂不提供恢复按钮。</div>'; return; }
text('workflow-recovery-hint', '需要 workflows:recover · 所有操作进入审计');
const items = Array.isArray(payload?.steps) ? payload.steps : [];
const buttons = [{ mode: 'continue', label: '解除流程核查', hint: '全部事项已处理' }];
items.forEach(step => {
const kind = String(step.kind || '').toUpperCase(); const status = recoveryStepState(step); const context = { step_key: step.key || step.id || '', activation_id: step.activation_id || '', expected_step_revision: step.revision || '', task_id: step.task_id || '' };
if (status !== 'NEEDS_REVIEW') return;
if (context.task_id) buttons.push({ mode: 'task-review', label: `任务核查 · ${step.key || step.id}`, hint: '记录任务最终结论', context });
buttons.push({ mode: 'step-resolve', label: `步骤结论 · ${step.key || step.id}`, hint: `${kind || 'STEP'} · 记录逻辑结果`, context });
if (kind === 'TASK' || kind === 'POLL') buttons.push({ mode: 'step-retry', label: `重试步骤 · ${step.key || step.id}`, hint: '确认安全后创建新激活', context });
if (kind === 'POLL') buttons.push({ mode: 'poll-continue', label: `接续查询 · ${step.key || step.id}`, hint: '确认 PENDING 后接续', context });
if (kind === 'SIGNAL' || kind === 'TIMER') buttons.push({ mode: 'wait-rearm', label: `重建等待 · ${step.key || step.id}`, hint: '确认等待事实后换代', context });
});
actions.innerHTML = buttons.map(button => `<button class="recovery-action-button" type="button" data-recovery-action="${escapeHTML(button.mode)}" data-recovery-context="${escapeHTML(JSON.stringify(button.context || {}))}"><b>${escapeHTML(button.label)}</b><small>${escapeHTML(button.hint)}</small></button>`).join('');
}
async function openWorkflowRecovery(mode, context = {}) {
if (!state.selectedWorkflow || state.selectedWorkflow.state !== 'NEEDS_REVIEW' || !workflowRecoveryLabels[mode]) return;
state.workflowRecoveryMode = mode; state.workflowRecoveryContext = context;
const needsStep = ['step-resolve', 'step-retry', 'poll-continue', 'wait-rearm'].includes(mode); const needsTask = mode === 'task-review'; const needsOutcome = ['task-review', 'step-resolve'].includes(mode);
$('workflow-recovery-form').hidden = false; $('workflow-recovery-error').textContent = ''; $('workflow-recovery-reason').value = ''; $('workflow-recovery-evidence').value = `evidence:console-${Date.now()}`; $('workflow-recovery-confirm').checked = false;
$('workflow-recovery-step-fields').hidden = !needsStep; $('workflow-recovery-task-fields').hidden = !needsTask; $('workflow-recovery-outcome-fields').hidden = !needsOutcome;
$('workflow-recovery-result').value = '{}'; $('workflow-recovery-step-key').value = context.step_key || ''; $('workflow-recovery-activation-id').value = context.activation_id || ''; $('workflow-recovery-step-revision').value = context.expected_step_revision || '';
$('workflow-recovery-task-id').value = context.task_id || ''; $('workflow-recovery-task-revision').value = '';
text('workflow-recovery-title', workflowRecoveryLabels[mode][0]); text('workflow-recovery-description', workflowRecoveryLabels[mode][1]); text('workflow-recovery-submit', `${workflowRecoveryLabels[mode][0]} →`); text('workflow-recovery-snapshot', `当前流程修订 ${state.selectedWorkflow.revision || '—'} · ${context.step_key ? `步骤 ${context.step_key}` : '流程级决定'}`);
text('workflow-recovery-confirm-label', mode === 'continue' ? '我确认全部核查事项已经处理,可以解除流程核查。' : '我确认已经核实当前事实,并承担提交此恢复决定的责任。');
$('workflow-recovery-reason').focus();
if (needsTask && context.task_id) {
try { const task = extractData(await request(`/v1/tasks/${encodeURIComponent(context.task_id)}`)); if (!$('workflow-recovery-task-revision').value && task.revision) $('workflow-recovery-task-revision').value = task.revision; }
catch { text('workflow-recovery-description', `${workflowRecoveryLabels[mode][1]} 当前任务修订读取失败,请手工填写任务 revision。`); }
}
}
function parseRecoveryResult(value) {
try { return JSON.parse(value); } catch { throw new Error('结果必须是合法 JSON,例如 {"status":"DONE"};没有结果时填写 null。'); }
}
function validRevision(value) { return /^[1-9]\d*$/.test(String(value || '').trim()); }
async function submitWorkflowRecovery(event) {
event.preventDefault(); if (!state.selectedWorkflow || !state.workflowRecoveryMode) return;
const mode = state.workflowRecoveryMode; const workflow = state.selectedWorkflow; const reason = $('workflow-recovery-reason').value.trim(); const evidence = $('workflow-recovery-evidence').value.trim();
const error = message => { $('workflow-recovery-error').textContent = message; };
if (!reason) { error('请填写核查原因。'); return; }
if (!evidence || /[\s\x00-\x1f\x7f]/.test(evidence)) { error('证据引用必填,且不能包含空格或控制字符。'); return; }
if (!validRevision(workflow.revision)) { error('当前流程 revision 无效,请重新打开流程详情。'); return; }
if (!$('workflow-recovery-confirm').checked) { error('请先确认本次核查决定。'); return; }
const body = { expected_revision: String(workflow.revision), reason, evidence_ref: evidence }; let path = `/v1/workflows/${encodeURIComponent(workflow.id)}/recovery/${mode}`;
if (mode === 'continue') body.review_resolved = true;
if (['step-resolve', 'step-retry', 'poll-continue', 'wait-rearm'].includes(mode)) {
const stepKey = $('workflow-recovery-step-key').value.trim(); const activationID = $('workflow-recovery-activation-id').value.trim(); const stepRevision = $('workflow-recovery-step-revision').value.trim();
if (!stepKey || !activationID || !validRevision(stepRevision)) { error('请填写当前步骤 key、激活 ID 和步骤 revision。'); return; }
Object.assign(body, { step_key: stepKey, activation_id: activationID, expected_step_revision: stepRevision });
if (mode === 'step-resolve') { const result = parseRecoveryResult($('workflow-recovery-result').value.trim()); body.execution_settled = true; body.outcome = $('workflow-recovery-outcome').value; body.result = result; }
if (mode === 'step-retry') body.safe_to_retry = true;
if (mode === 'poll-continue') body.pending_confirmed = true;
if (mode === 'wait-rearm') body.wait_verified = true;
}
if (mode === 'task-review') {
const taskID = $('workflow-recovery-task-id').value.trim(); const taskRevision = $('workflow-recovery-task-revision').value.trim(); const result = parseRecoveryResult($('workflow-recovery-result').value.trim());
if (!taskID || !validRevision(taskRevision)) { error('请填写待核查任务 ID 和当前任务 revision。'); return; }
if (result === null) { error('任务核查的 result 不能为 null,请填写实际 JSON 结果。'); return; }
Object.assign(body, { task_id: taskID, expected_task_revision: taskRevision, execution_settled: true, outcome: $('workflow-recovery-outcome').value, result });
}
const button = $('workflow-recovery-submit'); button.disabled = true; error(''); const workflowID = workflow.id;
try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(`workflow-recovery-${mode}`) }, body: JSON.stringify(body) }); showToast(response?.data?.deduplicated ? '已恢复原恢复回执' : `${workflowRecoveryLabels[mode][0]}已提交`); closeWorkflowRecovery(); await refresh(); await openWorkflowDetail(workflowID); }
catch (err) { error(err.message || '流程恢复失败'); }
finally { button.disabled = false; }
}
function renderWorkflowDetail(workflow) {
state.selectedWorkflow = workflow;
text('workflow-detail-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', workflow.state || 'UNKNOWN'); $('workflow-detail-state').className = `state-pill ${stateClass(workflow.state)}`;
text('workflow-detail-updated', `最后更新 ${formatTime(workflow.updated_at)}`); text('workflow-detail-definition', workflow.definition_id || '—'); text('workflow-detail-version', workflow.version || '—'); text('workflow-detail-revision', workflow.revision || '—'); text('workflow-detail-control-revision', workflow.control_revision || '—'); text('workflow-detail-business-key', workflow.business_key || '—'); text('workflow-detail-deadline', formatTime(workflow.deadline_at));
const outputBlock = $('workflow-output-block'); const hasOutput = Object.prototype.hasOwnProperty.call(workflow, 'output'); outputBlock.hidden = !hasOutput; if (hasOutput) $('workflow-output').textContent = formatJSON(workflow.output) || 'null';
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(String(workflow.state || '')); const canCancel = !terminal && !workflow.cancel_requested_at && !workflow.stop_requested_at; $('workflow-actions').hidden = terminal || (!canCancel && !workflow.paused); $('workflow-control-button').hidden = terminal; $('workflow-cancel-button').hidden = !canCancel; text('workflow-control-button', workflow.paused ? '恢复流程' : '暂停流程'); text('workflow-detail-note', workflow.state === 'NEEDS_REVIEW' ? '流程处于人工核查状态;暂停/恢复/取消不会替代带证据的恢复 API。控制写入仍由服务端校验权限、控制修订和审计。' : '流程控制需要 workflows:control 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。');
}
function renderWorkflowSteps(payload, error) {
const items = Array.isArray(payload?.steps) ? payload.steps : [];
text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`);
renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => {
const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活');
return `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
});
}
function renderWorkflowEvents(payload, error) {
const items = Array.isArray(payload?.items) ? payload.items : [];
text('workflow-events-count', error ? '权限不足' : `${items.length} 条`);
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}</b><small>${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
}
async function openWorkflowDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; }
showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
const workflow = extractData(workflowResult.value); const stepsPayload = stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {};
renderWorkflowDetail(workflow);
renderWorkflowSteps(stepsPayload, stepsResult.status === 'rejected');
renderWorkflowRecovery(workflow, stepsPayload, stepsResult.status === 'rejected');
renderWorkflowEvents(eventsResult.status === 'fulfilled' ? extractData(eventsResult.value) : {}, eventsResult.status === 'rejected');
}
function renderSubscriptionDetail(subscription) {
state.selectedSubscription = subscription;
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
$('subscription-actions').hidden = false; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
}
function renderSubscriptionDeliveries(payload, error) {
const items = Array.isArray(payload?.items) ? payload.items : [];
text('subscription-deliveries-count', error ? '权限不足' : `${items.length} 条`);
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
}
function openSubscriptionAction() {
if (!state.selectedSubscription) return;
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); $('subscription-action-reason').focus();
}
async function submitSubscriptionAction(event) {
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:configure`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify({ expected_revision: String(subscription.revision), enabled, reason }) }); showToast(enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
finally { button.disabled = false; }
}
function openSubscriptionRetry(id, delivery) {
if (!state.selectedSubscription || !id || !delivery) return;
state.selectedDelivery = delivery; $('subscription-retry-form').hidden = false; $('subscription-retry-error').textContent = ''; $('subscription-retry-reason').value = ''; $('subscription-retry-attempts').value = '1'; $('subscription-retry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); $('subscription-retry-reason').focus();
}
async function submitSubscriptionRetry(event) {
event.preventDefault(); if (!state.selectedSubscription || !state.selectedDelivery) return;
const reason = $('subscription-retry-reason').value.trim(); if (!reason) { $('subscription-retry-error').textContent = '请填写操作原因。'; return; }
const additionalAttempts = Number($('subscription-retry-attempts').value); if (!Number.isSafeInteger(additionalAttempts) || additionalAttempts < 1 || additionalAttempts > 100) { $('subscription-retry-error').textContent = '追加次数必须是 1 到 100 的整数。'; return; }
const subscription = state.selectedSubscription; const delivery = state.selectedDelivery; const button = $('subscription-retry-submit'); button.disabled = true; $('subscription-retry-error').textContent = '';
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}/deliveries/${encodeURIComponent(delivery.id)}:retry`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('subscription-delivery-retry') }, body: JSON.stringify({ expected_revision: String(delivery.revision), reason, additional_attempts: additionalAttempts }) }); showToast('过期交付已追加预算并重新排队'); closeSubscriptionRetry(); await refresh(); await openSubscriptionDetail(subscription.id); }
catch (error) { $('subscription-retry-error').textContent = error.message || '交付重试失败'; }
finally { button.disabled = false; }
}
async function openSubscriptionDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; }
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
renderSubscriptionDetail(extractData(subscriptionResult.value)); renderSubscriptionDeliveries(deliveriesResult.status === 'fulfilled' ? extractData(deliveriesResult.value) : {}, deliveriesResult.status === 'rejected');
}
async function refresh() {
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
try {
await loadHealth();
if (!state.token || !state.queue) { setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); renderTasks(extractItems(tasks));
const optional = await Promise.allSettled([
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
request('/v1/workflows?limit=20'),
request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
]);
if (optional[0].status === 'fulfilled') renderSchedules(extractItems(optional[0].value));
if (optional[1].status === 'fulfilled') renderWorkflows(extractItems(optional[1].value));
if (optional[2].status === 'fulfilled') renderSubscriptions(extractItems(optional[2].value));
setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常');
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
}
function connect() {
state.base = $('api-url').value.trim().replace(/\/$/, '') || location.origin; state.token = $('token').value.trim(); state.queue = $('queue-id').value.trim() || String(state.consoleConfig?.default_queue_id || '').trim();
$('queue-id').value = state.queue;
sessionStorage.setItem('orbit.console.queue', state.queue);
if (!state.token) showToast('服务状态可以直接查看;选择凭据 JSON 后可读取业务数据');
else if (!state.queue) showToast('队列会从凭据 JSON 自动读取,也可配置 ORBIT_CONSOLE_QUEUE_ID');
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')); $('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); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
$('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); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
window.addEventListener('hashchange', () => setView(currentView(), false));
setView(currentView(), false);
loadConsoleConfig().finally(() => refresh()); state.timer = setInterval(refresh, 10000);
})();