Files

34 lines
7.3 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ============================================================================
// 외부 작업 복구 목록 (v1.6.31, 요구 20) — 배포·도메인 구매·메일 계정·인증서·데이터 파일 중 정상 완료되지 않은 것을 모아 관리자에게 보인다.
//
// 항목 모양: { kind, id, siteId, siteTitle, userId, state, at, detail, userTodo, actions: [{ id, label }], needsAdmin }
// kind: deploy(마지막 배포가 준비 완료가 아님) · purchase(구매 원장 pending/unknown) · mail(메일 계정 deprovision_pending/needs-admin/login_conflict)
// · cert(와일드카드 인증서 만료 임박·갱신 실패) · data(손상 파일) · job(서버 재시작으로 끊긴 작업)
// actions 는 server.js 의 POST /api/admin/recovery/action 이 실행한다 (이미 끝난 부분은 다시 하지 않는다 — 각 실행 함수가 멱등).
// ============================================================================
const path = require('path');
function items(ctx) {
const { store, deploy, domains, mailsys, jsonstore, ssl } = ctx; const out = [];
const PHASE = { failed: '전송 실패', 'vhost-wait': '웹서버 설정 적용 대기', 'vhost-failed': '도메인 연결 실패', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP 로 열림)', 'check-failed': '접속 확인 실패', transferred: '전송 완료(확인 생략)' };
for (const s0 of store.list()) {
const site = store.load(s0.id); if (!site) continue;
const last = [...(site.deployments || [])].reverse().find((d) => d.kind !== 'unpublish'); if (!last) continue;
if (last.ok && last.ready && last.phase === 'ready') continue;
if (last.kind === 'rollback') continue;
const phase = last.phase || (last.ok ? 'transferred' : 'failed'); if (phase === 'transferred' && last.ok) continue;
const todo = phase === 'dns-wait' ? '사용자: 도메인 DNS 의 A 레코드가 이 서버를 가리키게 하고 전파를 기다린 뒤 "다시 배포"' : phase === 'cert-wait' ? '운영자: 와일드카드/사이트 인증서 발급 (관리자 콘솔 › 설정 › SSL)' : phase === 'vhost-failed' ? '운영자: 배포 서버 sudo·nginx 상태 확인(배포 서버 탭 연결 테스트) 뒤 "다시 배포"' : '사용자/운영자: 원인을 확인하고 "다시 배포"';
out.push({ kind: 'deploy', id: site.id, siteId: site.id, siteTitle: site.title, userId: site.ownerId, state: phase, stateLabel: PHASE[phase] || phase, at: last.at, detail: last.phaseDetail || last.detail || '', userTodo: todo, actions: [{ id: 'recheck', label: '다시 확인' } /* v1.6.33 */, { id: 'redeploy', label: '다시 배포' }, ...(last.hasPrev || (deploy.get(last.targetId) || {}).type === 'ssh' ? [{ id: 'rollback', label: '이전 배포본으로' }] : [])], needsAdmin: phase === 'vhost-failed' || phase === 'cert-wait' });
}
for (const p of domains.purchases({ open: true })) out.push({ kind: 'purchase', id: p.id, siteId: null, userId: p.userId, state: p.state, stateLabel: { pending_provider: '업체 호출 중/끊김', provider_succeeded: '업체 등록됨 · 차감 전', charged: '차감됨 · 기록 전', unknown: '확인 필요(응답 없음)' }[p.state] || p.state, at: p.updatedAt || p.at, detail: `${p.name} (${p.op}, ${p.period}년, 견적 ${p.quoteKrw}원, 업체 ${p.registrar})${p.error ? ' — ' + p.error : ''}`, userTodo: '관리자: "업체에 확인" 으로 등록 여부를 조회해 반영(등록됐으면 차감·기록, 아니면 실패로 닫음)', actions: [{ id: 'reconcile-purchase', label: '업체에 확인' }], needsAdmin: true });
// v1.6.32: 등록 업체가 비동기로 처리 중인 도메인(Route 53 Domains) — 완료 확인·영역 생성을 여기서도 할 수 있다
for (const x of domains.adminList().filter((x) => x.status === 'PENDING' || x.status === 'FAILED')) out.push({ kind: 'domain', id: x.id, siteId: null, userId: x.userId, state: x.status === 'FAILED' ? 'registration-failed' : 'registration-pending', stateLabel: x.status === 'FAILED' ? '도메인 등록 실패(업체)' : '도메인 등록 처리 중(업체)', at: x.createdAt, detail: `${x.name} (${x.registrar})${x.zoneError ? ' — ' + x.zoneError : ''}`, userTodo: x.status === 'FAILED' ? '관리자: AWS 콘솔 › Route 53 › 등록된 도메인 › 요청에서 실패 원인 확인. 비용이 차감됐으면 회원·청구에서 환불' : '관리자: 등록자 이메일 확인 요청(AWS)이 있으면 사용자에게 승인 안내, "등록 상태 확인" 으로 완료 반영', actions: x.status === 'FAILED' ? [] : [{ id: 'finalize-domain', label: '등록 상태 확인' }], needsAdmin: x.status === 'FAILED' });
for (const a of mailsys.listAccounts()) { if (a.state === 'active' || !a.state) continue; out.push({ kind: 'mail', id: `${a.siteId}:${a.memberId}`, siteId: a.siteId, siteTitle: (store.load(a.siteId) || {}).title, userId: (store.load(a.siteId) || {}).ownerId, state: a.state, stateLabel: { deprovision_pending: '메일 서버 계정 삭제 대기', 'needs-admin': '관리자 확인 필요(소유 불명확)' }[a.state] || a.state, at: a.updatedAt, detail: `${a.login || ''}${a.address ? ' <' + a.address + '>' : ''}${a.providerId ? ' · 공급자 id ' + a.providerId : ''}${a.lastError ? ' — ' + a.lastError : ''}`, userTodo: a.state === 'needs-admin' ? '관리자: 메일 서버(Arimail 관리자 페이지)에서 이 계정이 이 사이트 것인지 확인 → "소유 확인 후 삭제" 또는 "기록만 정리"' : '관리자: 메일 서버 연결을 확인한 뒤 "삭제 다시 시도"', actions: a.state === 'needs-admin' ? [{ id: 'mail-delete-confirmed', label: '소유 확인 후 삭제' }, { id: 'mail-forget', label: '기록만 정리(서버 계정 유지)' }] : [{ id: 'mail-delete-retry', label: '삭제 다시 시도' }], needsAdmin: true }); }
try { const st = ssl && ssl.lastState ? ssl.lastState() : null; if (st && st.last && !st.last.ok) out.push({ kind: 'cert', id: 'wildcard', state: 'issue-failed', stateLabel: '와일드카드 인증서 발급 실패', at: st.last.at, detail: st.last.detail, userTodo: '운영자: 관리자 콘솔 › 설정 › SSL 에서 원인(DNS TXT 등)을 확인하고 다시 발급', actions: [], needsAdmin: true }); } catch (_) {}
for (const h of jsonstore.health()) out.push({ kind: 'data', id: h.file, state: h.recoveredFromBackup ? 'recovered' : 'corrupt', stateLabel: h.recoveredFromBackup ? '손상 — 백업(.bak)으로 읽는 중' : '손상 — 읽을 수 없음', at: h.at, detail: `${path.basename(h.file)}: ${h.error}`, userTodo: '관리자: 서비스를 멈추고 .bak 또는 backups/ 의 백업으로 파일을 복구한 뒤 재시작 (docs/CONFIG.md 복구 절차)', actions: [], needsAdmin: true });
for (const s0 of store.list()) { const site = store.load(s0.id); if (!site) continue; const last = (site.commands || []).slice(-1)[0]; if (last && last.status === 'interrupted') out.push({ kind: 'job', id: site.id, siteId: site.id, siteTitle: site.title, userId: site.ownerId, state: 'interrupted', stateLabel: '서버 재시작으로 끊긴 작업', at: last.finishedAt || last.startedAt, detail: `#${last.no} ${last.kind}: ${String(last.instruction || '').slice(0, 80)}`, userTodo: '사용자: 수정 탭에서 같은 지시를 다시 실행 (지금까지의 변경은 보존됨)', actions: [], needsAdmin: false }); }
out.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
return out;
}
module.exports = { items };