feat: schedule persistent archive jobs with console management

This commit is contained in:
2026-09-23 11:29:32 +08:00
parent 6eb429e21f
commit 0140c78ea7
32 changed files with 2077 additions and 20 deletions

View File

@@ -0,0 +1,108 @@
package httpserver
import (
"net/http"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// RegisterConsoleArchiveJobs 注册周期归档管理;只接受管理员会话,目标目录由部署配置固定。
func RegisterConsoleArchiveJobs(server *http.Server, consoleAuth RetentionConsoleAuth, store retention.SchedulerStore, storage core.ID) {
if server == nil || consoleAuth == nil || store == nil {
return
}
mux, ok := server.Handler.(*http.ServeMux)
if !ok {
return
}
parent := mux
mux = http.NewServeMux()
for _, method := range []string{"GET", "PUT", "POST"} {
parent.Handle(method+" /console/archive/", http.NewCrossOriginProtection().Handler(mux))
}
check := func(w http.ResponseWriter, r *http.Request) (auth.Token, bool) {
t, ok := consoleCookieToken(r)
if !ok || consoleAuth.ValidateSession(r.Context(), t) != nil {
writeError(w, "", fault.Unauthorized)
return auth.Token{}, false
}
return t, true
}
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
keys := r.Header.Values("Idempotency-Key")
if len(keys) != 1 {
writeError(w, "", fault.Invalid("exactly one Idempotency-Key is required"))
return "", false
}
return keys[0], true
}
mux.HandleFunc("GET /console/archive/plans", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
items, err := store.ListArchivePlans(r.Context(), t)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": map[string]any{"items": items, "executor_configured": storage != "", "storage_id": string(storage)}})
})
mux.HandleFunc("PUT /console/archive/plans/{kind}", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
k, ok := key(w, r)
if !ok {
return
}
var in retention.PlanInput
if err := decodeBody(r, &in); err != nil {
writeError(w, "", err)
return
}
out, err := store.SaveArchivePlan(r.Context(), t, k, r.PathValue("kind"), storage, in)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": out})
})
mux.HandleFunc("GET /console/archive/jobs", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
out, err := store.ListArchiveJobs(r.Context(), t, r.URL.Query().Get("before"))
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": out})
})
mux.HandleFunc("POST /console/archive/jobs/{id}/actions", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)
if !ok {
return
}
k, ok := key(w, r)
if !ok {
return
}
var in retention.JobActionInput
if err := decodeBody(r, &in); err != nil {
writeError(w, "", err)
return
}
out, err := store.ActArchiveJob(r.Context(), t, core.ID(r.PathValue("id")), k, in)
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": out})
})
}

View File

@@ -0,0 +1,69 @@
package httpserver
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
type fakeArchiveManager struct {
retention.SchedulerStore
calls int
}
func (f *fakeArchiveManager) ListArchivePlans(context.Context, auth.Token) ([]retention.ArchivePlan, error) {
f.calls++
return []retention.ArchivePlan{}, nil
}
func TestArchiveRoutesRequireCookieAndRejectCrossOrigin(t *testing.T) {
ns, _ := core.NewID()
cred, _ := core.NewID()
raw, _, _ := auth.Issue(ns, cred)
volume, _ := core.NewID()
authStore := &fakeConsoleAuth{valid: true}
store := &fakeArchiveManager{}
server := NewWithConsoleAuth("127.0.0.1:0", nil, authStore)
RegisterConsoleArchiveJobs(server, authStore, store, volume)
WrapConsoleAuth(server, authStore)
tests := []struct {
name, method, path, origin string
cookie, bearer bool
status int
}{
{"no session", "GET", "/console/archive/plans", "", false, false, 401},
{"bearer alone", "GET", "/console/archive/plans", "", false, true, 401},
{"cookie read", "GET", "/console/archive/plans", "", true, false, 200},
{"missing key", "PUT", "/console/archive/plans/tasks", "", true, false, 400},
{"cross origin", "PUT", "/console/archive/plans/tasks", "https://foreign.invalid", true, false, 403},
{"cross origin retry", "POST", "/console/archive/jobs/11111111111111111111111111111111/actions", "https://foreign.invalid", true, false, 403},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(tt.method, tt.path, strings.NewReader(`{}`))
r.Header.Set("Content-Type", "application/json")
if tt.cookie {
r.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
}
if tt.bearer {
r.Header.Set("Authorization", "Bearer "+raw)
}
if tt.origin != "" {
r.Header.Set("Origin", tt.origin)
}
w := httptest.NewRecorder()
server.Handler.ServeHTTP(w, r)
if w.Code != tt.status {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
})
}
if store.calls != 1 {
t.Fatal("unexpected store access", store.calls)
}
}

View File

@@ -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);
})();

View File

@@ -24,6 +24,7 @@
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span><em>04</em></button>
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
</nav>
<div class="sidebar-bottom">
<div class="rail-card">
@@ -130,6 +131,41 @@
</section>
</div>
<div class="page-view" id="view-archives" data-page="archives">
<div class="archive-heading"><div><span class="panel-kicker">DATA / ARCHIVE JOBS</span><h1>让历史数据,有序归档。</h1><p>配置周期,自动分批保存历史;每次执行都有进度与校验记录。</p></div><button class="secondary-button compact" id="archive-refresh">刷新作业 ↻</button></div>
<div class="archive-stats"><article><span>周期计划</span><strong id="archive-enabled-count">—</strong><small>已启用 / 7 个类别</small></article><article><span>本页待处理</span><strong id="archive-failed-count">—</strong><small>失败作业保留进度,等待处理</small></article><article><span>本页归档记录</span><strong id="archive-root-count">—</strong><small>数据库已确认的根记录数</small></article></div>
<div id="archive-error" class="action-error" role="alert"></div>
<section class="archive-grid">
<article class="panel archive-config">
<div class="panel-heading"><div><span class="panel-kicker">SCHEDULE</span><h2>周期配置</h2></div><span class="connection-state" id="archive-executor">正在读取</span></div>
<form id="archive-plan-form">
<label class="field-label" for="archive-kind">归档类别</label><select id="archive-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select>
<div class="retention-switch"><div><b>开启周期归档</b><small>首次启用立即运行,之后按周期执行。</small></div><label class="switch"><input id="archive-enabled" type="checkbox" aria-label="开启周期归档"><span></span></label></div>
<div class="archive-input-grid"><label for="archive-interval">执行间隔(小时)<input id="archive-interval" type="number" min="1" max="720" required></label><label for="archive-batch">每批根记录数<input id="archive-batch" type="number" min="1" max="100" required></label></div>
<label class="field-label" for="archive-budget">单作业容量上限(MiB)</label><input id="archive-budget" type="number" min="1" max="1024" required>
<div class="archive-plan-meta"><span id="archive-plan-revision">配置尚未保存</span><span id="archive-next-run">下次执行 —</span><span id="archive-policy-note"></span></div>
<label class="field-label" for="archive-reason">变更原因</label><input id="archive-reason" type="text" maxlength="1024" placeholder="说明本次周期配置的调整原因" required>
<button id="archive-plan-save" class="primary-button compact" type="submit">保存周期配置 <span>→</span></button>
<p class="panel-description" id="archive-plan-hint">归档使用已保存的保留策略。关闭周期只停止新作业,已有作业继续结清。</p>
</form>
<div class="archive-plan-chips" id="archive-plan-chips"></div>
<button class="text-button" data-view="retention">调整历史保留时间 ↗</button>
</article>
<article class="panel archive-history">
<div class="panel-heading"><div><span class="panel-kicker">EXECUTION HISTORY</span><h2>归档作业</h2></div><span class="table-count" id="archive-job-count">0 项</span></div>
<p class="panel-description">源数据库记录始终保留。文件写入服务端持久卷,页面展示已确认的批次和 SHA-256 摘要。</p>
<div class="archive-job-list" id="archive-job-list"><div class="empty-row">正在读取归档作业…</div></div>
<div class="archive-pager"><button class="secondary-button compact" id="archive-first">返回最新</button><button class="secondary-button compact" id="archive-next" disabled>更早作业 ↓</button></div>
<form id="archive-action-form" hidden>
<h3 id="archive-action-title">处理失败作业</h3><p class="panel-description" id="archive-action-note"></p>
<label class="field-label" for="archive-action-budget">单作业容量上限(MiB)</label><input id="archive-action-budget" type="number" min="1" max="1024" required>
<label class="field-label" for="archive-action-reason">处理原因</label><input id="archive-action-reason" type="text" maxlength="1024" placeholder="说明核查情况和处理原因" required>
<div class="action-form-buttons"><button id="archive-action-submit" type="submit" class="primary-button compact">确认处理</button><button id="archive-action-cancel" type="button" class="secondary-button compact">取消</button></div>
</form>
</article>
</section>
</div>
<div class="page-view" id="view-retention" data-page="retention">
<section class="content-grid single-column">
<article class="panel wide-panel retention-panel">

View File

@@ -10,3 +10,5 @@
.retention-tools{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.retention-select{display:flex;align-items:center;gap:10px;color:#9eb4cb;font-size:10px}.retention-select select{min-width:150px;border:1px solid rgba(156,183,218,.15);border-radius:9px;background:#0a1a2e;color:#dceafb;font:inherit;padding:9px 11px;outline:0}.retention-select select:focus{border-color:rgba(98,230,212,.58)}.retention-tool-buttons{display:flex;gap:9px}.secondary-button.compact{min-height:37px;padding:0 14px}.retention-preview{margin-top:13px;padding:13px 14px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,13,24,.28);color:#7189a3;font-size:10px;line-height:1.5}.retention-preview-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:8px;color:#c9d8e8}.retention-preview-head small{color:#7189a3}.retention-preview-row{display:grid;grid-template-columns:1fr auto 1.6fr;gap:12px;padding:7px 0;border-top:1px solid rgba(156,183,218,.08)}.retention-preview-row b{color:#9beade;font-weight:600}.retention-preview-row small{color:#7189a3;text-align:right}@media(max-width:780px){.retention-tools{align-items:stretch;flex-direction:column}.retention-tool-buttons{justify-content:flex-start}.retention-preview-row{grid-template-columns:1fr auto}.retention-preview-row small{grid-column:span 2;text-align:left}}
.retention-tool-buttons{flex-wrap:wrap}.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid rgba(156,183,218,.18);border-radius:9px;background:rgba(23,46,65,.6);color:#b9cddd;font:inherit;font-size:10px;cursor:pointer}.secondary-button:hover{border-color:#62e6d4;color:#9beade}.retention-form button:disabled{opacity:.45;cursor:not-allowed}.retention-export-result{display:flex;flex-wrap:wrap;gap:10px;color:#9eb4cb;font-size:10px;line-height:1.6}.retention-export-result a{color:#62e6d4}.retention-export-result code{width:100%;font-size:9px;overflow-wrap:anywhere;color:#7189a3}.retention-export-result [hidden]{display:none}.retention-tools{flex-wrap:wrap}
.archive-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:22px 0}.archive-heading h1{font-size:28px;letter-spacing:-.7px;margin:12px 0;color:#e4effb}.archive-heading p{color:#849bb3;font-size:12px;line-height:1.7}.archive-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;margin-bottom:20px}.archive-stats article{padding:22px 24px;border:1px solid #20354a;border-radius:18px;background:linear-gradient(125deg,#112439,#0a1728)}.archive-stats article:first-child{background:linear-gradient(125deg,#13373e,#0b202d);border-color:#24535a}.archive-stats span,.archive-stats small{display:block;color:#8ba2b8;font-size:11px}.archive-stats strong{display:block;font-size:30px;margin:10px 0;color:#e5f9f5}.archive-grid{display:grid;grid-template-columns:minmax(290px,360px) minmax(0,1fr);gap:20px;align-items:start}.archive-config,.archive-history{padding:24px;min-width:0}.archive-config .panel-heading,.archive-history .panel-heading{padding:0;margin-bottom:20px}.archive-config input:not([type=checkbox]),.archive-config select,#archive-action-form input{width:100%;border:1px solid #294157;border-radius:9px;padding:11px 12px;background:#09192a;color:#dbe8f5;font:inherit;font-size:12px;outline:0}.archive-config input:focus,.archive-config select:focus,#archive-action-form input:focus{border-color:#61dfd0;box-shadow:0 0 0 3px #61dfd010}.archive-config .field-label,#archive-action-form .field-label{display:block;margin:18px 0 9px;font-size:11px}.archive-config .retention-switch{margin-top:19px;padding:13px 12px}.archive-input-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px;color:#94adc4;font-size:11px}.archive-input-grid input{margin-top:9px}.archive-plan-meta{display:grid;gap:9px;margin:17px 0;color:#7893ae;font-size:10px;line-height:1.6}.archive-config .primary-button{width:100%;margin:17px 0 6px}.archive-config .panel-description{line-height:1.8;font-size:10px}.archive-plan-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.archive-plan-chips span{padding:6px 8px;border:1px solid #24374a;border-radius:7px;font-size:9px;color:#7590aa}.archive-plan-chips .enabled{color:#85e5d8;border-color:#2d6564;background:#184b4b44}.archive-history>.panel-description{margin-bottom:18px;line-height:1.8}.archive-job{border:1px solid #243a50;border-radius:13px;padding:18px;margin:12px 0;background:#09172766}.archive-job-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.archive-job-head>div{display:flex;align-items:center;gap:12px}.archive-job-head b{font-size:13px;color:#d2e4f1}.archive-job-head time{font-size:10px;color:#7c98b1}.archive-job-id{display:block;margin:10px 0;color:#65849f;font-size:10px;overflow-wrap:anywhere}.archive-progress{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0;color:#8eaac1;font-size:10px}.archive-progress b{color:#ccdee9;font-size:12px;font-weight:550}.archive-job details{font-size:10px;color:#7297b5;line-height:1.7}.archive-job summary{cursor:pointer}.archive-digest{display:block;overflow-wrap:anywhere;margin-top:8px;color:#9cbad0}.archive-job-error{padding:10px 12px;background:#59412338;border-radius:8px;color:#d9b484;font-size:11px;line-height:1.8}.archive-job-note{font-size:10px;color:#7698b2}.archive-job-actions,.archive-pager{display:flex;gap:14px;align-items:center;margin-top:16px}.archive-pager{justify-content:space-between}.archive-empty{padding:60px 20px}.archive-empty b,.archive-empty small{display:block;margin-top:15px}.archive-grid button:disabled{opacity:.4;cursor:not-allowed}.archive-history [hidden]{display:none}#archive-action-form{border:1px solid #36736f;border-radius:13px;padding:20px;margin-top:20px;background:#102c3544}#archive-action-form h3{font-size:16px;color:#cdeae6}.state-pill.abandoned{color:#9bb0c2;background:#52637933}@media(max-width:1150px){.archive-grid{grid-template-columns:1fr}.archive-stats{gap:10px}.archive-heading h1{font-size:24px}}@media(max-width:600px){.archive-stats{grid-template-columns:1fr}.archive-heading{align-items:flex-start;flex-direction:column}.archive-config,.archive-history{padding:18px}.archive-job-head{align-items:flex-start;flex-direction:column}}