feat(console): simplify workspace and add filtered resource pagination

This commit is contained in:
2026-09-29 22:31:19 +08:00
parent 6138c103d5
commit f7409afd24
14 changed files with 666 additions and 306 deletions

View File

@@ -2,6 +2,10 @@
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) {
@@ -23,21 +27,24 @@
}
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));
text('page-title', pageNames[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) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
window.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}`;
@@ -119,7 +126,7 @@
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;
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];
@@ -140,7 +147,7 @@
$('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>';
$('task-table').innerHTML = '<tr><td colspan="7" class="empty-row"><span class="empty-icon">◇</span><b>当前范围未加载</b><small>请检查应用范围,或点击“重置范围”</small></td></tr>';
text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`);
}
}
@@ -401,65 +408,103 @@
$('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.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 `<span class="state-pill ${stateClass(value)}" title="${escapeHTML(value)}">${escapeHTML(label(value))}</span>`; }
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 `<button class="text-button resource-name" data-resource-kind="${kind}" data-resource-id="${escapeHTML(item.id)}" title="${escapeHTML(item.id)}">${escapeHTML(name || shortID(item.id))}</button>`; }
function detailButton(kind, item) { return resourceButton(kind, item, '详情 →'); }
function renderList(kind, items) {
const body = $(listBodies[kind]);
if (!items.length) { body.innerHTML = `<tr><td colspan="${listColumns[kind]}" class="empty-row"><span class="empty-icon">○</span><b>没有符合条件的记录</b><small>检查应用与队列,或调整筛选条件。</small></td></tr>`; return; }
body.innerHTML = items.map(item => {
let cells;
if (kind === 'tasks') cells = `<td><div class="task-id">${resourceButton(kind,item,shortID(item.id))}<small title="${escapeHTML(item.business_key || '')}">${escapeHTML(item.business_key || '未设置业务键')}</small></div></td><td>${scopeBadge()}</td><td class="task-processor">${escapeHTML(item.handler || '—')}<small>${escapeHTML(item.handler_version || '')}</small></td><td>${badge(item.state)}</td><td>${escapeHTML(String(item.attempt_count ?? 0))} 次</td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
if (kind === 'schedules') cells = `<td>${resourceButton(kind,item,item.name)}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.paused ? 'PAUSED' : 'ENABLED')}</td><td>${escapeHTML(String(item.current_version || '—'))}</td><td>${escapeHTML(item.paused ? '已暂停' : formatTime(item.next_fire_at))}</td>`;
if (kind === 'workflows') cells = `<td>${resourceButton(kind,item,item.business_key || shortID(item.id))}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.state)}${item.paused ? ' '+badge('PAUSED') : ''}</td><td>${escapeHTML(item.version || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
if (kind === 'subscriptions') cells = `<td>${resourceButton(kind,item,item.name)}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.retired_at ? 'RETIRED' : item.enabled === false ? 'PAUSED' : 'ENABLED')}</td><td>${escapeHTML(String(item.pending_count ?? 0))} <span class="muted">/ ${escapeHTML(String(item.max_pending ?? '—'))}</span></td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
return `<tr class="resource-row">${cells}<td>${detailButton(kind,item)}</td></tr>`;
}).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 = `<tr><td colspan="${listColumns[kind]}" class="empty-row">正在读取…</td></tr>`;
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;
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) {
text('metric-health', '离线'); text('metric-health-sub', error.message); setConnection(false); throw error;
}
if (!page.fail(ticket)) return;
const note = error.status === 403 ? '当前账号没有读取权限,请联系管理员。' : `${error.message}。原筛选与页码已保留,请刷新重试。`;
text(`${kind}-error`,note); $(`${kind}-error`).hidden = false;
$(listBodies[kind]).innerHTML = `<tr><td colspan="${listColumns[kind]}" class="empty-row">本页读取失败</td></tr>`;
text(`${kind}-summary`,'读取失败');
} finally { if (ticket === page.sequence) syncPager(kind); }
}
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 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=>`<tr><td>${resourceButton('tasks',t,shortID(t.id))}<small>${escapeHTML(t.business_key || '未设置业务键')}</small></td><td class="task-processor">${escapeHTML(t.handler)}</td><td>${badge(t.state)}</td><td>${escapeHTML(formatTime(t.created_at))}</td><td>${detailButton('tasks',t)}</td></tr>`).join('') : `<tr><td colspan="5" class="empty-row">${tasks ? '当前队列还没有任务' : '任务暂时无法读取,请检查权限或稍后刷新'}</td></tr>`;
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(`<button class="attention-item" data-attention="${kind}" data-filter-state="${state}"><span class="attention-number">${number}</span><span><strong>${title}</strong><small>${note}</small></span><span>→</span></button>`); };
add(taskReview,'tasks','任务等待核查','执行结果不明确,需要先核对证据','NEEDS_REVIEW');
add(failed,'tasks','任务执行失败','查看失败原因和执行历史','FAILED');
add(flowReview,'workflows','流程等待核查','查看流程步骤和恢复记录','NEEDS_REVIEW');
add(pending,'subscriptions','结果等待确认','检查消费者状态和交付历史');
$('attention-list').innerHTML = attention.join('') || `<div class="attention-clear"><strong>${tasks && workflows && subscriptions ? '当前快照暂无异常' : '部分数据暂不可用'}</strong>${tasks && workflows && subscriptions ? '最近记录中没有待核查任务、失败任务或未确认结果。' : '请根据页面提示检查连接和读取权限。'}</div>`;
}
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); } });
});
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)) {
@@ -469,25 +514,25 @@
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 other = (counts.failed || 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)); });
[['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'].forEach(name => { $(`stat-bar-${name}`).style.width = '0%'; text(`stat-count-${name}`, '—'); });
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', '暂无计划权限'); }
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', '暂无流程权限'); }
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', '暂无事件权限'); }
} 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])); }
@@ -500,8 +545,32 @@
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(); }
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 = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
@@ -509,12 +578,12 @@
}
function renderTaskDetail(task) {
state.selectedTask = task;
text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN');
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-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 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;
@@ -522,19 +591,20 @@
}
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>`);
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(label(item.state))}</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>`);
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(label(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;
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 = '<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; }
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');
@@ -562,27 +632,28 @@
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 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 => `<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>`);
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(label(item.decision || 'UNKNOWN'))} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(label(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>`;
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 = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(label(definition.policy?.misfire || '—'))} · 重叠 ${escapeHTML(label(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 ticket = showScheduleDrawer(); state.selectedSchedule = null; closeScheduleAction(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', 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; }
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() {
@@ -597,14 +668,14 @@
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 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() { $('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 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() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeSubscriptionAction(); closeSubscriptionRetry(); }
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 = '';
@@ -699,7 +770,7 @@
}
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-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 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。');
@@ -709,19 +780,20 @@
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>`;
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(label(item.state))} · ${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>`);
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(label(item.type || '状态事件'))} · ${escapeHTML(label(item.state || '—'))}</b><small>${escapeHTML(label(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 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 = '<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 (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);
@@ -732,13 +804,13 @@
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(', ') : '全部结果类型');
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 => `<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>`);
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(label(item.state))} · 序号 ${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;
@@ -772,36 +844,48 @@
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 ticket = showSubscriptionDrawer(); state.selectedSubscription = null; closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', 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 (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() {
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
async function refresh(background = false) {
const view = currentView(); $('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'); }
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();
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) {
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; } 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; });
@@ -815,13 +899,13 @@
$('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);
$('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(() => 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);
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 && !document.activeElement?.matches('input,select,textarea') && !(currentView()==='applications' && document.querySelector('#access-detail details[open]'))) refresh(true); }, 10000);
})();