feat: connect task controls to Orbit console
This commit is contained in:
@@ -36,7 +36,9 @@
|
|||||||
|
|
||||||
### 控制台第一版
|
### 控制台第一版
|
||||||
|
|
||||||
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份、跨命名空间聚合及写操作在管理 API 与权限模型明确后再接入。
|
已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。
|
||||||
|
|
||||||
|
控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情、计划控制和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。
|
||||||
|
|
||||||
## 2. 持续推进方式
|
## 2. 持续推进方式
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ http://127.0.0.1:8080/console/
|
|||||||
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
||||||
- 通过用户选择的本地凭据 JSON 或手工 Token/队列 ID 调用现有授权 API。
|
- 通过用户选择的本地凭据 JSON 或手工 Token/队列 ID 调用现有授权 API。
|
||||||
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
||||||
|
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
||||||
|
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
||||||
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
|
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
|
||||||
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
||||||
|
|
||||||
@@ -26,6 +28,6 @@ http://127.0.0.1:8080/console/
|
|||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
当前版本是管理查询控制台,不提供独立管理员账号、SSO、跨命名空间聚合或直接操作数据库。所有任务/计划/流程/订阅读取仍经过现有 Bearer Token、动作和队列范围校验;敏感凭据不进入页面源码。
|
控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
||||||
|
|
||||||
下一阶段增加写操作前,应先补独立的管理身份与权限模型,再接入取消、重试、暂停、恢复、交付重试和审计操作。不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。
|
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划控制和交付重试等更高风险操作。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false };
|
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '' };
|
||||||
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 sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
|
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
function shortID(id) { return id && id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : (id || '—'); }
|
function shortID(id) { return id && id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : (id || '—'); }
|
||||||
function stateClass(value) { return String(value || '').toLowerCase(); }
|
function stateClass(value) { return String(value || '').toLowerCase(); }
|
||||||
function extractItems(payload) { return payload?.data?.items || payload?.items || []; }
|
function extractItems(payload) { return payload?.data?.items || payload?.items || []; }
|
||||||
|
function extractData(payload) { return payload?.data ?? payload ?? {}; }
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
||||||
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
||||||
@@ -59,7 +60,11 @@
|
|||||||
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
|
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
|
||||||
const body = $('task-table');
|
const body = $('task-table');
|
||||||
if (!items.length) { body.innerHTML = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
if (!items.length) { body.innerHTML = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
||||||
body.innerHTML = items.slice(0, 8).map(item => `<tr><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
body.innerHTML = items.slice(0, 8).map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
||||||
|
body.querySelectorAll('.task-row').forEach(row => {
|
||||||
|
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
|
||||||
|
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function renderSchedules(items) {
|
function renderSchedules(items) {
|
||||||
text('metric-schedules', String(items.length));
|
text('metric-schedules', String(items.length));
|
||||||
@@ -79,6 +84,77 @@
|
|||||||
el.innerHTML = items.slice(0, 4).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.slice(0, 4).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('');
|
||||||
}
|
}
|
||||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||||
|
function formatJSON(value) {
|
||||||
|
if (value === undefined || value === null || value === '') return '';
|
||||||
|
try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2); } catch { return String(value); }
|
||||||
|
}
|
||||||
|
function operationKey(prefix) {
|
||||||
|
const id = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
return `console-${prefix}-${id}`.slice(0, 128);
|
||||||
|
}
|
||||||
|
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
||||||
|
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||||
|
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
|
||||||
|
function renderDetailList(id, items, emptyText, renderItem) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!items.length) { el.innerHTML = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
|
||||||
|
el.innerHTML = items.map(renderItem).join('');
|
||||||
|
}
|
||||||
|
function renderTaskDetail(task) {
|
||||||
|
state.selectedTask = task;
|
||||||
|
text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN');
|
||||||
|
$('detail-state').className = `state-pill ${stateClass(task.state)}`;
|
||||||
|
text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`);
|
||||||
|
text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '待核查' : '已确认');
|
||||||
|
text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at));
|
||||||
|
const resultBlock = $('detail-result-block'); resultBlock.hidden = !task.result; if (task.result) $('detail-result').textContent = formatJSON(task.result);
|
||||||
|
const canCancel = !terminalState(task.state) && !task.cancel_requested_at;
|
||||||
|
const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain;
|
||||||
|
$('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry;
|
||||||
|
text('detail-action-note', task.state === 'NEEDS_REVIEW' ? '此任务需要先完成带证据的人工核查,确认旧执行已经结清后再决定最终状态。' : '写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。');
|
||||||
|
}
|
||||||
|
function renderAttempts(items, error) {
|
||||||
|
text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
|
||||||
|
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
|
||||||
|
}
|
||||||
|
function renderOperations(items, error) {
|
||||||
|
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
|
||||||
|
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.action || '操作')}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
|
||||||
|
}
|
||||||
|
async function openTaskDetail(id) {
|
||||||
|
if (!id) return;
|
||||||
|
if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; }
|
||||||
|
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
||||||
|
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
||||||
|
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
||||||
|
if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); return; }
|
||||||
|
const task = extractData(taskResult.value); renderTaskDetail(task);
|
||||||
|
renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected');
|
||||||
|
renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected');
|
||||||
|
}
|
||||||
|
function closeActionForm() { state.actionMode = ''; $('task-action-form').hidden = true; $('action-error').textContent = ''; }
|
||||||
|
function openActionForm(mode) {
|
||||||
|
if (!state.selectedTask) return;
|
||||||
|
state.actionMode = mode; $('task-action-form').hidden = false; $('action-error').textContent = ''; $('action-reason').value = ''; $('action-evidence').value = `evidence:console-${Date.now()}`; $('action-safe').checked = false;
|
||||||
|
const retry = mode === 'retry'; text('action-kicker', retry ? 'TASK RECOVERY' : 'TASK CONTROL'); text('action-title', retry ? '安全重试任务' : '取消任务'); text('action-description', retry ? '只有确认旧执行不会继续产生外部效果时,才能创建新的关联任务。' : '取消会记录持久化取消意图;运行中的处理器仍需上报结果或等待恢复。');
|
||||||
|
$('action-evidence-wrap').hidden = !retry; $('action-timeout-wrap').hidden = !retry; $('action-safe-wrap').hidden = !retry; $('action-submit').textContent = retry ? '创建重试任务 →' : '提交取消 →'; $('action-reason').focus();
|
||||||
|
}
|
||||||
|
async function submitAction(event) {
|
||||||
|
event.preventDefault(); if (!state.selectedTask || !state.actionMode) return;
|
||||||
|
const reason = $('action-reason').value.trim(); if (!reason) { $('action-error').textContent = '请填写操作原因。'; return; }
|
||||||
|
const task = state.selectedTask; const retry = state.actionMode === 'retry'; const body = { expected_revision: String(task.revision), reason };
|
||||||
|
const path = `/v1/tasks/${encodeURIComponent(task.id)}:${retry ? 'retry' : 'cancel'}`;
|
||||||
|
if (retry) {
|
||||||
|
const timeout = Number($('action-timeout').value); if (!$('action-safe').checked) { $('action-error').textContent = '请先确认安全重试声明。'; return; }
|
||||||
|
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2592000000) { $('action-error').textContent = '任务总时限必须是 1 到 2592000000 毫秒。'; return; }
|
||||||
|
const evidence = $('action-evidence').value.trim(); if (!evidence) { $('action-error').textContent = '请填写证据引用。'; return; }
|
||||||
|
Object.assign(body, { evidence_ref: evidence, safe_to_retry: true, task_timeout_ms: timeout });
|
||||||
|
}
|
||||||
|
const button = $('action-submit'); button.disabled = true; $('action-error').textContent = '';
|
||||||
|
try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(retry ? 'retry' : 'cancel') }, body: JSON.stringify(body) }); showToast(retry ? '已创建关联重试任务' : '取消意图已提交'); closeActionForm(); await refresh(); await openTaskDetail(task.id); if (response?.data?.task?.id && retry) showToast(`重试任务:${shortID(response.data.task.id)}`); }
|
||||||
|
catch (error) { $('action-error').textContent = error.message || '操作失败'; }
|
||||||
|
finally { button.disabled = false; }
|
||||||
|
}
|
||||||
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 {
|
||||||
@@ -102,6 +178,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')); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); } });
|
||||||
$('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-scroll]').forEach(button => button.addEventListener('click', () => { const target = $(button.dataset.scroll); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.scroll === button.dataset.scroll)); }));
|
document.querySelectorAll('[data-scroll]').forEach(button => button.addEventListener('click', () => { const target = $(button.dataset.scroll); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.scroll === button.dataset.scroll)); }));
|
||||||
|
|||||||
@@ -96,6 +96,41 @@
|
|||||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||||
|
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
|
||||||
|
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-status-line"><span class="state-pill" id="detail-state">—</span><span id="detail-updated">—</span></div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div><span>处理器</span><b id="detail-handler">—</b></div>
|
||||||
|
<div><span>修订</span><b id="detail-revision">—</b></div>
|
||||||
|
<div><span>执行尝试</span><b id="detail-attempt-count">—</b></div>
|
||||||
|
<div><span>效果状态</span><b id="detail-effect">—</b></div>
|
||||||
|
<div><span>创建时间</span><b id="detail-created">—</b></div>
|
||||||
|
<div><span>截止时间</span><b id="detail-deadline">—</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block" id="detail-result-block" hidden><div class="detail-block-heading"><span>最终结果</span><small>只读快照</small></div><pre id="detail-result"></pre></div>
|
||||||
|
<div class="detail-block"><div class="detail-block-heading"><span>执行时间线</span><small id="detail-attempts-count">加载中</small></div><div class="detail-list" id="detail-attempts"><div class="detail-loading">正在读取尝试历史…</div></div></div>
|
||||||
|
<div class="detail-block"><div class="detail-block-heading"><span>操作审计</span><small id="detail-operations-count">加载中</small></div><div class="detail-list" id="detail-operations"><div class="detail-loading">正在读取操作审计…</div></div></div>
|
||||||
|
<div class="drawer-actions" id="detail-actions" hidden>
|
||||||
|
<button class="quiet-button danger-button" id="task-cancel-button" type="button">取消任务</button>
|
||||||
|
<button class="primary-button compact" id="task-retry-button" type="button">安全重试 <span>→</span></button>
|
||||||
|
</div>
|
||||||
|
<p class="drawer-note" id="detail-action-note">写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。</p>
|
||||||
|
<form class="action-form" id="task-action-form" hidden>
|
||||||
|
<div class="action-form-heading"><span class="panel-kicker" id="action-kicker">TASK CONTROL</span><h3 id="action-title">任务操作</h3></div>
|
||||||
|
<p id="action-description"></p>
|
||||||
|
<label class="field-label" for="action-reason">操作原因</label><textarea id="action-reason" rows="3" maxlength="1024" placeholder="说明本次操作的原因"></textarea>
|
||||||
|
<div id="action-evidence-wrap"><label class="field-label" for="action-evidence">证据引用</label><input id="action-evidence" type="text" maxlength="512" placeholder="例如 evidence:reconcile-001"></div>
|
||||||
|
<div id="action-timeout-wrap"><label class="field-label" for="action-timeout">新任务总时限(毫秒)</label><input id="action-timeout" type="number" min="1" max="2592000000" step="1" value="60000"></div>
|
||||||
|
<label class="check-row" id="action-safe-wrap"><input id="action-safe" type="checkbox"><span>我确认旧执行已经结束,按原业务身份重新执行是安全的。</span></label>
|
||||||
|
<div class="action-form-buttons"><button class="quiet-button" id="action-cancel" type="button">返回</button><button class="primary-button compact" id="action-submit" type="submit">提交操作 <span>→</span></button></div>
|
||||||
|
<div class="action-error" id="action-error" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user