feat: add workflow and subscription detail views

This commit is contained in:
2026-09-21 01:59:32 +08:00
parent 9e3d74443e
commit c03f8ab3d5
5 changed files with 110 additions and 6 deletions

View File

@@ -38,7 +38,7 @@
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。 已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。
控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。 控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线;结果订阅详情已接入配置、积压窗口和交付历史,权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。
定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。 定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。

View File

@@ -14,6 +14,8 @@ http://127.0.0.1:8080/console/
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 - 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。 - 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。 - 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
- 点击流程实例查看当前权威状态、固定版本、步骤状态和事件时间线;步骤读取沿用 `workflows:read`,事件时间线没有 `workflows:audit:read` 时会明确显示权限提示。
- 点击结果订阅查看冻结配置、游标、积压窗口和最近交付历史;页面不展示租约秘密,也不绕过 `events:read` / `events:manage` 权限。
- 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。 - 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。 - 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。 - 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
@@ -32,4 +34,4 @@ http://127.0.0.1:8080/console/
控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。 控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划版本发布和交付重试等更高风险操作。 不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、订阅交付重试和独立管理身份仍应通过受保护 API/SDK 设计后再接入页面。

View File

@@ -1,5 +1,5 @@
(() => { (() => {
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null }; const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, selectedSubscription: null };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; }; const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' }; const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
@@ -91,14 +91,22 @@
function renderWorkflows(items) { function renderWorkflows(items) {
text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例'); text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; } const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join(''); el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
el.querySelectorAll('.workflow-row').forEach(row => {
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
});
} }
function renderSubscriptions(items) { function renderSubscriptions(items) {
text('metric-subscriptions', String(items.length)); 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}%`; 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} 个已启用 · 交付自动恢复` : '当前没有结果订阅'); text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; } const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join(''); el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
el.querySelectorAll('.subscription-row').forEach(row => {
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
});
} }
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); } function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function formatJSON(value) { function formatJSON(value) {
@@ -207,6 +215,57 @@
catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; } catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; }
finally { button.disabled = false; } finally { button.disabled = false; }
} }
function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
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';
}
function renderWorkflowSteps(payload, error) {
const items = Array.isArray(payload?.steps) ? payload.steps : [];
text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`);
renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => {
const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活');
return `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
});
}
function renderWorkflowEvents(payload, error) {
const items = Array.isArray(payload?.items) ? payload.items : [];
text('workflow-events-count', error ? '权限不足' : `${items.length} 条`);
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}</b><small>${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
}
async function openWorkflowDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; }
showWorkflowDrawer(); 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; }
renderWorkflowDetail(extractData(workflowResult.value));
renderWorkflowSteps(stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {}, stepsResult.status === 'rejected');
renderWorkflowEvents(eventsResult.status === 'fulfilled' ? extractData(eventsResult.value) : {}, eventsResult.status === 'rejected');
}
function renderSubscriptionDetail(subscription) {
state.selectedSubscription = subscription;
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
}
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><em>${escapeHTML(shortID(item.event_id))}</em></div>`);
}
async function openSubscriptionDetail(id) {
if (!id) return;
if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; }
showSubscriptionDrawer(); 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() { async function refresh() {
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning'); if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
try { try {
@@ -230,7 +289,7 @@
sessionStorage.setItem('orbit.console.queue', state.queue); refresh(); sessionStorage.setItem('orbit.console.queue', state.queue); refresh();
} }
$('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', () => $('connection-panel').scrollIntoView({ behavior: 'smooth', block: 'center' })); $('refresh-button').addEventListener('click', refresh); $('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', () => $('connection-panel').scrollIntoView({ behavior: 'smooth', block: 'center' })); $('refresh-button').addEventListener('click', refresh);
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); } }); $('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(); }); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; }); $('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; });
$('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); $('token').value = credential.token || ''; $('queue-id').value = credential.queue_id || ''; showToast('凭据已读取,只保留在当前页面内'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); }); $('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); $('token').value = credential.token || ''; $('queue-id').value = credential.queue_id || ''; showToast('凭据已读取,只保留在当前页面内'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); })); document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));

View File

@@ -178,6 +178,48 @@
</form> </form>
</aside> </aside>
</div> </div>
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
<div class="drawer-header">
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
</div>
<div class="drawer-status-line"><span class="state-pill" id="workflow-detail-state">—</span><span id="workflow-detail-updated">—</span></div>
<div class="detail-grid">
<div><span>定义</span><b id="workflow-detail-definition">—</b></div>
<div><span>固定版本</span><b id="workflow-detail-version">—</b></div>
<div><span>实例修订</span><b id="workflow-detail-revision">—</b></div>
<div><span>控制修订</span><b id="workflow-detail-control-revision">—</b></div>
<div><span>业务键</span><b id="workflow-detail-business-key">—</b></div>
<div><span>总截止时间</span><b id="workflow-detail-deadline">—</b></div>
</div>
<div class="detail-block"><div class="detail-block-heading"><span>步骤状态</span><small id="workflow-steps-count">加载中</small></div><div class="detail-list" id="workflow-steps"><div class="detail-loading">正在读取步骤…</div></div></div>
<div class="detail-block"><div class="detail-block-heading"><span>事件时间线</span><small id="workflow-events-count">加载中</small></div><div class="detail-list" id="workflow-events"><div class="detail-loading">正在读取事件…</div></div></div>
<div class="detail-block" id="workflow-output-block" hidden><div class="detail-block-heading"><span>流程输出</span><small>只读快照</small></div><pre id="workflow-output"></pre></div>
<p class="drawer-note" id="workflow-detail-note">流程详情只读取当前权威视图;事件审计需要 workflows:audit:read 权限,所有控制操作仍由服务端权限和并发版本校验。</p>
</aside>
</div>
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
<div class="drawer-header">
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
</div>
<div class="drawer-status-line"><span class="state-pill" id="subscription-detail-state">—</span><span id="subscription-detail-updated">—</span></div>
<div class="detail-grid">
<div><span>订阅名称</span><b id="subscription-detail-name">—</b></div>
<div><span>队列</span><b id="subscription-detail-queue">—</b></div>
<div><span>配置修订</span><b id="subscription-detail-revision">—</b></div>
<div><span>待确认交付</span><b id="subscription-detail-pending">—</b></div>
<div><span>未确认上限</span><b id="subscription-detail-max-pending">—</b></div>
<div><span>单条次数预算</span><b id="subscription-detail-max-attempts">—</b></div>
<div><span>游标序号</span><b id="subscription-detail-cursor">—</b></div>
<div><span>事件类型</span><b id="subscription-detail-types">—</b></div>
</div>
<div class="detail-block"><div class="detail-block-heading"><span>最近交付</span><small id="subscription-deliveries-count">加载中</small></div><div class="detail-list" id="subscription-deliveries"><div class="detail-loading">正在读取交付历史…</div></div></div>
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
</aside>
</div>
<div class="toast" id="toast" role="status"></div> <div class="toast" id="toast" role="status"></div>
<script src="/console/app.js"></script> <script src="/console/app.js"></script>
</body> </body>

View File

@@ -2,3 +2,4 @@
[hidden]{display:none!important}.task-row{cursor:pointer;transition:background .2s}.task-row:hover,.task-row:focus{outline:0;background:rgba(98,230,212,.055)}.task-row:focus td:first-child{box-shadow:inset 2px 0 0 var(--cyan)}.drawer-open{overflow:hidden}.drawer-backdrop{position:fixed;inset:0;z-index:20;background:rgba(2,8,16,.68);backdrop-filter:blur(8px)}.task-drawer{position:absolute;right:0;top:0;height:100%;width:min(570px,100%);overflow-y:auto;padding:29px 28px 34px;background:linear-gradient(165deg,#122943,#091828 70%);border-left:1px solid rgba(98,230,212,.18);box-shadow:-22px 0 70px rgba(0,0,0,.35);animation:drawer-in .24s ease-out}@keyframes drawer-in{from{transform:translateX(35px);opacity:.4}to{transform:translateX(0);opacity:1}}.drawer-header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.drawer-header h2{margin:0 0 8px;font-size:22px;letter-spacing:-.03em}.drawer-id{display:block;max-width:390px;color:#6f87a2;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.drawer-status-line{display:flex;align-items:center;gap:11px;margin:22px 0 19px;color:#748aa4;font-size:10px}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;border:1px solid var(--line);border-radius:15px;overflow:hidden;margin-bottom:18px}.detail-grid>div{padding:13px 14px;background:rgba(5,15,27,.34);min-width:0}.detail-grid span{display:block;color:#637b96;font-size:9px;margin-bottom:6px}.detail-grid b{display:block;color:#d4e2f1;font-size:11px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-block{border-top:1px solid rgba(156,183,218,.1);padding-top:19px;margin-top:19px}.detail-block-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;color:#b4c7db;font-size:12px}.detail-block-heading small{color:#617995;font-size:9px}.detail-list{display:grid;gap:0}.detail-list-row{position:relative;display:flex;align-items:center;gap:11px;padding:11px 0;border-bottom:1px solid rgba(156,183,218,.08)}.detail-list-row:last-child{border-bottom:0}.detail-list-row>div{min-width:0;flex:1}.detail-list-row b{display:block;color:#bdcfe1;font-size:10px;font-weight:500}.detail-list-row small{display:block;margin-top:4px;color:#6d829c;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-list-row em{font-style:normal;color:#778da7;font-size:9px}.timeline-dot{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:#657991;box-shadow:0 0 0 4px rgba(101,121,145,.1)}.timeline-dot.succeeded{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.running{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.failed,.timeline-dot.unknown{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.timeline-dot.audit{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.detail-loading,.detail-empty{padding:17px 0;color:#607895;font-size:10px}.detail-block pre{max-height:180px;overflow:auto;margin:0;padding:12px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,11,20,.48);color:#a7e9df;font:10px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word}.drawer-actions{display:flex;gap:10px;margin-top:23px;padding-top:19px;border-top:1px solid rgba(156,183,218,.1)}.danger-button{color:#ff9baa;border-color:rgba(255,120,140,.25);background:rgba(255,120,140,.07)}.danger-button:hover{color:#ffd5db;border-color:rgba(255,120,140,.5)}.drawer-note{color:#687f99;font-size:9px;line-height:1.7;margin:15px 0 0}.action-form{margin-top:21px;padding:19px;border:1px solid rgba(98,230,212,.17);border-radius:16px;background:rgba(4,14,25,.45)}.action-form-heading h3{margin:0;color:#d7e6f4;font-size:15px}.action-form p{color:#7890a9;font-size:10px;line-height:1.6;margin:10px 0 4px}.action-form textarea,.action-form input{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.action-form textarea:focus,.action-form input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.check-row{display:flex;gap:9px;align-items:flex-start;margin-top:15px;color:#9eb3c9;font-size:10px;line-height:1.5}.check-row input{accent-color:var(--cyan);margin-top:2px}.action-form-buttons{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}.action-error{min-height:15px;color:#ff9baa;font-size:10px;margin-top:9px}.action-form .quiet-button{padding:10px 14px}@media(max-width:780px){.task-drawer{width:100%;padding:24px 18px 28px}.detail-grid{grid-template-columns:1fr 1fr}} [hidden]{display:none!important}.task-row{cursor:pointer;transition:background .2s}.task-row:hover,.task-row:focus{outline:0;background:rgba(98,230,212,.055)}.task-row:focus td:first-child{box-shadow:inset 2px 0 0 var(--cyan)}.drawer-open{overflow:hidden}.drawer-backdrop{position:fixed;inset:0;z-index:20;background:rgba(2,8,16,.68);backdrop-filter:blur(8px)}.task-drawer{position:absolute;right:0;top:0;height:100%;width:min(570px,100%);overflow-y:auto;padding:29px 28px 34px;background:linear-gradient(165deg,#122943,#091828 70%);border-left:1px solid rgba(98,230,212,.18);box-shadow:-22px 0 70px rgba(0,0,0,.35);animation:drawer-in .24s ease-out}@keyframes drawer-in{from{transform:translateX(35px);opacity:.4}to{transform:translateX(0);opacity:1}}.drawer-header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.drawer-header h2{margin:0 0 8px;font-size:22px;letter-spacing:-.03em}.drawer-id{display:block;max-width:390px;color:#6f87a2;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.drawer-status-line{display:flex;align-items:center;gap:11px;margin:22px 0 19px;color:#748aa4;font-size:10px}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;border:1px solid var(--line);border-radius:15px;overflow:hidden;margin-bottom:18px}.detail-grid>div{padding:13px 14px;background:rgba(5,15,27,.34);min-width:0}.detail-grid span{display:block;color:#637b96;font-size:9px;margin-bottom:6px}.detail-grid b{display:block;color:#d4e2f1;font-size:11px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-block{border-top:1px solid rgba(156,183,218,.1);padding-top:19px;margin-top:19px}.detail-block-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;color:#b4c7db;font-size:12px}.detail-block-heading small{color:#617995;font-size:9px}.detail-list{display:grid;gap:0}.detail-list-row{position:relative;display:flex;align-items:center;gap:11px;padding:11px 0;border-bottom:1px solid rgba(156,183,218,.08)}.detail-list-row:last-child{border-bottom:0}.detail-list-row>div{min-width:0;flex:1}.detail-list-row b{display:block;color:#bdcfe1;font-size:10px;font-weight:500}.detail-list-row small{display:block;margin-top:4px;color:#6d829c;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-list-row em{font-style:normal;color:#778da7;font-size:9px}.timeline-dot{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:#657991;box-shadow:0 0 0 4px rgba(101,121,145,.1)}.timeline-dot.succeeded{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.running{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.failed,.timeline-dot.unknown{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.timeline-dot.audit{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.detail-loading,.detail-empty{padding:17px 0;color:#607895;font-size:10px}.detail-block pre{max-height:180px;overflow:auto;margin:0;padding:12px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,11,20,.48);color:#a7e9df;font:10px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word}.drawer-actions{display:flex;gap:10px;margin-top:23px;padding-top:19px;border-top:1px solid rgba(156,183,218,.1)}.danger-button{color:#ff9baa;border-color:rgba(255,120,140,.25);background:rgba(255,120,140,.07)}.danger-button:hover{color:#ffd5db;border-color:rgba(255,120,140,.5)}.drawer-note{color:#687f99;font-size:9px;line-height:1.7;margin:15px 0 0}.action-form{margin-top:21px;padding:19px;border:1px solid rgba(98,230,212,.17);border-radius:16px;background:rgba(4,14,25,.45)}.action-form-heading h3{margin:0;color:#d7e6f4;font-size:15px}.action-form p{color:#7890a9;font-size:10px;line-height:1.6;margin:10px 0 4px}.action-form textarea,.action-form input{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.action-form textarea:focus,.action-form input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.check-row{display:flex;gap:9px;align-items:flex-start;margin-top:15px;color:#9eb3c9;font-size:10px;line-height:1.5}.check-row input{accent-color:var(--cyan);margin-top:2px}.action-form-buttons{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}.action-error{min-height:15px;color:#ff9baa;font-size:10px;margin-top:9px}.action-form .quiet-button{padding:10px 14px}@media(max-width:780px){.task-drawer{width:100%;padding:24px 18px 28px}.detail-grid{grid-template-columns:1fr 1fr}}
.schedule-row{cursor:pointer;transition:background .2s;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px}.schedule-row:hover,.schedule-row:focus{outline:0;background:rgba(98,230,212,.055)}.schedule-rule-card{padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:12px;background:rgba(4,12,23,.38)}.schedule-rule-card b{display:block;color:#c8e5e6;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-word}.schedule-rule-card small{display:block;margin-top:7px;color:#7189a1;font-size:9px}.timeline-dot.fired{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.coalesced{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.timeline-dot.skipped{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)} .schedule-row{cursor:pointer;transition:background .2s;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px}.schedule-row:hover,.schedule-row:focus{outline:0;background:rgba(98,230,212,.055)}.schedule-rule-card{padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:12px;background:rgba(4,12,23,.38)}.schedule-rule-card b{display:block;color:#c8e5e6;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-word}.schedule-rule-card small{display:block;margin-top:7px;color:#7189a1;font-size:9px}.timeline-dot.fired{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.coalesced{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.timeline-dot.skipped{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)}
.page-view{display:none}.page-view.active{display:block}.content-grid.single-column{grid-template-columns:1fr}.page-view>.content-grid{min-height:360px}.page-view>.content-grid>.panel{min-height:330px}.page-view>.content-grid>.wide-panel{min-height:420px} .page-view{display:none}.page-view.active{display:block}.content-grid.single-column{grid-template-columns:1fr}.page-view>.content-grid{min-height:360px}.page-view>.content-grid>.panel{min-height:330px}.page-view>.content-grid>.wide-panel{min-height:420px}
.workflow-row,.subscription-row{cursor:pointer;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px;transition:background .2s}.workflow-row:hover,.workflow-row:focus,.subscription-row:hover,.subscription-row:focus{outline:0;background:rgba(98,230,212,.055)}.workflow-row:focus,.subscription-row:focus{box-shadow:inset 2px 0 0 var(--cyan)}.timeline-dot.leased{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.acked{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.expired{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)}.timeline-dot.cancelled{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.workflow-drawer,.subscription-drawer{width:min(640px,100%)}