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

828 lines
91 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: '', 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 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';
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));
text('page-title', pageNames[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) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
if (next === 'retention' && state.scopeReady) loadRetention();
if (next === 'archives' && state.scopeReady) loadArchives();
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
}
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 `<span class="application-badge" title="${escapeHTML(e.application + ' / ' + e.environment + ' / ' + e.namespace)}"><i></i><b>${escapeHTML(e.application)}</b><em>${escapeHTML(e.environment)}</em></span>`;
}
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 ? ' · 已暂停' : ''}`;
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 }); 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 = '<tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>当前范围未加载</b><small>请检查应用范围,或点击“重置范围”</small></td></tr>';
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 = `<div class="retention-preview-head"><b>候选预览 · 版本 ${escapeHTML(String(preview.policy?.revision || '0'))}</b><small>每类最多 ${escapeHTML(String(preview.sample_limit))} 条样本,不是全库总数</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))}${item.more ? '+' : ''} 条</b><small>截止 ${escapeHTML(retentionCutoff(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).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 => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div>${scopeBadge()}<code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
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.querySelector('.status-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() {
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="6" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
body.innerHTML = items.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>${scopeBadge()}</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>当前应用与队列还没有创建计划</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-application">${scopeBadge()}</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>当前应用环境还没有启动流程</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-application">${scopeBadge()}</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>当前应用与队列还没有创建订阅</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-application">${scopeBadge()}</div><div class="list-meta">${item.retired_at ? '已退役' : 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 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.cancelled || 0) + (counts.needs_review || 0);
const width = value => `${total ? value / total * 100 : 0}%`;
[['succeeded', counts.succeeded || 0], ['running', counts.running || 0], ['waiting', waiting], ['other', other]].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'].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 => ({ '&': '&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.authenticated) { redirectToLogin(); 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.authenticated) { redirectToLogin(); 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.authenticated) { redirectToLogin(); 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.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.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 => `<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)); }));
}
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; }
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 (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; }
if (!state.authenticated || !state.scopeReady) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
const tasks = state.queue ? await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : { items: [] }; const taskItems = extractItems(tasks); renderTasks(taskItems);
const optional = await Promise.allSettled([
state.queue ? request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }),
request('/v1/workflows?limit=20'),
state.queue ? request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }),
]);
const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null;
const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
renderSchedules(scheduleItems || []);
if (!scheduleItems) $('schedule-list').textContent = '定时计划读取失败,请刷新重试或检查权限';
renderWorkflows(workflowItems || []);
if (!workflowItems) { $('workflow-list').textContent = '流程实例读取失败,请刷新重试或检查权限'; text('metric-workflows', '—'); text('metric-workflows-sub', '读取失败'); }
renderSubscriptions(subscriptionItems || []);
if (!subscriptionItems) { $('subscription-list').textContent = '结果订阅读取失败,请刷新重试或检查权限'; text('metric-subscriptions', '—'); text('metric-subscriptions-sub', '读取失败'); }
setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); 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'); }
}
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-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').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(() => Promise.all([state.scopeReady ? loadRetention() : Promise.resolve(), refresh(), state.scopeReady && currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (state.scopeReady && currentView() === 'archives') loadArchives(); } }, 10000);
})();