(() => {
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, scope: null, scopeReady: false, scopeNamespace: '', mutations: 0, retention: 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 core = window.OrbitConsole;
const label = core.label;
const lists = Object.fromEntries(['tasks', 'schedules', 'workflows', 'subscriptions'].map(kind => [kind, new core.ListPage()]));
let overviewSequence = 0, healthPromise = null;
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
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';
if (updateHash && location.hash !== `#/${next}`) { location.hash = `/${next}`; return; }
const leavingApplications = state.scopeReady && $('application-scope').hidden && next !== 'applications';
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); if (item.dataset.view === next) item.setAttribute('aria-current','page'); else item.removeAttribute('aria-current'); });
text('page-title', pageNames[next]); document.title = `${pageNames[next]} · Orbit 控制台`;
document.querySelector('.scope-queue').hidden = ['workflows','retention','archives'].includes(next);
$('application-scope').hidden = next === 'applications';
if (leavingApplications && state.scope?.can_switch) {
scopeUI.nextApplication = '';
loadScopeApplications().catch(error => showToast(`应用目录读取失败:${error.message}`));
}
text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
$('main-content').scrollTo({ top: 0, behavior: 'instant' });
if (matchMedia('(max-width:640px)').matches) document.querySelector('.nav-item.active')?.scrollIntoView({inline:'nearest',block:'nearest'});
if (next === 'retention' && state.scopeReady) loadRetention();
if (next === 'archives' && state.scopeReady) loadArchives();
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
if (state.authenticated && state.scopeReady && ['overview', ...Object.keys(lists)].includes(next)) refresh();
}
function redirectToLogin() {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
location.replace(`/console/login?return=${encodeURIComponent(returnTo.startsWith('/console') ? returnTo : '/console/')}`);
}
async function ensureSession() {
const response = await fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
const body = await response.json().catch(() => ({}));
if (!body.authenticated) { redirectToLogin(); return false; }
state.authenticated = true; return true;
}
async function request(path, options = {}) {
const scoped = path.startsWith('/v1/') || path === '/console/scope' || path.startsWith('/console/scope?') || path.startsWith('/console/retention') || path.startsWith('/console/archive/');
const headers = { Accept: 'application/json', ...(scoped && state.scopeNamespace ? { 'X-Orbit-Namespace': state.scopeNamespace, 'X-Orbit-Console': '1' } : {}), ...(options.headers || {}) };
const mutation = options.method && options.method !== 'GET';
if (mutation) state.mutations++;
try {
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
const raw = await response.text(); let body = {};
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
if (!response.ok) { const error = new Error(window.orbitResourceMessage(body?.error?.message || body?.message || `HTTP ${response.status}`)); error.status = response.status; throw error; }
return body;
} finally { if (mutation) state.mutations--; }
}
async function loadConsoleConfig() {
try {
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, credentials: 'same-origin', 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;
renderConnectionSummary();
}
} catch {
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
}
}
const scopeUI = { applications: [], nextApplication: '', nextQueue: '', busy: false };
function scopeBadge() {
const e = state.scope?.entry;
if (!e) return '';
return `${escapeHTML(e.application)}${escapeHTML(e.environment)}`;
}
function appendScopeOptions(select, items, selected, more, kind) {
select.replaceChildren();
for (const item of items) {
const option = document.createElement('option');
option.value = kind === 'application' ? item.namespace_id : item.id;
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : `${item.name}${item.retired_at ? ' · 已退役' : item.paused ? ' · 已暂停' : ''}`;
if (kind === 'application') {
option.dataset.title = item.application;
option.dataset.subtitle = `${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}`;
}
option.disabled = kind === 'application' && !item.enabled;
select.append(option);
}
if (!items.length) select.append(new Option('暂无队列', ''));
if (more) select.append(new Option('加载更多…', '__more__'));
select.value = selected;
}
async function loadScopeApplications() {
const page = extractData(await request(`/console/applications${scopeUI.nextApplication ? `?after=${encodeURIComponent(scopeUI.nextApplication)}` : ''}`));
for (const item of page.items || []) if (!scopeUI.applications.some(e => e.namespace_id === item.namespace_id)) scopeUI.applications.push(item);
scopeUI.nextApplication = page.next || '';
appendScopeOptions($('scope-application'), scopeUI.applications, state.scope.entry.namespace_id, scopeUI.nextApplication, 'application');
}
async function loadScope() {
const params = new URLSearchParams(location.search);
state.scopeNamespace = params.get('application') || '';
try {
const scope = extractData(await request('/console/scope'));
state.scope = scope;
// 指定管理员在默认应用内也使用显式管理范围,普通账号继续沿用原有队列权限。
if (scope.can_switch && !state.scopeNamespace) {
state.scopeNamespace = scope.entry.namespace_id;
state.scope = extractData(await request('/console/scope'));
}
const queues = state.scope.queues;
const requested = params.get('queue');
const preferred = requested || state.consoleConfig?.default_queue_id;
state.queue = queues.some(q => q.id === preferred) ? preferred : (queues[0]?.id || '');
if (requested && !queues.some(q => q.id === requested)) {
const queue = extractData(await request(`/v1/queues/${encodeURIComponent(requested)}`));
queues.push({ id: queue.id, name: queue.name, paused: queue.paused, retired_at: queue.retired_at }); state.queue = requested;
}
scopeUI.nextQueue = state.scope.next || '';
scopeUI.applications = [state.scope.entry];
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
$('scope-application').disabled = !scope.can_switch;
$('scope-queue').disabled = !queues.length;
if (scope.can_switch) {
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
}
document.querySelectorAll('.detail-scope').forEach(el => { el.innerHTML = scopeBadge(); });
$('retention-scope').innerHTML = scopeBadge();
text('scope-summary', `${scope.entry.application} / ${scope.entry.environment} / ${scope.entry.namespace}`);
state.scopeReady = true; renderConnectionSummary();
} catch (error) {
state.queue = ''; state.scopeReady = false;
text('scope-summary', '范围不可用');
$('scope-application').replaceChildren(new Option('范围不可用', ''));
$('scope-queue').replaceChildren(new Option('未加载', ''));
$('scope-application').disabled = true; $('scope-queue').disabled = true;
$('task-table').innerHTML = '
| ◇当前范围未加载请检查应用范围,或点击“重置范围” |
';
text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`);
}
}
function navigateScope(namespace, queue = '') {
if (state.mutations || retentionUI.busy || archiveUI.busy || retentionUI.pendingSave || archiveUI.pending) { showToast('有操作尚未确认,请先核对完成后再切换应用'); return false; }
if (retentionUI.dirty || archiveUI.dirty || document.querySelector('.drawer-backdrop:not([hidden])')) { showToast('请先保存配置并关闭详情,再切换查看范围'); return false; }
const url = new URL(location.href); url.searchParams.delete('application'); url.searchParams.delete('queue');
if (namespace) url.searchParams.set('application', namespace);
if (queue) url.searchParams.set('queue', queue);
// 完整导航隔离不同范围的请求、详情及幂等状态;后退和刷新都恢复 URL 中的范围。
location.assign(url.href); return true;
}
$('scope-application').addEventListener('change', async event => {
const value = event.target.value; event.target.value = state.scope?.entry.namespace_id || '';
if (scopeUI.busy) return;
if (value !== '__more__') { navigateScope(value); return; }
scopeUI.busy = true;
try { await loadScopeApplications(); } catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
});
$('scope-queue').addEventListener('change', async event => {
const value = event.target.value; event.target.value = state.queue;
if (scopeUI.busy) return;
if (value !== '__more__') { navigateScope(state.scopeNamespace, value); return; }
scopeUI.busy = true;
try {
const page = extractData(await request(`/console/scope?after=${encodeURIComponent(scopeUI.nextQueue)}`));
for (const queue of page.queues) if (!state.scope.queues.some(q => q.id === queue.id)) state.scope.queues.push(queue);
scopeUI.nextQueue = page.next || '';
appendScopeOptions($('scope-queue'), state.scope.queues, state.queue, scopeUI.nextQueue, 'queue');
} catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
});
$('scope-reset').addEventListener('click', () => navigateScope(''));
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
function syncRetentionControls() {
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
$('retention-preview-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention;
$('retention-export-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done;
$('retention-export-button').textContent = retentionUI.cursor ? '导出下一批 ↓' : '导出首批 ↓';
$('retention-restart-button').disabled = retentionUI.busy || retentionUI.batch === 0;
text('retention-export-hint', retentionUI.dirty ? '有未保存的修改,请先保存策略后再预览或导出。' : !state.retention?.enabled ? '可预览候选;导出前请开启只读归档并保存策略。' : '每批最多 20 条根记录及关联历史;生成后可下载下一批。源数据始终保留。');
}
function resetRetentionExport() {
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
Object.assign(retentionUI, { cursor: '', batch: 0, count: 0, done: false, download: '' });
$('retention-download').hidden = true;
$('retention-download').removeAttribute('href');
text('retention-export-status', '尚未导出');
text('retention-export-sha', '');
syncRetentionControls();
}
function renderRetention(policy) {
if (!policy) return;
const changed = !state.retention || String(state.retention.revision) !== String(policy.revision);
state.retention = policy;
retentionUI.dirty = false;
$('retention-enabled').checked = Boolean(policy.enabled);
$('retention-idempotency').value = policy.idempotency_days ?? '';
$('retention-task').value = policy.task_history_days ?? '';
$('retention-workflow').value = policy.workflow_history_days ?? '';
$('retention-event').value = policy.event_log_days ?? '';
$('retention-delivery').value = policy.delivery_history_days ?? '';
$('retention-inbox').value = policy.inbox_history_days ?? '';
$('retention-audit').value = policy.audit_log_days ?? '';
text('retention-source', policy.source === 'database' ? '已保存配置' : '默认策略');
text('retention-revision', String(policy.revision || '0') === '0' ? '默认' : String(policy.revision));
text('retention-updated', policy.source === 'database' ? formatTime(policy.updated_at) : '尚未保存');
if (changed) {
resetRetentionExport();
text('retention-preview', '点击预览,查看当前已保存策略下的候选样本。');
}
syncRetentionControls();
}
async function loadRetention() {
if (retentionUI.busy || retentionUI.dirty) return;
retentionUI.busy = true; syncRetentionControls();
try {
const response = await request('/console/retention');
renderRetention(extractData(response));
text('retention-error', '');
} catch (error) { text('retention-error', error.message || '保留策略读取失败'); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
function retentionNumber(id) {
const value = Number($(id).value);
return Number.isSafeInteger(value) && value >= 1 && value <= 3650 ? value : 0;
}
async function submitRetention(event) {
event.preventDefault();
if (retentionUI.busy || !state.retention) return;
const fields = { idempotency: 'retention-idempotency', task: 'retention-task', workflow: 'retention-workflow', event: 'retention-event', delivery: 'retention-delivery', inbox: 'retention-inbox', audit: 'retention-audit' };
const values = Object.fromEntries(Object.entries(fields).map(([key, id]) => [key, retentionNumber(id)]));
if (Object.values(values).some(value => !value) || values.idempotency < 90) { text('retention-error', '幂等记录至少保留 90 天;其他窗口为 1 到 3650 的整数。'); return; }
const reason = $('retention-reason').value.trim();
if (!reason || /[\r\n]/.test(reason) || new TextEncoder().encode(reason).length > 1024) { text('retention-error', '请填写单行变更原因,最多 1024 字节(中文约 341 字)。'); return; }
const body = JSON.stringify({ expected_revision: String(state.retention.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason });
// 网络响应不明时,原样重试必须复用同一个操作键。
if (retentionUI.pendingSave?.body !== body) retentionUI.pendingSave = { body, key: operationKey('retention-policy') };
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
try {
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retentionUI.pendingSave.key }, body });
renderRetention(extractData(response)); retentionUI.pendingSave = null;
$('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
} catch (error) { text('retention-error', `${error.message || '保留策略保存失败'}。网络故障可直接重试;版本冲突请刷新页面后核对。`); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
function retentionCutoff(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }).format(date) + ' UTC';
}
function renderRetentionPreview(preview) {
const el = $('retention-preview');
const items = Array.isArray(preview?.items) ? preview.items : [];
if (!items.length) { el.textContent = '当前策略没有可预览的候选数据。'; return; }
const labels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
el.innerHTML = `候选预览 · 版本 ${escapeHTML(String(preview.policy?.revision || '0'))}每类最多 ${escapeHTML(String(preview.sample_limit))} 条样本,不是全库总数
${items.map(item => `${escapeHTML(labels[item.kind] || item.kind)}${escapeHTML(String(item.count || 0))}${item.more ? '+' : ''} 条截止 ${escapeHTML(retentionCutoff(item.cutoff))}${item.more ? ' · 还有更多' : ''}
`).join('')}`;
}
async function previewRetention() {
if (retentionUI.busy || retentionUI.dirty) return;
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
try {
const response = await request('/console/retention/preview');
const preview = extractData(response);
if (String(preview.policy?.revision) !== String(state.retention?.revision)) throw new Error('策略已被更新,请刷新页面核对后再预览');
renderRetentionPreview(preview);
} catch (error) { text('retention-error', error.message || '候选预览失败'); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
async function exportRetention() {
if (retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done) return;
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
try {
if (!window.crypto?.subtle) throw new Error('完整性校验需要 HTTPS 或本机 localhost 地址');
const kind = $('retention-kind').value;
const revision = String(state.retention.revision || 0);
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, cursor: retentionUI.cursor, expected_revision: revision }) });
const snapshot = extractData(response);
if (snapshot.format_version !== 1 || typeof snapshot.content !== 'string' || !snapshot.content) throw new Error('服务端返回了无效的归档快照');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshot.content));
const sha = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
if (sha !== snapshot.sha256) throw new Error('快照完整性校验失败,请重新导出本批次');
const data = JSON.parse(snapshot.content);
if (data.kind !== kind || String(data.policy?.revision) !== revision || data.source_retained !== true || !Number.isSafeInteger(data.root_count) || data.root_count < 0 || data.root_count > 20 || (data.next_cursor != null && typeof data.next_cursor !== 'string')) throw new Error('快照与当前导出请求不一致');
// 下载信封同时保留原始正文和摘要,离线校验时不要重新序列化 content。
const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json;charset=utf-8' }));
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
Object.assign(retentionUI, { download: url, cursor: data.next_cursor || '', batch: retentionUI.batch + 1, count: retentionUI.count + data.root_count, done: !data.next_cursor });
const anchor = $('retention-download');
anchor.href = url; anchor.download = `orbit-retention-${kind}-r${revision}-batch${retentionUI.batch}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
anchor.hidden = false; anchor.click();
text('retention-export-status', `第 ${retentionUI.batch} 批 · 本批 ${data.root_count} 条 · 累计 ${retentionUI.count} 条根记录 · ${retentionUI.done ? '已到末批' : '可继续下一批'} · SHA-256 校验通过`);
text('retention-export-sha', sha);
showToast('快照已生成,源数据保留');
} catch (error) { text('retention-error', error.message || '归档快照导出失败'); }
finally { retentionUI.busy = false; syncRetentionControls(); }
}
const archiveUI = { busy: false, loading: false, dirty: false, plans: [], jobs: [], configured: false, policy: null, editing: null, editPolicy: '0', before: '', next: '', pending: null, selected: null };
const archiveLabels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
const archiveStates = { PENDING: '等待执行', RUNNING: '执行中', SUCCEEDED: '已完成', FAILED: '待处理', ABANDONED: '已关闭' };
const archiveErrors = { FILE_CHECK_FAILED: '文件检查未通过,请核查持久卷、原目录与文件完整性。', STORAGE_LIMIT: '归档容量达到上限,请核查单作业与归档卷容量。', STORAGE_BUSY: '文件正被其他执行器使用,确认旧进程退出后可重试。', POLICY_CHANGED: '保留策略已变更或关闭。原作业不能替换策略版本,可关闭后重新配置周期。', PERMISSION_DENIED: '所属应用或命名空间已停用。', EXPORT_FAILED: '数据库导出未完成,确认数据库恢复后可重试。', INTERRUPTED: '执行中断,核查后可从原作业续跑。' };
function syncArchiveControls() {
document.querySelectorAll('#archive-plan-form input, #archive-plan-form select, #archive-plan-form button, #archive-action-form input, #archive-action-form button, [data-archive-action]').forEach(el => { el.disabled = archiveUI.busy; });
$('archive-plan-save').disabled = archiveUI.busy || !archiveUI.editing || archiveUI.editPolicy === '0';
$('archive-action-budget').disabled = archiveUI.busy || archiveUI.selected?.action === 'abandon';
$('archive-next').disabled = archiveUI.busy || archiveUI.loading || !archiveUI.next;
$('archive-first').disabled = archiveUI.busy || archiveUI.loading || !archiveUI.before;
}
function renderArchiveEditor() {
const p = archiveUI.plans.find(p => p.kind === $('archive-kind').value); if (!p) return;
archiveUI.editing = p; archiveUI.editPolicy = String(archiveUI.policy?.revision || '0'); archiveUI.dirty = false;
$('archive-enabled').checked = p.enabled; $('archive-interval').value = p.interval_seconds / 3600;
$('archive-batch').value = p.batch_size; $('archive-budget').value = p.max_bytes / 1048576;
text('archive-plan-revision', `计划版本 ${p.revision} · 保留策略 ${archiveUI.editPolicy}`);
text('archive-next-run', p.active_job_id ? `当前作业 ${shortID(p.active_job_id)} · 完成或关闭后继续周期` : `下次执行 ${p.enabled ? formatTime(p.next_run_at) : '尚未开启'}`);
const stale = p.enabled && String(p.policy_revision) !== archiveUI.editPolicy;
text('archive-policy-note', !archiveUI.policy?.enabled ? '请先在“保留策略”中允许只读归档。' : stale ? '保留策略已变更,重新保存本计划后才会触发新作业。' : '每个作业固定创建时的策略版本。');
text('archive-plan-hint', archiveUI.configured ? '关闭周期只停止新作业,已有作业继续结清。失败作业需先处理,后续周期才会继续。' : '当前节点尚未配置归档持久卷,可查看历史或关闭已保存的周期。');
syncArchiveControls();
}
function archiveSize(bytes) { return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1048576).toFixed(2)} MiB`; }
function renderArchiveJobs() {
const jobs = archiveUI.jobs;
text('archive-job-count', `${jobs.length} 项`);
text('archive-enabled-count', String(archiveUI.plans.filter(p => p.enabled).length));
text('archive-failed-count', String(jobs.filter(j => j.state === 'FAILED').length));
text('archive-root-count', jobs.reduce((sum, j) => sum + j.roots, 0).toLocaleString('zh-CN'));
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
$('archive-executor').classList.toggle('ready', archiveUI.configured);
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}`).join('');
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `${escapeHTML(archiveLabels[j.kind] || j.kind)}${escapeHTML(archiveStates[j.state] || j.state)}
${scopeBadge()}${escapeHTML(j.id)}${j.batches} 批已确认${j.roots.toLocaleString('zh-CN')} 条根记录${archiveSize(j.bytes)} / ${j.max_bytes / 1048576} MiB策略 v${escapeHTML(j.policy_revision)}
${j.last_sha256 ? `查看文件校验摘要
${escapeHTML(j.last_sha256)} ` : ''}${j.error_code ? `${escapeHTML(archiveErrors[j.error_code] || j.error_code)}
` : ''}${j.state === 'RUNNING' ? `领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。
` : ''}${j.state === 'FAILED' ? `` : ''}`).join('') : '▤还没有归档作业保存并开启周期后,系统会按保留窗口生成作业。
';
syncArchiveControls();
}
async function loadArchives() {
if (archiveUI.loading || archiveUI.busy) return; archiveUI.loading = true;
try {
const [plans, policy, jobs] = await Promise.all([request('/console/archive/plans'), request('/console/retention'), request(`/console/archive/jobs${archiveUI.before ? `?before=${encodeURIComponent(archiveUI.before)}` : ''}`)]);
const config = extractData(plans), page = extractData(jobs);
archiveUI.plans = config.items || []; archiveUI.configured = config.executor_configured;
archiveUI.policy = extractData(policy); archiveUI.jobs = page.items || []; archiveUI.next = page.next_before || '';
if (!archiveUI.dirty) renderArchiveEditor(); renderArchiveJobs();
} catch (error) { text('archive-error', error.message || '归档作业读取失败'); }
finally { archiveUI.loading = false; syncArchiveControls(); }
}
async function archiveMutation(path, method, input) {
const body = JSON.stringify(input), signature = `${method}:${path}:${body}`;
if (archiveUI.pending && archiveUI.pending.signature !== signature) throw new Error('上次操作尚未确认,请保持原内容重试;确认结果后再修改。');
if (!archiveUI.pending) archiveUI.pending = { signature, key: crypto.randomUUID() };
try {
const response = await request(path, { method, headers: { 'Content-Type': 'application/json', 'Idempotency-Key': archiveUI.pending.key }, body });
archiveUI.pending = null; return response;
} catch (error) {
// 明确的客户端拒绝允许核对后更正;网络故障和服务端错误保留原幂等键。
if (error.status >= 400 && error.status < 500) archiveUI.pending = null;
throw error;
}
}
async function saveArchivePlan(event) {
event.preventDefault(); if (archiveUI.busy || !archiveUI.editing) return;
archiveUI.busy = true; syncArchiveControls(); text('archive-error', '');
try {
await archiveMutation(`/console/archive/plans/${encodeURIComponent(archiveUI.editing.kind)}`, 'PUT', { expected_revision: String(archiveUI.editing.revision), policy_revision: archiveUI.editPolicy, enabled: $('archive-enabled').checked, interval_seconds: Number($('archive-interval').value) * 3600, batch_size: Number($('archive-batch').value), max_bytes: Number($('archive-budget').value) * 1048576, reason: $('archive-reason').value });
archiveUI.dirty = false; $('archive-reason').value = ''; showToast('周期配置已保存,操作已留存审计');
} catch (error) { text('archive-error', `${error.message}。网络故障请保持原内容重试;版本冲突请刷新核对。`); }
finally { archiveUI.busy = false; syncArchiveControls(); }
if (!archiveUI.dirty) await loadArchives();
}
function openArchiveAction(id, action) {
if (archiveUI.busy) return; const job = archiveUI.jobs.find(j => j.id === id); if (!job) return;
archiveUI.selected = { job, action }; $('archive-action-form').hidden = false;
text('archive-action-title', action === 'retry' ? '从原作业继续' : '关闭失败作业');
text('archive-action-note', action === 'retry' ? `${shortID(id)} · 校验原文件后续跑,保留原策略与作业身份。容量不足时可提高上限。` : `${shortID(id)} · 保留已有文件和审计,不再重试本作业。已开启的计划可继续生成后续作业。`);
$('archive-action-budget').value = job.max_bytes / 1048576; $('archive-action-budget').min = job.max_bytes / 1048576;
$('archive-action-reason').value = ''; syncArchiveControls(); $('archive-action-reason').focus();
}
async function submitArchiveAction(event) {
event.preventDefault(); if (archiveUI.busy || !archiveUI.selected) return;
archiveUI.busy = true; syncArchiveControls(); text('archive-error', '');
try {
const { job, action } = archiveUI.selected;
await archiveMutation(`/console/archive/jobs/${encodeURIComponent(job.id)}/actions`, 'POST', { expected_revision: String(job.revision), action, max_bytes: Number($('archive-action-budget').value) * 1048576, reason: $('archive-action-reason').value });
$('archive-action-form').hidden = true; archiveUI.selected = null; showToast('作业处理已保存,操作已留存审计');
} catch (error) { text('archive-error', `${error.message}。网络故障请保持原内容重试。`); }
finally { archiveUI.busy = false; syncArchiveControls(); }
await loadArchives();
}
function renderConnectionSummary() {
text('summary-api', state.base || location.origin);
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
text('summary-queue', state.queue ? shortID(state.queue) : '未配置');
text('summary-credential', state.authenticated ? '账号会话' : '未登录');
text('summary-note', state.authenticated ? '当前页面已通过账号会话访问服务,数据按账号权限显示。' : '登录后加载业务数据。');
}
function setConnection(connected, detail = '') {
state.connected = connected;
const authenticated = connected && state.authenticated;
$('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.querySelectorAll('.status-dot').forEach(dot => dot.classList.toggle('ready', connected));
document.querySelector('.live-chip').classList.toggle('ready', connected);
$('live-label').textContent = connected ? '环境在线' : '本地环境';
renderConnectionSummary();
if (detail) showToast(detail);
}
async function loadHealth() {
if (healthPromise) return healthPromise;
healthPromise = (async () => {
try {
const [ready, version] = await Promise.all([request('/health/ready'), request('/version')]);
if (ready?.status !== 'ready') throw new Error('服务尚未就绪');
text('metric-health', '运行正常');
text('metric-health-sub', `${version?.version || 'Orbit'} · 数据库已就绪`);
setConnection(true); return version;
} catch (error) { text('metric-health', '暂不可用'); text('metric-health-sub', '请检查服务与数据库连接'); setConnection(false); throw error; }
finally { healthPromise = null; }
})();
return healthPromise;
}
function badge(value) { return `${escapeHTML(label(value))}`; }
const listBodies = { tasks:'task-table', schedules:'schedule-list', workflows:'workflow-list', subscriptions:'subscription-list' };
const listColumns = { tasks:7, schedules:6, workflows:6, subscriptions:6 };
const detailOpeners = { tasks:openTaskDetail, schedules:openScheduleDetail, workflows:openWorkflowDetail, subscriptions:openSubscriptionDetail };
function resourceButton(kind, item, name) { return ``; }
function detailButton(kind, item) { return resourceButton(kind, item, '详情 →'); }
function renderList(kind, items) {
const body = $(listBodies[kind]);
if (!items.length) { body.innerHTML = `| ○没有符合条件的记录检查应用与队列,或调整筛选条件。 |
`; return; }
body.innerHTML = items.map(item => {
let cells;
if (kind === 'tasks') cells = `${resourceButton(kind,item,shortID(item.id))}${escapeHTML(item.business_key || '未设置业务键')} | ${scopeBadge()} | ${escapeHTML(item.handler || '—')}${escapeHTML(item.handler_version || '')} | ${badge(item.state)} | ${escapeHTML(String(item.attempt_count ?? 0))} 次 | ${escapeHTML(formatTime(item.created_at))} | `;
if (kind === 'schedules') cells = `${resourceButton(kind,item,item.name)}${escapeHTML(shortID(item.id))} | ${scopeBadge()} | ${badge(item.paused ? 'PAUSED' : 'ENABLED')} | ${escapeHTML(String(item.current_version || '—'))} | ${escapeHTML(item.paused ? '已暂停' : formatTime(item.next_fire_at))} | `;
if (kind === 'workflows') cells = `${resourceButton(kind,item,item.business_key || shortID(item.id))}${escapeHTML(shortID(item.id))} | ${scopeBadge()} | ${badge(item.state)}${item.paused ? ' '+badge('PAUSED') : ''} | ${escapeHTML(item.version || '—')} | ${escapeHTML(formatTime(item.created_at))} | `;
if (kind === 'subscriptions') cells = `${resourceButton(kind,item,item.name)}${escapeHTML(shortID(item.id))} | ${scopeBadge()} | ${badge(item.retired_at ? 'RETIRED' : item.enabled === false ? 'PAUSED' : 'ENABLED')} | ${escapeHTML(String(item.pending_count ?? 0))} / ${escapeHTML(String(item.max_pending ?? '—'))} | ${escapeHTML(formatTime(item.created_at))} | `;
return `${cells}| ${detailButton(kind,item)} |
`;
}).join('');
}
function syncPager(kind) {
const page = lists[kind];
$(`${kind}-next`).disabled = page.loading || !page.next;
$(`${kind}-prev`).disabled = page.loading || !page.history.length;
$(`${kind}-first`).disabled = page.loading || !page.history.length;
text(`${kind}-page`, `第 ${page.history.length+1} 页`);
$(listBodies[kind]).closest('table').setAttribute('aria-busy',String(page.loading));
}
async function loadList(kind, background = false) {
const page = lists[kind], ticket = page.begin(); syncPager(kind);
$(`${kind}-error`).hidden = true;
if (!background) $(listBodies[kind]).innerHTML = `| 正在读取… |
`;
try {
const data = extractData(await request(core.listURL(kind,state.queue,page.filters,page.cursor,page.limit)));
if (!page.accept(ticket,data)) return;
renderList(kind,data.items || []);
text(`${kind}-summary`, `本页 ${data.items?.length || 0} 条${Object.keys(page.filters).length ? ' · 已应用筛选' : ''} · ${formatTime(new Date())}`);
} catch (error) {
if (!page.fail(ticket)) return;
const note = error.status === 403 ? '当前账号没有读取权限,请联系管理员。' : `${error.message}。原筛选与页码已保留,请刷新重试。`;
text(`${kind}-error`,note); $(`${kind}-error`).hidden = false;
$(listBodies[kind]).innerHTML = `| 本页读取失败 |
`;
text(`${kind}-summary`,'读取失败');
} finally { if (ticket === page.sequence) syncPager(kind); }
}
function renderOverviewLists(tasks, workflows, subscriptions) {
text('metric-tasks', tasks ? String(tasks.length) : '—');
text('metric-tasks-sub','当前队列 · 最近 20 条');
text('metric-workflows',workflows ? String(workflows.length) : '—');
text('metric-workflows-sub','当前应用 · 最近 20 条');
text('metric-subscriptions',subscriptions ? String(subscriptions.length) : '—');
text('metric-subscriptions-sub','当前队列 · 最近 20 条');
const total = tasks?.length || 0;
['succeeded','running','waiting','other'].forEach(name => {
const values = name === 'running' ? ['RUNNING','LEASED'] : name === 'other' ? ['FAILED','NEEDS_REVIEW','CANCELLED'] : [name.toUpperCase()];
$(`task-bar-${name}`).style.width = `${total ? tasks.filter(t=>values.includes(t.state)).length/total*100 : 0}%`;
});
$('subscription-meter-fill').style.width = `${subscriptions?.length ? subscriptions.filter(s=>s.enabled&&!s.retired_at).length/subscriptions.length*100 : 0}%`;
$('overview-task-list').innerHTML = tasks?.length ? tasks.slice(0,6).map(t=>`| ${resourceButton('tasks',t,shortID(t.id))}${escapeHTML(t.business_key || '未设置业务键')} | ${escapeHTML(t.handler)} | ${badge(t.state)} | ${escapeHTML(formatTime(t.created_at))} | ${detailButton('tasks',t)} |
`).join('') : `| ${tasks ? '当前队列还没有任务' : '任务暂时无法读取,请检查权限或稍后刷新'} |
`;
const attention = [];
const count = (items, value) => items?.filter(i=>i.state===value).length || 0;
const taskReview = count(tasks,'NEEDS_REVIEW'), failed = count(tasks,'FAILED'), flowReview = count(workflows,'NEEDS_REVIEW');
const pending = subscriptions?.reduce((n,item)=>n+(Number(item.pending_count)||0),0) || 0;
const add = (number,kind,title,note,state='') => { if(number) attention.push(``); };
add(taskReview,'tasks','任务等待核查','执行结果不明确,需要先核对证据','NEEDS_REVIEW');
add(failed,'tasks','任务执行失败','查看失败原因和执行历史','FAILED');
add(flowReview,'workflows','流程等待核查','查看流程步骤和恢复记录','NEEDS_REVIEW');
add(pending,'subscriptions','结果等待确认','检查消费者状态和交付历史');
$('attention-list').innerHTML = attention.join('') || `${tasks && workflows && subscriptions ? '当前快照暂无异常' : '部分数据暂不可用'}${tasks && workflows && subscriptions ? '最近记录中没有待核查任务、失败任务或未确认结果。' : '请根据页面提示检查连接和读取权限。'}
`;
}
async function loadOverview() {
const ticket = ++overviewSequence;
const kinds = ['tasks','schedules','workflows','subscriptions'];
const results = await Promise.allSettled(kinds.map(kind => !state.queue && kind!=='workflows' ? Promise.resolve({data:{items:[]}}) : request(core.listURL(kind,state.queue))));
if (ticket !== overviewSequence) return;
const items = results.map(result=>result.status==='fulfilled' ? extractItems(result.value) : null);
renderOverviewLists(items[0],items[2],items[3]); renderRuntimeStats(...items);
const failed = kinds.filter((_,i)=>results[i].status==='rejected');
if (currentView()==='overview') { $('refresh-error').hidden = !failed.length; text('refresh-error',failed.length ? `${failed.map(k=>pageNames[k]).join('、')}读取失败,其他统计仅基于已成功读取的数据。请检查权限或刷新重试。` : ''); }
}
function renderRuntimeStats(tasks, schedules, workflows, subscriptions) {
if (Array.isArray(tasks)) {
const counts = tasks.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {});
const total = tasks.length;
text('stat-success-rate', total ? `${Math.round((counts.succeeded || 0) / total * 100)}%` : '—');
text('stat-success-rate-sub', total ? `${counts.succeeded || 0} 成功 / ${total} 项` : '当前没有任务');
text('stat-task-total', `${total} 项`);
const waiting = (counts.waiting || 0) + (counts.pending || 0);
const other = (counts.failed || 0) + (counts.needs_review || 0);
const width = value => `${total ? value / total * 100 : 0}%`;
[['succeeded', counts.succeeded || 0], ['running', (counts.running || 0) + (counts.leased || 0)], ['waiting', waiting], ['other', other], ['cancelled',counts.cancelled || 0]].forEach(([name, value]) => { $(`stat-bar-${name}`).style.width = width(value); text(`stat-count-${name}`, String(value)); });
} else {
text('stat-success-rate', '—'); text('stat-success-rate-sub', '任务读取失败'); text('stat-task-total', '暂无数据');
['succeeded', 'running', 'waiting', 'other', 'cancelled'].forEach(name => { $(`stat-bar-${name}`).style.width = '0%'; text(`stat-count-${name}`, '—'); });
}
if (Array.isArray(schedules)) {
const active = schedules.filter(item => item.paused !== true).length;
text('stat-active-schedules', String(active)); text('stat-active-schedules-sub', `最近 ${schedules.length} 项计划 · ${schedules.length - active} 项暂停`);
} else { text('stat-active-schedules', '—'); text('stat-active-schedules-sub', '计划读取失败'); }
if (Array.isArray(workflows)) {
const review = workflows.filter(item => stateClass(item.state) === 'needs_review').length;
text('stat-review-workflows', String(review)); text('stat-review-workflows-sub', `最近 ${workflows.length} 项流程实例`);
} else { text('stat-review-workflows', '—'); text('stat-review-workflows-sub', '流程读取失败'); }
if (Array.isArray(subscriptions)) {
const pending = subscriptions.reduce((sum, item) => sum + (Number(item.pending_count) || 0), 0);
text('stat-pending-deliveries', String(pending)); text('stat-pending-deliveries-sub', `${subscriptions.length} 个订阅 · 待确认结果`);
} else { text('stat-pending-deliveries', '—'); text('stat-pending-deliveries-sub', '订阅读取失败'); }
text('stats-snapshot', state.connected ? `更新于 ${formatTime(new Date())}` : '等待连接');
}
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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); }
let drawerSequence = 0, drawerOpener = null;
function showDrawer(kind) {
const backdrop = $(`${kind}-drawer-backdrop`);
if (backdrop.hidden) drawerOpener = document.activeElement;
backdrop.hidden = false; document.body.classList.add('drawer-open');
document.querySelector('.app-shell').inert = true;
backdrop.querySelectorAll('.detail-grid b').forEach(el => { el.textContent = '—'; });
$(`close-${kind}-drawer`).focus();
return ++drawerSequence;
}
function hideDrawer(kind) {
const backdrop = $(`${kind}-drawer-backdrop`); if (backdrop.hidden) return;
drawerSequence++; backdrop.hidden = true; document.body.classList.remove('drawer-open');
document.querySelector('.app-shell').inert = false;
if (drawerOpener?.isConnected) drawerOpener.focus();
}
document.addEventListener('keydown', event => {
const drawer = document.querySelector('.drawer-backdrop:not([hidden])');
if (event.key !== 'Tab' || !drawer) return;
const focusable = [...drawer.querySelectorAll('button,input,select,textarea,a[href],summary')].filter(el=>!el.disabled && el.getClientRects().length);
const first = focusable[0], last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
});
function showTaskDrawer() { return showDrawer('task'); }
function closeTaskDrawer() { hideDrawer('task'); closeActionForm(); }
function renderDetailList(id, items, emptyText, renderItem) {
const el = $(id);
if (!items.length) { el.innerHTML = `${escapeHTML(emptyText)}
`; 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', label(task.state));
$('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 === undefined; if (task.result !== undefined) $('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 => `第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(label(item.state))}${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}
${item.retryable ? '可重试' : '不可重试'} `);
}
function renderOperations(items, error) {
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `${escapeHTML(label(item.action || '操作'))}${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}
${escapeHTML(shortID(item.id))} `);
}
async function openTaskDetail(id) {
if (!id) return;
if (!state.authenticated) { redirectToLogin(); return; }
const ticket = showTaskDrawer(); state.selectedTask = null; closeActionForm(); $('detail-result-block').hidden = true; text('detail-title', '正在读取任务…'); text('detail-id', id); text('detail-state', '读取中'); $('detail-actions').hidden = true;
$('detail-attempts').innerHTML = '正在读取尝试历史…
'; $('detail-operations').innerHTML = '正在读取操作审计…
';
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 (ticket !== drawerSequence) return;
if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); text('detail-state','不可用'); renderAttempts([],false); renderOperations([],false); 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() { return showDrawer('schedule'); }
function closeScheduleDrawer() { hideDrawer('schedule'); 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 => `${escapeHTML(label(item.decision || 'UNKNOWN'))} · ${escapeHTML(formatTime(item.scheduled_at))}${escapeHTML(label(item.reason || '—'))} · 版本 ${escapeHTML(item.schedule_version || '—')}
${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'} `);
}
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', schedule.paused ? `已暂停(原触发点 ${formatTime(schedule.next_fire_at)})` : 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', label(rule.kind)); $('schedule-detail-rule').innerHTML = `${escapeHTML(scheduleRuleLabel(rule))}漏跑 ${escapeHTML(label(definition.policy?.misfire || '—'))} · 重叠 ${escapeHTML(label(definition.policy?.overlap || '—'))} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}`;
$('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.authenticated) { redirectToLogin(); return; }
const ticket = showScheduleDrawer(); state.selectedSchedule = null; closeScheduleAction(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', id); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '正在读取触发记录…
';
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
if (ticket !== drawerSequence) return;
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); text('schedule-occurrences','无法读取触发记录'); 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() { return showDrawer('workflow'); }
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() { hideDrawer('workflow'); closeWorkflowAction(); closeWorkflowRecovery(); }
function showSubscriptionDrawer() { return showDrawer('subscription'); }
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() { hideDrawer('subscription'); 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 = '当前无法读取步骤,暂不提供恢复按钮。
'; 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 => ``).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', label(workflow.state)); $('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 `${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}${escapeHTML(label(item.state))} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}
${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'} `;
});
}
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 => `${escapeHTML(label(item.type || '状态事件'))} · ${escapeHTML(label(item.state || '—'))}${escapeHTML(label(item.state_scope || item.aggregate_kind || 'WORKFLOW'))} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}
修订 ${escapeHTML(item.aggregate_revision || '—')} `);
}
async function openWorkflowDetail(id) {
if (!id) return;
if (!state.authenticated) { redirectToLogin(); return; }
const ticket = showWorkflowDrawer(); state.selectedWorkflow = null; closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', id); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '正在读取步骤…
'; $('workflow-events').innerHTML = '正在读取事件…
';
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 (ticket !== drawerSequence) return;
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.retired_at ? '已退役' : 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.map(label).join('、') : '全部结果类型');
$('subscription-actions').hidden = !!subscription.retired_at; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', subscription.retired_at ? `退役于 ${formatTime(subscription.retired_at)},截止水位 ${subscription.retired_sequence};未来结果不再订阅,历史交付仍可查询。` : '订阅控制需要 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 => `${escapeHTML(label(item.state))} · 序号 ${escapeHTML(item.sequence || '—')}${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}
${String(item.state || '') === 'EXPIRED' ? `
` : `
${escapeHTML(shortID(item.event_id))}`}
`);
$('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)); }));
}
let subscriptionRetirement = null;
function openSubscriptionAction(mode) {
if (!state.selectedSubscription) return;
subscriptionRetirement = null;
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 ? '提交启用 →' : '提交暂停 →'); if (mode === 'RETIRE') { state.subscriptionActionMode = 'RETIRE'; text('subscription-action-title','永久退役订阅'); text('subscription-action-description','仅在历史结果全部确认后成功。退役不可恢复,后续结果不再订阅;历史交付和审计保留,活动名额释放。'); text('subscription-action-submit','确认退役 →'); } $('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 { const retire = state.subscriptionActionMode === 'RETIRE'; if (retire) subscriptionRetirement ||= {key:operationKey('subscription-retire'),body:{expected_revision:String(subscription.revision),reason}}; await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:${retire ? 'retire' : 'configure'}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retire ? subscriptionRetirement.key : operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify(retire ? subscriptionRetirement.body : { expected_revision: String(subscription.revision), enabled, reason }) }); showToast(retire ? '订阅已退役,历史保留' : 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.authenticated) { redirectToLogin(); return; }
const ticket = showSubscriptionDrawer(); state.selectedSubscription = null; closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', id); $('subscription-deliveries').innerHTML = '正在读取交付历史…
';
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
if (ticket !== drawerSequence) return;
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(background = false) {
const view = currentView(); $('refresh-button').classList.add('spinning');
try {
await loadHealth();
if (view !== currentView()) return;
$('refresh-error').hidden = true;
if (!state.authenticated || !state.scopeReady) return;
if (lists[view]) await loadList(view, background === true);
else if (view === 'overview') await loadOverview();
else if (view === 'applications') await window.orbitApplications.load({ background: background === true });
else if (view === 'retention') await loadRetention();
else if (view === 'archives') await loadArchives();
text('last-updated', `更新于 ${formatTime(new Date())}`);
} catch(error) { text('refresh-error',`服务暂不可用:${error.message}。请刷新重试。`); $('refresh-error').hidden=false; text('last-updated','刷新失败'); }
finally { $('refresh-button').classList.remove('spinning'); }
}
for (const [kind,page] of Object.entries(lists)) {
for (const direction of ['first','prev','next']) $(`${kind}-${direction}`).addEventListener('click',()=>{ if(page.move(direction)) loadList(kind); });
$(`${kind}-limit`).addEventListener('change',event=>{ page.limit=Number(event.target.value); page.reset(); loadList(kind); });
const form = $(`${kind}-filters`);
if(form) {
const more = form.querySelector('.filter-more');
more.addEventListener('click', () => { const dates = $(more.getAttribute('aria-controls')); dates.hidden = !dates.hidden; more.setAttribute('aria-expanded', String(!dates.hidden)); });
form.addEventListener('submit',event=>{ event.preventDefault(); try { page.reset(core.readFilters(Object.fromEntries(new FormData(form)))); loadList(kind); } catch(error) { text(`${kind}-error`,error.message); $(`${kind}-error`).hidden=false; } });
form.addEventListener('reset',()=>{ page.reset({}); loadList(kind); });
}
}
document.querySelectorAll('[data-lookup]').forEach(form=>form.addEventListener('submit',event=>{event.preventDefault(); const kind=form.dataset.lookup; detailOpeners[kind]($(`${kind}-lookup`).value.trim());}));
document.addEventListener('click',event=>{
const resource=event.target.closest('[data-resource-id]');
if(resource) detailOpeners[resource.dataset.resourceKind]?.(resource.dataset.resourceId);
const attention=event.target.closest('[data-attention]');
if(attention) { const kind=attention.dataset.attention, value=attention.dataset.filterState; lists[kind].reset(value ? {state:value} : {}); const form=$(`${kind}-filters`); if(form) { for(const input of form.querySelectorAll('input,select')) input.value=''; if(value) form.elements.state.value=value; window.OrbitSelect?.sync(form.elements.state); } setView(kind); }
const copy=event.target.closest('[data-copy-id]');
if(copy) { const value=$(copy.dataset.copyId)?.textContent; if(value && value!=='—') navigator.clipboard.writeText(value).then(()=>showToast('资源 ID 已复制')).catch(()=>showToast('复制失败,请选择 ID 手动复制')); }
});
document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); $('main-content').focus(); });
$('auto-refresh').addEventListener('change',()=>{ text('refresh-mode-label',$('auto-refresh').checked ? '每 10 秒自动刷新' : '自动刷新已暂停'); if($('auto-refresh').checked) refresh(); });
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
$('archive-plan-form').addEventListener('submit', saveArchivePlan);
$('archive-plan-form').addEventListener('input', () => { archiveUI.dirty = true; });
$('archive-kind').addEventListener('change', () => { if (archiveUI.pending) { $('archive-kind').value = archiveUI.editing.kind; showToast('请先保持原内容重试,确认上次操作'); return; } renderArchiveEditor(); $('archive-reason').value = ''; });
$('archive-refresh').addEventListener('click', () => { if (!archiveUI.busy && !archiveUI.pending) { archiveUI.dirty = false; text('archive-error', ''); loadArchives(); } });
$('archive-next').addEventListener('click', () => { if (archiveUI.loading || archiveUI.busy) return; archiveUI.before = archiveUI.next; loadArchives(); });
$('archive-first').addEventListener('click', () => { if (archiveUI.loading || archiveUI.busy) return; archiveUI.before = ''; loadArchives(); });
$('archive-job-list').addEventListener('click', event => { const button = event.target.closest('[data-archive-action]'); if (button) openArchiveAction(button.dataset.id, button.dataset.archiveAction); });
$('archive-action-form').addEventListener('submit', submitArchiveAction);
$('archive-action-cancel').addEventListener('click', () => { if (archiveUI.pending) { showToast('请先保持原内容重试,确认上次操作'); return; } $('archive-action-form').hidden = true; archiveUI.selected = null; });
$('retention-kind').addEventListener('change', resetRetentionExport);
$('retention-restart-button').addEventListener('click', resetRetentionExport);
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
$('logout-button').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
$('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-retire-button').addEventListener('click', () => openSubscriptionAction('RETIRE')); $('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(); });
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);
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(loadScope).then(() => refresh()); }).catch(() => redirectToLogin());
state.timer = setInterval(() => { if (state.authenticated && $('auto-refresh').checked && !document.hidden && !document.querySelector('.drawer-backdrop:not([hidden]), dialog[open]') && !lists[currentView()]?.loading && !window.OrbitSelect?.isOpen() && !document.activeElement?.matches('input,select,textarea') && !(currentView()==='applications' && document.querySelector('#access-detail details[open]'))) refresh(true); }, 10000);
})();