/* portal/sites/public/sites.js — 사이트 화면 스크립트. data-page 로 화면을 고르고 /api/sites/* 를 채운다. 모든 화면: 불러오는 중 → 데이터 | 비어 있음 | 지연(stale) | 부분/전체 실패 | 권한 없음. */
(() => {
const $ = (s, r = document) => r.querySelector(s); const esc = aw.esc; const fmt = aw.fmt;
const B = (n) => { if (n == null) return '제공되지 않음'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < 4) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; };
const L = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' };
const H = { ok: ['ok', '정상'], degraded: ['warning', '성능 저하'], partial_outage: ['warning', '일부 장애'], down: ['failed', '중단'], unknown: ['neutral', '확인 전'], stale: ['warning', '정보 확인 지연'], not_provided: ['neutral', '제공되지 않음'] };
const V = { verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' };
const SC = { none: '-', reserved: '예약됨', in_use: '사용 중', returning: '반환 중' };
const badge = (kind, text) => `${({ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' })[kind] || '○'} ${esc(text)} `;
const onErr = (el, e, retry) => aw.state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' || e.status === 503 ? 'stale' : 'failed', e.message, { retry });
const siteId = () => $('[data-site]')?.dataset.site;
const jobAction = (sid) => async (a, j) => { if (a === 'cancel' && !(await aw.confirmDanger({ title: '작업 취소', target: j.label, impacts: ['진행 중인 단계는 안전한 지점에서 멈추고, 남은 중간 결과는 배포 화면에 표시됩니다'] }))) return; if (a === 'revert' && !(await aw.confirmDanger({ title: '되돌리기', target: j.label, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '실제 외부 접속을 확인한 뒤 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/jobs/${j.id}/${a === 'go' ? 'resume' : a}`); aw.toast('요청했습니다'); location.reload(); } catch (e) { aw.toast(e.message); } };
// ---------- 목록 ----------
async function list() {
const el = $('#list'); try { const r = await aw.api('GET', `/api/sites${el.dataset.filter ? '?filter=' + el.dataset.filter : ''}`); if (!r.sites.length) return aw.state(el, 'empty', '아직 사이트가 없습니다. "새 사이트" 로 시작하세요', { href: '/sites/new', hrefLabel: '새 사이트' });
const sc = r.siteCount; el.innerHTML = `
운영 사이트 수: 사용 중 ${sc.allocated} · 예약 ${sc.reserved} · 구매 ${sc.purchased} (사용 가능 ${sc.unallocated}) — 초안·제작 중 사이트는 포함되지 않습니다
이름 · 대표 도메인 제작·배포 상태 배포 방식 메일 · 백업 최근 배포 건강 · 조치 ${r.sites.map((s) => { const h = H[s.health] || H.unknown; return `${esc(s.name)} ${esc(s.primaryDomain || '대표 도메인 없음')} ${badge(s.lifecycle === 'live' ? 'ok' : ['paused', 'needs_domain'].includes(s.lifecycle) ? 'warning' : 'neutral', L[s.lifecycle] || s.lifecycle)}${s.deployState ? `${esc(V[s.deployState.verify] || s.deployState.status)} ` : ''}사이트 수 ${esc(SC[s.siteCountState] || s.siteCountState)} ${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부' : '미선택 '} ${s.mail ? '✉ 메일' : '메일 없음 '} ${s.backup ? '⟲ 백업 ' + esc(s.backup.status) : '백업 없음 '} ${s.lastDeployAt ? esc(fmt(s.lastDeployAt)) : '없음 '} ${badge(h[0], h[1])}${s.actionNeeded.map((a) => `! ${esc(a.text)} `).join('')} `; }).join('')}
`; } catch (e) { onErr(el, e, list); }
}
// ---------- 생성 ----------
async function create() {
const f = $('#newSite'); const pick = $('#projectPick'); const bm = $('#buildModePick');
f.querySelectorAll('[name=projectMode]').forEach((r) => { r.onchange = async () => { pick.hidden = r.value !== 'existing' || !r.checked; bm.hidden = r.value !== 'new'; if (r.value === 'existing' && r.checked) { const sel = $('#projectId'); try { const r2 = await aw.api('GET', '/api/webmaker/projects'); sel.innerHTML = r2.projects.length ? r2.projects.map((p) => `${esc(p.title)} (${p.versions}개 저장본${p.link ? ', 이미 연결됨' : ''}) `).join('') : '연결할 수 있는 프로젝트가 없습니다 '; } catch (e) { sel.innerHTML = `${esc(e.message)} `; } } }; });
f.onsubmit = async (e) => { e.preventDefault(); f.querySelectorAll('.err').forEach((x) => { x.textContent = ''; }); const fd = new FormData(f); const body = Object.fromEntries(fd.entries()); const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
try { const r = await aw.api('POST', '/api/sites', body); aw.markClean(); f.hidden = true; const c = $('#created'); c.hidden = false; c.innerHTML = `사이트 "${esc(r.site.name)}" 을(를) 만들었습니다 ${r.projectNote ? `${esc(r.projectNote)}
` : ''}다음 단계
${r.nextSteps.map((s) => `${s.unsupported ? `${esc(s.label)} — ${esc(s.unsupported)} ` : `${esc(s.label)} `}${s.done ? ' ✔' : ''} `).join('')} `; }
catch (err) { btn.disabled = false; const t = f.querySelector(`[data-err="${err.detail && err.detail.field || 'form'}"]`) || f.querySelector('[data-err=form]'); t.textContent = err.message; } };
}
// ---------- 개요 ----------
async function overview() {
const el = $('#overview'); const sid = siteId();
const render = (o) => {
const head = `상태 서비스 상태 ${badge(o.lifecycle === 'live' ? 'ok' : 'neutral', L[o.lifecycle] || o.lifecycle)} 배포 작업 ${o.lastDeploy ? badge(o.lastDeploy.status === 'done' ? 'done' : o.lastDeploy.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[o.lastDeploy.status] || o.lastDeploy.status) + (o.lastDeploy.verify ? ' ' + esc(V[o.lastDeploy.verify]) : '') : '없음 '} 외부 건강 ${o.kind === 'external' ? badge('neutral', '제공되지 않음') : o.health ? badge((H[o.health] || H.unknown)[0], (H[o.health] || H.unknown)[1]) + (o.healthCheckedAt ? ` ${esc(fmt(o.healthCheckedAt))} ` : '') : '-'} 사이트 수 ${esc(SC[o.siteCountState] || '-')}${o.returnDueAt ? ` (반환 예정 ${esc(fmt(o.returnDueAt))}) ` : ''} ${o.nextJob ? `다음 예정 작업 ${esc(o.nextJob.label)} ${o.nextJob.at ? esc(fmt(o.nextJob.at)) : ''} ` : ''}`;
const actions = o.actionNeeded.length ? `
` : '';
if (o.kind === 'not_deployed') return `${head}${actions}
준비 단계 ${esc(o.message)}
${o.readiness.map((s) => `${s.unsupported ? `${esc(s.label)} — ${esc(s.unsupported)} ` : `${esc(s.label)} `} ${s.done ? '✔' : ''} `).join('')} `;
if (o.kind === 'external') return `${head}${actions}외부 배포 사이트 Agent 연결 ${o.agent ? badge(o.agent.status === 'connected' ? 'ok' : 'warning', { connected: '연결됨', pending: '연결 대기', offline: '응답 없음', revoked: '해제됨' }[o.agent.status] || o.agent.status) + (o.agent.lastSeenAt ? ` 마지막 응답 ${esc(fmt(o.agent.lastSeenAt))} ` : '') : '없음 '} 마지막 배포 ${o.lastDeployResult ? `${o.lastDeployResult.ok ? '성공' : '실패'} · ${esc(fmt(o.lastDeployResult.at))} · 버전 ${esc(o.lastDeployResult.version || '-')}` : '없음'} 마지막 외부 확인 ${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} ${esc(o.lastExternalCheck.url || '')} (${o.lastExternalCheck.status || '-'})` : '제공되지 않음'} 고객 책임 범위 ${o.customerResponsibility.map(esc).join(' ')} 제공되지 않음 ${o.notProvided.map((x) => `${esc(x)} — 제공되지 않음 `).join('')} `;
const w = (p) => p == null ? '' : p >= 100 ? 'w100' : p >= 95 ? 'w95' : p >= 90 ? 'w90' : p >= 80 ? 'w80' : p >= 70 ? 'w70' : '';
return `${head}${actions}접속 ${o.stale ? `
정보 확인 지연 ${esc(o.staleReason)}
` : ''}
대표 도메인 ${o.primaryDomain ? `${esc(o.primaryDomain)} ` : '없음'} 현재 배포 버전 ${esc(o.currentVersion || '-')} ${esc(fmt(o.lastDeployAt))} 최근 24시간 접속 ${o.visits24h != null ? `${o.visits24h.toLocaleString()} 방문 · ${(o.requests24h || 0).toLocaleString()} 요청` : '수집 전'} 7일 추이 ${o.series.series7d.length ? o.series.series7d.map((d) => `${esc(d.date.slice(5))}: ${d.pv}`).join(' · ') : '수집 전'} 30일 추이 ${o.series.series30d.length ? o.series.series30d.length + '일 수집' : '수집 전'} 마지막 수집 ${o.measuredAt ? esc(fmt(o.measuredAt)) : '-'}
디스크와 데이터베이스
사용 ${B(o.disk.used)} / 한도 ${B(o.disk.limit)} ${o.disk.percent != null ? `(${o.disk.percent}%)` : ''} 데이터베이스 ${B(o.disk.db)} 업로드 ${B(o.disk.uploads)}
네트워크 아웃바운드 일/월/누적 ${B(o.network.out.day)} / ${B(o.network.out.month)} / ${B(o.network.out.total)} ${o.network.out.limit ? `(월 한도 ${B(o.network.out.limit)})` : ''} 인바운드 일/월/누적 ${B(o.network.in.day)} / ${B(o.network.in.month)} / ${B(o.network.in.total)}
오류와 성능 오류율 ${o.errors.errorRate != null ? o.errors.errorRate + '%' : '수집 전'} 응답 시간(p95) ${o.errors.p95ms != null ? o.errors.p95ms + ' ms' : '수집 전'} 마지막 외부 확인 ${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} (${o.lastExternalCheck.status || '-'}, ${o.lastExternalCheck.ms || '-'}ms)` : '-'} `;
};
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/overview`); el.innerHTML = render(r.overview); return !!r.refreshJob; } catch (e) { onErr(el, e, load); return false; } };
if (await load()) aw.poll(async () => { await load(); return false; }, 15000);
}
// ---------- 제작 탭 ----------
async function build() {
const el = $('#buildInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); if (!r.project) return aw.state(el, 'empty', 'Webmaker 프로젝트가 연결되어 있지 않습니다'); el.innerHTML = `프로젝트 ${esc(r.project.title)} (${esc(r.project.id.slice(0, 8))}…, rev ${r.project.rev}) 제작 방식 ${esc({ ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '제목·내용 입력형' }[r.project.mode] || r.project.mode)} 내용 ${r.project.hasContent ? '홈 페이지 있음' : '아직 페이지가 없습니다'} 저장본 ${r.versions.length}개 ${r.versions.length ? `(최근: #${r.versions[0].no} ${esc(r.versions[0].message || r.versions[0].kind)} · ${esc(fmt(r.versions[0].at))})` : ''} `; const wl = $('#wallet'); if (wl) wl.innerHTML = r.wallet && !r.wallet.stale && r.wallet.credit ? `AI Wallet AI 생성·수정 비용은 AI Wallet(캐시)에서 차감되며 호스팅 디스크·트래픽 사용량과 분리됩니다. 충전·사용 이력은 상품 및 결제 화면에서 봅니다.
잔액 ${r.wallet.credit.balance.toLocaleString()} ${esc(r.wallet.unit || '원')} 사용 ${r.wallet.credit.used.toLocaleString()} ${esc(r.wallet.unit || '원')} ` : `AI Wallet 정보 확인 지연 잔액을 지금 확인할 수 없습니다
`; } catch (e) { onErr(el, e, build); }
}
// ---------- 배포 탭 ----------
async function deploy() {
const sid = siteId(); const cur = $('#deployCurrent'); const start = $('#deployStart'); const hist = $('#deployHistory');
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/deployments`); const s = r.site; const active = r.activeJobs.find((j) => ['site.deploy', 'site.rollback', 'site.cleanup', 'site.mode_change'].includes(j.kind)); const last = r.deployments[0];
cur.innerHTML = `현재 배포 현재 버전 ${esc(s.current_version || '없음')} ${s.deploy_verify ? badge(s.deploy_verify === 'public' ? 'ok' : 'warning', V[s.deploy_verify]) : ''} 배포 방식 ${s.deploy_mode === 'managed' ? '관리형' : s.deploy_mode === 'external' ? '외부' : '미선택'} 변경 사이트 수 ${esc(SC[s.site_count_state] || '-')}${s.return_due_at ? ` · 반환 예정 ${esc(fmt(s.return_due_at))} 지금 정리하고 반환 ` : ''} ${s.pending_cleanup_json ? '' : ''}
${active ? '' : `${last && last.previous_version ? '이전 버전으로 되돌리기 ' : ''}
`}`;
if (active) { aw.job($('#activeJob'), active, { onAction: jobAction(sid) }); } else if (last && last.job && ['failed', 'needs_user'].includes(last.job.status)) { aw.job($('#activeJob'), last.job, { onAction: jobAction(sid) }); }
const rb = $('#btnRollback'); if (rb) rb.onclick = async () => { if (!(await aw.confirmDanger({ title: '이전 버전으로 되돌리기', target: `${s.current_version} → ${last.previous_version}`, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/rollback`); aw.toast('되돌리기를 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
const cl = $('#btnCleanup'); if (cl) cl.onclick = async () => { if (!(await aw.confirmDanger({ title: '중간 결과 정리와 사이트 수 반환', target: s.name, impacts: ['배포 위치에 남은 중간 파일을 제거합니다', '사이트 수 예약을 반환합니다(다른 사이트가 쓸 수 있게 됨)', '현재 운영 중인 버전이 있으면 유지됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/cleanup`); aw.toast('정리 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
hist.innerHTML = `배포 기록 ${r.deployments.length ? `시각 버전 결과 완료 상태 작업 ${r.deployments.map((d) => `${esc(fmt(d.created_at))} ${esc(d.version_label || d.version_ref)}${d.previous_version ? `이전 ${esc(d.previous_version)} ` : ''} ${badge(d.status === 'done' ? 'done' : d.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[d.status] || d.status)}${d.result && d.result.error ? `${esc(d.result.error)} 문의 번호 ${esc(d.job_id || '')} ` : ''} ${d.verify_state ? esc(V[d.verify_state]) : '-'} ${d.job ? `${esc(aw.JOB_LABEL[d.job.status] || d.job.status)} ${d.job.attempt > 1 ? `(${d.job.attempt}회)` : ''}` : '-'} `).join('')}
` : '배포 기록이 없습니다
'}`;
if (!active) await startForm(s); else start.innerHTML = '';
return !!active; } catch (e) { onErr(cur, e, load); return false; } };
const startForm = async (s) => { try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); const opts = [`현재 저장 상태 (rev ${r.project ? r.project.rev : '-'}) `, ...r.versions.map((v) => `#${v.no} ${esc(v.message || v.kind)} · ${esc(fmt(v.at))} `)].join(''); start.innerHTML = `배포 시작 배포할 저장본 ${opts} 이전 저장본을 골라 배포할 수 있습니다. 배포 전 Webmaker 미리보기로 내용을 확인하세요(운영 버전은 배포 완료 전까지 바뀌지 않습니다)
배포 전 확인 배포 시작
`; $('#btnPrecheck').onclick = precheck; $('#ver').onchange = () => { $('#btnDeploy').disabled = true; $('#precheck').innerHTML = ''; }; $('#btnDeploy').onclick = async () => { $('#btnDeploy').disabled = true; try { const v = $('#ver').value; const r2 = await aw.api('POST', `/api/sites/${sid}/deploy`, { versionRef: v, versionLabel: $('#ver').selectedOptions[0].textContent.trim() }); aw.toast(r2.created ? '배포를 시작했습니다. 화면을 닫아도 계속 진행됩니다' : '이미 같은 배포가 진행 중입니다'); load(); } catch (e) { $('#precheck').innerHTML = `${esc(e.message)}
` + (e.detail && e.detail.items ? renderPrecheck(e.detail.items) : ''); } }; } catch (e) { start.innerHTML = `배포 시작 `; aw.state(start.appendChild(document.createElement('div')), e.code === 'service_unreachable' ? 'stale' : 'failed', e.message); } };
const renderPrecheck = (items) => `${items.map((i) => `${i.state === 'block' ? '✘' : i.state === 'pass' ? '✔' : i.state === 'warn' ? '!' : 'ⓘ'} ${esc(i.title)} — ${esc(i.text)} ${i.href ? `해결하기 ` : ''} `).join('')} `;
const precheck = async () => { const pc = $('#precheck'); aw.state(pc, 'loading'); try { const r = await aw.api('GET', `/api/sites/${sid}/precheck?version=${encodeURIComponent($('#ver').value)}`); pc.innerHTML = `${r.precheck.ok ? '✔ 모든 항목을 통과했습니다' : '✘ 차단된 항목이 있습니다. 해결 후 다시 확인하세요'}
${renderPrecheck(r.precheck.items)}`; $('#btnDeploy').disabled = !r.precheck.ok; } catch (e) { onErr(pc, e, precheck); } };
if (await load()) aw.poll(load, 4000);
}
// ---------- 배포 방식 ----------
async function mode() {
const el = $('#modeInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/mode`); const m = r.managed; const x = r.external;
el.innerHTML = `관리형 배포 ${r.current === 'managed' ? badge('ok', '현재') : ''} ${esc(m.description)}
모니터링 범위 ${m.monitoring.map(esc).join(' ')} 용량·트래픽 한도 ${m.limits.map((l) => `${esc(l.label)}: 구매 ${B(l.purchased)} · 미배정 ${B(l.unallocated)}`).join(' ') || '구매한 권리 없음'} 백업 옵션 ${esc(m.backupNote)} 서버 이전 ${esc(m.relocation)} 예상 배포 시간 약 ${m.estimatedMinutes}분 승인된 비용 ${m.cost != null ? esc(String(m.cost)) : esc(m.costNote || '-')} 지원 실행 형태 정적: ${esc(m.runtimeNotes.static)} 동적: ${esc(m.runtimeNotes.node)} 배치 가능 서버 ${m.halStale ? '정보 확인 지연 ' : m.serversAvailable + '대'} 관리형 선택
`;
el.querySelectorAll('[data-mode]').forEach((b) => { b.onclick = async () => { const md = b.dataset.mode; try { if (md === 'external' && !r.consent.agreed) { if (!$('#agree').checked) return aw.toast('확인 사항에 동의해야 합니다'); await aw.api('POST', `/api/sites/${sid}/consent`, { version: r.consent.version, agree: true }); } if (r.current && r.current !== md && !(await aw.confirmDanger({ title: '배포 방식 변경', target: `${r.current} → ${md}`, impacts: ['새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리 순서로 진행합니다', '완료 전까지 현재 위치가 유지되고, 실패하면 되돌립니다'] }))) return; const r2 = await aw.api('POST', `/api/sites/${sid}/mode`, { mode: md }); if (r2.job) { aw.toast('배포 방식 변경 작업을 시작했습니다'); aw.job($('#modeJob'), r2.job, { onAction: jobAction(sid) }); } else { aw.toast('배포 방식을 저장했습니다'); location.href = `/sites/${sid}?tab=deploy`; } } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, mode); }
}
// ---------- 외부 서버 ----------
async function external() {
const el = $('#external'); const sid = siteId();
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/external`); const a = r.agent; const st = a ? ({ connected: ['ok', '연결됨'], pending: ['warning', '연결 대기'], offline: ['failed', '응답 없음'], revoked: ['neutral', '해제됨'] }[a.status] || ['neutral', a.status]) : null;
el.innerHTML = `External Pull Agent ${!r.consent.agreed ? `
동의 필요 외부 배포 확인 사항에 먼저 동의하세요. 배포 방식 화면
` : ''}
연결 상태 ${a ? badge(st[0], st[1]) + (a.stale ? ` ${esc(a.staleReason)} ` : '') : '없음'} ${a ? `이름 ${esc(a.label || '-')} 마지막 응답 ${a.last_seen_at ? esc(fmt(a.last_seen_at)) : '-'} Agent 버전 ${esc(a.agent_version || '-')} 마지막 배포 ${a.lastDeploy ? esc(JSON.stringify(a.lastDeploy)).slice(0, 200) : '-'} 마지막 외부 확인 ${a.lastCheck ? esc(JSON.stringify(a.lastCheck)).slice(0, 200) : '제공되지 않음'} ` : ''}
${!a || a.status !== 'connected' ? `
연결 토큰 발급 ` : ''}${a && a.status === 'pending' ? '
연결 확인 ' : ''}${a && a.status === 'connected' ? `
배포 / 재배포 배포 제거 ` : ''}${a && a.status !== 'revoked' ? '
Agent 해제 ' : ''}
설치 안내 고객 서버(지원 Linux)에 root 또는 sudo 계정으로 접속합니다. "연결 토큰 발급" 을 누르면 설치 명령이 한 번만 표시됩니다(토큰은 10분 유효, 1회용). 복사해 서버에서 실행하세요. Agent 가 HAL 에 연결되면 상태가 "연결됨" 으로 바뀝니다. 그 뒤 배포 탭에서 배포합니다. 실패한 배포의 남은 파일·실행 상태는 "배포 제거" 로 정리할 수 있습니다. 외부 서버의 운영·보안·장애 대응은 고객 책임입니다. Ariweb 는 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.
`;
const bi = $('#btnIssue'); if (bi) bi.onclick = async () => { try { const r2 = await aw.api('POST', `/api/sites/${sid}/external`, { label: prompt('서버 이름(표시용)', '') || '' }); $('#issued').innerHTML = `한 번만 표시됩니다 아래 명령을 복사해 서버에서 실행하세요 (만료 ${esc(fmt(r2.expiresAt))})
${esc(r2.installCommand)} 복사 `; } catch (e) { aw.toast(e.message); } };
const bc = $('#btnCheck'); if (bc) bc.onclick = load;
const br = $('#btnRevoke'); if (br) br.onclick = async () => { if (!(await aw.confirmDanger({ title: 'Agent 해제', target: a.label || a.hal_agent_id, impacts: ['이 서버로의 배포·재배포·제거가 더 이상 되지 않습니다', '이미 배포된 파일은 서버에 남습니다(고객이 직접 정리)'] }))) return; try { await aw.api('DELETE', `/api/sites/${sid}/external`); load(); } catch (e) { aw.toast(e.message); } };
const bm = $('#btnRemove'); if (bm) bm.onclick = async () => { if (!(await aw.confirmDanger({ title: '외부 서버 배포 제거', target: a.label || a.hal_agent_id, impacts: ['외부 서버의 사이트 파일과 실행 상태를 제거합니다', '남은 파일 목록이 결과에 표시됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/external/remove`); aw.toast('제거 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
} catch (e) { onErr(el, e, load); } };
load();
}
// ---------- 사용량·활동·설정·도메인 ----------
async function usage() { const el = $('#usage'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/usage`); if (r.overview.kind !== 'managed') return aw.state(el, 'empty', r.overview.kind === 'external' ? '외부 배포 사이트에는 관리형 사용량이 제공되지 않습니다' : '정상 배포되지 않은 사이트에는 운영 사용량을 표시하지 않습니다'); el.innerHTML = `배정과 사용 자원 배정 사용 마지막 계측 ${r.allocations.map((a) => `${esc(a.label)} ${a.allocation ? B(a.allocation.amount) : '미배정'} ${a.usage ? B(a.usage.used) : '-'} ${a.usage ? esc(fmt(a.usage.measured_at)) : '-'} `).join('')}
배정량 변경은 사용량 화면 에서 합니다.
최근 계측값 항목 값 기간 시각 ${r.samples.slice(0, 60).map((s) => `${esc(s.metric)} ${/bytes/.test(s.metric) ? B(s.value) : s.value} ${esc(s.period || '')} ${esc(fmt(s.measured_at))} `).join('') || '수집 전 '}
`; } catch (e) { onErr(el, e, usage); } }
async function activity() { const el = $('#activity'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/activity`); if (!r.items.length && !r.jobs.length) return aw.state(el, 'empty', '기록이 없습니다'); el.innerHTML = `작업 ${r.jobs.map((j) => `
${esc(j.label)} ${esc(aw.JOB_LABEL[j.status] || j.status)} ${esc(fmt(j.updatedAt))}
`).join('') || '
없음
'}
활동 기록 시각 누가 무엇 결과 ${r.items.map((a) => `${esc(fmt(a.at))} ${a.automatic ? '자동' : esc(a.actor_label || a.actor_id || '-')} ${esc(a.action)} ${esc(a.outcome)} `).join('')}
`; } catch (e) { onErr(el, e, activity); } }
async function settings() { const f = $('#settings'); const sid = siteId(); f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); const body = { name: fd.get('name'), description: fd.get('description'), lang: fd.get('lang'), timezone: fd.get('timezone'), runtime: fd.get('runtime'), maintenance: fd.get('maintenance') === 'on', searchVisible: fd.get('searchVisible') === 'on' }; if (body.maintenance !== (f.dataset.maint === '1') && $('[data-lifecycle]')?.dataset.lifecycle === 'live') { if (!(await aw.confirmDanger({ title: '유지보수 모드 변경', target: fd.get('name'), impacts: [body.maintenance ? '방문자에게 안내 페이지가 보이고 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '즉시 다시 공개됩니다'] }))) return; } try { const r = await aw.api('PATCH', `/api/sites/${sid}`, body); aw.markClean(); aw.toast(r.impact.length ? r.impact.join(' ') : '저장했습니다'); } catch (err) { f.querySelector('[data-err=form]').textContent = err.message; } }; f.dataset.maint = f.querySelector('[name=maintenance]').checked ? '1' : '0';
const pe = $('#perms'); try { const r = await aw.api('GET', `/api/sites/${sid}/permissions`); pe.innerHTML = r.members && r.members.length ? `구성원 권한 ${r.members.map((m) => `${esc(m.alias || m.email)} ${(m.perms || []).join(', ') || '(그룹 역할 기준)'} `).join('')}
권한 변경은 사용자 그룹 설정 › 사이트별 권한에서 합니다.
` : '사이트별 권한이 없습니다(그룹 역할 기준)
'; } catch (e) { aw.state(pe, e.status === 404 ? 'empty' : 'failed', e.status === 404 ? '사이트별 권한 기능은 사용자 그룹 설정에서 제공됩니다' : e.message); } }
async function siteDomains() { const el = $('#siteDomains'); const sid = siteId(); try { const r = await aw.api('GET', `/api/domains?site=${sid}`); if (!r.domains.length) return aw.state(el, 'empty', '연결된 도메인이 없습니다. 여러 도메인을 연결해도 운영 사이트 수는 하나만 씁니다'); el.innerHTML = `도메인 대표 소유 확인 DNS HTTPS 마지막 확인 ${r.domains.map((d) => `${esc(d.name)} ${d.is_primary ? '★ 대표' : `대표로 지정 `} ${esc(d.ownership_state)} ${esc(d.dns_state)} ${esc(d.cert_state)} ${d.last_checked_at ? esc(fmt(d.last_checked_at)) : '-'} `).join('')}
`; el.querySelectorAll('[data-primary]').forEach((b) => { b.onclick = async () => { const redirect = confirm('기존 대표 주소를 새 대표 도메인으로 이동(301) 처리할까요?'); try { await aw.api('POST', `/api/domains/${b.dataset.primary}/primary`, { redirectOld: redirect }); aw.toast('대표 도메인을 지정했습니다'); siteDomains(); } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, siteDomains); } }
// ---------- Source ----------
async function source() { const el = $('#source'); const sid = siteId(); const load = async () => { try { const r = await aw.api('GET', `/api/source/${sid}`); const o = r.option; const s = r.source;
if (!o) return (el.innerHTML = `Source 옵션 ${r.entitlement.unallocated > 0 ? 'Source 옵션 권리가 있습니다. 이 사이트에 활성화하면 Gitea 저장소·변경 이력·clone·export 를 쓸 수 있습니다.' : 'Source 옵션을 구매한 사이트에서 제공합니다. 상품 및 결제 에서 옵션을 추가하세요.'}
${r.entitlement.unallocated > 0 ? '
이 사이트에 Source 옵션 활성화 ' : ''}
`, $('#btnAct') && ($('#btnAct').onclick = async () => { try { await aw.api('POST', `/api/source/${sid}/activate`); load(); } catch (e) { aw.toast(e.message); } }));
const stBadge = o.status === 'active' ? badge('ok', '활성') : o.status === 'readonly' ? badge('warning', '읽기 전용 보존') : badge('failed', '삭제 완료');
el.innerHTML = `Source 옵션 ${stBadge} ${o.status === 'active' ? `만료 예정 ${o.expires_at ? esc(fmt(o.expires_at)) : '상품 갱신과 연동'} ` : ''}${o.status === 'readonly' ? `읽기 전용 기간 ${esc(fmt(o.readonly_until))} 까지 조회·clone·export 가능 (push·새 변경 불가) 삭제 예정일 ${esc(fmt(o.delete_after))} 재활성화 Source 옵션을 다시 구매 하면 삭제 전까지 복구됩니다 ` : ''}${o.status === 'deleted' ? `삭제 완료 ${esc(fmt(o.deleted_at))} — 저장소 데이터는 복구할 수 없습니다 ` : ''}
${s ? `저장소 ${s.stale ? '
정보 확인 지연 Webmaker/Gitea 상태를 지금 확인할 수 없습니다
' : ''}
저장소 ${esc(s.remote || '-')} (${esc(s.provider)}) 기본 브랜치 ${esc(s.mainBranch || '-')} ${s.mainUrl ? `열기 ` : ''} 선택한 revision ${esc(r.selectedRevision || '현재 작업 트리')} 마지막 배포 revision ${esc(r.lastDeployedRevision || '-')} ${r.lastDeployedVersion ? `(배포 버전 ${esc(r.lastDeployedVersion)})` : ''} 저장소 사용량 ${B(s.repoBytes)} / 한도 ${r.limits ? B(r.limits.repoBytes) : '상품정책'} LFS ${s.lfsSupported ? B(s.lfsBytes) : '제공되지 않음(이번 버전)'} 첨부 파일 ${B(s.uploadBytes)} clone ${esc(r.cloneInfo || '-')} ${o.status !== 'deleted' ? `
export (tar.gz) ` : ''}${o.status === 'active' ? `
저장소 연결 해제 ` : ''}
변경 이력 ${s.changes && s.changes.length ? `
# 종류 메시지 시각 브랜치 배포 ${s.changes.slice().reverse().map((c) => `${c.no} ${esc(c.kind)} ${esc(c.message || '')} ${esc(fmt(c.at))} ${esc(c.branch || '')} ${c.pushed ? '' : '(push 대기)'} ${r.deployedCommits && r.deployedCommits[c.commit] ? '✔ ' + esc(r.deployedCommits[c.commit]) : ''} `).join('')}
` : '
변경 이력이 없습니다
'}
` : ''}`;
const bu = $('#btnUnlink'); if (bu) bu.onclick = async () => { if (!(await aw.confirmDanger({ title: '저장소 연결 해제', target: s.remote, impacts: ['새 변경이 Gitea 에 push 되지 않습니다', '30일 동안 read-only 조회·clone·export 가 가능하고 그 뒤 삭제됩니다'] }))) return; try { await aw.api('POST', `/api/source/${sid}/unlink`); load(); } catch (e) { aw.toast(e.message); } };
} catch (e) { onErr(el, e, load); } }; load(); }
const page = document.querySelector('[data-page]')?.dataset.page;
({ 'sites-list': list, 'sites-new': create, 'site-overview': overview, 'site-build': build, 'site-deploy': deploy, 'site-mode': mode, 'site-external': external, 'site-usage': usage, 'site-activity': activity, 'site-settings': settings, 'site-domains': siteDomains, 'site-source': source }[page] || (() => {}))();
})();