Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
59 lines
14 KiB
JavaScript
59 lines
14 KiB
JavaScript
/* portal/backup/public/backup.js — 백업·복원 화면 스크립트(§19·§20). 서버 JSON(/api/backup/*)을 그려 준다. */
|
|
(() => {
|
|
const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw;
|
|
const bytes = (n) => { n = Number(n) || 0; return n >= 1073741824 ? (n / 1073741824).toFixed(1) + ' GB' : n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(0) + ' KB' : n + ' B'; };
|
|
const OPT = { active: ['ok', '활성'], paused: ['warning', '중지됨'], ended: ['failed', '종료됨'] };
|
|
const RS = { awaiting_payment: ['waiting', '결제 대기'], awaiting_approval: ['waiting', '승인 대기'], queued: ['waiting', '처리 대기'], running: ['running', '진행 중'], needs_user: ['warning', '사용자 확인 필요'], done: ['done', '완료'], failed: ['failed', '실패'], reverted: ['ok', '되돌림 완료'], rejected: ['failed', '거절됨'], cancelled: ['neutral', '취소됨'] };
|
|
const badge = (m, k) => { const [c, l] = m[k] || ['neutral', k]; return `<span class="badge ${c}">${esc(l)}</span>`; };
|
|
const v = (x, unit = '') => (x === null || x === undefined || x === '' ? '<span class="muted">미확정(후속 상품정책)</span>' : esc(x) + unit);
|
|
|
|
const list = document.getElementById('bk-list');
|
|
if (list) poll(async () => {
|
|
try {
|
|
const d = await api('GET', '/api/backup/overview');
|
|
if (!d.sites.length) { state(list, 'empty', '사이트가 없습니다. 사이트를 만든 뒤 관리형으로 배포하면 백업 옵션을 살 수 있습니다', { href: '/sites', hrefLabel: '사이트로' }); return; }
|
|
list.innerHTML = `<div class="card"><p class="sub">${esc(d.mailBackupNotice)} · 외부 배포 사이트에는 백업 옵션을 제공하지 않습니다.</p></div>
|
|
<table class="r"><thead><tr><th>사이트</th><th>배포</th><th>옵션</th><th>최근 백업</th><th>다음 예정</th><th>작업</th></tr></thead><tbody>${d.sites.map((s) => `<tr><td data-l="사이트"><a href="/sites/${s.id}/backup">${esc(s.name)}</a></td><td data-l="배포">${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부(백업 미제공)' : '미정'}</td><td data-l="옵션">${s.option ? badge(OPT, s.option.status) + (s.option.pausedReason ? ` <span class="sub">${esc(s.option.pausedReason)}</span>` : '') + (s.option.deleteAfter ? ` <span class="sub">백업 삭제 예정 ${esc(fmt(s.option.deleteAfter))}</span>` : '') : s.eligible ? '<span class="muted">미구매</span>' : '<span class="muted">제공 불가</span>'}</td><td data-l="최근 백업">${s.lastBackup ? `${esc(fmt(s.lastBackup.created_at))} ${s.lastBackup.verify_state === 'verified' ? '✔ 검증됨' : '검증 안 됨'}` : '—'}${s.option && s.option.consecutiveFailures ? ` <span class="err">실패 ${s.option.consecutiveFailures}회</span>` : ''}</td><td data-l="다음 예정">${s.option && s.option.status === 'active' ? esc(fmt(s.option.nextRunAt)) : '—'}</td><td data-l="작업"><a class="btn" href="/sites/${s.id}/backup">${s.option ? '관리' : s.eligible ? '옵션 구매' : '보기'}</a></td></tr>`).join('')}</tbody></table>
|
|
${d.restores.length ? `<h2>복원 요청</h2><table class="r"><thead><tr><th>사이트</th><th>상태</th><th>요청 시각</th><th>비용</th></tr></thead><tbody>${d.restores.map((r) => `<tr><td data-l="사이트"><a href="/sites/${r.siteId}/backup">${esc(r.siteName)}</a></td><td data-l="상태">${badge(RS, r.status)}</td><td data-l="요청">${esc(fmt(r.createdAt))}</td><td data-l="비용">${r.cost}</td></tr>`).join('')}</tbody></table>` : ''}`;
|
|
} catch (e) { state(list, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' ? 'stale' : 'failed', e.message, { retry: () => location.reload() }); }
|
|
}, 15000);
|
|
|
|
const el = document.getElementById('bk-site');
|
|
if (el) {
|
|
const siteId = el.dataset.site;
|
|
const render = async () => {
|
|
let d; try { d = await api('GET', `/api/backup/sites/${siteId}`); } catch (e) { state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: render }); return false; }
|
|
const p = d.purchase; const o = d.option;
|
|
const scope = `<p><b>포함:</b> ${Object.values(d.scope).map(esc).join(', ')}<br><b>제외:</b> ${d.excluded.map(esc).join(', ')}</p><p class="sub">${esc(d.mailBackupNotice)}</p>`;
|
|
const policy = `<dl class="kv"><dt>저장 한도</dt><dd>${p.storageLimitBytes != null ? bytes(p.storageLimitBytes) : v(null)}</dd><dt>현재 사용량</dt><dd>${bytes(p.currentUsageBytes)}</dd><dt>정기 실행</dt><dd>${p.schedule ? '매일 ' + esc(p.schedule.daily || '') + ' (KST)' : v(null)}</dd><dt>보존 개수</dt><dd>${p.retention ? `Daily ${p.retention.daily} · Weekly ${p.retention.weekly} · Monthly ${p.retention.monthly}` : v(null)}</dd><dt>첫 백업 예정</dt><dd>${o ? esc(fmt(o.first_backup_due_at)) + (o.firstBackupPending ? ' (대기 중)' : ' (완료)') : p.firstBackupDueAt ? '구매 후 ' + p.firstBackupWithinHours + '시간 이내' : v(null)}</dd><dt>원격 사본 검증</dt><dd>${v(p.verifyMethod)}</dd><dt>추가 저장 옵션</dt><dd>${(p.extraStorageOptions || []).length ? p.extraStorageOptions.map((x) => esc(x.label || JSON.stringify(x))).join(', ') : '없음'}</dd><dt>복원 비용</dt><dd>${p.restoreCost != null ? esc(p.restoreCost) + ' ' + esc(p.productVersion ? p.productVersion.currency : '') : v(null)}</dd><dt>옵션 만료 후 보관</dt><dd>${p.expiryRetentionDays}일 뒤 삭제</dd></dl>${p.deferred.length ? `<p class="err">미확정 정책 항목: ${p.deferred.map(esc).join(', ')}</p>` : ''}`;
|
|
let head;
|
|
if (!o || o.status === 'ended') head = `<div class="card"><h2>백업 옵션</h2>${o ? `<p>${badge(OPT, 'ended')} 종료 ${esc(fmt(o.ends_at))} · 백업 삭제 예정 ${esc(fmt(o.delete_after))} (보존 중에는 복원 요청 가능)</p>` : ''}${p.eligible ? `<p>${p.productVersion ? `${esc(p.productVersion.name)} · ${p.productVersion.price} ${esc(p.productVersion.currency)} / ${esc(p.productVersion.billingCycle)}` : ''}</p>${policy}${scope}<button class="btn primary" id="bk-buy">백업 옵션 활성화</button>` : `<div class="aw-state"><b>이 사이트에는 제공하지 않습니다</b><p>${p.reasons.map(esc).join('<br>')}</p></div>`}</div>`;
|
|
else head = `<div class="card"><h2>백업 옵션 ${badge(OPT, o.status)}${o.pausedReasonLabel ? ` <span class="sub">사유: ${esc(o.pausedReasonLabel)}</span>` : ''}</h2>${policy}<dl class="kv"><dt>마지막 성공</dt><dd>${o.last_success_at ? esc(fmt(o.last_success_at)) : '아직 없음'}</dd><dt>마지막 실패</dt><dd>${o.last_failure_at ? esc(fmt(o.last_failure_at)) + ` (연속 ${o.consecutive_failures}회, ${o.failureNotifyAfter}회부터 알림)` : '없음'}</dd><dt>다음 예정</dt><dd>${o.status === 'active' ? esc(fmt(o.next_run_at)) : '중지됨'}</dd><dt>저장 서버</dt><dd>${o.store_server_id ? '운영 서버와 분리된 저장소' : '첫 백업 때 결정'}</dd><dt>재개 방식</dt><dd>${o.resume_mode === 'auto' ? '사이트 재개 후 자동' : '사이트 재개 후 사용자 확인'}</dd></dl>${scope}<div class="actions">${o.status === 'active' ? '<button class="btn" id="bk-run">지금 백업</button>' : o.status === 'paused' ? '<button class="btn primary" id="bk-resume">정기 백업 다시 시작</button>' : ''}<button class="btn danger" id="bk-end">옵션 해지</button></div>${d.lastRun ? '<h3>최근 실행</h3><div id="bk-job"></div>' : ''}</div>`;
|
|
const rows = d.backups.map((b) => `<tr><td data-l="생성">${esc(fmt(b.createdAt))}</td><td data-l="등급">${esc(b.tier || b.kind)}</td><td data-l="범위">${Object.keys(b.scope).filter((k) => b.scope[k] === true).join(', ')}</td><td data-l="크기">${bytes(b.sizeBytes)}</td><td data-l="검증">${b.verifyState === 'verified' ? '<span class="badge ok">✔ 검증됨</span>' : '<span class="badge warning">! 미검증</span>'}</td><td data-l="만료">${esc(fmt(b.expiresAt))}</td><td data-l="작업">${b.canRestore ? `<a class="btn" href="/sites/${siteId}/restore/${b.id}">복원 요청</a>` : '—'}</td></tr>`).join('');
|
|
el.innerHTML = head + `<div class="card"><h2>보관 목록</h2>${rows ? `<table class="r"><thead><tr><th>생성</th><th>등급</th><th>범위</th><th>크기</th><th>검증</th><th>만료</th><th>작업</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="aw-state empty"><b>비어 있음</b><p>아직 완료된 백업이 없습니다</p></div>'}</div>
|
|
${d.restores.length ? `<div class="card"><h2>복원 기록</h2>${d.restores.map((r) => `<div class="kv-row"><p>${badge(RS, r.status)} 요청 ${esc(fmt(r.created_at))} · 백업 ${esc(r.backup_id)} · 비용 ${r.cost}${r.protect_until ? ` · 보호 시점 유지 ${esc(fmt(r.protect_until))}` : ''}${r.revert_until ? ` · 되돌리기 가능 ${esc(fmt(r.revert_until))} 까지` : ''}</p><div id="rs-${r.id}"></div></div>`).join('')}</div>` : ''}`;
|
|
if (d.lastRun) job('#bk-job', d.lastRun);
|
|
for (const r of d.restores) if (r.job) job(`#rs-${r.id}`, r.job, { onAction: async (a) => { try { if (a === 'confirm' || a === 'revert') { if (a === 'revert' && !(await confirmDanger({ title: '복원 전으로 되돌리기', target: '이 사이트', impacts: ['복원 뒤 생성된 데이터는 사라집니다', '보호 시점으로 되돌립니다'], confirmLabel: '되돌리기' }))) return; await api('POST', `/api/backup/restores/${r.id}/decision`, { action: a, confirm: true }); } else if (a === 'cancel') await api('POST', `/api/backup/restores/${r.id}/cancel`); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } } });
|
|
const q = (id) => document.getElementById(id);
|
|
if (q('bk-buy')) q('bk-buy').onclick = async () => { if (!(await confirmDanger({ title: '백업 옵션 활성화', target: '이 사이트', impacts: ['백업 권리 1개를 사용합니다', `첫 백업은 ${p.firstBackupWithinHours}시간 이내에 완료됩니다`], confirmLabel: '활성화' }))) return; try { await api('POST', `/api/backup/sites/${siteId}/option`, { confirm: true }); toast('활성화했습니다'); render(); } catch (e) { toast(e.message + (e.detail && e.detail.link ? ' — 상품 구매로 이동' : '')); if (e.detail && e.detail.link) setTimeout(() => (location.href = e.detail.link), 1200); } };
|
|
if (q('bk-run')) q('bk-run').onclick = async () => { try { await api('POST', `/api/backup/sites/${siteId}/run`); toast('백업을 시작했습니다'); render(); } catch (e) { toast(e.message); } };
|
|
if (q('bk-resume')) q('bk-resume').onclick = async () => { try { await api('POST', `/api/backup/sites/${siteId}/option/resume`); toast('다시 시작합니다'); render(); } catch (e) { toast(e.message); } };
|
|
if (q('bk-end')) q('bk-end').onclick = async () => { if (!(await confirmDanger({ title: '백업 옵션 해지', target: '이 사이트의 백업 옵션', impacts: ['새 백업이 중지됩니다', `기존 백업은 ${p.expiryRetentionDays}일 보관 뒤 삭제됩니다`, '삭제 뒤에는 복구할 수 없습니다'], typeToConfirm: '해지', confirmLabel: '해지' }))) return; try { await api('POST', `/api/backup/sites/${siteId}/option/end`, { confirm: true }); toast('해지했습니다'); render(); } catch (e) { toast(e.message); } };
|
|
return true;
|
|
};
|
|
poll(render, 8000);
|
|
}
|
|
|
|
const rs = document.getElementById('bk-restore');
|
|
if (rs) (async () => {
|
|
const siteId = rs.dataset.site; const bid = rs.dataset.backup;
|
|
try {
|
|
const d = await api('GET', `/api/backup/backups/${bid}`); const i = d.restoreInfo; const b = i.backup;
|
|
rs.innerHTML = `<div class="card"><h2>복원할 백업</h2><dl class="kv"><dt>생성 시각</dt><dd>${esc(fmt(b.createdAt))}</dd><dt>포함 데이터</dt><dd>${i.included.map(esc).join(', ')}</dd><dt>제외</dt><dd>${i.excluded.map(esc).join(', ')}</dd><dt>검증</dt><dd>${esc(b.verifyState)}</dd><dt>서비스 영향</dt><dd>${esc(i.impact)}</dd><dt>예상 중단 시간</dt><dd>${i.estimatedDowntimeMin != null ? i.estimatedDowntimeMin + '분' : v(null)}</dd><dt>복원 비용</dt><dd>${i.cost != null ? i.cost + ' ' + esc(i.currency) : v(null)}</dd><dt>되돌리기 범위</dt><dd>복원 직전 보호 시점(${i.protectPointDays != null ? i.protectPointDays + '일 유지' : v(null)})으로 ${i.revertHours != null ? i.revertHours + '시간' : v(null)} 안에 되돌릴 수 있습니다</dd><dt>완료 방식</dt><dd>${i.completionMode === 'user_confirm' ? '고객 확인 후 완료' : '자동 완료'}</dd><dt>승인</dt><dd>운영자 승인 뒤 시작${i.cost ? ' (비용 확인·결제 선행)' : ''}</dd></dl>
|
|
${i.conflicts.length ? `<p class="err">진행 중인 작업이 끝나야 요청할 수 있습니다: ${i.conflicts.map((c) => esc(c.type + ' ' + c.status)).join(', ')}</p>` : ''}
|
|
<div class="actions"><a class="btn" href="/sites/${siteId}/backup">뒤로</a><button class="btn primary" id="rs-go" ${i.canRequest ? '' : 'disabled'}>복원 요청</button></div></div>`;
|
|
document.getElementById('rs-go').onclick = async () => { if (!(await confirmDanger({ title: '복원 요청', target: `백업 ${fmt(b.createdAt)}`, impacts: [i.impact, '운영자 승인과 (해당 시) 결제 확인 뒤 시작됩니다', '시작 전 최종 확인 화면에 그룹·사이트·도메인이 표시됩니다'], confirmLabel: '요청' }))) return; try { const r = await api('POST', `/api/backup/sites/${siteId}/restore`, { backupId: bid, confirm: true }); toast(r.created ? `복원 요청 접수 (${r.finalConfirmation.group} / ${r.finalConfirmation.site} / ${r.finalConfirmation.domain || '도메인 없음'})` : '이미 열린 요청이 있습니다'); location.href = `/sites/${siteId}/backup`; } catch (e) { toast(e.message); } };
|
|
} catch (e) { state(rs, e.status === 403 ? 'forbidden' : 'failed', e.message); }
|
|
})();
|
|
})();
|