/* 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) => ``).join('')}
사이트배포옵션최근 백업다음 예정작업
${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 ? '옵션 구매' : '보기'}
${d.restores.length ? `

복원 요청

${d.restores.map((r) => ``).join('')}
사이트상태요청 시각비용
${esc(r.siteName)}${badge(RS, r.status)}${esc(fmt(r.createdAt))}${r.cost}
` : ''}`; } 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 + `

보관 목록

${rows ? `${rows}
생성등급범위크기검증만료작업
` : '
비어 있음

아직 완료된 백업이 없습니다

'}
${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); } })(); })();