/* 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 `${esc(l)}`; };
const v = (x, unit = '') => (x === null || x === undefined || x === '' ? '미확정(후속 상품정책)' : 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 = `
${esc(d.mailBackupNotice)} · 외부 배포 사이트에는 백업 옵션을 제공하지 않습니다.
| 사이트 | 배포 | 옵션 | 최근 백업 | 다음 예정 | 작업 |
${d.sites.map((s) => `| ${esc(s.name)} | ${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부(백업 미제공)' : '미정'} | ${s.option ? badge(OPT, s.option.status) + (s.option.pausedReason ? ` ${esc(s.option.pausedReason)}` : '') + (s.option.deleteAfter ? ` 백업 삭제 예정 ${esc(fmt(s.option.deleteAfter))}` : '') : s.eligible ? '미구매' : '제공 불가'} | ${s.lastBackup ? `${esc(fmt(s.lastBackup.created_at))} ${s.lastBackup.verify_state === 'verified' ? '✔ 검증됨' : '검증 안 됨'}` : '—'}${s.option && s.option.consecutiveFailures ? ` 실패 ${s.option.consecutiveFailures}회` : ''} | ${s.option && s.option.status === 'active' ? esc(fmt(s.option.nextRunAt)) : '—'} | ${s.option ? '관리' : s.eligible ? '옵션 구매' : '보기'} |
`).join('')}
${d.restores.length ? `복원 요청
| 사이트 | 상태 | 요청 시각 | 비용 |
${d.restores.map((r) => `| ${esc(r.siteName)} | ${badge(RS, r.status)} | ${esc(fmt(r.createdAt))} | ${r.cost} |
`).join('')}
` : ''}`;
} 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 = `포함: ${Object.values(d.scope).map(esc).join(', ')}
제외: ${d.excluded.map(esc).join(', ')}
${esc(d.mailBackupNotice)}
`;
const policy = `- 저장 한도
- ${p.storageLimitBytes != null ? bytes(p.storageLimitBytes) : v(null)}
- 현재 사용량
- ${bytes(p.currentUsageBytes)}
- 정기 실행
- ${p.schedule ? '매일 ' + esc(p.schedule.daily || '') + ' (KST)' : v(null)}
- 보존 개수
- ${p.retention ? `Daily ${p.retention.daily} · Weekly ${p.retention.weekly} · Monthly ${p.retention.monthly}` : v(null)}
- 첫 백업 예정
- ${o ? esc(fmt(o.first_backup_due_at)) + (o.firstBackupPending ? ' (대기 중)' : ' (완료)') : p.firstBackupDueAt ? '구매 후 ' + p.firstBackupWithinHours + '시간 이내' : v(null)}
- 원격 사본 검증
- ${v(p.verifyMethod)}
- 추가 저장 옵션
- ${(p.extraStorageOptions || []).length ? p.extraStorageOptions.map((x) => esc(x.label || JSON.stringify(x))).join(', ') : '없음'}
- 복원 비용
- ${p.restoreCost != null ? esc(p.restoreCost) + ' ' + esc(p.productVersion ? p.productVersion.currency : '') : v(null)}
- 옵션 만료 후 보관
- ${p.expiryRetentionDays}일 뒤 삭제
${p.deferred.length ? `미확정 정책 항목: ${p.deferred.map(esc).join(', ')}
` : ''}`;
let head;
if (!o || o.status === 'ended') head = `백업 옵션
${o ? `
${badge(OPT, 'ended')} 종료 ${esc(fmt(o.ends_at))} · 백업 삭제 예정 ${esc(fmt(o.delete_after))} (보존 중에는 복원 요청 가능)
` : ''}${p.eligible ? `
${p.productVersion ? `${esc(p.productVersion.name)} · ${p.productVersion.price} ${esc(p.productVersion.currency)} / ${esc(p.productVersion.billingCycle)}` : ''}
${policy}${scope}
` : `
이 사이트에는 제공하지 않습니다${p.reasons.map(esc).join('
')}
`}
`;
else head = `백업 옵션 ${badge(OPT, o.status)}${o.pausedReasonLabel ? ` 사유: ${esc(o.pausedReasonLabel)}` : ''}
${policy}
- 마지막 성공
- ${o.last_success_at ? esc(fmt(o.last_success_at)) : '아직 없음'}
- 마지막 실패
- ${o.last_failure_at ? esc(fmt(o.last_failure_at)) + ` (연속 ${o.consecutive_failures}회, ${o.failureNotifyAfter}회부터 알림)` : '없음'}
- 다음 예정
- ${o.status === 'active' ? esc(fmt(o.next_run_at)) : '중지됨'}
- 저장 서버
- ${o.store_server_id ? '운영 서버와 분리된 저장소' : '첫 백업 때 결정'}
- 재개 방식
- ${o.resume_mode === 'auto' ? '사이트 재개 후 자동' : '사이트 재개 후 사용자 확인'}
${scope}
${o.status === 'active' ? '' : o.status === 'paused' ? '' : ''}
${d.lastRun ? '
최근 실행
' : ''}
`;
const rows = d.backups.map((b) => `| ${esc(fmt(b.createdAt))} | ${esc(b.tier || b.kind)} | ${Object.keys(b.scope).filter((k) => b.scope[k] === true).join(', ')} | ${bytes(b.sizeBytes)} | ${b.verifyState === 'verified' ? '✔ 검증됨' : '! 미검증'} | ${esc(fmt(b.expiresAt))} | ${b.canRestore ? `복원 요청` : '—'} |
`).join('');
el.innerHTML = head + `
${d.restores.length ? `복원 기록
${d.restores.map((r) => `
${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))} 까지` : ''}
`).join('')}
` : ''}`;
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 = `복원할 백업
- 생성 시각
- ${esc(fmt(b.createdAt))}
- 포함 데이터
- ${i.included.map(esc).join(', ')}
- 제외
- ${i.excluded.map(esc).join(', ')}
- 검증
- ${esc(b.verifyState)}
- 서비스 영향
- ${esc(i.impact)}
- 예상 중단 시간
- ${i.estimatedDowntimeMin != null ? i.estimatedDowntimeMin + '분' : v(null)}
- 복원 비용
- ${i.cost != null ? i.cost + ' ' + esc(i.currency) : v(null)}
- 되돌리기 범위
- 복원 직전 보호 시점(${i.protectPointDays != null ? i.protectPointDays + '일 유지' : v(null)})으로 ${i.revertHours != null ? i.revertHours + '시간' : v(null)} 안에 되돌릴 수 있습니다
- 완료 방식
- ${i.completionMode === 'user_confirm' ? '고객 확인 후 완료' : '자동 완료'}
- 승인
- 운영자 승인 뒤 시작${i.cost ? ' (비용 확인·결제 선행)' : ''}
${i.conflicts.length ? `
진행 중인 작업이 끝나야 요청할 수 있습니다: ${i.conflicts.map((c) => esc(c.type + ' ' + c.status)).join(', ')}
` : ''}
`;
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); }
})();
})();