/* 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 ``; }).join('')}
이름 · 대표 도메인제작·배포 상태배포 방식메일 · 백업최근 배포건강 · 조치
${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('')}
`; } 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) => ``).join('') : ''; } catch (e) { sel.innerHTML = ``; } } }; }); 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) => `
  1. ${s.unsupported ? `${esc(s.label)} — ${esc(s.unsupported)}` : `${esc(s.label)}`}${s.done ? ' ✔' : ''}
  2. `).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) => `
  1. ${s.unsupported ? `${esc(s.label)} — ${esc(s.unsupported)}` : `${esc(s.label)}`} ${s.done ? '✔' : ''}
  2. `).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('
')}

제공되지 않음

`; 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) => ``).join('')}
시각버전결과완료 상태작업
${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}회)` : ''}` : '-'}
` : '

배포 기록이 없습니다

'}`; 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 = [``, ...r.versions.map((v) => ``)].join(''); start.innerHTML = `

배포 시작

이전 저장본을 골라 배포할 수 있습니다. 배포 전 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) => ``; 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 + '대'}

외부 배포 ${r.current === 'external' ? badge('ok', '현재') : ''}

지원 대상
${x.targets.map(esc).join('
')}
준비할 것
${x.prepare.map(esc).join('
')}
건별 배포 비용
${x.perDeployCost != null ? esc(String(x.perDeployCost)) : esc(x.costNote || '-')}
제공하지 않음
${x.notProvided.map(esc).join(', ')}
장애 분리
${esc(x.isolation)}
사이트 수
${esc(x.siteCountNote)}
Agent
${r.agent ? esc(r.agent.status) : '없음'} — 외부 서버 연결 화면

${esc(r.consent.title)} (버전 ${esc(r.consent.version)})

`; 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' ? '' : ''}

설치 안내

  1. 고객 서버(지원 Linux)에 root 또는 sudo 계정으로 접속합니다.
  2. "연결 토큰 발급" 을 누르면 설치 명령이 한 번만 표시됩니다(토큰은 10분 유효, 1회용). 복사해 서버에서 실행하세요.
  3. Agent 가 HAL 에 연결되면 상태가 "연결됨" 으로 바뀝니다. 그 뒤 배포 탭에서 배포합니다.
  4. 실패한 배포의 남은 파일·실행 상태는 "배포 제거" 로 정리할 수 있습니다.

외부 서버의 운영·보안·장애 대응은 고객 책임입니다. 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) => ``).join('')}
자원배정사용마지막 계측
${esc(a.label)}${a.allocation ? B(a.allocation.amount) : '미배정'}${a.usage ? B(a.usage.used) : '-'}${a.usage ? esc(fmt(a.usage.measured_at)) : '-'}

배정량 변경은 사용량 화면에서 합니다.

최근 계측값

${r.samples.slice(0, 60).map((s) => ``).join('') || ''}
항목값기간시각
${esc(s.metric)}${/bytes/.test(s.metric) ? B(s.value) : s.value}${esc(s.period || '')}${esc(fmt(s.measured_at))}
수집 전
`; } 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) => ``).join('')}
시각누가무엇결과
${esc(fmt(a.at))}${a.automatic ? '자동' : esc(a.actor_label || a.actor_id || '-')}${esc(a.action)}${esc(a.outcome)}
`; } 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) => ``).join('')}
구성원권한
${esc(m.alias || m.email)}${(m.perms || []).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 = `${r.domains.map((d) => ``).join('')}
도메인대표소유 확인DNSHTTPS마지막 확인
${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)) : '-'}
`; 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 ? '' : ''}
`, $('#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) => ``).join('')}
#종류메시지시각브랜치배포
${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]) : ''}
` : '

변경 이력이 없습니다

'}
` : ''}`; 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] || (() => {}))(); })();