feat: schedule persistent archive jobs with console management
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, retention: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略' };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业' };
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
@@ -29,6 +29,7 @@
|
||||
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (next === 'retention' && state.authenticated) loadRetention();
|
||||
if (next === 'archives' && state.authenticated) loadArchives();
|
||||
}
|
||||
function redirectToLogin() {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
@@ -46,7 +47,7 @@
|
||||
const raw = await response.text(); let body = {};
|
||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
|
||||
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
||||
return body;
|
||||
}
|
||||
async function loadConsoleConfig() {
|
||||
@@ -186,6 +187,96 @@
|
||||
} catch (error) { text('retention-error', error.message || '归档快照导出失败'); }
|
||||
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||
}
|
||||
const archiveUI = { busy: false, loading: false, dirty: false, plans: [], jobs: [], configured: false, policy: null, editing: null, editPolicy: '0', before: '', next: '', pending: null, selected: null };
|
||||
const archiveLabels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
|
||||
const archiveStates = { PENDING: '等待执行', RUNNING: '执行中', SUCCEEDED: '已完成', FAILED: '待处理', ABANDONED: '已关闭' };
|
||||
const archiveErrors = { FILE_CHECK_FAILED: '文件检查未通过,请核查持久卷、原目录与文件完整性。', STORAGE_LIMIT: '归档容量达到上限,请核查单作业与归档卷容量。', STORAGE_BUSY: '文件正被其他执行器使用,确认旧进程退出后可重试。', POLICY_CHANGED: '保留策略已变更或关闭。原作业不能替换策略版本,可关闭后重新配置周期。', PERMISSION_DENIED: '所属应用或命名空间已停用。', EXPORT_FAILED: '数据库导出未完成,确认数据库恢复后可重试。', INTERRUPTED: '执行中断,核查后可从原作业续跑。' };
|
||||
function syncArchiveControls() {
|
||||
document.querySelectorAll('#archive-plan-form input, #archive-plan-form select, #archive-plan-form button, #archive-action-form input, #archive-action-form button, [data-archive-action]').forEach(el => { el.disabled = archiveUI.busy; });
|
||||
$('archive-plan-save').disabled = archiveUI.busy || !archiveUI.editing || archiveUI.editPolicy === '0';
|
||||
$('archive-action-budget').disabled = archiveUI.busy || archiveUI.selected?.action === 'abandon';
|
||||
$('archive-next').disabled = archiveUI.busy || archiveUI.loading || !archiveUI.next;
|
||||
$('archive-first').disabled = archiveUI.busy || archiveUI.loading || !archiveUI.before;
|
||||
}
|
||||
function renderArchiveEditor() {
|
||||
const p = archiveUI.plans.find(p => p.kind === $('archive-kind').value); if (!p) return;
|
||||
archiveUI.editing = p; archiveUI.editPolicy = String(archiveUI.policy?.revision || '0'); archiveUI.dirty = false;
|
||||
$('archive-enabled').checked = p.enabled; $('archive-interval').value = p.interval_seconds / 3600;
|
||||
$('archive-batch').value = p.batch_size; $('archive-budget').value = p.max_bytes / 1048576;
|
||||
text('archive-plan-revision', `计划版本 ${p.revision} · 保留策略 ${archiveUI.editPolicy}`);
|
||||
text('archive-next-run', p.active_job_id ? `当前作业 ${shortID(p.active_job_id)} · 完成或关闭后继续周期` : `下次执行 ${p.enabled ? formatTime(p.next_run_at) : '尚未开启'}`);
|
||||
const stale = p.enabled && String(p.policy_revision) !== archiveUI.editPolicy;
|
||||
text('archive-policy-note', !archiveUI.policy?.enabled ? '请先在“保留策略”中允许只读归档。' : stale ? '保留策略已变更,重新保存本计划后才会触发新作业。' : '每个作业固定创建时的策略版本。');
|
||||
text('archive-plan-hint', archiveUI.configured ? '关闭周期只停止新作业,已有作业继续结清。失败作业需先处理,后续周期才会继续。' : '当前节点尚未配置归档持久卷,可查看历史或关闭已保存的周期。');
|
||||
syncArchiveControls();
|
||||
}
|
||||
function archiveSize(bytes) { return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1048576).toFixed(2)} MiB`; }
|
||||
function renderArchiveJobs() {
|
||||
const jobs = archiveUI.jobs;
|
||||
text('archive-job-count', `${jobs.length} 项`);
|
||||
text('archive-enabled-count', String(archiveUI.plans.filter(p => p.enabled).length));
|
||||
text('archive-failed-count', String(jobs.filter(j => j.state === 'FAILED').length));
|
||||
text('archive-root-count', jobs.reduce((sum, j) => sum + j.roots, 0).toLocaleString('zh-CN'));
|
||||
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
|
||||
$('archive-executor').classList.toggle('ready', archiveUI.configured);
|
||||
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
|
||||
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div><code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
|
||||
syncArchiveControls();
|
||||
}
|
||||
async function loadArchives() {
|
||||
if (archiveUI.loading || archiveUI.busy) return; archiveUI.loading = true;
|
||||
try {
|
||||
const [plans, policy, jobs] = await Promise.all([request('/console/archive/plans'), request('/console/retention'), request(`/console/archive/jobs${archiveUI.before ? `?before=${encodeURIComponent(archiveUI.before)}` : ''}`)]);
|
||||
const config = extractData(plans), page = extractData(jobs);
|
||||
archiveUI.plans = config.items || []; archiveUI.configured = config.executor_configured;
|
||||
archiveUI.policy = extractData(policy); archiveUI.jobs = page.items || []; archiveUI.next = page.next_before || '';
|
||||
if (!archiveUI.dirty) renderArchiveEditor(); renderArchiveJobs();
|
||||
} catch (error) { text('archive-error', error.message || '归档作业读取失败'); }
|
||||
finally { archiveUI.loading = false; syncArchiveControls(); }
|
||||
}
|
||||
async function archiveMutation(path, method, input) {
|
||||
const body = JSON.stringify(input), signature = `${method}:${path}:${body}`;
|
||||
if (archiveUI.pending && archiveUI.pending.signature !== signature) throw new Error('上次操作尚未确认,请保持原内容重试;确认结果后再修改。');
|
||||
if (!archiveUI.pending) archiveUI.pending = { signature, key: crypto.randomUUID() };
|
||||
try {
|
||||
const response = await request(path, { method, headers: { 'Content-Type': 'application/json', 'Idempotency-Key': archiveUI.pending.key }, body });
|
||||
archiveUI.pending = null; return response;
|
||||
} catch (error) {
|
||||
// 明确的客户端拒绝允许核对后更正;网络故障和服务端错误保留原幂等键。
|
||||
if (error.status >= 400 && error.status < 500) archiveUI.pending = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function saveArchivePlan(event) {
|
||||
event.preventDefault(); if (archiveUI.busy || !archiveUI.editing) return;
|
||||
archiveUI.busy = true; syncArchiveControls(); text('archive-error', '');
|
||||
try {
|
||||
await archiveMutation(`/console/archive/plans/${encodeURIComponent(archiveUI.editing.kind)}`, 'PUT', { expected_revision: String(archiveUI.editing.revision), policy_revision: archiveUI.editPolicy, enabled: $('archive-enabled').checked, interval_seconds: Number($('archive-interval').value) * 3600, batch_size: Number($('archive-batch').value), max_bytes: Number($('archive-budget').value) * 1048576, reason: $('archive-reason').value });
|
||||
archiveUI.dirty = false; $('archive-reason').value = ''; showToast('周期配置已保存,操作已留存审计');
|
||||
} catch (error) { text('archive-error', `${error.message}。网络故障请保持原内容重试;版本冲突请刷新核对。`); }
|
||||
finally { archiveUI.busy = false; syncArchiveControls(); }
|
||||
if (!archiveUI.dirty) await loadArchives();
|
||||
}
|
||||
function openArchiveAction(id, action) {
|
||||
if (archiveUI.busy) return; const job = archiveUI.jobs.find(j => j.id === id); if (!job) return;
|
||||
archiveUI.selected = { job, action }; $('archive-action-form').hidden = false;
|
||||
text('archive-action-title', action === 'retry' ? '从原作业继续' : '关闭失败作业');
|
||||
text('archive-action-note', action === 'retry' ? `${shortID(id)} · 校验原文件后续跑,保留原策略与作业身份。容量不足时可提高上限。` : `${shortID(id)} · 保留已有文件和审计,不再重试本作业。已开启的计划可继续生成后续作业。`);
|
||||
$('archive-action-budget').value = job.max_bytes / 1048576; $('archive-action-budget').min = job.max_bytes / 1048576;
|
||||
$('archive-action-reason').value = ''; syncArchiveControls(); $('archive-action-reason').focus();
|
||||
}
|
||||
async function submitArchiveAction(event) {
|
||||
event.preventDefault(); if (archiveUI.busy || !archiveUI.selected) return;
|
||||
archiveUI.busy = true; syncArchiveControls(); text('archive-error', '');
|
||||
try {
|
||||
const { job, action } = archiveUI.selected;
|
||||
await archiveMutation(`/console/archive/jobs/${encodeURIComponent(job.id)}/actions`, 'POST', { expected_revision: String(job.revision), action, max_bytes: Number($('archive-action-budget').value) * 1048576, reason: $('archive-action-reason').value });
|
||||
$('archive-action-form').hidden = true; archiveUI.selected = null; showToast('作业处理已保存,操作已留存审计');
|
||||
} catch (error) { text('archive-error', `${error.message}。网络故障请保持原内容重试。`); }
|
||||
finally { archiveUI.busy = false; syncArchiveControls(); }
|
||||
await loadArchives();
|
||||
}
|
||||
|
||||
function renderConnectionSummary() {
|
||||
text('summary-api', state.base || location.origin);
|
||||
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
|
||||
@@ -597,6 +688,15 @@
|
||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||
}
|
||||
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
||||
$('archive-plan-form').addEventListener('submit', saveArchivePlan);
|
||||
$('archive-plan-form').addEventListener('input', () => { archiveUI.dirty = true; });
|
||||
$('archive-kind').addEventListener('change', () => { if (archiveUI.pending) { $('archive-kind').value = archiveUI.editing.kind; showToast('请先保持原内容重试,确认上次操作'); return; } renderArchiveEditor(); $('archive-reason').value = ''; });
|
||||
$('archive-refresh').addEventListener('click', () => { if (!archiveUI.busy && !archiveUI.pending) { archiveUI.dirty = false; text('archive-error', ''); loadArchives(); } });
|
||||
$('archive-next').addEventListener('click', () => { if (archiveUI.loading || archiveUI.busy) return; archiveUI.before = archiveUI.next; loadArchives(); });
|
||||
$('archive-first').addEventListener('click', () => { if (archiveUI.loading || archiveUI.busy) return; archiveUI.before = ''; loadArchives(); });
|
||||
$('archive-job-list').addEventListener('click', event => { const button = event.target.closest('[data-archive-action]'); if (button) openArchiveAction(button.dataset.id, button.dataset.archiveAction); });
|
||||
$('archive-action-form').addEventListener('submit', submitArchiveAction);
|
||||
$('archive-action-cancel').addEventListener('click', () => { if (archiveUI.pending) { showToast('请先保持原内容重试,确认上次操作'); return; } $('archive-action-form').hidden = true; archiveUI.selected = null; });
|
||||
$('retention-kind').addEventListener('change', resetRetentionExport);
|
||||
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
||||
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
||||
@@ -607,6 +707,6 @@
|
||||
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(() => Promise.all([loadRetention(), refresh()])); }).catch(() => redirectToLogin());
|
||||
state.timer = setInterval(() => { if (state.authenticated) refresh(); }, 10000);
|
||||
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => Promise.all([loadRetention(), refresh(), currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
|
||||
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (currentView() === 'archives') loadArchives(); } }, 10000);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user