fix: complete retention archive pagination and validation
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
@@ -168,11 +169,19 @@ func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
|
||||
}
|
||||
ids = append(ids, string(out.Task.ID))
|
||||
}
|
||||
completedAt := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Microsecond)
|
||||
for _, id := range ids {
|
||||
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=UTC_TIMESTAMP(6)-INTERVAL 2 DAY,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, boot.NamespaceID, id); err != nil {
|
||||
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=UNHEX(?)`, completedAt, boot.NamespaceID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 同一完成时间验证复合游标;未知效果和活跃租约不能因时间足够旧而被导出。
|
||||
if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='NEEDS_REVIEW',completed_at=NULL,effect_uncertain=TRUE WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "UPDATE pulse_tasks SET state='RUNNING',completed_at=NULL,current_attempt_id=UNHEX('00000000000000000000000000000001'),lease_until=UTC_TIMESTAMP(6)+INTERVAL 1 HOUR WHERE namespace_id=? AND id=UNHEX(?)", boot.NamespaceID, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "ANALYZE TABLE pulse_tasks, orbit_events, orbit_audit_events"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -213,7 +222,103 @@ func TestIntegrationRetentionDiskGrowthAndPreview(t *testing.T) {
|
||||
if snapshot.SHA256 != hex.EncodeToString(sum[:]) || !json.Valid([]byte(snapshot.Content)) || !strings.Contains(snapshot.Content, `"source_retained":true`) {
|
||||
t.Fatalf("retention snapshot integrity/source marker failed: sha=%s content=%s", snapshot.SHA256, snapshot.Content[:min(len(snapshot.Content), 200)])
|
||||
}
|
||||
t.Logf("bounded retention growth sample inserted %d terminal tasks; bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
|
||||
var first archiveData
|
||||
if err := json.Unmarshal([]byte(snapshot.Content), &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.NextCursor == "" {
|
||||
t.Fatal("first page lost continuation cursor")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
page := first
|
||||
batches := 0
|
||||
for {
|
||||
batches++
|
||||
if page.Cutoff != first.Cutoff || page.Policy.Revision != 1 || !page.SourceRetained {
|
||||
t.Fatalf("batch changed boundary: %+v", page.Policy)
|
||||
}
|
||||
if page.RootCount != len(page.Tables["pulse_tasks"]) {
|
||||
t.Fatal("root count mismatch")
|
||||
}
|
||||
for _, row := range page.Tables["pulse_tasks"] {
|
||||
id, ok := row["id"].(string)
|
||||
if !ok || seen[id] || id == ids[0] || id == ids[1] {
|
||||
t.Fatalf("duplicate/unsafe root: %v", row["id"])
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
if batches > 10 {
|
||||
t.Fatal("cursor did not make bounded progress")
|
||||
}
|
||||
next, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", Limit: 37, Cursor: page.NextCursor, ExpectedRevision: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(next.Content))
|
||||
if next.SHA256 != hex.EncodeToString(digest[:]) {
|
||||
t.Fatal("batch digest mismatch")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(next.Content), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(ids)-2 {
|
||||
t.Fatalf("exported %d roots; want %d", len(seen), len(ids)-2)
|
||||
}
|
||||
var retained int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?", boot.NamespaceID).Scan(&retained); err != nil || retained != len(ids) {
|
||||
t.Fatalf("source data changed: count=%d err=%v", retained, err)
|
||||
}
|
||||
for _, in := range []retention.ArchiveInput{
|
||||
{Kind: "audit", Cursor: first.NextCursor, ExpectedRevision: 1},
|
||||
{Kind: "tasks", Cursor: "not-a-cursor", ExpectedRevision: 1},
|
||||
{Kind: "tasks", Limit: 101, ExpectedRevision: 1},
|
||||
} {
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, in); err == nil {
|
||||
t.Fatalf("invalid export accepted: %+v", in)
|
||||
}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(first.NextCursor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var foreign archiveCursor
|
||||
if err := json.Unmarshal(raw, &foreign); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign.Namespace = testID(t)
|
||||
raw, err = json.Marshal(foreign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: base64.RawURLEncoding.EncodeToString(raw)}); err == nil {
|
||||
t.Fatal("foreign namespace cursor accepted")
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, apiToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1}); err == nil {
|
||||
t.Fatal("business bearer token accepted as console admin")
|
||||
}
|
||||
policyChange := retention.UpdateInput{ExpectedRevision: 1, Enabled: true, IdempotencyDays: 90, TaskHistoryDays: 2, WorkflowHistoryDays: 30, EventLogDays: 30, DeliveryHistoryDays: 30, InboxHistoryDays: 30, AuditLogDays: 365, Reason: "verify frozen cursor boundary"}
|
||||
if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-revise", policyChange); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 1, Cursor: first.NextCursor}); faultCode(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("stale policy allowed: %v", err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 2, Cursor: first.NextCursor}); err == nil {
|
||||
t.Fatal("old cursor accepted with new policy")
|
||||
}
|
||||
policyChange.ExpectedRevision, policyChange.Enabled = 2, false
|
||||
if _, err := store.UpdateRetentionPolicy(ctx, consoleToken, "growth-retention-disable", policyChange); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ExportRetention(ctx, consoleToken, retention.ArchiveInput{Kind: "tasks", ExpectedRevision: 3}); faultCode(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("disabled policy allowed: %v", err)
|
||||
}
|
||||
t.Logf("archive pagination: batches=%d unique_roots=%d protected_roots=2 source_rows=%d; digest, namespace, kind, revision, disabled policy and console-only checks passed", batches, len(seen), retained)
|
||||
t.Logf("bounded retention growth sample inserted %d tasks (254 terminal, 2 protected); bytes before=%d after=%d delta=%d; preview tasks=%d more=%v", len(ids), before, after, after-before, taskSample.Count, taskSample.More)
|
||||
}
|
||||
|
||||
func retentionTableBytes(t *testing.T, ctx context.Context, db interface {
|
||||
|
||||
@@ -63,9 +63,29 @@
|
||||
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
||||
}
|
||||
}
|
||||
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
|
||||
function syncRetentionControls() {
|
||||
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
|
||||
$('retention-preview-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention;
|
||||
$('retention-export-button').disabled = retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done;
|
||||
$('retention-export-button').textContent = retentionUI.cursor ? '导出下一批 ↓' : '导出首批 ↓';
|
||||
$('retention-restart-button').disabled = retentionUI.busy || retentionUI.batch === 0;
|
||||
text('retention-export-hint', retentionUI.dirty ? '有未保存的修改,请先保存策略后再预览或导出。' : !state.retention?.enabled ? '可预览候选;导出前请开启只读归档并保存策略。' : '每批最多 20 条根记录及关联历史;生成后可下载下一批。源数据始终保留。');
|
||||
}
|
||||
function resetRetentionExport() {
|
||||
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
|
||||
Object.assign(retentionUI, { cursor: '', batch: 0, count: 0, done: false, download: '' });
|
||||
$('retention-download').hidden = true;
|
||||
$('retention-download').removeAttribute('href');
|
||||
text('retention-export-status', '尚未导出');
|
||||
text('retention-export-sha', '');
|
||||
syncRetentionControls();
|
||||
}
|
||||
function renderRetention(policy) {
|
||||
if (!policy) return;
|
||||
const changed = !state.retention || String(state.retention.revision) !== String(policy.revision);
|
||||
state.retention = policy;
|
||||
retentionUI.dirty = false;
|
||||
$('retention-enabled').checked = Boolean(policy.enabled);
|
||||
$('retention-idempotency').value = policy.idempotency_days ?? '';
|
||||
$('retention-task').value = policy.task_history_days ?? '';
|
||||
@@ -75,16 +95,23 @@
|
||||
$('retention-inbox').value = policy.inbox_history_days ?? '';
|
||||
$('retention-audit').value = policy.audit_log_days ?? '';
|
||||
text('retention-source', policy.source === 'database' ? '已保存配置' : '默认策略');
|
||||
text('retention-revision', policy.revision ? String(policy.revision) : '默认');
|
||||
text('retention-updated', policy.updated_at ? formatTime(policy.updated_at) : '尚未保存');
|
||||
text('retention-revision', String(policy.revision || '0') === '0' ? '默认' : String(policy.revision));
|
||||
text('retention-updated', policy.source === 'database' ? formatTime(policy.updated_at) : '尚未保存');
|
||||
if (changed) {
|
||||
resetRetentionExport();
|
||||
text('retention-preview', '点击预览,查看当前已保存策略下的候选样本。');
|
||||
}
|
||||
syncRetentionControls();
|
||||
}
|
||||
async function loadRetention() {
|
||||
if (retentionUI.busy || retentionUI.dirty) return;
|
||||
retentionUI.busy = true; syncRetentionControls();
|
||||
try {
|
||||
const response = await request('/console/retention');
|
||||
renderRetention(extractData(response));
|
||||
} catch (error) {
|
||||
$('retention-error').textContent = error.message || '保留策略读取失败';
|
||||
}
|
||||
text('retention-error', '');
|
||||
} catch (error) { text('retention-error', error.message || '保留策略读取失败'); }
|
||||
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||
}
|
||||
function retentionNumber(id) {
|
||||
const value = Number($(id).value);
|
||||
@@ -92,50 +119,72 @@
|
||||
}
|
||||
async function submitRetention(event) {
|
||||
event.preventDefault();
|
||||
if (retentionUI.busy || !state.retention) return;
|
||||
const fields = { idempotency: 'retention-idempotency', task: 'retention-task', workflow: 'retention-workflow', event: 'retention-event', delivery: 'retention-delivery', inbox: 'retention-inbox', audit: 'retention-audit' };
|
||||
const values = Object.fromEntries(Object.entries(fields).map(([key, id]) => [key, retentionNumber(id)]));
|
||||
if (Object.values(values).some(value => !value)) { $('retention-error').textContent = '所有保留天数必须是 1 到 3650 的整数。'; return; }
|
||||
if (Object.values(values).some(value => !value) || values.idempotency < 90) { text('retention-error', '幂等记录至少保留 90 天;其他窗口为 1 到 3650 的整数。'); return; }
|
||||
const reason = $('retention-reason').value.trim();
|
||||
if (!reason) { $('retention-error').textContent = '请填写变更原因。'; return; }
|
||||
const button = $('retention-submit'); button.disabled = true; $('retention-error').textContent = '';
|
||||
const current = state.retention || {};
|
||||
if (!reason || /[\r\n]/.test(reason) || new TextEncoder().encode(reason).length > 1024) { text('retention-error', '请填写单行变更原因,最多 1024 字节(中文约 341 字)。'); return; }
|
||||
const body = JSON.stringify({ expected_revision: String(state.retention.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason });
|
||||
// 网络响应不明时,原样重试必须复用同一个操作键。
|
||||
if (retentionUI.pendingSave?.body !== body) retentionUI.pendingSave = { body, key: operationKey('retention-policy') };
|
||||
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
|
||||
try {
|
||||
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey('retention-policy') }, body: JSON.stringify({ expected_revision: String(current.revision || 0), enabled: $('retention-enabled').checked, idempotency_days: values.idempotency, task_history_days: values.task, workflow_history_days: values.workflow, event_log_days: values.event, delivery_history_days: values.delivery, inbox_history_days: values.inbox, audit_log_days: values.audit, reason }) });
|
||||
renderRetention(extractData(response)); $('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
|
||||
} catch (error) { $('retention-error').textContent = error.message || '保留策略保存失败'; }
|
||||
finally { button.disabled = false; }
|
||||
const response = await request('/console/retention', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retentionUI.pendingSave.key }, body });
|
||||
renderRetention(extractData(response)); retentionUI.pendingSave = null;
|
||||
$('retention-reason').value = ''; showToast('保留策略已保存,已写入管理审计');
|
||||
} catch (error) { text('retention-error', `${error.message || '保留策略保存失败'}。网络故障可直接重试;版本冲突请刷新页面后核对。`); }
|
||||
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||
}
|
||||
function retentionCutoff(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }).format(date) + ' UTC';
|
||||
}
|
||||
function renderRetentionPreview(preview) {
|
||||
const el = $('retention-preview');
|
||||
const items = Array.isArray(preview?.items) ? preview.items : [];
|
||||
if (!items.length) { el.innerHTML = '<span>当前策略没有可预览的候选数据。</span>'; return; }
|
||||
if (!items.length) { el.textContent = '当前策略没有可预览的候选数据。'; return; }
|
||||
const labels = { tasks: '任务历史', workflows: '流程历史', events: '事件日志', audit: '管理审计', deliveries: '交付历史', inbox: 'Inbox 历史', idempotency: '幂等记录' };
|
||||
el.innerHTML = `<div class="retention-preview-head"><b>候选预览</b><small>仅展示样本,不会删除数据</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))} 条</b><small>截止 ${escapeHTML(formatTime(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
|
||||
el.innerHTML = `<div class="retention-preview-head"><b>候选预览 · 版本 ${escapeHTML(String(preview.policy?.revision || '0'))}</b><small>每类最多 ${escapeHTML(String(preview.sample_limit))} 条样本,不是全库总数</small></div>${items.map(item => `<div class="retention-preview-row"><span>${escapeHTML(labels[item.kind] || item.kind)}</span><b>${escapeHTML(String(item.count || 0))}${item.more ? '+' : ''} 条</b><small>截止 ${escapeHTML(retentionCutoff(item.cutoff))}${item.more ? ' · 还有更多' : ''}</small></div>`).join('')}`;
|
||||
}
|
||||
async function previewRetention() {
|
||||
const button = $('retention-preview-button'); button.disabled = true; $('retention-error').textContent = '';
|
||||
if (retentionUI.busy || retentionUI.dirty) return;
|
||||
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
|
||||
try {
|
||||
const response = await request('/console/retention/preview');
|
||||
renderRetentionPreview(extractData(response));
|
||||
} catch (error) { $('retention-error').textContent = error.message || '候选预览失败'; }
|
||||
finally { button.disabled = false; }
|
||||
const preview = extractData(response);
|
||||
if (String(preview.policy?.revision) !== String(state.retention?.revision)) throw new Error('策略已被更新,请刷新页面核对后再预览');
|
||||
renderRetentionPreview(preview);
|
||||
} catch (error) { text('retention-error', error.message || '候选预览失败'); }
|
||||
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||
}
|
||||
async function exportRetention() {
|
||||
const button = $('retention-export-button'); button.disabled = true; $('retention-error').textContent = '';
|
||||
if (retentionUI.busy || retentionUI.dirty || !state.retention?.enabled || retentionUI.done) return;
|
||||
retentionUI.busy = true; syncRetentionControls(); text('retention-error', '');
|
||||
try {
|
||||
if (!window.crypto?.subtle) throw new Error('完整性校验需要 HTTPS 或本机 localhost 地址');
|
||||
const kind = $('retention-kind').value;
|
||||
const current = state.retention || {};
|
||||
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, expected_revision: String(current.revision || 0) }) });
|
||||
const revision = String(state.retention.revision || 0);
|
||||
const response = await request('/console/retention/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, limit: 20, cursor: retentionUI.cursor, expected_revision: revision }) });
|
||||
const snapshot = extractData(response);
|
||||
const content = String(snapshot.content || '');
|
||||
if (!content) throw new Error('服务端返回了空的归档快照');
|
||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = `orbit-retention-${kind}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
showToast(`快照已下载 · SHA256 ${String(snapshot.sha256 || '').slice(0, 12)}…`);
|
||||
} catch (error) { $('retention-error').textContent = error.message || '归档快照导出失败'; }
|
||||
finally { button.disabled = false; }
|
||||
if (snapshot.format_version !== 1 || typeof snapshot.content !== 'string' || !snapshot.content) throw new Error('服务端返回了无效的归档快照');
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshot.content));
|
||||
const sha = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
|
||||
if (sha !== snapshot.sha256) throw new Error('快照完整性校验失败,请重新导出本批次');
|
||||
const data = JSON.parse(snapshot.content);
|
||||
if (data.kind !== kind || String(data.policy?.revision) !== revision || data.source_retained !== true || !Number.isSafeInteger(data.root_count) || data.root_count < 0 || data.root_count > 20 || (data.next_cursor != null && typeof data.next_cursor !== 'string')) throw new Error('快照与当前导出请求不一致');
|
||||
// 下载信封同时保留原始正文和摘要,离线校验时不要重新序列化 content。
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json;charset=utf-8' }));
|
||||
if (retentionUI.download) URL.revokeObjectURL(retentionUI.download);
|
||||
Object.assign(retentionUI, { download: url, cursor: data.next_cursor || '', batch: retentionUI.batch + 1, count: retentionUI.count + data.root_count, done: !data.next_cursor });
|
||||
const anchor = $('retention-download');
|
||||
anchor.href = url; anchor.download = `orbit-retention-${kind}-r${revision}-batch${retentionUI.batch}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
anchor.hidden = false; anchor.click();
|
||||
text('retention-export-status', `第 ${retentionUI.batch} 批 · 本批 ${data.root_count} 条 · 累计 ${retentionUI.count} 条根记录 · ${retentionUI.done ? '已到末批' : '可继续下一批'} · SHA-256 校验通过`);
|
||||
text('retention-export-sha', sha);
|
||||
showToast('快照已生成,源数据保留');
|
||||
} catch (error) { text('retention-error', error.message || '归档快照导出失败'); }
|
||||
finally { retentionUI.busy = false; syncRetentionControls(); }
|
||||
}
|
||||
function renderConnectionSummary() {
|
||||
text('summary-api', state.base || location.origin);
|
||||
@@ -548,6 +597,9 @@
|
||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||
}
|
||||
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
||||
$('retention-kind').addEventListener('change', resetRetentionExport);
|
||||
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
||||
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
||||
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||
|
||||
@@ -134,11 +134,11 @@
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel wide-panel retention-panel">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
||||
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;归档执行器还会核对终态、未确认结果、备份和审计要求。</p>
|
||||
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
||||
<form id="retention-form" class="retention-form">
|
||||
<div class="retention-switch"><div><b>允许归档执行器使用策略</b><small>默认关闭。打开后仍需独立归档任务执行,未确认任务、交付和 Inbox 不会按窗口清理。</small></div><label class="switch"><input id="retention-enabled" type="checkbox"><span></span></label></div>
|
||||
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
||||
<div class="retention-grid">
|
||||
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="1" max="3650" required></label>
|
||||
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
|
||||
<label class="retention-field"><span>任务历史</span><small>天</small><input id="retention-task" type="number" min="1" max="3650" required></label>
|
||||
<label class="retention-field"><span>流程历史</span><small>天</small><input id="retention-workflow" type="number" min="1" max="3650" required></label>
|
||||
<label class="retention-field"><span>事件日志</span><small>天</small><input id="retention-event" type="number" min="1" max="3650" required></label>
|
||||
@@ -148,12 +148,14 @@
|
||||
</div>
|
||||
<div class="retention-meta"><span>当前版本 <b id="retention-revision">默认</b></span><span>最后修改 <b id="retention-updated">—</b></span></div>
|
||||
<label class="field-label" for="retention-reason">变更原因</label>
|
||||
<textarea id="retention-reason" rows="3" maxlength="1024" placeholder="说明为什么调整历史保留窗口"></textarea>
|
||||
<input id="retention-reason" class="retention-reason" type="text" maxlength="1024" placeholder="说明为什么调整历史保留窗口" required>
|
||||
<div class="action-form-buttons"><button class="primary-button compact" id="retention-submit" type="submit">保存保留策略 <span>→</span></button></div>
|
||||
<div class="retention-tools">
|
||||
<label class="retention-select"><span>历史类型</span><select id="retention-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></label>
|
||||
<div class="retention-tool-buttons"><button class="secondary-button compact" id="retention-preview-button" type="button">预览候选</button><button class="secondary-button compact" id="retention-export-button" type="button">导出只读快照 <span>↓</span></button></div>
|
||||
<div class="retention-tool-buttons"><button class="secondary-button compact" id="retention-preview-button" type="button">预览候选</button><button class="secondary-button compact" id="retention-export-button" type="button">导出首批 ↓</button><button class="secondary-button compact" id="retention-restart-button" type="button" disabled>从首批开始</button></div>
|
||||
</div>
|
||||
<p class="panel-description" id="retention-export-hint"></p>
|
||||
<div class="retention-export-result" aria-live="polite"><span id="retention-export-status">尚未导出</span><a id="retention-download" hidden>重新下载本批</a><code id="retention-export-sha"></code></div>
|
||||
<div class="retention-preview" id="retention-preview" aria-live="polite"><span>保存策略后可预览当前窗口下的候选数据;预览不会删除任何记录。</span></div>
|
||||
<div class="action-error" id="retention-error" role="alert"></div>
|
||||
</form>
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
.detail-row-action{border:1px solid rgba(255,179,107,.28);border-radius:8px;padding:6px 9px;color:var(--orange);background:rgba(255,179,107,.07);font:10px inherit;cursor:pointer;white-space:nowrap}.detail-row-action:hover{border-color:rgba(255,179,107,.65);color:#ffe0bb;background:rgba(255,179,107,.13)}
|
||||
.recovery-block{border-color:rgba(255,179,107,.2);background:linear-gradient(135deg,rgba(255,179,107,.045),rgba(98,230,212,.025))}.recovery-intro{margin:0 0 12px;color:#8399b0;font-size:10px;line-height:1.6}.recovery-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.recovery-action-button{display:flex;flex-direction:column;align-items:flex-start;gap:4px;border:1px solid rgba(255,179,107,.2);border-radius:10px;padding:10px 11px;color:#e6f0ff;background:rgba(4,12,23,.4);text-align:left;cursor:pointer;transition:border-color .2s,background .2s,transform .2s}.recovery-action-button:hover,.recovery-action-button:focus{outline:0;border-color:rgba(98,230,212,.6);background:rgba(98,230,212,.08);transform:translateY(-1px)}.recovery-action-button b{font-size:10px;font-weight:650}.recovery-action-button small{color:#7890a8;font-size:9px;line-height:1.4}.recovery-form{border-color:rgba(255,179,107,.22)}.recovery-snapshot{margin:0 0 14px;padding:8px 10px;border-radius:8px;background:rgba(98,230,212,.06);color:#8db4b6;font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.recovery-inline-fields{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px}.recovery-inline-fields select{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}.recovery-inline-fields select:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}@media(max-width:780px){.recovery-action-grid{grid-template-columns:1fr}.recovery-inline-fields{grid-template-columns:1fr}}
|
||||
.overview-grid{grid-template-columns:minmax(0,1.45fr) minmax(300px,.7fr)}.stats-panel,.connection-summary-panel{min-height:326px}.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.stat-tile{padding:14px 13px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.stat-tile span{display:block;color:#7188a3;font-size:9px}.stat-tile strong{display:block;margin-top:10px;color:#e4f1fc;font-size:23px;letter-spacing:-.04em}.stat-tile small{display:block;margin-top:5px;color:#607791;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.distribution-block{margin-top:22px;padding-top:18px;border-top:1px solid rgba(156,183,218,.1)}.distribution-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;color:#afc2d6;font-size:11px}.distribution-heading small{color:#687f99;font-size:9px}.distribution-list{display:grid;gap:10px}.distribution-row{display:grid;grid-template-columns:42px minmax(0,1fr) 28px;gap:10px;align-items:center;color:#7187a1;font-size:9px}.distribution-row b{text-align:right;color:#c1d2e2;font-size:10px;font-weight:500}.status-track{height:5px;border-radius:5px;background:#1c3048;overflow:hidden}.status-track i{display:block;width:0;height:100%;border-radius:5px;transition:width .45s ease}.distribution-row:nth-child(1) .status-track i{background:var(--cyan)}.distribution-row:nth-child(2) .status-track i{background:var(--blue)}.distribution-row:nth-child(3) .status-track i{background:var(--purple)}.distribution-row:nth-child(4) .status-track i{background:var(--orange)}.connection-summary-copy{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid rgba(98,230,212,.12);border-radius:14px;background:rgba(4,15,26,.3)}.connection-summary-icon{display:grid;place-items:center;width:33px;height:33px;border-radius:11px;background:rgba(98,230,212,.1);color:var(--cyan);font-size:17px}.connection-summary-copy b{display:block;color:#d5e5f4;font-size:12px;max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.connection-summary-copy small{display:block;margin-top:5px;color:#68809a;font-size:9px}.connection-summary-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;margin-top:12px;border:1px solid rgba(156,183,218,.1);border-radius:13px;overflow:hidden}.connection-summary-grid>div{padding:12px;background:rgba(4,13,24,.25)}.connection-summary-grid span{display:block;color:#647b96;font-size:9px;margin-bottom:5px}.connection-summary-grid b{display:block;color:#b9cde0;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.connection-summary-note{min-height:31px;margin:15px 0 14px;color:#6f86a0;font-size:10px;line-height:1.6}.connection-summary-panel>.quiet-button{width:100%;justify-content:center}.connection-settings-intro{margin:0 0 18px}.connection-drawer{width:min(510px,100%)}@media(max-width:1200px){.stat-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:780px){.overview-grid{grid-template-columns:1fr}.stat-grid{grid-template-columns:repeat(2,1fr)}.connection-drawer{width:100%}}
|
||||
.retention-panel{max-width:980px}.retention-panel .panel-description{max-width:760px}.retention-form{max-width:820px}.retention-switch{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 17px;border:1px solid rgba(98,230,212,.16);border-radius:14px;background:rgba(4,15,26,.34)}.retention-switch b{display:block;color:#dceafb;font-size:11px;font-weight:550}.retention-switch small{display:block;margin-top:6px;color:#7086a0;font-size:9px;line-height:1.5}.switch{position:relative;display:block;width:43px;height:24px;flex:0 0 43px}.switch input{position:absolute;opacity:0;width:0;height:0}.switch span{position:absolute;inset:0;border-radius:999px;background:#243852;cursor:pointer;transition:.2s}.switch span:before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;border-radius:50%;background:#8ca3ba;transition:.2s}.switch input:checked+span{background:rgba(98,230,212,.55);box-shadow:0 0 18px rgba(98,230,212,.18)}.switch input:checked+span:before{transform:translateX(19px);background:#ecfffb}.retention-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px;margin-top:15px}.retention-field{display:block;padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.retention-field span{color:#afc2d6;font-size:10px}.retention-field small{float:right;color:#637b96;font-size:9px}.retention-field input{display:block;width:100%;margin-top:12px;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:13px;padding:10px}.retention-field input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-field-wide{grid-column:span 3}.retention-meta{display:flex;gap:25px;margin-top:16px;color:#637b96;font-size:9px}.retention-meta b{margin-left:5px;color:#afc2d6;font-weight:500}.retention-form>.field-label{margin-top:22px}.retention-form>textarea{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}.retention-form>textarea:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-form .action-form-buttons{justify-content:flex-start}.retention-form .action-error{min-height:18px}@media(max-width:780px){.retention-grid{grid-template-columns:1fr 1fr}.retention-field-wide{grid-column:span 2}.retention-meta{flex-direction:column;gap:7px}}
|
||||
.retention-panel{max-width:980px}.retention-panel .panel-description{max-width:760px}.retention-form{max-width:820px}.retention-switch{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 17px;border:1px solid rgba(98,230,212,.16);border-radius:14px;background:rgba(4,15,26,.34)}.retention-switch b{display:block;color:#dceafb;font-size:11px;font-weight:550}.retention-switch small{display:block;margin-top:6px;color:#7086a0;font-size:9px;line-height:1.5}.switch{position:relative;display:block;width:43px;height:24px;flex:0 0 43px}.switch input{position:absolute;inset:0;z-index:1;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}.switch input:focus-visible+span{outline:2px solid #9beade;outline-offset:3px}.switch input:disabled{cursor:not-allowed}.switch span{position:absolute;inset:0;border-radius:999px;background:#243852;cursor:pointer;transition:.2s}.switch span:before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;border-radius:50%;background:#8ca3ba;transition:.2s}.switch input:checked+span{background:rgba(98,230,212,.55);box-shadow:0 0 18px rgba(98,230,212,.18)}.switch input:checked+span:before{transform:translateX(19px);background:#ecfffb}.retention-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px;margin-top:15px}.retention-field{display:block;padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:13px;background:rgba(4,13,24,.28)}.retention-field span{color:#afc2d6;font-size:10px}.retention-field small{float:right;color:#637b96;font-size:9px}.retention-field input{display:block;width:100%;margin-top:12px;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:13px;padding:10px}.retention-field input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-field-wide{grid-column:span 3}.retention-meta{display:flex;gap:25px;margin-top:16px;color:#637b96;font-size:9px}.retention-meta b{margin-left:5px;color:#afc2d6;font-weight:500}.retention-form>.field-label{margin-top:22px}.retention-form>.retention-reason{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}.retention-form>.retention-reason:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.retention-form .action-form-buttons{justify-content:flex-start}.retention-form .action-error{min-height:18px}@media(max-width:780px){.retention-grid{grid-template-columns:1fr 1fr}.retention-field-wide{grid-column:span 2}.retention-meta{flex-direction:column;gap:7px}}
|
||||
.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}
|
||||
|
||||
Reference in New Issue
Block a user