feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
@@ -0,0 +1,334 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Microsoft YaHei", Arial, sans-serif;
background: #f3f6fb;
color: #1f2937;
}
.page {
max-width: 1600px;
margin: 0 auto;
padding: 20px;
}
.hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.hero h1 {
margin: 0 0 6px;
font-size: 28px;
}
.hero p {
margin: 0;
color: #64748b;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.card {
background: #fff;
border: 1px solid #dbe4f0;
border-radius: 14px;
padding: 16px;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.card-head h2 {
margin: 0;
font-size: 18px;
}
.section-subtitle {
margin: 0 0 8px;
font-size: 14px;
color: #475569;
font-weight: 600;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.mini-card {
border: 1px solid #e5edf6;
border-radius: 12px;
padding: 14px;
background: #f8fbff;
}
.mini-title {
font-size: 13px;
color: #64748b;
margin-bottom: 10px;
}
.status-text {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
}
.muted {
color: #64748b;
font-size: 13px;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 12px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: #e0ecff;
color: #1d4ed8;
font-size: 12px;
}
.inline-actions {
display: flex;
gap: 8px;
align-items: center;
}
.inline-actions.wrap {
flex-wrap: wrap;
}
.btn {
border: 1px solid #c9d7eb;
background: #fff;
color: #1f2937;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
font-size: 14px;
}
.btn:hover {
background: #f8fafc;
}
.btn-primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.input,
.textarea {
width: 100%;
border: 1px solid #cdd8e6;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
background: #fff;
}
.textarea {
min-height: 140px;
resize: vertical;
}
.progress-block {
margin-top: 14px;
border: 1px solid #e5edf6;
border-radius: 12px;
padding: 14px;
background: #f8fbff;
}
.progress-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-weight: 600;
}
.progress-bar {
width: 100%;
height: 12px;
border-radius: 999px;
background: #dbe7f7;
overflow: hidden;
}
.progress-inner {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
transition: width 0.25s ease;
}
.summary-box {
margin: 14px 0 0;
background: #0f172a;
color: #dbeafe;
padding: 12px;
border-radius: 12px;
min-height: 110px;
overflow: auto;
}
.table-wrap {
overflow: auto;
border: 1px solid #e5edf6;
border-radius: 12px;
}
.catalog-wrap {
max-height: 360px;
}
.alerts-wrap {
max-height: 220px;
margin-bottom: 12px;
}
.logs-wrap {
max-height: 340px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid #edf2f7;
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: #f8fbff;
z-index: 1;
}
.form-grid,
.textarea-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.textarea-grid {
margin-top: 12px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: #475569;
}
.field-inline {
min-width: 140px;
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 12px;
}
.key-text {
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
word-break: break-all;
white-space: pre-wrap;
}
.btn-link {
color: #2563eb;
text-decoration: none;
font-size: 13px;
font-weight: 600;
}
.btn-link:hover {
text-decoration: underline;
}
.action-sep {
margin: 0 6px;
color: #94a3b8;
}
.notice {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #ecfdf5;
color: #166534;
border: 1px solid #bbf7d0;
}
.notice-err {
background: #fef2f2;
color: #b91c1c;
border-color: #fecaca;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #475569;
}
@media (max-width: 900px) {
.hero {
flex-direction: column;
align-items: flex-start;
}
}
@@ -0,0 +1,413 @@
(() => {
const el = id => document.getElementById(id)
let currentConfig = null
let pollTimer = null
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const setNotice = (text, isError) => {
const box = el('save-notice')
if (!box) return
if (!text) {
box.style.display = 'none'
box.textContent = ''
box.className = 'notice'
return
}
box.style.display = ''
box.textContent = text
box.className = isError ? 'notice notice-err' : 'notice'
}
const setAuthWarning = text => {
const box = el('auth-warning')
if (!box) return
if (!text) {
box.style.display = 'none'
box.textContent = ''
return
}
box.style.display = ''
box.textContent = text
}
const getJson = async url => {
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}_ts=${Date.now()}`, {
cache: 'no-store',
credentials: 'same-origin'
})
const data = await resp.json().catch(() => null)
if (resp.status === 401) throw new Error('UNAUTHORIZED')
if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
return data
}
const postJson = async (url, body) => {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
credentials: 'same-origin',
body: JSON.stringify(body || {})
})
const data = await resp.json().catch(() => null)
if (resp.status === 401) throw new Error('UNAUTHORIZED')
if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
return data
}
const formatStatus = status => {
const map = {
success: '成功',
partial_success: '部分成功',
failed: '失败',
running: '运行中',
idle: '空闲'
}
return map[String(status || '')] || (status || '-')
}
const formatSize = bytes => {
const n = Number(bytes || 0)
if (!Number.isFinite(n) || n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = n
let idx = 0
while (value >= 1024 && idx < units.length - 1) {
value /= 1024
idx += 1
}
return `${value >= 100 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}`
}
const fillLines = (id, values) => {
const node = el(id)
if (!node) return
node.value = Array.isArray(values) ? values.join('\n') : ''
}
const readLines = id => {
const node = el(id)
return String((node && node.value) || '')
.split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean)
}
const renderOverview = data => {
const high = data.highfreq || {}
const low = data.lowfreq || {}
const current = data.current_run || {}
const manual = data.manual_baidu_backup || {}
const scheduler = data.scheduler || {}
const verify = data.verify || {}
const restore = data.restore || {}
el('highfreq-status').textContent = formatStatus(high.last_status || '-')
el('highfreq-last').textContent = `上次:${high.last_run_at || '-'}`
el('highfreq-next').textContent = `下次:${scheduler.next_highfreq_at || '-'}`
el('lowfreq-status').textContent = formatStatus(low.last_status || '-')
el('lowfreq-last').textContent = `上次:${low.last_run_at || '-'}`
el('lowfreq-next').textContent = `下次:${scheduler.next_lowfreq_at || '-'}`
el('current-run-status').textContent = formatStatus(current.status || 'idle')
el('current-run-stage').textContent = `阶段:${current.stage || '-'}`
el('current-run-message').textContent = `消息:${current.message || '-'}`
el('manual-last').textContent = manual.last_recorded_at || '-'
el('oss-ready').textContent = data.oss_ready ? 'OSS状态:已就绪' : 'OSS状态:未就绪'
el('verify-last').textContent = `最近抽检:${verify.last_run_at ? `${formatStatus(verify.last_status || '-')} / ${verify.last_run_at}` : '-'}`
el('restore-last').textContent = `最近演练:${restore.last_run_at ? `${formatStatus(restore.last_status || '-')} / ${restore.last_run_at}` : '-'}`
el('credential-file').textContent = `凭证文件:${data.credential_file || '-'}`
}
const renderCurrentRun = run => {
const current = run || {}
const percent = Math.max(0, Math.min(100, Number(current.percent || 0)))
el('progress-title').textContent = current.run_id ? `${formatStatus(current.status)} / ${current.mode || '-'}` : '当前无任务'
el('progress-percent').textContent = `${percent}%`
el('progress-inner').style.width = `${percent}%`
el('progress-detail').textContent = current.message || '等待任务启动'
const summary = current.stats && Object.keys(current.stats).length
? JSON.stringify(current.stats, null, 2)
: '暂无结果'
el('run-summary').textContent = summary
const running = current.status === 'running'
;['btn-run-high', 'btn-run-low', 'btn-run-all', 'btn-run-verify', 'btn-run-restore', 'btn-manual-record', 'btn-config-save'].forEach(id => {
const node = el(id)
if (node) node.disabled = running
})
}
const renderAlerts = rows => {
const body = el('alerts-body')
if (!body) return
body.innerHTML = ''
const list = Array.isArray(rows) ? rows : []
if (!list.length) {
body.innerHTML = '<tr><td colspan="4" class="muted">暂无告警</td></tr>'
return
}
body.innerHTML = list.map(item => `
<tr>
<td>${esc(item.created_at || '')}</td>
<td>${esc(item.severity || '')}</td>
<td>${esc(item.title || '')}</td>
<td>${esc(item.message || '')}</td>
</tr>
`).join('')
}
const renderLogs = rows => {
const body = el('logs-body')
if (!body) return
body.innerHTML = ''
const list = Array.isArray(rows) ? rows : []
if (!list.length) {
body.innerHTML = '<tr><td colspan="4" class="muted">暂无日志</td></tr>'
return
}
body.innerHTML = list.map(item => `
<tr>
<td>${esc(item.created_at || '')}</td>
<td>${esc(item.mode || '')}</td>
<td>${esc(item.event_type || '')}</td>
<td>${esc(item.message || '')}</td>
</tr>
`).join('')
}
const renderCatalog = payload => {
const highBody = el('catalog-highfreq-body')
const lowBody = el('catalog-lowfreq-body')
if (!highBody || !lowBody) return
const highfreqRuns = Array.isArray(payload.highfreq_runs) ? payload.highfreq_runs : []
const lowfreqObjects = Array.isArray(payload.lowfreq_objects) ? payload.lowfreq_objects : []
if (!highfreqRuns.length) {
highBody.innerHTML = '<tr><td colspan="6" class="muted">暂无高频批次</td></tr>'
} else {
highBody.innerHTML = highfreqRuns.map(item => {
const high = item.highfreq || {}
const key = esc(high.oss_key || '-')
const runGuid = esc(high.run_guid || '-')
const info = `${Number(high.file_count || 0)} 文件 / ${formatSize(high.zip_size_bytes || 0)}`
const time = esc(item.ended_at || item.started_at || '')
const status = esc(formatStatus(item.status || ''))
const href = `/api/box_backup/download/highfreq?id=${encodeURIComponent(item.id)}`
return `
<tr>
<td>${time || '-'}</td>
<td>${status}</td>
<td><div class="key-text">${runGuid}</div></td>
<td>${esc(info)}</td>
<td><div class="key-text">${key}</div></td>
<td><a class="btn-link" href="${href}">下载归档</a></td>
</tr>
`
}).join('')
}
if (!lowfreqObjects.length) {
lowBody.innerHTML = '<tr><td colspan="6" class="muted">暂无低频对象</td></tr>'
} else {
lowBody.innerHTML = lowfreqObjects.map(item => {
const meta = item.meta || {}
const encryption = item.encryption || {}
const storedName = esc(meta.stored_name || '-')
const key = esc(item.oss_key || '-')
const relPath = esc(item.relative_path || '-')
const time = esc(item.last_uploaded_at || item.deleted_at || '')
const statusTextBase = item.is_deleted ? `${formatStatus(item.upload_status || '')} / 已物理删除` : formatStatus(item.upload_status || '')
const statusText = encryption.encrypted ? `${statusTextBase} / ${encryption.label || '加密文件'}` : statusTextBase
const action = String(item.upload_status || '') === 'uploaded'
? `${encryption.encrypted ? `<a class="btn-link" href="/api/box_backup/download/lowfreq_decrypted?id=${encodeURIComponent(item.id)}">解密下载</a><span class="action-sep">|</span>` : ''}<a class="btn-link" href="/api/box_backup/download/lowfreq?id=${encodeURIComponent(item.id)}">${encryption.encrypted ? '原件下载' : '下载文件'}</a>`
: `<span class="muted">${esc(item.last_error || '-')}</span>`
return `
<tr>
<td>${time || '-'}</td>
<td>${esc(statusText)}</td>
<td><div class="key-text">${relPath}</div></td>
<td><div class="key-text">${storedName}</div></td>
<td><div class="key-text">${key}</div></td>
<td>${action}</td>
</tr>
`
}).join('')
}
}
const renderConfig = payload => {
currentConfig = payload.config || {}
const cfg = currentConfig
const status = payload.credential_status || {}
el('cfg-high-cron').value = ((cfg.scheduler || {}).highfreq_cron) || ''
el('cfg-low-cron').value = ((cfg.scheduler || {}).lowfreq_cron) || ''
el('cfg-poll-seconds').value = ((cfg.scheduler || {}).poll_seconds) || 30
el('cfg-oss-root-prefix').value = ((cfg.oss || {}).root_prefix) || status.root_prefix || ''
el('cfg-oss-root-prefix-key').value = ((cfg.oss || {}).root_prefix_key) || 'BOX_BACKUP_OSS_ROOT_PREFIX'
el('cfg-lowfreq-dir-name').value = ((cfg.oss || {}).lowfreq_dir_name) || status.lowfreq_dir_name || ''
el('cfg-oss-bucket').value = ((cfg.oss || {}).bucket) || ''
el('cfg-oss-endpoint').value = ((cfg.oss || {}).endpoint) || ''
el('cfg-credential-file').value = ((cfg.oss || {}).credential_file_name) || ''
el('cfg-ak-key').value = ((cfg.oss || {}).access_key_id_key) || ''
el('cfg-sk-key').value = ((cfg.oss || {}).access_key_secret_key) || ''
el('cfg-bucket-key').value = ((cfg.oss || {}).bucket_key) || ''
el('cfg-endpoint-key').value = ((cfg.oss || {}).endpoint_key) || ''
el('cfg-sample-size').value = ((cfg.verification || {}).lowfreq_sample_size) || 3
el('cfg-restore-max-mb').value = ((cfg.verification || {}).restore_max_download_mb) || 2048
fillLines('cfg-high-code-paths', (((cfg.paths || {}).highfreq || {}).code_paths) || [])
fillLines('cfg-high-root-files', (((cfg.paths || {}).highfreq || {}).root_files) || [])
fillLines('cfg-high-exts', (((cfg.paths || {}).highfreq || {}).tool_code_extensions) || [])
fillLines('cfg-low-paths', (((cfg.paths || {}).lowfreq || {}).include_paths) || [])
fillLines('cfg-high-excludes', (((cfg.paths || {}).highfreq || {}).exclude_dirs) || [])
fillLines('cfg-low-excludes', (((cfg.paths || {}).lowfreq || {}).exclude_dirs) || [])
}
const collectConfig = () => {
const cfg = JSON.parse(JSON.stringify(currentConfig || {}))
cfg.scheduler = cfg.scheduler || {}
cfg.paths = cfg.paths || {}
cfg.paths.highfreq = cfg.paths.highfreq || {}
cfg.paths.lowfreq = cfg.paths.lowfreq || {}
cfg.oss = cfg.oss || {}
cfg.scheduler.highfreq_cron = el('cfg-high-cron').value.trim()
cfg.scheduler.lowfreq_cron = el('cfg-low-cron').value.trim()
cfg.scheduler.poll_seconds = Number(el('cfg-poll-seconds').value || 30)
cfg.oss.root_prefix = el('cfg-oss-root-prefix').value.trim()
cfg.oss.root_prefix_key = el('cfg-oss-root-prefix-key').value.trim()
cfg.oss.lowfreq_dir_name = el('cfg-lowfreq-dir-name').value.trim()
cfg.oss.bucket = el('cfg-oss-bucket').value.trim()
cfg.oss.endpoint = el('cfg-oss-endpoint').value.trim()
cfg.oss.credential_file_name = el('cfg-credential-file').value.trim()
cfg.oss.access_key_id_key = el('cfg-ak-key').value.trim()
cfg.oss.access_key_secret_key = el('cfg-sk-key').value.trim()
cfg.oss.bucket_key = el('cfg-bucket-key').value.trim()
cfg.oss.endpoint_key = el('cfg-endpoint-key').value.trim()
cfg.verification = cfg.verification || {}
cfg.verification.lowfreq_sample_size = Number(el('cfg-sample-size').value || 3)
cfg.verification.restore_max_download_mb = Number(el('cfg-restore-max-mb').value || 2048)
cfg.paths.highfreq.code_paths = readLines('cfg-high-code-paths')
cfg.paths.highfreq.root_files = readLines('cfg-high-root-files')
cfg.paths.highfreq.tool_code_extensions = readLines('cfg-high-exts')
cfg.paths.lowfreq.include_paths = readLines('cfg-low-paths')
cfg.paths.highfreq.exclude_dirs = readLines('cfg-high-excludes')
cfg.paths.lowfreq.exclude_dirs = readLines('cfg-low-excludes')
return cfg
}
const refreshOverview = async () => {
const [overview, currentRun, alerts] = await Promise.all([
getJson('/api/box_backup/overview'),
getJson('/api/box_backup/current_run'),
getJson('/api/box_backup/alerts?limit=30')
])
renderOverview(overview)
renderCurrentRun(currentRun.run)
renderAlerts(alerts.rows || [])
}
const refreshLogs = async () => {
const failuresOnly = !!(el('filter-failures') && el('filter-failures').checked)
const logs = await getJson(`/api/box_backup/events?limit=200${failuresOnly ? '&failures_only=1' : ''}`)
renderLogs(logs.rows || [])
}
const refreshConfig = async () => {
const payload = await getJson('/api/box_backup/config')
renderConfig(payload)
}
const refreshCatalog = async () => {
const status = el('catalog-status-filter') ? el('catalog-status-filter').value.trim() : ''
const payload = await getJson(`/api/box_backup/catalog?run_limit=20&object_limit=200${status ? `&status=${encodeURIComponent(status)}` : ''}`)
renderCatalog(payload)
}
const refreshAll = async () => {
try {
setAuthWarning('')
await Promise.all([refreshOverview(), refreshLogs(), refreshConfig(), refreshCatalog()])
} catch (error) {
if (String(error.message || error) === 'UNAUTHORIZED') {
setAuthWarning('未授权。请从带 token 的入口进入,或先完成导航鉴权。')
return
}
setNotice(String(error.message || error), true)
}
}
const runMode = async mode => {
try {
setNotice('', false)
await postJson(`/api/box_backup/run/${mode}`, {})
await Promise.all([refreshOverview(), refreshLogs(), refreshCatalog()])
} catch (error) {
setNotice(String(error.message || error), true)
}
}
const saveConfig = async () => {
try {
const cfg = collectConfig()
const payload = await postJson('/api/box_backup/config/save', { config: cfg })
renderConfig(payload)
setNotice('配置已保存并热生效', false)
await Promise.all([refreshOverview(), refreshCatalog()])
} catch (error) {
setNotice(String(error.message || error), true)
}
}
const recordManual = async () => {
try {
await postJson('/api/box_backup/manual_baidu_backup', {
note: el('manual-note').value.trim()
})
el('manual-note').value = ''
setNotice('已记录手工百度云盘备份时间', false)
await refreshOverview()
} catch (error) {
setNotice(String(error.message || error), true)
}
}
const startPolling = () => {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
try {
await refreshOverview()
await refreshLogs()
await refreshCatalog()
} catch {}
}, 4000)
}
const bind = () => {
el('btn-refresh').onclick = refreshAll
el('btn-run-high').onclick = () => runMode('high')
el('btn-run-low').onclick = () => runMode('low')
el('btn-run-all').onclick = () => runMode('all')
el('btn-run-verify').onclick = () => runMode('verify')
el('btn-run-restore').onclick = () => runMode('restore')
el('btn-config-reload').onclick = refreshConfig
el('btn-config-save').onclick = saveConfig
el('btn-manual-record').onclick = recordManual
el('filter-failures').onchange = refreshLogs
el('btn-catalog-refresh').onclick = refreshCatalog
el('catalog-status-filter').onchange = refreshCatalog
}
document.addEventListener('DOMContentLoaded', async () => {
bind()
await refreshAll()
startPolling()
})
})()
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOX容灾哨兵</title>
<link rel="stylesheet" href="/tools/box_disaster_sentinel/box_disaster_sentinel.css">
</head>
<body>
<div class="page">
<header class="hero">
<div>
<h1>BOX容灾哨兵</h1>
<p>OSS 二线备份控制台:状态、操作、告警、配置一体化</p>
</div>
<div class="hero-actions">
<button id="btn-refresh" class="btn btn-primary">刷新</button>
</div>
</header>
<div id="auth-warning" class="notice notice-err" style="display:none;"></div>
<div id="save-notice" class="notice" style="display:none;"></div>
<main class="grid">
<section class="card">
<div class="card-head">
<h2>1. 状态总览</h2>
</div>
<div class="overview-grid">
<div class="mini-card">
<div class="mini-title">高频备份</div>
<div id="highfreq-status" class="status-text">-</div>
<div id="highfreq-last" class="muted">上次:-</div>
<div id="highfreq-next" class="muted">下次:-</div>
</div>
<div class="mini-card">
<div class="mini-title">低频备份</div>
<div id="lowfreq-status" class="status-text">-</div>
<div id="lowfreq-last" class="muted">上次:-</div>
<div id="lowfreq-next" class="muted">下次:-</div>
</div>
<div class="mini-card">
<div class="mini-title">当前任务</div>
<div id="current-run-status" class="status-text">空闲</div>
<div id="current-run-stage" class="muted">阶段:-</div>
<div id="current-run-message" class="muted">消息:-</div>
</div>
<div class="mini-card">
<div class="mini-title">百度云盘手工备份</div>
<div id="manual-last" class="status-text">-</div>
<div class="inline-actions">
<input id="manual-note" class="input" placeholder="备注,可选">
<button id="btn-manual-record" class="btn">我已完成</button>
</div>
</div>
</div>
<div class="meta-row">
<span id="oss-ready" class="pill">OSS状态:-</span>
<span id="verify-last" class="pill">最近抽检:-</span>
<span id="restore-last" class="pill">最近演练:-</span>
<span id="credential-file" class="muted">凭证文件:-</span>
</div>
</section>
<section class="card">
<div class="card-head">
<h2>2. 备份操作</h2>
</div>
<div class="inline-actions wrap">
<button id="btn-run-high" class="btn btn-primary">手动触发高频</button>
<button id="btn-run-low" class="btn btn-primary">手动触发低频</button>
<button id="btn-run-all" class="btn btn-primary">手动触发全部</button>
<button id="btn-run-verify" class="btn">回读抽检</button>
<button id="btn-run-restore" class="btn">恢复演练</button>
</div>
<div class="progress-block">
<div class="progress-head">
<span id="progress-title">当前无任务</span>
<span id="progress-percent">0%</span>
</div>
<div class="progress-bar">
<div id="progress-inner" class="progress-inner"></div>
</div>
<div id="progress-detail" class="muted">等待任务启动</div>
</div>
<pre id="run-summary" class="summary-box">暂无结果</pre>
</section>
<section class="card">
<div class="card-head">
<h2>3. 告警 & 日志</h2>
<div class="inline-actions">
<label class="checkbox">
<input id="filter-failures" type="checkbox">
<span>只看失败/重试</span>
</label>
</div>
</div>
<div class="table-wrap alerts-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>级别</th>
<th>标题</th>
<th>内容</th>
</tr>
</thead>
<tbody id="alerts-body"></tbody>
</table>
</div>
<div class="table-wrap logs-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>模式</th>
<th>类型</th>
<th>内容</th>
</tr>
</thead>
<tbody id="logs-body"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">
<h2>4. 备份清单</h2>
<div class="inline-actions">
<label class="field field-inline">
<span>低频状态</span>
<select id="catalog-status-filter" class="input">
<option value="">全部</option>
<option value="uploaded">已上传</option>
<option value="failed">失败</option>
<option value="pending">待上传</option>
</select>
</label>
<button id="btn-catalog-refresh" class="btn">刷新清单</button>
</div>
</div>
<div class="catalog-grid">
<div>
<div class="section-subtitle">高频批次</div>
<div class="table-wrap catalog-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>状态</th>
<th>Run GUID</th>
<th>内容</th>
<th>OSS Key</th>
<th>操作</th>
</tr>
</thead>
<tbody id="catalog-highfreq-body"></tbody>
</table>
</div>
</div>
<div>
<div class="section-subtitle">低频对象</div>
<div class="table-wrap catalog-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>状态</th>
<th>原始路径</th>
<th>存储名</th>
<th>OSS Key</th>
<th>操作</th>
</tr>
</thead>
<tbody id="catalog-lowfreq-body"></tbody>
</table>
</div>
</div>
</div>
</section>
<section class="card">
<div class="card-head">
<h2>5. 配置管理</h2>
<div class="inline-actions">
<button id="btn-config-reload" class="btn">重新读取</button>
<button id="btn-config-save" class="btn btn-primary">保存并热生效</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>高频 cron</span>
<input id="cfg-high-cron" class="input">
</label>
<label class="field">
<span>低频 cron</span>
<input id="cfg-low-cron" class="input">
</label>
<label class="field">
<span>轮询秒数</span>
<input id="cfg-poll-seconds" class="input" type="number" min="10">
</label>
<label class="field">
<span>OSS 根目录</span>
<input id="cfg-oss-root-prefix" class="input">
</label>
<label class="field">
<span>根目录 Key 名</span>
<input id="cfg-oss-root-prefix-key" class="input">
</label>
<label class="field">
<span>低频固定目录</span>
<input id="cfg-lowfreq-dir-name" class="input">
</label>
<label class="field">
<span>OSS Bucket</span>
<input id="cfg-oss-bucket" class="input">
</label>
<label class="field">
<span>OSS Endpoint</span>
<input id="cfg-oss-endpoint" class="input">
</label>
<label class="field">
<span>凭证文件名</span>
<input id="cfg-credential-file" class="input">
</label>
<label class="field">
<span>AK Key 名</span>
<input id="cfg-ak-key" class="input">
</label>
<label class="field">
<span>SK Key 名</span>
<input id="cfg-sk-key" class="input">
</label>
<label class="field">
<span>Bucket Key 名</span>
<input id="cfg-bucket-key" class="input">
</label>
<label class="field">
<span>Endpoint Key 名</span>
<input id="cfg-endpoint-key" class="input">
</label>
<label class="field">
<span>通知方式</span>
<input id="cfg-notification-placeholder" class="input" value="第一版预留,暂不启用" readonly>
</label>
<label class="field">
<span>抽检样本数</span>
<input id="cfg-sample-size" class="input" type="number" min="1">
</label>
<label class="field">
<span>恢复演练最大下载(MB)</span>
<input id="cfg-restore-max-mb" class="input" type="number" min="128">
</label>
</div>
<div class="textarea-grid">
<label class="field">
<span>高频代码路径(每行一个)</span>
<textarea id="cfg-high-code-paths" class="textarea"></textarea>
</label>
<label class="field">
<span>高频根文件(每行一个)</span>
<textarea id="cfg-high-root-files" class="textarea"></textarea>
</label>
<label class="field">
<span>高频扩展名(每行一个)</span>
<textarea id="cfg-high-exts" class="textarea"></textarea>
</label>
<label class="field">
<span>低频路径(每行一个)</span>
<textarea id="cfg-low-paths" class="textarea"></textarea>
</label>
<label class="field">
<span>高频排除目录(每行一个)</span>
<textarea id="cfg-high-excludes" class="textarea"></textarea>
</label>
<label class="field">
<span>低频排除目录(每行一个)</span>
<textarea id="cfg-low-excludes" class="textarea"></textarea>
</label>
</div>
</section>
</main>
</div>
<script src="/tools/box_disaster_sentinel/box_disaster_sentinel.js"></script>
</body>
</html>