Files
AriWeb-Suite/modules/webmaker/public/admin.js
T

1246 lines
178 KiB
JavaScript
Raw Normal View History

'use strict';
const $ = (id) => document.getElementById(id);
const fmt = (n) => Math.round(n || 0).toLocaleString(uiLocale());
const usd = (n) => '$' + (n || 0).toFixed(3);
let cashUnit = '원'; // v1.4.1 캐시 표시 단위 (설정을 불러오면 바뀜)
const krw = (n) => { const v = Math.round(n || 0).toLocaleString(uiLocale()); return cashUnit === '원' ? TF('{1}원', v) : `${v}${/^[가-힣]+$/.test(cashUnit) ? '' : ' '}${cashUnit}`; }; // v1.3.10 통화 단위 번역, v1.4.1 관리자 지정 단위
const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '');
const KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확인', 'plan-structure': '구조 제안', 'plan-design': '디자인 3안', 'plan-verify': '검증', 'site-analyze': '사이트 분석', 'seo-draft': '검색·AI 최적화 초안' };
const MODE = { ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '기본', import: '사이트 관리(가져옴)', copy: '사이트 복사' };
const TIER = { basic: '기본', standard: '중급', premium: '고급' };
const STATUS = { running: '실행 중', deploying: '배포 중', 'waiting-credit': '캐시 대기', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김', idle: '대기' };
const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원' };
let overview = null, es = null, me = null, curSite = null, billingData = null, curBillUser = null, settingsData = null, siteFilter = 'all';
const DS = { pending: '배포 대기중', deployed: '발행 완료', working: '작업 중', deploying: '배포 중', empty: '완성 전', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' }; // v1.6.31 (요구 14) 단계 구분
const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본' };
/**
* 관리자 API 호출.
* v1.3.0 수정: 1.1.x~1.2.0 은 본문이 FormData(파일 업로드)여도 Content-Type 을 application/json 으로 고정해
* 브라우저가 multipart 경계(boundary)를 붙이지 못했고, 서버가 파일을 JSON 으로 읽으려다 "JSON 파싱 실패" 를 냈다
* (언어 파일·ZIP 업로드가 모두 실패한 원인). FormData 일 때는 헤더를 비워 브라우저가 multipart 헤더를 만들게 한다.
*/
/**
* v1.6.31 (요구 4): 서비스 API 는 세션 쿠키만으로는 쓸 수 없고, 출처가 이 화면(Referer)이거나 X-WM-CSRF 헤더가 있어야 한다.
* 토큰은 /api/auth/csrf 가 "출처가 빌더·관리자 화면인 요청" 에만 준다 — 같은 주소에서 열린 생성 사이트 페이지는 받을 수 없다.
*/
let _csrf = null; let _csrfP = null;
async function csrf() { if (_csrf) return _csrf; if (!_csrfP) _csrfP = fetch('/api/auth/csrf', { headers: { 'X-UI-Lang': (window.I18N && window.I18N.lang) || 'ko' } }).then((r) => r.ok ? r.json() : {}).then((b) => { _csrf = (b && b.csrf) || ''; return _csrf; }).catch(() => ''); return _csrfP; }
async function api(path, opt = {}) {
const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
if (!/^\/api\/auth\/csrf/.test(path)) { const tok = await csrf(); if (tok) headers['X-WM-CSRF'] = tok; }
const r = await fetch(path, { headers, ...opt });
const b = await r.json().catch(() => ({}));
if (r.status === 401) { location.href = '/login'; throw new Error('로그인이 필요합니다'); }
if (r.status === 403) { document.body.innerHTML = '<p style="padding:24px;color:#e6ebf5">관리자만 접근할 수 있습니다.</p>'; throw new Error('403'); }
if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
return b;
}
function td(tr, text, cls) { const c = document.createElement('td'); if (text instanceof Node) c.appendChild(text); else c.textContent = text == null ? '' : text; if (cls) c.className = cls; tr.appendChild(c); return c; }
function mkBtn(label, cls, fn) { const b = document.createElement('button'); b.type = 'button'; b.className = cls || 'ghost'; b.textContent = label; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; }
function showTab(name) { document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('hidden', t.id !== 'tab-' + name)); document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name)); if (es && name !== 'detail') { es.close(); es = null; } }
/**
* 탭별 데이터 불러오기 (v1.3.2). 탭을 열 때와 "새로고침" 때 같은 함수를 쓴다.
* sites 는 loadOverview 가 그리므로 별도 함수가 없다. detail 은 열려 있는 사이트 상세를 다시 연다.
*/
const TAB_LOADERS = { templates: () => loadTemplates(), search: () => loadSearchRegs(), feedback: () => loadFeedback(), i18n: () => loadI18n(), errors: () => loadErrors(), billing: () => loadBilling(), settings: () => loadSettings(), recovery: () => loadRecovery(), audit: () => loadAudit() /* v1.6.31 */, ai: () => loadSettings() /* v1.5.16 AI 연결 탭: 인증·사용할 Claude 모델·제공자 — 설정과 같은 응답을 쓴다 */, deploy: () => loadTargets(), mail: () => loadMailSys(), theme: () => loadTheme(), detail: () => (curSite ? openSite(curSite.id || curSite) : null) };
let activeAdminTab = 'sites';
/** 탭 열기: 화면 전환 + 데이터 불러오기 + 주소의 #탭 기록(브라우저 새로고침에도 같은 탭 유지) */
function openTab(name) { activeAdminTab = name; showTab(name); try { history.replaceState(null, '', name === 'sites' ? location.pathname : '#' + name); } catch (_) {} const f = TAB_LOADERS[name]; if (f) Promise.resolve(f()).catch(() => {}); }
document.querySelectorAll('.tabs button').forEach((b) => (b.onclick = () => openTab(b.dataset.tab)));
/**
* 새로고침 (v1.3.2 수정): 1.3.1 까지는 사이트 목록(개요)만 다시 불러와, 다른 탭을 보고 있으면 아무 변화가 없어
* "작동하지 않는" 것처럼 보였다. 이제 다른 화면으로 이동하지 않고 개요(위쪽 숫자·배지)와 지금 보고 있는 탭의
* 데이터를 모두 다시 불러오며, 끝나면 갱신 시각을 표시한다. 스크롤 위치는 그대로 둔다.
*/
$('btnReload').onclick = async () => {
const b = $('btnReload'); const y = window.scrollY; b.disabled = true; b.textContent = '갱신 중…';
try { await Promise.all([loadOverview(), Promise.resolve(TAB_LOADERS[activeAdminTab] ? TAB_LOADERS[activeAdminTab]() : null)]); $('reloadInfo').textContent = `갱신 ${new Date().toLocaleTimeString(uiLocale())}`; }
catch (e) { $('reloadInfo').textContent = '갱신 실패: ' + e.message; }
finally { b.disabled = false; b.textContent = '새로고침'; window.scrollTo(0, y); }
};
$('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login'; };
$('dBack').onclick = () => openTab('sites');
// ---------- 사이트 관리 ----------
async function loadOverview() {
// v1.5.27 (요구 6): 가입 승인 대기 수를 회원 · 청구 탭 배지에
overview = await api('/api/admin/overview');
{ const n = overview.pendingApprovals || 0; const b = $('apBadge'); if (b) { b.textContent = n ? String(n) : ''; b.classList.toggle('hidden', !n); } }
const kpi = $('kpi'); kpi.innerHTML = '';
const tot = overview.sites.reduce((a, s) => { a.tokens += (s.totals && s.totals.total) || 0; a.cost += (s.totals && s.totals.costUsd) || 0; a.errors += s.errors + s.toolErrors; a.running += s.status === 'running' ? 1 : 0; return a; }, { tokens: 0, cost: 0, errors: 0, running: 0 });
for (const [k, v] of [['회원', overview.users.length], ['사이트', overview.sites.length], ['실행 중', tot.running], ['총 토큰', fmt(tot.tokens)], ['총 비용', usd(tot.cost)], ['오류 건수', tot.errors], ['버전', overview.version || '-']]) { const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `<span class="k">${k}</span><span class="v">${v}</span>`; kpi.appendChild(d); }
if (overview.version) $('ver').textContent = `v${overview.version}`;
if (overview.targets) targets = overview.targets;
{ const fb = overview.feedback || {}; const b = $('fbBadge'); b.textContent = fb.new || ''; b.classList.toggle('hidden', !fb.new); const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `<span class="k">사용자 문의</span><span class="v">${fb.new || 0} 새 글 / ${fb.total || 0}건</span>`; kpi.appendChild(d); const l = document.createElement('div'); l.className = 'tot'; l.innerHTML = `<span class="k">활성 언어</span><span class="v">${(overview.languages || 0) + 1}개</span>`; kpi.appendChild(l); }
// v1.3.5: "문의 메일 발송 정상(도메인)" 칸 삭제 — 메일은 사이트 단위 메일 시스템·외부 연동 접속 정보에서 관리하며 여러 도메인을 지원한다
{ const st = overview.stats || {}; const l = st.local || {}; $('statsCollectLine').textContent = l.at ? `접속 로그 수집: ${l.ok ? `${dt(l.at)} 기준 정상 (${l.lines || 0}줄)` : `${dt(l.at)} 실패 — ${l.detail || ''}`}` : '접속 로그 수집: 아직 실행되지 않음'; }
const pendingCount = overview.sites.filter((x) => x.deployState && x.deployState.state === 'pending').length;
{ const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `<span class="k">배포 대기중</span><span class="v" style="color:${pendingCount ? 'var(--accent2)' : 'inherit'}">${pendingCount}</span>`; kpi.insertBefore(d, kpi.children[2]); }
renderSites();
}
function renderSites() {
const list = overview.sites.filter((x) => siteFilter === 'all' || (x.deployState && x.deployState.state === siteFilter));
const order = { working: 0, pending: 1, deployed: 2, empty: 3 };
list.sort((a, b) => (order[(a.deployState || {}).state] ?? 9) - (order[(b.deployState || {}).state] ?? 9) || ((b.deployState || {}).lastChangeAt || '').localeCompare((a.deployState || {}).lastChangeAt || ''));
$('siteCount').textContent = `${list.length}개${siteFilter !== 'all' ? ` (전체 ${overview.sites.length})` : ''}`;
const tb = $('sitesTable').querySelector('tbody'); tb.innerHTML = '';
for (const s of list) {
const ds = s.deployState || { state: 'empty' };
const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => openSite(s.id);
const b = document.createElement('span'); b.className = 'badge ' + ds.state; b.textContent = DS[ds.state] || ds.state; b.title = ds.state === 'pending' ? (ds.neverDeployed ? '아직 한 번도 배포되지 않았습니다' : `마지막 배포 ${dt(ds.lastDeployAt)} (${ds.lastDeployTarget || ''}) 이후 변경됨`) : (ds.lastDeployAt ? `마지막 배포 ${dt(ds.lastDeployAt)} → ${ds.lastDeployTarget || ''}` : ''); td(tr, b);
td(tr, ds.lastChangeAt ? `${dt(ds.lastChangeAt)} · ${T(CHG[ds.lastChangeKind] || (ds.neverDeployed ? '신규 생성' : ''))}` : '-'); // v1.3.10 조각 번역
td(tr, s.title); td(tr, `${s.ownerName || ''} (${s.ownerId || '-'})`); td(tr, (s.domains || []).join(', ') || (s.source ? '원본: ' + s.source : '-'));
{ const c = document.createElement('td'); if (s.source) c.textContent = '원본 서버'; else { const sel = document.createElement('select'); sel.style.margin = '0'; sel.style.minWidth = '120px'; sel.title = '이 사이트의 배포 서버'; sel.appendChild(new Option(`기본 서버 (${(targets.targets.find((t) => t.isDefault) || {}).name || '없음'})`, '')); for (const t of targets.targets || []) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}`, t.id)); sel.value = s.deployTargetId && (targets.targets || []).some((t) => t.id === s.deployTargetId) ? s.deployTargetId : ''; sel.onclick = (e) => e.stopPropagation(); sel.onchange = async (e) => { e.stopPropagation(); try { await api(`/api/admin/sites/${s.id}/target`, { method: 'POST', body: JSON.stringify({ targetId: sel.value || null }) }); s.deployTargetId = sel.value || null; } catch (err) { alert(err.message); } }; c.appendChild(sel); } tr.appendChild(c); }
td(tr, MODE[s.mode] || s.mode); td(tr, s.stage || '-'); td(tr, STATUS[s.status] || s.status, 'st-' + s.status);
td(tr, s.commands, 'num'); td(tr, s.errors + s.toolErrors, (s.errors + s.toolErrors) ? 'st-error num' : 'num'); td(tr, fmt(s.totals && s.totals.total), 'num'); td(tr, usd(s.totals && s.totals.costUsd), 'num');
const act = document.createElement('td'); act.className = 'act'; const open = document.createElement('a'); open.className = 'btn ghost'; open.href = s.url; open.target = '_blank'; open.rel = 'noopener'; open.textContent = '열기'; open.onclick = (e) => e.stopPropagation(); act.appendChild(open);
if (ds.state === 'pending') act.appendChild(mkBtn(ds.neverDeployed ? '첫 배포' : '재배포', 'primary', () => quickDeploy(s)));
if (s.status === 'running') act.appendChild(mkBtn('중단', 'ghost warn', () => cancelSite(s.id)));
act.appendChild(mkBtn('삭제', 'ghost danger', () => deleteSite(s.id, s.title))); tr.appendChild(act); tb.appendChild(tr);
}
if (!list.length) { const tr = document.createElement('tr'); td(tr, siteFilter === 'pending' ? '배포 대기중인 사이트가 없습니다' : '사이트가 없습니다').colSpan = 14; tb.appendChild(tr); }
}
document.querySelectorAll('[data-filter]').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); siteFilter = b.dataset.filter; document.querySelectorAll('[data-filter]').forEach((x) => x.classList.toggle('active', x === b)); renderSites(); }));
async function quickDeploy(s) {
if (s.source) { if (!confirm(`가져온 기존 사이트입니다. 수정 내용을 원본 서버(${s.source})에 반영할까요?`)) return; return runDeploy(s, { id: 'source', name: '원본 서버 ' + s.source, manageVhost: false, enableSsl: false }, null, null); }
if (!targets.targets.length) { try { targets = await api('/api/admin/deploy-targets'); } catch (_) {} }
if (!targets.targets.length) return alert('배포 서버가 없습니다. 배포 서버 탭에서 먼저 등록하세요.');
const first = s.deployState && s.deployState.neverDeployed;
if (!first) {
// 재배포: 정보 입력 없이 지정 서버(없으면 기본)로 바로 진행
// v1.3.17 (B24): 목록의 배포 서버 선택(s.deployTargetId)이 최신 지정 — 예전 배포 기록(s.deploy.targetId)보다 우선 ('' = 기본 서버)
const tid = s.deployTargetId || targets.defaultId; // 서버와 같은 규칙: 사이트 지정 서버 → 없으면 기본 서버 const t = targets.targets.find((x) => x.id === tid) || targets.targets.find((x) => x.isDefault);
return runDeploy(s, t, null, null);
}
// 첫 배포: 정보 입력 대화상자
const sel = $('ddTarget'); sel.innerHTML = ''; for (const t of targets.targets) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}${t.type === 'ssh' && !t.hasKey ? ' (키 없음)' : ''}`, t.id)); sel.value = (s.deploy && s.deploy.targetId) || targets.defaultId || targets.targets[0].id;
$('ddTitle').textContent = '첫 배포 정보 입력'; $('ddSite').textContent = `${s.title} (${s.ownerName || ''} ${s.ownerId || ''})`; $('ddDomains').value = (s.domains || []).join(', '); $('ddSsl').checked = true; $('ddMsg').textContent = '';
$('deployDialog').classList.remove('hidden');
$('ddCancel').onclick = () => $('deployDialog').classList.add('hidden');
$('ddGo').onclick = async () => { const t = targets.targets.find((x) => x.id === sel.value); if (!t) return; $('ddGo').disabled = true; $('ddMsg').textContent = '배포 중…'; try { await runDeploy(s, t, $('ddDomains').value, $('ddSsl').checked); $('deployDialog').classList.add('hidden'); } finally { $('ddGo').disabled = false; } };
}
function deployStatus(kind, text, urls) {
const box = $('deployStatus'); box.classList.remove('hidden'); box.className = 'deploy-status ' + kind; box.innerHTML = '';
const sp = document.createElement('span'); sp.className = 'ds-icon'; sp.textContent = kind === 'running' ? '' : (kind === 'ok' ? '✓' : '✗'); box.appendChild(sp);
const tx = document.createElement('span'); tx.textContent = text; box.appendChild(tx);
for (const u of urls || []) { const a = document.createElement('a'); a.href = u.url || u; a.target = '_blank'; a.rel = 'noopener'; a.textContent = u.url || u; a.title = u.note || ''; box.appendChild(a); if (u.note) box.appendChild(document.createTextNode(`(${u.ok === false ? '접속 불가: ' : ''}${u.note}) `)); }
if (kind !== 'running') { const x = document.createElement('button'); x.className = 'ghost small-btn'; x.textContent = '닫기'; x.onclick = () => box.classList.add('hidden'); box.appendChild(x); }
}
async function runDeploy(s, t, domainsText, ssl) {
const started = Date.now(); const tick = setInterval(() => deployStatus('running', `배포 진행 중: ${s.title} → ${t.name} · 경과 ${Math.round((Date.now() - started) / 1000)}초`), 1000);
deployStatus('running', `배포 진행 중: ${s.title} → ${t.name}`);
try {
if (domainsText !== null) { const r = await api(`/api/admin/sites/${s.id}/domains`, { method: 'POST', body: JSON.stringify({ domains: domainsText }) }); s.domains = r.domains; }
// v1.3.17 (B25): 첫 배포 대화상자의 SSL 체크는 이 사이트에만 적용한다(배포 요청에 ssl 로 보냄). 예전에는 서버 공용 설정을 꺼서 다른 사이트에도 영향
if ((s.domains || []).length && (t.manageVhost !== false || t.enableSsl !== false) && t.allowSudo !== true) { if (!confirm(`도메인 연결·SSL 에는 배포 서버 "${t.name}" 에서 sudo 실행이 필요합니다. 허용할까요? (허용하지 않으면 파일만 배포)`)) { /* 파일만 */ } else { await api(`/api/admin/deploy-targets/${t.id}`, { method: 'PUT', body: JSON.stringify({ ...t, allowSudo: true }) }); } }
const r = await api(`/api/admin/sites/${s.id}/deploy`, { method: 'POST', body: JSON.stringify({ targetId: t.id, ...(ssl === true || ssl === false ? { ssl } : {}) }) });
clearInterval(tick); deployStatus('ok', `${r.redeploy ? '재배포' : '배포'} 완료: ${s.title} → ${t.name} (${Math.round((Date.now() - started) / 1000)}초) — ${r.detail} `, (r.access && r.access.items) || (r.domainUrls || []).map((u) => ({ url: u })));
} catch (e) { clearInterval(tick); deployStatus('fail', `배포 실패: ${s.title} → ${t.name} — ${e.message}`); $('ddMsg').textContent = '실패: ' + e.message; throw e; }
finally { clearInterval(tick); loadOverview(); }
}
async function cancelSite(id) { try { await api(`/api/admin/sites/${id}/cancel`, { method: 'POST', body: '{}' }); setTimeout(loadOverview, 1500); } catch (e) { alert(e.message); } }
async function deleteSite(id, title) { if (!confirm(`"${title}" 사이트와 파일을 모두 삭제할까요? 되돌릴 수 없습니다.`)) return; try { await api(`/api/admin/sites/${id}`, { method: 'DELETE' }); showTab('sites'); loadOverview(); } catch (e) { alert(e.message); } }
function logTo(pre, l) { const t = new Date(l.ts).toLocaleTimeString(uiLocale(), { hour12: false }); const s1 = document.createElement('span'); s1.className = 't'; s1.textContent = `[${t}] `; const s2 = document.createElement('span'); s2.className = l.level || 'info'; s2.textContent = `${l.level === 'debug' ? '[상세] ' : ''}${l.text}`; pre.appendChild(s1); pre.appendChild(s2); pre.appendChild(document.createTextNode('\n')); pre.scrollTop = pre.scrollHeight; }
async function openSite(id) {
const s = await api(`/api/admin/sites/${id}`); curSite = s;
showTab('detail'); activeAdminTab = 'detail'; $('dTitle').textContent = `${s.title} — ${s.ownerName || ''} (${s.ownerId || '-'}) · ${MODE[s.mode] || s.mode}${s.plan ? ' · ' + s.plan.stage : ''}${s.deployState ? ' · ' + (DS[s.deployState.state] || s.deployState.state) : ''} · 등급 ${TIER[s.tier] || s.tier || '-'} (${s.modelRef || '-'})${s.creditWait ? ' · 캐시 충전 대기 중' : ''}`; $('dOpen').href = s.url; $('dReport').href = `/report/${id}`;
if (s.stats) { const st = s.stats; const useLog = st.sources.log; const v = (a) => (useLog ? a.visitors : a.beaconVisitors); const pv = (a) => (useLog ? a.pv : a.beaconPv); $('dTitle').textContent += ` · 방문자 오늘 ${v(st.today)} / 이달 ${v(st.month)} / 누적 ${v(st.total)} (페이지뷰 ${pv(st.total)})${useLog ? ` · 송신 ${(st.total.bytesOut / 1048576).toFixed(1)}MB` : ''} · 디스크 ${(st.disk.total / 1048576).toFixed(1)}MB`; }
$('dCancel').classList.toggle('hidden', !s.running); $('dCancel').onclick = () => cancelSite(id); $('dDelete').onclick = () => deleteSite(id, s.title);
const tb = $('cmdTable').querySelector('tbody'); tb.innerHTML = ''; const pre = $('dLog'); pre.textContent = '';
const showCmd = (c) => { pre.textContent = ''; $('dLogHint').textContent = `명령 #${c.no} ${KIND[c.kind] || c.kind} (${(c.log || []).length}줄)`; for (const l of c.log || []) logTo(pre, l); if (c.error) logTo(pre, { ts: Date.parse(c.finishedAt || c.startedAt) || Date.now(), level: 'warn', text: '오류: ' + c.error }); if (c.prompt) logTo(pre, { ts: Date.parse(c.startedAt) || Date.now(), level: 'debug', text: '프롬프트:\n' + c.prompt.slice(0, 3000) }); };
for (const c of [...s.commands].reverse()) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showCmd(c); td(tr, c.no); td(tr, KIND[c.kind] || c.kind); td(tr, c.model || ''); td(tr, STATUS[c.status] || c.status, 'st-' + c.status); td(tr, dt(c.startedAt)); td(tr, c.usage ? fmt(c.usage.total) + (c.usageEstimated ? '*' : '') : '-', 'num'); td(tr, c.usage && !c.usageEstimated ? usd(c.costUsd) : '-', 'num'); td(tr, c.error || '', 'wrap'); tb.appendChild(tr); }
const vt = $('verTable').querySelector('tbody'); vt.innerHTML = '';
for (const v of [...(s.versions || [])].reverse()) { const tr = document.createElement('tr'); td(tr, v.no); td(tr, VER[v.kind] || v.kind); td(tr, dt(v.at)); td(tr, v.message, 'wrap'); vt.appendChild(tr); }
$('dPlan').textContent = s.plan ? JSON.stringify({ stage: s.plan.stage, warnings: s.plan.warnings, refsFetched: s.plan.refsFetched, verification: s.plan.verification && s.plan.verification.rules && s.plan.verification.rules.summary, brief: s.plan.brief }, null, 1) : JSON.stringify({ title: s.title, purpose: s.purpose, content: s.content, design: s.design, extra: s.extra }, null, 1);
await renderSiteDeploy(s);
if (s.commands.length) showCmd(s.commands[s.commands.length - 1]);
if (es) es.close();
es = new EventSource(`/api/admin/sites/${id}/events`);
es.onmessage = (m) => { const ev = JSON.parse(m.data); if (ev.type === 'log') { $('dLogHint').textContent = '실시간'; logTo(pre, ev); } else if (ev.type === 'done') openSite(id); else if (ev.type === 'status') $('dCancel').classList.toggle('hidden', ev.status !== 'running'); };
}
// ---------- 로그 보기 (v1.3.2) ----------
/**
* GET /api/admin/logs 로 종류·기간·회사(회원)·사이트·검색어 조건에 맞는 로그를 받는다.
* 종류 버튼은 여러 개를 함께 켤 수 있고(기본: 오류·경고·중단), 버튼의 숫자는 다른 조건만 적용한 건수다.
* 행을 누르면 해당 사이트 상세(명령 이력·상세 로그)로 간다.
*/
const LOG_CLS = { error: 'st-error', warn: 'st-warn', cancelled: 'st-idle', deploy: 'st-done', done: 'st-done', debug: '' };
let lgTypes = ['error', 'warn', 'cancelled']; let lgTimer = null;
const kstDate = (offsetDays) => new Date(Date.now() + 9 * 3600000 - offsetDays * 86400000).toISOString().slice(0, 10);
async function loadErrors() {
const range = $('lgRange').value; const custom = range === 'custom';
$('lgFrom').classList.toggle('hidden', !custom); $('lgTo').classList.toggle('hidden', !custom);
const p = new URLSearchParams({ types: lgTypes.join(','), owner: $('lgOwner').value, site: $('lgSite').value, q: $('lgQ').value.trim() });
if (custom) { if ($('lgFrom').value) p.set('from', $('lgFrom').value); if ($('lgTo').value) p.set('to', $('lgTo').value); }
else if (range !== 'all') p.set('from', kstDate(Number(range) - 1));
const r = await api('/api/admin/logs?' + p.toString());
// 종류 버튼
const kb = $('lgKinds'); kb.innerHTML = '';
for (const [k, label] of Object.entries(r.types)) { const b = document.createElement('button'); b.type = 'button'; b.className = 'lg-kind ' + k + (lgTypes.includes(k) ? ' on' : ''); b.textContent = `${T(label)} ${r.counts[k] || 0}`; b.onclick = () => { lgTypes = lgTypes.includes(k) ? lgTypes.filter((x) => x !== k) : [...lgTypes, k]; if (!lgTypes.length) lgTypes = [k]; loadErrors(); }; kb.appendChild(b); }
// 회사·사이트 선택 목록 (선택값 유지)
const fill = (sel, items, first, fmt) => { const v = sel.value; sel.innerHTML = ''; sel.appendChild(new Option(first, '')); for (const it of items) sel.appendChild(new Option(fmt(it), it.id)); sel.value = v; };
fill($('lgOwner'), r.owners, '모든 회사(회원)', (o) => `${o.name || o.id} (${o.id})`);
fill($('lgSite'), r.sites.filter((x) => !$('lgOwner').value || x.ownerId === $('lgOwner').value), '모든 사이트', (x) => x.title || x.id);
$('lgSummary').textContent = `— ${r.total}건${r.total > r.items.length ? ` (최근 ${r.items.length}건 표시)` : ''}`;
const tb = $('errTable').querySelector('tbody'); tb.innerHTML = '';
for (const e of r.items) {
const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => openSite(e.siteId);
td(tr, dt(e.at)); td(tr, r.types[e.type] || e.type, LOG_CLS[e.type] || ''); td(tr, `${e.ownerName || ''} (${e.ownerId || '-'})`, 'wrap'); td(tr, e.siteTitle || '-', 'wrap'); td(tr, e.no ? `#${e.no} ${KIND[e.kind] || e.kind}` : (e.kind === 'unpublish' ? '발행 취소' : '배포')); td(tr, e.text, 'wrap');
tb.appendChild(tr);
}
if (!r.items.length) { const tr = document.createElement('tr'); td(tr, '조건에 맞는 로그가 없습니다').colSpan = 6; tb.appendChild(tr); }
$('lgMore').textContent = r.total > r.items.length ? '조건을 좁히면(기간·회사·사이트·검색어) 나머지 기록도 볼 수 있습니다.' : '';
}
for (const id of ['lgRange', 'lgFrom', 'lgTo', 'lgOwner', 'lgSite']) $(id).onchange = () => { if (id === 'lgOwner') $('lgSite').value = ''; loadErrors(); };
$('lgQ').oninput = () => { clearTimeout(lgTimer); lgTimer = setTimeout(loadErrors, 300); };
$('lgReset').onclick = () => { lgTypes = ['error', 'warn', 'cancelled']; $('lgRange').value = '7'; $('lgOwner').value = ''; $('lgSite').value = ''; $('lgQ').value = ''; $('lgFrom').value = ''; $('lgTo').value = ''; loadErrors(); };
// ---------- 배포 서버 ----------
let targets = { defaultId: null, targets: [] };
async function loadTargets() {
targets = await api('/api/admin/deploy-targets'); const tb = $('targetTable').querySelector('tbody'); tb.innerHTML = '';
for (const t of targets.targets) {
const tr = document.createElement('tr'); td(tr, t.isDefault ? '★' : '', 'star'); td(tr, t.name); td(tr, t.type === 'local' ? '로컬' : 'SSH'); td(tr, t.type === 'local' ? '(이 서버)' : `${t.user}@${t.host}:${t.port}`); td(tr, t.baseDir); td(tr, t.publicBaseUrl || '-');
td(tr, t.type === 'local' ? '-' : (t.hasKey ? '있음' : '없음'), t.type !== 'local' && !t.hasKey ? 'st-error' : ''); td(tr, t.allowSudo === true ? '허용' : '미허용', t.allowSudo === true ? 'st-done' : 'st-cancelled'); td(tr, t.enableSsl !== false ? '자동' : '끔'); td(tr, t.lastTest ? `${t.lastTest.ok ? '성공' : '실패'} · ${dt(t.lastTest.at)}` : '-', t.lastTest ? (t.lastTest.ok ? 'st-done' : 'st-error') : '');
const act = document.createElement('td'); act.className = 'act';
if (t.type !== 'local' || t.allowSudo === true) act.appendChild(mkBtn('오류 서버 점검', 'ghost', async () => { if (t.allowSudo !== true) return alert('sudo 사용 허용이 필요합니다 (수정에서 켜 주세요)'); try { const r = await api(`/api/admin/deploy-targets/${t.id}/default-server`, { method: 'POST', body: '{}' }); alert((r.ok ? '정상: ' : '주의: ') + r.detail); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('연결 테스트', 'ghost', async () => { try { const r = await api(`/api/admin/deploy-targets/${t.id}/test`, { method: 'POST', body: '{}' }); alert((r.ok ? '성공: ' : '실패: ') + r.detail); loadTargets(); } catch (e) { alert(e.message); } }));
// v1.5.13 (요구 3): 생성·조회한 공개키는 별도 창(showKeyDialog)에 한 줄로 보이고 "공개키 복사" 버튼으로 복사한다
if (t.type === 'ssh') act.appendChild(mkBtn(t.hasKey ? '키 재생성' : '키 생성', 'ghost', async () => { if (t.hasKey && !confirm('기존 키를 새 키로 바꿉니다. 배포 서버에 새 공개키를 다시 등록해야 합니다. 계속할까요?')) return; try { const r = await api(`/api/admin/deploy-targets/${t.id}/generate-key`, { method: 'POST', body: '{}' }); showKeyDialog(r.name, r.publicKey, true); loadTargets(); } catch (e) { alert(e.message); } }));
if (t.publicKey) act.appendChild(mkBtn('공개키 보기', 'ghost', () => showKeyDialog(t.name, t.publicKey, false)));
if (!t.isDefault) act.appendChild(mkBtn('기본으로', 'ghost', async () => { await api(`/api/admin/deploy-targets/${t.id}/default`, { method: 'POST', body: '{}' }); loadTargets(); }));
act.appendChild(mkBtn('수정', 'ghost', () => fillTargetForm(t)));
act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`"${t.name}" 배포 서버를 삭제할까요? (이미 배포된 사이트 파일은 남습니다)`)) return; await api(`/api/admin/deploy-targets/${t.id}`, { method: 'DELETE' }); loadTargets(); }));
tr.appendChild(act); tb.appendChild(tr);
}
if (!targets.targets.length) { const tr = document.createElement('tr'); td(tr, '등록된 배포 서버가 없습니다. "배포 서버 추가" 를 누르세요.').colSpan = 11; tb.appendChild(tr); }
}
/**
* v1.5.13 (요구 3): 공개키 창. 키 한 줄을 읽기 전용 textarea 에 넣고 "공개키 복사" 로 클립보드에 복사한다.
* 클립보드 API 는 https 또는 localhost 에서만 쓸 수 있으므로(사내 http://IP 접속), 안 되면 textarea 선택 + execCommand('copy') 로, 그것도 안 되면 prompt 로 보여 준다.
* @param {string} name 배포 서버 이름 @param {string} publicKey 공개키 한 줄 @param {boolean} fresh 방금 생성했는지(안내 문구)
*/
function showKeyDialog(name, publicKey, fresh) {
const key = String(publicKey || '').trim();
$('tKeyTitle').textContent = `${fresh ? '새로 만든 ' : ''}공개키 — ${name}`;
$('tKeyNote').innerHTML = (fresh ? '키를 새로 만들었습니다. 이 공개키를 등록해야 배포 서버에 접속할 수 있습니다. ' : '') + '배포 서버의 <code>~/.ssh/authorized_keys</code> 에 아래 한 줄을 그대로 추가하세요 (접속 사용자의 홈). 줄바꿈 없이 한 줄입니다.';
$('tKeyText').value = key; $('tKeyMsg').textContent = '';
const dlg = $('tKeyDialog'); if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', '');
setTimeout(() => { $('tKeyText').focus(); $('tKeyText').select(); }, 50);
}
async function copyKey() {
const ta = $('tKeyText'); const key = ta.value; const done = () => { $('tKeyMsg').textContent = '복사했습니다 — 배포 서버의 ~/.ssh/authorized_keys 에 붙여넣으세요'; $('tKeyCopy').textContent = '복사됨'; setTimeout(() => ($('tKeyCopy').textContent = '공개키 복사'), 1500); };
try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(key); return done(); } } catch (_) {}
try { ta.focus(); ta.select(); ta.setSelectionRange(0, key.length); if (document.execCommand('copy')) return done(); } catch (_) {}
$('tKeyMsg').textContent = '자동 복사가 되지 않는 환경입니다(https 가 아닌 주소). 키가 선택되어 있으니 Ctrl+C 로 복사하세요.';
}
$('tKeyCopy').onclick = copyKey; $('tKeyClose').onclick = () => $('tKeyDialog').close();
/** v1.5.13 (요구 4): 추가·수정 폼을 대화상자로 연다. t 가 있으면 수정, 없으면 빈 폼(추가) */
function fillTargetForm(t) {
$('tFormTitle').textContent = t ? `배포 서버 수정: ${t.name}` : '배포 서버 추가'; $('tId').value = t ? t.id : '';
document.querySelector(`input[name=tType][value=${t ? t.type : 'ssh'}]`).checked = true;
$('tName').value = t ? t.name : ''; $('tHost').value = t ? (t.host || '') : ''; $('tPort').value = t ? (t.port || 22) : ''; $('tUser').value = t ? (t.user || '') : ''; $('tBaseDir').value = t ? t.baseDir : ''; $('tPublicUrl').value = t ? t.publicBaseUrl : ''; $('tNote').value = t ? (t.note || '') : ''; $('tVhost').checked = t ? t.manageVhost !== false : true; $('tSsl').checked = t ? t.enableSsl !== false : true; $('tSudo').checked = t ? t.allowSudo === true : false; $('tKey').value = ''; $('tMsg').textContent = '';
toggleTypeFields();
const dlg = $('tDialog'); if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', '');
setTimeout(() => $('tName').focus(), 50);
}
function closeTargetForm() { const dlg = $('tDialog'); if (dlg.open) dlg.close(); }
function toggleTypeFields() { const local = document.querySelector('input[name=tType]:checked').value === 'local'; for (const id of ['tHost', 'tPort', 'tUser', 'tKey']) $(id).disabled = local; }
document.querySelectorAll('input[name=tType]').forEach((r) => (r.onchange = toggleTypeFields));
$('tNew').onclick = () => fillTargetForm(null);
$('tCancel').onclick = closeTargetForm;
$('tSave').onclick = async () => {
const id = $('tId').value; const body = { type: document.querySelector('input[name=tType]:checked').value, name: $('tName').value, host: $('tHost').value, port: $('tPort').value || 22, user: $('tUser').value, baseDir: $('tBaseDir').value, publicBaseUrl: $('tPublicUrl').value, note: $('tNote').value, manageVhost: $('tVhost').checked, enableSsl: $('tSsl').checked, allowSudo: $('tSudo').checked, privateKey: $('tKey').value };
try { const r = await api(id ? `/api/admin/deploy-targets/${id}` : '/api/admin/deploy-targets', { method: id ? 'PUT' : 'POST', body: JSON.stringify(body) }); $('tListMsg').textContent = `저장됨 (${r.name})${r.type === 'ssh' && !r.hasKey ? ' — 행의 "키 생성" 으로 키를 만들거나 수정 창에서 개인키를 붙여넣은 뒤 연결 테스트를 하세요' : ''}`; closeTargetForm(); loadTargets(); }
catch (e) { $('tMsg').textContent = '저장 실패: ' + e.message; }
};
async function renderSiteDeploy(s) {
if (!targets.targets.length) { try { targets = await api('/api/admin/deploy-targets'); } catch (_) {} }
const sel = $('dTarget'); sel.innerHTML = ''; sel.appendChild(new Option('(기본 배포 서버)', ''));
for (const t of targets.targets) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}`, t.id));
sel.value = s.deployTargetId || ''; $('dDomains').value = (s.domains || []).join(', ');
$('dDomains').onchange = async () => { try { const r = await api(`/api/admin/sites/${s.id}/domains`, { method: 'POST', body: JSON.stringify({ domains: $('dDomains').value }) }); $('dDomains').value = r.domains.join(', '); } catch (e) { alert(e.message); } };
sel.onchange = async () => { try { await api(`/api/admin/sites/${s.id}/target`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); } catch (e) { alert(e.message); } };
const box = $('dDeployResult');
const showResult = (cls, title, lines, access) => {
box.className = 'deploy-result ' + cls; box.innerHTML = ''; const h = document.createElement('h4'); h.textContent = title; box.appendChild(h);
for (const l of lines) { const p = document.createElement('div'); p.textContent = l; box.appendChild(p); }
if (access && access.items && access.items.length) { const ul = document.createElement('ul'); for (const it of access.items) { const li = document.createElement('li'); const a = document.createElement('a'); a.href = it.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = it.url; li.appendChild(a); li.appendChild(document.createTextNode(` — ${it.ok ? '✓ ' : '✗ '}${it.note}`)); li.className = it.ok ? 'st-done' : 'st-error'; ul.appendChild(li); } box.appendChild(ul); }
};
async function ensureSudo() {
const tid = sel.value || targets.defaultId; const t = targets.targets.find((x) => x.id === tid); if (!t) return true;
const needs = (t.manageVhost !== false || t.enableSsl !== false) && (s.domains || []).length > 0;
if (!needs || t.allowSudo === true) return true;
if (!confirm(`도메인 연결(nginx 설정)과 SSL 인증서 발급에는 배포 서버 "${t.name}" 에서 sudo 명령 실행이 필요합니다.\n이 서버에서 sudo 사용을 허용할까요? (허용하지 않으면 파일만 배포되고 도메인/SSL 은 설정되지 않습니다)`)) return false;
try { await api(`/api/admin/deploy-targets/${t.id}`, { method: 'PUT', body: JSON.stringify({ ...t, allowSudo: true }) }); targets = await api('/api/admin/deploy-targets'); } catch (e) { alert('설정 저장 실패: ' + e.message); return false; }
return true;
}
$('dDeploy').onclick = async () => {
const ok = await ensureSudo(); if (!ok) { showResult('fail', '배포 취소', ['sudo 사용을 허용하지 않아 배포를 진행하지 않았습니다. 파일만 배포하려면 배포 서버 설정에서 vhost/SSL 자동 설정을 끄세요.']); return; }
showResult('busy', '배포 중…', ['파일 전송 → 도메인 연결 → SSL 발급 → 이전 버전 정리 → 접속 확인 순으로 진행합니다. SSL 발급은 1~2분 걸릴 수 있습니다.']); $('dDeploy').disabled = true;
try {
const r = await api(`/api/admin/sites/${s.id}/deploy`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) });
const okAll = r.access && r.access.items.length ? r.access.items.some((i) => i.ok) : true;
showResult(okAll ? 'ok' : 'fail', okAll ? '배포 완료' : '배포는 되었지만 접속되는 주소가 없습니다', [r.detail, r.defaultServer ? `기본 오류 서버: ${r.defaultServer.ok ? '정상' : '주의'} — ${r.defaultServer.detail}` : '', r.ssl ? `SSL: ${r.ssl.detail}` : '', r.access && r.access.items.length ? '접속 가능 여부:' : '접속 주소: 도메인이나 공개 URL 이 없어 확인할 주소가 없습니다. 사이트 도메인을 입력하거나 배포 서버의 공개 URL 을 설정하세요.'].filter(Boolean), r.access);
} catch (e) { showResult('fail', '배포 실패', [e.message]); }
$('dDeploy').disabled = false; const keep = box.cloneNode(true); await openSite(s.id); $('dDeployResult').replaceWith(keep);
};
$('dSsl').onclick = async () => { const ok = await ensureSudo(); if (!ok) return; showResult('busy', 'SSL 인증서 발급 중… (1~2분)', []); $('dSsl').disabled = true; try { const r = await api(`/api/admin/sites/${s.id}/ssl`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); showResult(r.ok ? 'ok' : 'fail', r.ok ? 'SSL 발급 완료' : 'SSL 발급 실패', [r.detail], r.access); } catch (e) { showResult('fail', 'SSL 발급 실패', [e.message]); } $('dSsl').disabled = false; };
// v1.6.31 (요구 15): 이전 배포본으로 되돌리기 — 서버에 직전 배포본이 있을 때만 활성
{ const ds = s.deployState || {}; $('dRollback').disabled = !ds.hasPrev; $('dRollback').title = ds.hasPrev ? '배포 서버에 보관된 직전 배포본으로 운영 주소를 되돌립니다' : '배포 서버에 보관된 직전 배포본이 없습니다 (1.6.31 이후 두 번째 배포부터 가능)';
$('dRollback').onclick = async () => { if (!confirm('운영 주소를 직전 배포본으로 되돌릴까요? (빌더의 사이트 내용은 바뀌지 않으며, 다음 배포 때 다시 현재 내용이 올라갑니다)')) return; showResult('busy', '되돌리는 중…', []); try { const r = await api(`/api/admin/recovery/action`, { method: 'POST', body: JSON.stringify({ kind: 'deploy', id: s.id, action: 'rollback' }) }); showResult(r.ok ? 'ok' : 'fail', r.ok ? '되돌리기 완료' : '되돌리기 실패', [r.detail || '']); } catch (e) { showResult('fail', '되돌리기 실패', [e.message]); } }; }
$('dAccess').onclick = async () => { showResult('busy', '접속 확인 중…', []); try { const r = await api(`/api/admin/sites/${s.id}/access-check`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); showResult(r.items.some((i) => i.ok) ? 'ok' : 'fail', '접속 확인 결과', [r.items.length ? '' : '확인할 주소가 없습니다 (도메인 또는 공개 URL 필요)'].filter(Boolean), r); } catch (e) { showResult('fail', '확인 실패', [e.message]); } };
const tb = $('depTable').querySelector('tbody'); tb.innerHTML = '';
for (const d of [...((s.deploy && s.deploy.history) || [])].reverse()) { const tr = document.createElement('tr'); td(tr, dt(d.at)); td(tr, d.targetName + ((d.domains || []).length ? ` (${d.domains.join(', ')}${d.vhostOk === false ? ' · vhost 실패' : ''}${d.sslOk === true ? ' · SSL' : (d.sslOk === false ? ' · SSL 실패' : '')})` : '')); td(tr, d.ok ? (d.access && d.access.items && d.access.items.length && !d.access.items.some((i) => i.ok) ? '성공(접속 불가)' : '성공') : '실패', d.ok ? (d.access && d.access.items && d.access.items.length && !d.access.items.some((i) => i.ok) ? 'st-cancelled' : 'st-done') : 'st-error'); const u = document.createElement('td'); if (d.url) { const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = d.url; a.style.color = 'var(--accent2)'; u.appendChild(a); } tr.appendChild(u); td(tr, d.detail, 'wrap'); td(tr, d.by || ''); tb.appendChild(tr); }
if (!((s.deploy && s.deploy.history) || []).length) { const tr = document.createElement('tr'); td(tr, '배포 이력이 없습니다').colSpan = 6; tb.appendChild(tr); }
}
// ---------- 회원 · 청구 ----------
let creditsData = { users: [] };
async function loadBilling() {
[billingData, creditsData] = await Promise.all([api('/api/admin/billing'), api('/api/admin/credits')]); const b = billingData;
const mt = $('modelTable').querySelector('tbody'); mt.innerHTML = '';
const prices = Object.fromEntries((settingsData ? settingsData.models : []).map((m) => [m.id, m]));
for (const m of b.models) { const tr = document.createElement('tr'); td(tr, m.model); td(tr, m.calls + (m.estimatedCalls ? ` (추정 ${m.estimatedCalls})` : ''), 'num'); td(tr, fmt(m.input), 'num'); td(tr, fmt(m.output), 'num'); td(tr, fmt(m.cacheCreate), 'num'); td(tr, fmt(m.cacheRead), 'num'); td(tr, fmt(m.tokens), 'num'); td(tr, usd(m.usd), 'num'); const p = prices[m.model]; td(tr, p ? `$${p.input} / $${p.output}` : '-'); mt.appendChild(tr); }
if (!b.models.length) { const tr = document.createElement('tr'); td(tr, '사용 기록이 없습니다').colSpan = 9; mt.appendChild(tr); }
$('fxLine').textContent = `오늘 적용 환율: 1 USD = ${b.fx.today.rate.toLocaleString(uiLocale())}원 (${b.fx.mode === 'manual' ? '관리자 입력' : `${b.fx.today.source}, 중간환율 ${b.fx.today.mid} + 스프레드 ${b.fx.spreadPct}%`}) · 마진 ${b.marginPct}% · 합계: 비용 ${usd(b.totals.usd)} → 정산 ${krw(b.totals.krw)} → 청구 ${krw(b.totals.charge)} · 캐시 잔액 합계 ${krw((creditsData.users || []).reduce((a, x) => a + (x.balance || 0), 0))}${b.rateErrors.length ? ' · 환율 조회 실패: ' + b.rateErrors.join(', ') : ''}`;
const bt = $('billTable').querySelector('tbody'); bt.innerHTML = '';
for (const u of b.users) {
const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showBillUser(u.userId);
const cr = (creditsData.users || []).find((x) => x.id === u.userId) || { balance: 0, used: 0, waiting: 0 };
td(tr, `${u.name || ''} (${u.userId})`); td(tr, u.pendingApproval ? '승인 대기' : (u.role === 'admin' ? '관리자 (한도 없음)' : (u.role ? '일반' : '-')), u.pendingApproval ? 'st-cancelled' : '') /* v1.5.27 승인 대기 표시 */; td(tr, u.sites, 'num'); td(tr, fmt(u.tokens), 'num'); td(tr, usd(u.usd), 'num'); td(tr, krw(cr.used), 'num'); td(tr, krw(cr.balance), 'num ' + (cr.balance <= 0 ? 'neg' : 'pos')); td(tr, cr.waiting ? `${cr.waiting}건` : '-', cr.waiting ? 'neg' : '');
const act = document.createElement('td'); act.className = 'act';
// v1.5.27 (요구 6) 승인 대기 계정: 승인(로그인 가능) 또는 거절(계정 삭제)
if (u.pendingApproval) { act.appendChild(mkBtn('가입 승인', 'primary', async () => { try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/approve`, { method: 'POST', body: '{}' }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); act.appendChild(mkBtn('거절(삭제)', 'ghost danger', async () => { if (!confirm(`${u.name || ''} (${u.userId}) 의 가입 신청을 거절하고 계정을 지울까요?`)) return; try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/reject`, { method: 'POST', body: '{}' }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); }
act.appendChild(mkBtn('캐시', 'primary', () => { showBillUser(u.userId); $('chargeAmount').focus(); }));
if (u.role) act.appendChild(mkBtn('비밀번호 재설정', 'ghost', async () => { const pw = prompt(`${u.name || ''} (${u.userId}) 의 새 비밀번호 (${window.pwRuleText || '6자 이상'})`); if (!pw) return; try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/password`, { method: 'POST', body: JSON.stringify({ password: pw }) }); alert('비밀번호를 재설정했습니다'); } catch (e) { alert(e.message); } }));
if (u.role) act.appendChild(mkBtn(u.role === 'admin' ? '일반으로' : '관리자로', 'ghost', async () => { if (me && u.userId === me.id) return alert('자기 자신의 역할은 바꿀 수 없습니다'); try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/role`, { method: 'POST', body: JSON.stringify({ role: u.role === 'admin' ? 'user' : 'admin' }) }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } }));
// v1.4.0 계정 종류: 서브 도메인 전용 / 자체 도메인 — 고르고 "변경" 을 눌러야 적용 (관리자는 항상 자체 도메인)
if (u.role === 'user') {
const box = document.createElement('span'); box.className = 'acct-type'; box.onclick = (e) => e.stopPropagation();
const sel = document.createElement('select'); for (const [v, l] of [['subdomain', '서브 도메인 전용'], ['domain', '자체 도메인']]) { const o = document.createElement('option'); o.value = v; o.textContent = T(l); sel.appendChild(o); } sel.value = u.accountType || 'domain';
box.appendChild(sel); box.appendChild(mkBtn('변경', 'ghost', async () => { try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/account-type`, { method: 'POST', body: JSON.stringify({ accountType: sel.value }) }); u.accountType = sel.value; alert(TF('계정 종류를 바꿨습니다: {1}', sel.options[sel.selectedIndex].textContent)); } catch (e) { alert(e.message); } }));
act.appendChild(box);
}
tr.appendChild(act); bt.appendChild(tr);
}
if (curBillUser) showBillUser(curBillUser);
loadPrepaid().catch(() => {}); // v1.6.40 선불 충전 상품·주문
}
/* ---------- v1.6.40 (요구 1) 선불 충전 상품 · 주문 ----------
* 상품: 이름 · 결제 금액 · 인센티브(정률 %/정액 원) · 표시 여부 · 순서. 적립 캐시 = 결제 금액 + 인센티브 (서버 lib/prepaid.js 가 계산해 준다).
* 주문: 사용자가 캐시·충전 화면에서 접수한 결제 대기 주문. PG 미연동이라 관리자가 입금(결제) 확인 → "입금 확인 → 적립" 으로 캐시 원장에 더한다. */
let prepaidData = null;
const PP_STATUS = { pending: '결제 대기', paid: '적립 완료', cancelled: '취소' };
async function loadPrepaid() {
prepaidData = await api('/api/admin/prepaid'); const d = prepaidData;
const tb = $('ppTable').querySelector('tbody'); tb.innerHTML = '';
for (const p of d.packages) {
const tr = document.createElement('tr'); td(tr, p.name + (p.note ? ` — ${p.note}` : ''), 'wrap'); td(tr, krw(p.amount), 'num'); td(tr, p.bonusType === 'percent' ? `${p.bonusValue}% (${krw(p.bonus)})` : krw(p.bonusValue), 'num'); td(tr, krw(p.credit), 'num pos'); td(tr, p.active ? T('표시') : T('숨김'), p.active ? 'pos' : 'neg'); td(tr, String(p.sort || 0), 'num');
const act = document.createElement('td'); act.className = 'act';
act.appendChild(mkBtn('수정', 'ghost', () => { $('ppId').value = p.id; $('ppName').value = p.name; $('ppAmount').value = p.amount; $('ppBonusType').value = p.bonusType; $('ppBonusValue').value = p.bonusValue; $('ppSort').value = p.sort || 0; $('ppActive').checked = p.active !== false; $('ppNote').value = p.note || ''; $('ppFormTitle').textContent = TF('상품 수정 — {1}', p.name); $('ppName').focus(); }));
act.appendChild(mkBtn(p.active ? '숨기기' : '표시', 'ghost', async () => { try { await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify({ id: p.id, active: !p.active }) }); await loadPrepaid(); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('상품 "{1}" 을 지울까요? 주문 기록이 있는 상품은 지우지 않고 숨김으로 바뀝니다.', p.name))) return; try { const r = await api(`/api/admin/prepaid/packages/${p.id}`, { method: 'DELETE' }); $('ppMsg').textContent = r.removed ? T('지웠습니다') : T('주문 기록이 있어 숨김으로 바꿨습니다'); await loadPrepaid(); } catch (e) { alert(e.message); } }));
tr.appendChild(act); tb.appendChild(tr);
}
if (!d.packages.length) { const tr = document.createElement('tr'); td(tr, '아직 상품이 없습니다 — 아래에서 만드세요').colSpan = 7; tb.appendChild(tr); }
const pend = d.orders.filter((o) => o.status === 'pending').length; const badge = $('ppBadge'); badge.textContent = pend ? String(pend) : ''; badge.classList.toggle('hidden', !pend);
drawPrepaidOrders();
}
function drawPrepaidOrders() {
const d = prepaidData; if (!d) return; const f = $('ppOrderFilter').value; const list = d.orders.filter((o) => !f || o.status === f);
const ot = $('ppOrders').querySelector('tbody'); ot.innerHTML = '';
for (const o of list) {
const tr = document.createElement('tr'); td(tr, dt(o.at)); td(tr, `${d.users[o.userId] || ''} (${o.userId})`); td(tr, o.name, 'wrap'); td(tr, krw(o.amount), 'num'); td(tr, krw(o.credit), 'num pos'); td(tr, T(PP_STATUS[o.status] || o.status), o.status === 'paid' ? 'pos' : (o.status === 'pending' ? 'st-cancelled' : ''));
const act = document.createElement('td'); act.className = 'act';
if (o.status === 'pending') {
act.appendChild(mkBtn('입금 확인 → 적립', 'primary', async () => { const memo = prompt(TF('{1} ({2}) 의 입금(결제)을 확인했습니까?\n결제 {3} → 적립 {4}\n메모(입금자명·승인번호 등, 선택):', d.users[o.userId] || '', o.userId, krw(o.amount), krw(o.credit))); if (memo === null) return; try { const r = await api(`/api/admin/prepaid/orders/${o.id}`, { method: 'POST', body: JSON.stringify({ action: 'paid', memo }) }); $('ppOrderMsg').textContent = TF('적립했습니다 — 잔액 {1}', krw(r.balance)); await loadPrepaid(); loadBilling(); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('취소', 'ghost danger', async () => { const memo = prompt(T('취소 사유 (선택):')); if (memo === null) return; try { await api(`/api/admin/prepaid/orders/${o.id}`, { method: 'POST', body: JSON.stringify({ action: 'cancel', memo }) }); await loadPrepaid(); } catch (e) { alert(e.message); } }));
} else if (o.memo) act.appendChild(document.createTextNode(o.memo));
tr.appendChild(act); ot.appendChild(tr);
}
if (!list.length) { const tr = document.createElement('tr'); td(tr, '주문이 없습니다').colSpan = 7; ot.appendChild(tr); }
}
document.addEventListener('DOMContentLoaded', () => {
const f = $('ppOrderFilter'); if (!f) return;
f.onchange = drawPrepaidOrders;
const reset = () => { $('ppId').value = ''; $('ppName').value = ''; $('ppAmount').value = ''; $('ppBonusType').value = 'percent'; $('ppBonusValue').value = ''; $('ppSort').value = ''; $('ppActive').checked = true; $('ppNote').value = ''; $('ppFormTitle').textContent = T('새 상품'); };
$('ppReset').onclick = reset;
$('ppSave').onclick = async () => {
const body = { id: $('ppId').value || undefined, name: $('ppName').value, amount: $('ppAmount').value, bonusType: $('ppBonusType').value, bonusValue: $('ppBonusValue').value, sort: $('ppSort').value, active: $('ppActive').checked, note: $('ppNote').value };
try { const r = await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify(body) }); $('ppMsg').textContent = TF('저장했습니다 — {1}: 결제 {2} → 적립 {3}', r.package.name, krw(r.package.amount), krw(r.package.credit)); reset(); await loadPrepaid(); } catch (e) { $('ppMsg').textContent = e.message; }
};
});
const LEDGER_TYPE = { set: '설정', add: '추가', adjust: '조정', usage: '사용', refund: '환불', migrate: '이관' };
async function showBillUser(uid) {
const u = billingData.users.find((x) => x.userId === uid); if (!u) return; curBillUser = uid;
let cr; try { cr = await api(`/api/admin/credits/${encodeURIComponent(uid)}`); } catch (e) { cr = { balance: 0, used: 0, ledger: [] }; }
$('billDetail').classList.remove('hidden'); $('billDetailTitle').textContent = `${u.name || ''} (${u.userId}) — 캐시 잔액 ${krw(cr.balance)} · 누적 사용 ${krw(cr.used)} · 누적 지급 ${krw(cr.granted || 0)}${u.role === 'admin' ? ' · 관리자(한도 미적용)' : ''}`;
const dtb = $('dayTable').querySelector('tbody'); dtb.innerHTML = '';
for (const d of u.byDay) { const tr = document.createElement('tr'); td(tr, d.day); td(tr, d.commands, 'num'); td(tr, `${d.rate.toLocaleString(uiLocale())}원`, 'num'); td(tr, usd(d.usd), 'num'); td(tr, krw(d.krw), 'num'); td(tr, krw(d.charge), 'num'); dtb.appendChild(tr); }
const ct = $('chargeTable').querySelector('tbody'); ct.innerHTML = '';
for (const e of cr.ledger || []) { const tr = document.createElement('tr'); td(tr, dt(e.at)); td(tr, LEDGER_TYPE[e.type] || e.type, e.type === 'usage' ? 'neg' : 'pos'); td(tr, krw(e.amount), 'num ' + (e.amount < 0 ? 'neg' : 'pos')); td(tr, krw(e.balanceAfter), 'num'); td(tr, e.memo || '', 'wrap'); td(tr, e.by || ''); ct.appendChild(tr); }
if (!(cr.ledger || []).length) { const tr = document.createElement('tr'); td(tr, '기록이 없습니다').colSpan = 6; ct.appendChild(tr); }
const post = async (type) => { const amount = +$('chargeAmount').value; if (!$('chargeAmount').value.trim() || !Number.isFinite(amount)) return alert('금액을 입력하세요'); if (type === 'set' && !confirm(`${u.name || ''} (${uid}) 의 캐시 잔액을 ${krw(amount)} 으로 설정할까요?`)) return; try { await api(`/api/admin/credits/${encodeURIComponent(uid)}`, { method: 'POST', body: JSON.stringify({ type, amount, memo: $('chargeMemo').value }) }); $('chargeAmount').value = ''; $('chargeMemo').value = ''; await loadBilling(); } catch (e) { alert(e.message); } };
$('btnCharge').onclick = () => post('add'); $('btnSetCredit').onclick = () => post('set');
$('billDetail').scrollIntoView({ behavior: 'smooth', block: 'start' }); // v1.1.6: 충전 폼이 원장 위에 있으므로 상세를 열면 바로 보인다
}
// ---------- 사용자 제안·오류 (v1.1.2) ----------
const FB_TYPE = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' }; const FB_STATUS = { new: '새 글', reviewing: '검토 중', done: '처리 완료', rejected: '처리 완료' }; // v1.2.0: 새 글(접수) → 검토 중(AI 검토) → 처리 완료(관리자 답변). 반려 없음
let fbData = null; let fbCur = null;
async function loadFeedback() {
fbData = await api('/api/admin/feedback'); const tb = $('fbTable').querySelector('tbody'); tb.innerHTML = '';
$('fbSummary').textContent = `— ${fbData.counts.total}건 (새 글 ${fbData.counts.new} · 다음 작업 반영 ${fbData.counts.planned || 0}) · AI 분석 비용 합계 ${krw(fbData.counts.costKrw)} (${usd(fbData.counts.costUsd)})`;
const tf = $('fbTypeFilter').value; // v1.3.2 종류 거르기
for (const x of fbData.items.filter((i) => !tf || i.type === tf)) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showFeedback(x.id); td(tr, dt(x.at)); td(tr, FB_TYPE[x.type] || x.type); td(tr, x.title, 'wrap'); td(tr, `${x.userName || ''} (${x.userId})`); td(tr, x.siteTitle || '-'); const a = x.analysis; td(tr, a ? `${a.category || ''} · 중요도 ${a.priority || '-'} · 작업량 ${a.effort || '-'}` : (x.comments.some((c) => c.by === 'ai') ? '분석 실패' : '분석 중…'), 'wrap'); td(tr, x.cost && x.cost.krw ? `${krw(x.cost.krw)} (${usd(x.cost.usd)})` : '-', 'num'); { const b = document.createElement('span'); if (x.planned) { b.className = 'badge planned'; b.textContent = x.collectedAt ? '반영 예정 · 수집됨' : '반영 예정'; b.title = x.planNote || ''; } else b.textContent = '-'; td(tr, b); } td(tr, FB_STATUS[x.status] || x.status, 'st-' + (x.status === 'done' ? 'done' : x.status === 'new' ? 'running' : 'idle')); tb.appendChild(tr); }
if (!fbData.items.length) { const tr = document.createElement('tr'); td(tr, '올라온 글이 없습니다').colSpan = 9; tb.appendChild(tr); }
if (fbCur) showFeedback(fbCur);
}
function showFeedback(id) {
const x = fbData.items.find((i) => i.id === id); if (!x) return; fbCur = id; $('fbDetail').classList.remove('hidden');
$('fbdTitle').textContent = `[${FB_TYPE[x.type] || x.type}] ${x.title} — ${x.userName || ''} (${x.userId}) · ${dt(x.at)}${x.siteTitle ? ' · 사이트: ' + x.siteTitle : ''}${x.page ? ' · 화면: ' + x.page : ''}`;
$('fbdBody').textContent = x.body || '(내용 없음)'; $('fbdStatus').value = x.status; $('fbdReply').value = ''; $('fbdMsg').textContent = '';
$('fbdEnv').textContent = x.env ? Object.entries(x.env).map(([k, v]) => `${k}: ${v}`).join('\n') : '(수집된 환경 정보 없음 — v1.1.6 이전 글)';
$('fbdPlanned').checked = !!x.planned; $('fbdPlanNote').value = x.planNote || '';
$('fbdPlanInfo').textContent = x.planned ? `반영 결정 ${dt(x.plannedAt)} (${x.plannedBy || ''})${x.collectedAt ? ` · 작업 서버 수집 ${dt(x.collectedAt)}` : ' · 아직 수집되지 않음'}` : (x.analysis ? 'AI 분석을 참고해 반영 여부와 반영할 내용을 정하세요.' : 'AI 분석이 끝나면 내용을 참고해 결정하세요.');
const box = $('fbdComments'); box.innerHTML = '';
for (const c of x.comments) { const d = document.createElement('div'); d.className = 'card'; d.style.marginBottom = '6px'; d.innerHTML = `<div class="muted" style="font-size:11px">${c.by === 'ai' ? 'AI 분석' + (c.model ? ' (' + c.model + ')' : '') : c.by === 'admin' ? '관리자' + (c.byName ? ' ' + c.byName : '') : c.by} · ${dt(c.at)}${c.usd ? ` · 비용 ${krw(c.krw)} (${usd(c.usd)})` : ''}</div><pre style="white-space:pre-wrap;margin:6px 0 0;font:inherit">${c.text.replace(/[<>&]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]))}</pre>`; box.appendChild(d); }
if (!x.comments.length) box.textContent = '아직 댓글이 없습니다 (AI 분석 진행 중일 수 있습니다 — 새로고침).';
// v1.2.0: 답변을 적으면 처리 상태를 "처리 완료"로 미리 맞춰 보여 준다(서버도 관리자 답변 시 자동으로 처리 완료)
$('fbdReply').oninput = () => { if ($('fbdReply').value.trim()) $('fbdStatus').value = 'done'; };
/**
* 저장: 반영 결정 → 상태 → 답변 순으로 서버가 처리한다.
* 1.1.6 까지는 저장 직후 목록을 다시 그리면서(showFeedback) 결과 메시지와 입력값이 지워져 "저장이 안 된 것처럼" 보였다.
* 이제 목록을 다시 불러온 뒤에 결과 메시지를 표시한다.
*/
$('fbdSave').onclick = async () => {
if ($('fbdPlanned').checked && !$('fbdPlanNote').value.trim()) { $('fbdMsg').textContent = '반영할 내용을 구체적으로 적어 주세요'; return; }
const reply = $('fbdReply').value.trim(); $('fbdSave').disabled = true;
try {
const r = await api(`/api/admin/feedback/${id}`, { method: 'PUT', body: JSON.stringify({ status: $('fbdStatus').value, reply, planned: $('fbdPlanned').checked, planNote: $('fbdPlanNote').value }) });
await loadFeedback(); loadOverview();
$('fbdMsg').textContent = reply ? `답변을 저장했습니다 — 상태: ${FB_STATUS[r.status] || r.status} (사용자 화면에 표시됩니다)` : `저장했습니다 — 상태: ${FB_STATUS[r.status] || r.status}`;
} catch (e) { $('fbdMsg').textContent = e.message; }
$('fbdSave').disabled = false;
};
// v1.3.2: AI 1차 분석의 답변 초안을 답변 칸에 넣기 (관리자가 고쳐서 저장)
$('fbdDraft').classList.toggle('hidden', !(x.analysis && x.analysis.replyDraft)); $('fbdDraft').onclick = () => { $('fbdReply').value = x.analysis.replyDraft; $('fbdStatus').value = 'done'; $('fbdReply').focus(); };
$('fbdReanalyze').onclick = async () => { try { await api(`/api/admin/feedback/${id}/analyze`, { method: 'POST', body: '{}' }); $('fbdMsg').textContent = 'AI 분석을 다시 요청했습니다. 잠시 후 새로고침하세요.'; } catch (e) { $('fbdMsg').textContent = e.message; } };
}
// 다음 작업 반영 목록 (v1.1.6): 관리자가 반영으로 결정한 글을 마크다운으로 — 작업 서버가 같은 내용을 수집한다
$('fbBacklogBtn').onclick = async () => { try { const r = await api('/api/admin/feedback/backlog'); $('fbBacklogMd').textContent = r.markdown; $('fbBacklogInfo').textContent = ` — ${r.items.length}건 · 수집 상태: ${r.items.filter((x) => x.collectedAt).length}건 수집됨`; $('fbBacklog').classList.remove('hidden'); } catch (e) { alert(e.message); } };
$('fbBacklogClose').onclick = () => $('fbBacklog').classList.add('hidden');
$('fbBacklogCopy').onclick = async () => { try { await navigator.clipboard.writeText($('fbBacklogMd').textContent); $('fbBacklogCopy').textContent = '복사됨'; setTimeout(() => ($('fbBacklogCopy').textContent = '마크다운 복사'), 1500); } catch (_) {} };
// ---------- 언어 (v1.1.2) ----------
async function loadI18n() {
// v1.3.5: 원본은 항상 지금 서비스 버전의 카탈로그다 (화면을 오래 열어 두면 예전 값이 남으므로 버전 감시가 다시 불러온다)
const d = await api('/api/admin/i18n'); $('i18nSource').textContent = `— 서비스 v${d.serviceVersion || d.source.version} · 원본(한국어) ${d.source.count}개 문구`;
const tb = $('i18nTable').querySelector('tbody'); tb.innerHTML = '';
for (const l of d.languages) { const tr = document.createElement('tr'); td(tr, l.lang); td(tr, l.name); td(tr, `${l.translated} / ${l.total} (${l.coverage}%)`, 'num'); td(tr, `v${l.sourceVersion}${l.outdated ? ` ≠ 서비스 v${l.serviceVersion || d.serviceVersion}` : ' (일치)'}`, l.outdated ? 'st-error' : 'st-done'); td(tr, dt(l.uploadedAt));
// v1.3.5: 실제 표시 상태(서버 계산)와 켜기 스위치를 따로 보여 준다
{ const c = document.createElement('div'); const st = document.createElement('span'); st.className = 'badge ' + ({ shown: 'deployed', outdated: 'empty', off: 'pending', 'off-outdated': 'pending' }[l.state] || 'pending'); st.textContent = l.stateLabel || (l.active ? '표시 중' : '숨김'); st.title = l.stateDetail || ''; c.appendChild(st); const sw = document.createElement('div'); sw.className = 'muted'; sw.style.fontSize = '11px'; sw.textContent = `스위치: ${l.enabled ? '켜짐' : '꺼짐'}`; c.appendChild(sw); td(tr, c); }
const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn(l.enabled ? '끄기' : '켜기', 'ghost', async () => { await api(`/api/admin/i18n/${l.lang}`, { method: 'PUT', body: JSON.stringify({ enabled: !l.enabled }) }); loadI18n(); loadOverview(); })); const ex = document.createElement('a'); ex.className = 'btn ghost'; ex.href = `/api/admin/i18n/export?lang=${encodeURIComponent(l.lang)}`; ex.textContent = '현재 번역 내려받기'; ex.title = '최신 원본 문구에 기존 번역을 채워 넣은 파일 — 바뀐 문구만 번역해 다시 올리면 됩니다'; act.appendChild(ex); act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`${l.name}(${l.lang}) 번역을 삭제할까요?`)) return; await api(`/api/admin/i18n/${l.lang}`, { method: 'DELETE' }); loadI18n(); loadOverview(); })); tr.appendChild(act); tb.appendChild(tr); }
if (!d.languages.length) { const tr = document.createElement('tr'); td(tr, '올린 언어가 없습니다. 원본 파일을 내려받아 번역한 뒤 올리세요.').colSpan = 7; tb.appendChild(tr); }
// v1.3.16 서비스 화면 기본 언어 (표시 중인 언어만)
try { const st = await api('/api/admin/settings'); const ui = st.uiLang || { default: 'ko' }; const sel = $('uiDefLang'); sel.innerHTML = ''; sel.appendChild(new Option('한국어 (ko)', 'ko')); for (const l of d.languages.filter((x) => x.state === 'shown')) sel.appendChild(new Option(`${l.name} (${l.lang})`, l.lang)); sel.value = ui.default || 'ko'; if (sel.value !== (ui.default || 'ko')) sel.value = 'ko'; $('uiDefForce').checked = false; $('uiDefMsg').textContent = ui.forcedAt ? TF('마지막 강제 적용: {1}', dt(ui.forcedAt)) : ''; markSaved(); } catch (_) {}
loadSiteMessages();
}
$('uiDefSave').onclick = async () => {
if ($('uiDefForce').checked && !confirm(T('모든 사용자의 화면 언어를 이 언어로 바꿉니다(이후 사용자가 다시 고를 수 있음). 계속할까요?'))) return;
try { const r = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ uiLang: { default: $('uiDefLang').value, force: $('uiDefForce').checked } }) }); $('uiDefMsg').textContent = TF('적용했습니다 — 기본 언어 {1}{2}', r.uiLang.default, $('uiDefForce').checked ? ' · ' + T('모든 사용자에게 강제') : ''); $('uiDefForce').checked = false; markSaved(); } catch (e) { $('uiDefMsg').textContent = e.message; }
};
// ---------- v1.3.16 생성 사이트 시스템 문구 번역 ----------
let smData = null; const smEdits = {};
async function loadSiteMessages() {
const lang = $('smLang').value || 'en';
try { smData = await api(`/api/admin/site-messages?lang=${encodeURIComponent(lang)}`); } catch (_) { return; }
const sel = $('smLang'); if (!sel.options.length) { for (const l of smData.locales) sel.appendChild(new Option(`${l.name} (${l.code})`, l.code)); sel.value = smData.lang; }
$('smSummary').textContent = TF('— {1}개 문구 · 번역 없음 {2}개', smData.items.length, smData.missing); renderSiteMessages();
}
function renderSiteMessages() {
if (!smData) return; const tb = $('smTable').querySelector('tbody'); tb.innerHTML = ''; const q = $('smQ').value.trim().toLowerCase();
for (const it of smData.items) {
if ($('smMissing').checked && it.translation) continue; if (q && !`${it.id} ${it.text} ${it.translation}`.toLowerCase().includes(q)) continue;
const tr = document.createElement('tr'); td(tr, it.id); td(tr, it.group); td(tr, it.text);
const c = document.createElement('td'); const inp = document.createElement('input'); inp.value = smEdits[it.id] !== undefined ? smEdits[it.id] : it.translation; inp.style.margin = '0'; inp.style.width = '100%'; inp.oninput = () => { smEdits[it.id] = inp.value; inp.classList.add('dirty'); }; c.appendChild(inp); tr.appendChild(c);
td(tr, { admin: T('관리자'), ai: 'AI', seed: T('기본 제공'), '': '—' }[it.origin]); tb.appendChild(tr);
}
}
$('smLang').onchange = () => { for (const k of Object.keys(smEdits)) delete smEdits[k]; loadSiteMessages(); };
$('smQ').oninput = renderSiteMessages; $('smMissing').onchange = renderSiteMessages;
$('smSave').onclick = async () => { const n = Object.keys(smEdits).length; if (!n) { $('smMsg').textContent = T('고친 번역이 없습니다'); return; } try { await api(`/api/admin/site-messages/${encodeURIComponent($('smLang').value)}`, { method: 'PUT', body: JSON.stringify({ messages: smEdits }) }); for (const k of Object.keys(smEdits)) delete smEdits[k]; $('smMsg').textContent = TF('{1}개 번역을 저장했습니다 (다음 다국어 맞춤·배포 때 사이트에 반영)', n); loadSiteMessages(); } catch (e) { $('smMsg').textContent = e.message; } };
async function uploadI18n(files) {
if (!files.length) return; const fd = new FormData(); for (const f of files) fd.append('files', f, f.name); $('i18nMsg').textContent = '올리는 중…';
try { const r = await api('/api/admin/i18n/upload', { method: 'POST', body: fd }); const box = $('i18nResults'); box.innerHTML = ''; for (const x of r.results) { const d = document.createElement('div'); d.className = 'muted'; d.style.color = x.ok ? '#6ee7b7' : '#fca5a5'; d.textContent = x.ok ? `✓ ${x.file}: ${x.name}(${x.lang}) ${x.translated}개 번역 저장${x.badPatternCount ? ` — 자리표시({1} 등)가 원문과 다른 ${x.badPatternCount}개 항목은 제외했습니다(예: ${x.badPatterns.slice(0, 2).join(' / ')})` : ''}${x.outdated ? ` — 이 파일은 v${x.sourceVersion} 원본 기준이라 현재 서비스(v${r.version || '?'})에서는 화면에 표시되지 않습니다(바뀐 문구가 섞이는 것 방지). 오른쪽 링크로 "최신 원본 + 지금 올린 번역" 파일을 받아 비어 있는 translation 만 채워 다시 올리세요.` : ''}` : `✗ ${x.error}`; box.appendChild(d);
// v1.3.0: 구버전 기준 번역은 바로 이어서 보충할 수 있게 최신 원본에 기존 번역을 채운 파일 링크를 붙인다
if (x.ok && x.outdated) { const a = document.createElement('a'); a.className = 'btn ghost'; a.style.marginLeft = '6px'; a.href = `/api/admin/i18n/export?lang=${encodeURIComponent(x.lang)}`; a.textContent = `${x.lang} 최신 원본 + 기존 번역 내려받기`; a.setAttribute('download', ''); d.appendChild(a); } } $('i18nMsg').textContent = ''; loadI18n(); loadOverview(); }
catch (e) { $('i18nMsg').textContent = '실패: ' + e.message; }
}
{ const dz = $('i18nDrop'); dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.style.background = '#1e293b'; }); dz.addEventListener('dragleave', () => { dz.style.background = ''; }); dz.addEventListener('drop', (e) => { e.preventDefault(); dz.style.background = ''; uploadI18n([...e.dataTransfer.files]); }); $('i18nFiles').onchange = (e) => { uploadI18n([...e.target.files]); e.target.value = ''; }; }
// ---------- AI 제공자 ----------
let provData = null, provCur = null, provModels = [];
async function loadProviders() {
provData = await api('/api/admin/providers'); const tb = $('provTable').querySelector('tbody'); tb.innerHTML = '';
for (const pv of provData.providers) {
if (pv.id === 'anthropic') continue;
const tr = document.createElement('tr'); td(tr, pv.label); td(tr, pv.enabled ? '활성' : '꺼짐', pv.enabled ? 'st-done' : 'st-idle'); td(tr, pv.id === 'openai' && pv.authMode === 'server-login' ? (pv.codex && pv.codex.loggedIn ? '서버 로그인' : '서버 로그인(미로그인)') : (pv.hasKey ? pv.apiKeyMasked : '없음'), (pv.hasKey || (pv.authMode === 'server-login' && pv.codex && pv.codex.loggedIn)) ? '' : 'st-cancelled'); td(tr, pv.models.length ? `${pv.models.length}개: ${pv.models.slice(0, 4).map((m) => m.id).join(', ')}${pv.models.length > 4 ? ' …' : ''}` : '-', 'wrap'); td(tr, pv.lastTest ? `${pv.lastTest.ok ? '성공' : '실패'} · ${dt(pv.lastTest.at)}` : '-', pv.lastTest ? (pv.lastTest.ok ? 'st-done' : 'st-error') : '');
// v1.6.38 (요구 1): 설정 탭 등급 목록에 들어가는지 — 빠졌으면 이유 (키 없음 등). 1.6.35 까지는 저장 문구가 "반영했습니다" 라고만 해서 원인을 알 수 없었다
td(tr, pv.enabled ? (pv.usable ? `포함 (${pv.usableModels}개)` : `제외 — ${pv.unavailableReason || ''}`) : '-', pv.enabled ? (pv.usable ? 'st-done wrap' : 'st-error wrap') : '');
const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn('설정', 'ghost', () => openProv(pv))); tr.appendChild(act); tb.appendChild(tr);
}
}
/** v1.6.38 (요구 1): 제공자 폼 아래 상태 줄 — 설정 탭 등급 목록에 들어가는지, 아니면 왜 빠졌는지 */
function showProvUsable(pv) {
const p = $('pUsable'); if (!p) return;
if (!pv.enabled) { p.textContent = '지금 상태: 꺼짐 — "사용" 을 켜고 저장하면 설정 탭 › 모델 등급 지정 목록에 이 제공자의 모델이 들어갑니다 (API 키와 제공할 모델이 있어야 함)'; p.className = 'muted'; return; }
if (pv.usable) { p.textContent = `지금 상태: 설정 탭 › 모델 등급 지정 목록에 포함됨 (모델 ${pv.usableModels}개)`; p.className = 'muted ok'; }
else { p.textContent = `지금 상태: 설정 탭의 등급 목록에서 제외됨 — ${pv.unavailableReason || ''}`; p.className = 'msg err'; }
}
function openProv(pv) {
provCur = pv; provModels = pv.models.slice(); $('provForm').classList.remove('hidden'); showProvUsable(pv);
$('pAuthRow').classList.toggle('hidden', pv.id !== 'openai'); if (pv.id === 'openai') { document.querySelector(`input[name=pAuth][value=${pv.authMode || 'api-key'}]`).checked = true; const c = pv.codex || {}; $('pCodexInfo').textContent = c.bin ? `— codex ${c.version || ''} ${c.loggedIn ? '로그인됨(' + (c.mode === 'chatgpt' ? 'ChatGPT' : 'API 키') + ')' : '로그인 안 됨: 서버에서 codex login --device-auth 실행'}` : '— 서버에 codex CLI 없음 (npm i -g @openai/codex)'; } document.querySelectorAll('input[name=pAuth]').forEach((r) => (r.onchange = () => { $('pKey').classList.toggle('hidden', r.value === 'server-login' && r.checked); })); $('pKey').classList.toggle('hidden', pv.id === 'openai' && (pv.authMode || 'api-key') === 'server-login'); $('provTitle').textContent = pv.label; $('pEnabled').checked = pv.enabled; $('pKey').value = ''; $('pKey').placeholder = pv.hasKey ? `저장됨: ${pv.apiKeyMasked} (바꾸려면 새 키 입력) · ${pv.keyHint}` : `API 키 (${pv.keyHint})`; $('pBase').classList.toggle('hidden', pv.id !== 'custom'); $('pBase').value = pv.baseUrl || ''; $('pMsg').textContent = ''; renderProvModels();
}
function renderProvModels(fetched) {
const tb = $('pModels').querySelector('tbody'); tb.innerHTML = '';
const list = fetched || provModels; const selected = new Set(provModels.map((m) => m.id));
for (const m of list) {
const tr = document.createElement('tr'); const c = document.createElement('td'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.width = 'auto'; cb.checked = selected.has(m.id); cb.onchange = () => { if (cb.checked) { if (!provModels.some((x) => x.id === m.id)) provModels.push({ id: m.id, label: m.label || m.id, input: m.input || 0, output: m.output || 0 }); } else provModels = provModels.filter((x) => x.id !== m.id); }; c.appendChild(cb); tr.appendChild(c);
td(tr, (m.label && m.label !== m.id ? `${m.id} — ${m.label}` : m.id) + (m.description ? ` · ${m.description}` : ''), 'wrap'); // v1.4.14 서버 로그인 목록의 설명·종료 예정 표시
for (const k of ['input', 'output']) { const cell = document.createElement('td'); const inp = document.createElement('input'); inp.type = 'number'; inp.step = '0.01'; inp.style.width = '90px'; inp.style.margin = '0'; inp.value = m[k] || 0; inp.onchange = () => { m[k] = +inp.value; const sel = provModels.find((x) => x.id === m.id); if (sel) sel[k] = +inp.value; }; cell.appendChild(inp); tr.appendChild(cell); }
tb.appendChild(tr);
}
if (!list.length) { const tr = document.createElement('tr'); td(tr, '모델 조회를 눌러 목록을 불러오세요').colSpan = 4; tb.appendChild(tr); }
}
$('pFetch').onclick = async () => { $('pMsg').textContent = '조회 중…'; try { if (provCur.id === 'openai') { const am = document.querySelector('input[name=pAuth]:checked').value; if (am !== provCur.authMode) { await api(`/api/admin/providers/openai`, { method: 'PUT', body: JSON.stringify({ authMode: am }) }); provCur.authMode = am; } } const serverLogin = provCur.id === 'openai' && provCur.authMode === 'server-login'; const r = await api(`/api/admin/providers/${provCur.id}/test`, { method: 'POST', body: JSON.stringify({ apiKey: serverLogin ? '' : ($('pKey').value.trim() || '••••'), baseUrl: $('pBase').value.trim() }) }); if (!r.ok) { $('pMsg').textContent = '실패: ' + r.error; return; } $('pMsg').textContent = `모델 ${r.models.length}개 조회됨 — 제공할 모델을 체크하세요`; renderProvModels(r.models.map((m) => { const sel = provModels.find((x) => x.id === m.id); return sel ? { ...m, input: sel.input, output: sel.output } : m; })); } catch (e) { $('pMsg').textContent = '실패: ' + e.message; } };
$('pSave').onclick = async () => { try { const body = { enabled: $('pEnabled').checked, models: provModels, baseUrl: $('pBase').value.trim() }; if (provCur.id === 'openai') body.authMode = document.querySelector('input[name=pAuth]:checked').value; if ($('pKey').value.trim()) body.apiKey = $('pKey').value.trim(); const r = await api(`/api/admin/providers/${provCur.id}`, { method: 'PUT', body: JSON.stringify(body) }); provCur = { ...provCur, ...r }; showProvUsable(r);
// v1.6.38 (요구 1): 저장은 됐지만 등급 목록에 못 들어가면(키 없음·모델 없음·서버 로그인 안 됨) 그 이유를 바로 말한다 — "추가했는데 설정 탭에 반영이 안 됨" 의 원인
$('pMsg').textContent = r.enabled && !r.usable ? `저장했지만 설정 탭의 등급 목록에는 나오지 않습니다 — ${r.unavailableReason || ''}` : `저장됨 (${r.models.length}개 모델 제공${r.enabled ? '' : ', 비활성'}) — 설정 탭의 "모델 등급 지정"·"내부 호출 모델" 목록에 반영했습니다`; $('pMsg').className = r.enabled && !r.usable ? 'msg err' : 'muted';
await loadSettings(); await loadProviders(); /* v1.4.14 (요구 6): 등급 지정 목록을 바로 다시 그린다 · v1.6.38: 제공자 표의 "등급 목록" 열도 */ } catch (e) { $('pMsg').textContent = '저장 실패: ' + e.message; } };
$('pClose').onclick = () => $('provForm').classList.add('hidden');
// ---------- 설정 ----------
// ---------- 외부 연동 접속 정보 (v1.3.1) ----------
/**
* 다른 서비스에 접속하는 주소·설정·키(API Key/Token)를 암호화 보관소(data/credentials.json)에 넣고 관리한다.
* 키 원문은 서버가 돌려주지 않으므로 화면에는 hint(앞뒤 몇 글자)만 보인다. 키 칸을 비워 저장하면 기존 키를 유지한다.
* API: GET/POST /api/admin/credentials, PUT/DELETE /api/admin/credentials/<id>, POST /api/admin/credentials/import, POST /api/admin/credentials/<id>/test
*/
let vaultData = null;
async function loadVault() {
try { vaultData = await api('/api/admin/credentials'); } catch (_) { return; }
const sel = $('vKind'); if (!sel.options.length) for (const [k, v] of Object.entries(vaultData.kinds)) sel.appendChild(new Option(v.label, k));
syncVaultKind();
const tb = $('vTable').querySelector('tbody'); tb.innerHTML = '';
for (const x of vaultData.items) {
const tr = document.createElement('tr'); td(tr, x.label === x.kindLabel ? x.kindLabel : `${x.label} · ${x.kindLabel}`, 'wrap'); td(tr, x.url || '-', 'wrap'); td(tr, x.hasSecret ? x.secretHint : '없음', x.hasSecret ? '' : 'st-error'); td(tr, x.source.startsWith('file:') ? '키 파일 ' + x.source.slice(5) : x.source.startsWith('upload:') ? '올린 파일 ' + x.source.slice(7) : x.source.startsWith('migrate:') ? '이전 설정에서 옮김' : '직접 입력', 'wrap'); td(tr, x.lastTest ? `${x.lastTest.ok ? '✓' : '✗'} ${x.lastTest.detail} (${dt(x.lastTest.at)})` : '-', 'wrap');
const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap';
act.appendChild(mkBtn('수정', 'ghost', () => { $('vKind').value = x.kind; syncVaultKind(); $('vId').value = x.id; $('vLabel').value = x.label; $('vUrl').value = x.url; $('vSettings').value = Object.entries(x.settings || {}).map(([k, v]) => `${k}=${v}`).join('\n'); $('vSecret').value = ''; $('vSecret').placeholder = x.hasSecret ? `저장됨: ${x.secretHint} (바꾸려면 새 값 입력)` : '키 또는 토큰'; $('vMsg').textContent = ''; }));
act.appendChild(mkBtn('연결 확인', 'ghost', async () => { try { const r = await api(`/api/admin/credentials/${x.id}/test`, { method: 'POST', body: '{}' }); alert(`${r.ok ? '✓' : '✗'} ${r.detail}`); loadVault(); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`"${x.label}" 접속 정보를 삭제할까요? 이 정보를 쓰는 기능이 멈출 수 있습니다.`)) return; try { await api(`/api/admin/credentials/${x.id}`, { method: 'DELETE' }); loadVault(); } catch (e) { alert(e.message); } }));
tr.appendChild(act); tb.appendChild(tr);
}
if (!vaultData.items.length) { const tr = document.createElement('tr'); td(tr, '저장된 접속 정보가 없습니다').colSpan = 6; tb.appendChild(tr); }
}
/** 종류를 바꾸면 식별자(고정 종류는 잠금)·키 칸 안내를 맞춘다 */
function syncVaultKind() { const k = vaultData && vaultData.kinds[$('vKind').value]; if (!k) return; $('vId').disabled = !!k.fixedId; if (k.fixedId) $('vId').value = k.fixedId; $('vSecret').placeholder = k.secretLabel; if (!$('vUrl').value && k.defaultUrl) $('vUrl').placeholder = k.defaultUrl; }
$('vKind').onchange = () => { $('vUrl').value = ''; syncVaultKind(); };
$('vSave').onclick = async () => { try { await api('/api/admin/credentials', { method: 'POST', body: JSON.stringify({ kind: $('vKind').value, id: $('vId').value.trim(), label: $('vLabel').value.trim(), url: $('vUrl').value.trim(), settings: $('vSettings').value, secret: $('vSecret').value }) }); $('vSecret').value = ''; $('vMsg').textContent = '암호화해 저장했습니다'; loadVault(); loadMail && loadMail(); } catch (e) { $('vMsg').textContent = e.message; } };
/** v1.3.2: 키 파일 자체를 올려 가져오기 (multipart, 파일은 서버에 저장되지 않음) */
$('vUploadGo').onclick = async () => {
const f = $('vUpload').files[0]; if (!f) return ($('vMsg').textContent = '올릴 키 파일을 고르세요');
const fd = new FormData(); fd.append('kind', $('vKind').value); fd.append('id', $('vId').value.trim()); fd.append('label', $('vLabel').value.trim()); fd.append('file', f, f.name);
try { const r = await api('/api/admin/credentials/upload', { method: 'POST', body: fd }); $('vUpload').value = ''; $('vMsg').textContent = `${f.name} 에서 가져왔습니다 — 키 항목 ${r.found.key}${r.found.url ? ', 주소 ' + r.found.url : ''}${r.found.settings.length ? ', 설정 ' + r.found.settings.join(', ') : ''} (파일의 항목: ${r.fileItems.join(', ')}). 원래 파일은 서버에 저장하지 않았습니다.`; loadVault(); } catch (e) { $('vMsg').textContent = e.message; }
};
$('vImport').onclick = async () => { const file = $('vFile').value.trim(); if (!file) return ($('vMsg').textContent = '키 파일 경로를 입력하세요'); try { const r = await api('/api/admin/credentials/import', { method: 'POST', body: JSON.stringify({ kind: $('vKind').value, id: $('vId').value.trim(), label: $('vLabel').value.trim(), file }) }); $('vMsg').textContent = `가져왔습니다 — 키 항목 ${r.found.key}${r.found.url ? ', 주소 ' + r.found.url : ''}${r.found.settings.length ? ', 설정 ' + r.found.settings.join(', ') : ''} (파일의 항목: ${r.fileItems.join(', ')}). 이제 평문 키 파일을 지워도 됩니다.`; loadVault(); } catch (e) { $('vMsg').textContent = e.message; } };
// ---------- 사이트 템플릿 (v1.3.10) ----------
/**
* GET /api/admin/templates (tree·items·tags·mySites), POST 등록, PUT /<id> 수정(사용자 사용 허용 포함),
* POST /<id>/resnapshot 다시 담기, DELETE /<id>. 별도 디렉터리: POST/PUT/DELETE /api/admin/template-dirs[/<id>].
*/
let tpDir = ''; let tpData = null; let tpTimer = null;
const tpDirName = (dir) => { if (!dir) return T('전체'); if (dir.startsWith('ind:')) return dir.slice(4).split('/').map((x) => T(x)).join(' › '); const f = (nodes) => { for (const n of nodes || []) { if (n.id === dir) return n.name; const r = f(n.children); if (r) return r; } return null; }; return f(tpData && tpData.tree.custom) || dir; };
function tpDirSelect(sel, value) {
sel.innerHTML = ''; sel.appendChild(new Option(T('디렉터리 선택'), ''));
const g1 = document.createElement('optgroup'); g1.label = T('업종'); for (const i of tpData.tree.industries) { g1.appendChild(new Option(T(i.name), i.id)); for (const m of i.children) g1.appendChild(new Option(` ${T(i.name)} › ${T(m.name)}`, m.id)); } sel.appendChild(g1);
const g2 = document.createElement('optgroup'); g2.label = T('별도 디렉터리'); const walk = (nodes, d) => { for (const n of nodes) { g2.appendChild(new Option(`${' '.repeat(d)}${n.name}`, n.id)); walk(n.children || [], d + 1); } }; walk(tpData.tree.custom, 0); sel.appendChild(g2);
if (value) sel.value = value;
}
async function loadTemplates() {
try { tpData = await api(`/api/admin/templates?q=${encodeURIComponent($('tpQ').value.trim())}&dir=${encodeURIComponent(tpDir)}`); } catch (_) { return; }
const d = tpData; $('tpSummary').textContent = `— ${d.tree.total}개 · ${T('사용자 허용')} ${d.items.filter((x) => x.enabled).length}개`; $('tpDirNow').textContent = tpDirName(tpDir);
// 디렉터리 트리 (업종은 템플릿이 있는 곳만, 별도 디렉터리는 모두)
const tr = $('tpTree'); tr.innerHTML = '';
const node = (n, depth) => {
if (n.kind === 'industry' && !n.count && depth === 0) return;
const row = document.createElement('div'); row.className = 'tp-node' + (tpDir === n.id ? ' on' : ''); row.style.paddingLeft = (6 + depth * 14) + 'px';
const b = document.createElement('button'); b.type = 'button'; b.className = 'link'; b.textContent = `${n.kind === 'industry' ? T(n.name) : '📁 ' + n.name} (${n.count})`; b.onclick = () => { tpDir = tpDir === n.id ? '' : n.id; loadTemplates(); }; row.appendChild(b);
if (n.kind === 'custom') { row.appendChild(mkBtn('이름', 'ghost', async () => { const nm = prompt(T('새 이름'), n.name); if (!nm) return; try { await api(`/api/admin/template-dirs/${encodeURIComponent(n.id)}`, { method: 'PUT', body: JSON.stringify({ name: nm }) }); loadTemplates(); } catch (e) { alert(e.message); } })); row.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('"{1}" 디렉터리를 지울까요? (비어 있어야 지워집니다)', n.name))) return; try { await api(`/api/admin/template-dirs/${encodeURIComponent(n.id)}`, { method: 'DELETE' }); if (tpDir === n.id) tpDir = ''; loadTemplates(); } catch (e) { alert(e.message); } })); }
tr.appendChild(row); for (const c of n.children || []) if (c.count || c.kind === 'custom') node(c, depth + 1);
};
const all = document.createElement('div'); all.className = 'tp-node' + (tpDir ? '' : ' on'); const ab = document.createElement('button'); ab.type = 'button'; ab.className = 'link'; ab.textContent = `${T('전체')} (${d.tree.total})`; ab.onclick = () => { tpDir = ''; loadTemplates(); }; all.appendChild(ab); tr.appendChild(all);
for (const n of d.tree.industries) node(n, 0); for (const n of d.tree.custom) node(n, 0);
// 별도 디렉터리 상위 선택, 등록 폼
const par = $('tpDirParent'); par.innerHTML = ''; par.appendChild(new Option(T('최상위'), '')); const walk = (nodes, dd) => { for (const n of nodes) { par.appendChild(new Option(`${' '.repeat(dd)}${n.name}`, n.id)); walk(n.children || [], dd + 1); } }; walk(d.tree.custom, 0);
tpDirSelect($('tpDir'), $('tpDir').value || (tpDir || ''));
const ss = $('tpSite'); const keep = ss.value; ss.innerHTML = ''; ss.appendChild(new Option(d.mySites.length ? T('사이트 선택') : T('관리자가 만든 사이트가 없습니다 — 빌더에서 먼저 만드세요'), '')); for (const x of d.mySites) ss.appendChild(new Option(x.title, x.id)); ss.value = keep;
// 목록
const tb = $('tpTable').querySelector('tbody'); tb.innerHTML = '';
for (const t of d.items) {
const tr2 = document.createElement('tr'); td(tr2, `${t.title}${t.description ? '\n' + t.description : ''}`, 'wrap'); td(tr2, tpDirName(t.dir), 'wrap'); td(tr2, t.tags.map((g) => '#' + g).join(' '), 'wrap');
{ const c = document.createElement('td'); const lab = document.createElement('label'); lab.className = 'radio'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled; cb.onchange = async () => { try { await api(`/api/admin/templates/${t.id}`, { method: 'PUT', body: JSON.stringify({ enabled: cb.checked }) }); loadTemplates(); } catch (e) { alert(e.message); cb.checked = !cb.checked; } }; lab.appendChild(cb); lab.appendChild(document.createTextNode(' ' + T(t.enabled ? '허용' : '허용 안 함'))); c.appendChild(lab); tr2.appendChild(c); }
td(tr2, `${t.files}`, 'num'); td(tr2, dt(t.snapshotAt));
const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap';
const pv = document.createElement('a'); pv.className = 'btn ghost'; pv.href = `/api/templates/${t.id}/preview/`; pv.target = '_blank'; pv.rel = 'noopener'; pv.textContent = T('미리보기'); act.appendChild(pv);
act.appendChild(mkBtn('수정', 'ghost', () => tpEdit(t)));
act.appendChild(mkBtn('다시 담기', 'ghost', async () => { if (!confirm(T('원본 사이트의 지금 파일로 템플릿을 다시 담을까요? (이미 이 템플릿으로 만든 사용자 사이트는 바뀌지 않습니다)'))) return; try { await api(`/api/admin/templates/${t.id}/resnapshot`, { method: 'POST', body: '{}' }); loadTemplates(); } catch (e) { alert(e.message); } }));
act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('템플릿 "{1}" 을 지울까요? (이 템플릿으로 만든 사이트는 그대로 남습니다)', t.title))) return; try { await api(`/api/admin/templates/${t.id}`, { method: 'DELETE' }); loadTemplates(); } catch (e) { alert(e.message); } }));
tr2.appendChild(act); tb.appendChild(tr2);
}
if (!d.items.length) { const tr2 = document.createElement('tr'); td(tr2, T('템플릿이 없습니다')).colSpan = 7; tb.appendChild(tr2); }
}
/** 템플릿 수정 (이름·설명·해시태그·디렉터리) — 간단한 입력 상자 */
async function tpEdit(t) {
const title = prompt(T('템플릿 이름'), t.title); if (title === null) return;
const tags = prompt(T('해시태그 (#으로 구분)'), t.tags.map((g) => '#' + g).join(' ')); if (tags === null) return;
const desc = prompt(T('설명'), t.description || ''); if (desc === null) return;
const dir = prompt(T('디렉터리 ID (예: ind:음식점·카페/한식 또는 dir:…) — 비우면 그대로'), t.dir); if (dir === null) return;
try { await api(`/api/admin/templates/${t.id}`, { method: 'PUT', body: JSON.stringify({ title, tags, description: desc, ...(dir.trim() ? { dir: dir.trim() } : {}) }) }); loadTemplates(); } catch (e) { alert(e.message); }
}
$('tpQ').oninput = () => { clearTimeout(tpTimer); tpTimer = setTimeout(loadTemplates, 250); };
$('tpDirAdd').onclick = async () => { try { await api('/api/admin/template-dirs', { method: 'POST', body: JSON.stringify({ name: $('tpDirName').value, parent: $('tpDirParent').value }) }); $('tpDirName').value = ''; loadTemplates(); } catch (e) { alert(e.message); } };
$('tpSite').onchange = () => { const x = tpData && tpData.mySites.find((y) => y.id === $('tpSite').value); if (x && !$('tpTitle').value) $('tpTitle').value = x.title; };
$('tpRegister').onclick = async () => { $('tpMsg').textContent = T('담는 중…'); try { const t = await api('/api/admin/templates', { method: 'POST', body: JSON.stringify({ siteId: $('tpSite').value, title: $('tpTitle').value, dir: $('tpDir').value, tags: $('tpTags').value, description: $('tpDesc').value }) }); $('tpMsg').textContent = TF('등록했습니다: {1} (파일 {2}개) — 사용자에게 보이려면 "사용자 사용 허용" 을 켜세요', t.title, t.files); $('tpTitle').value = ''; $('tpTags').value = ''; $('tpDesc').value = ''; loadTemplates(); } catch (e) { $('tpMsg').textContent = e.message; } };
// ---------- 검색 등록 (v1.3.5) ----------
/**
* GET /api/admin/search — 등록을 시작한 사이트와 엔진별 단계. 동작은 POST /api/admin/search/<사이트ID>/<동작>
* (start|code|apply|step|check|indexnow|stop, 사용자 관리 탭과 같은 서버 함수 searchAction).
*/
async function loadSearchRegs() {
let d; try { d = await api('/api/admin/search'); } catch (_) { return; }
// v1.3.10: 사람이 해야 하는 단계(선택 단계 제외)만 "수동 작업" 으로 센다 — 구글 서비스 계정 키가 있으면 구글은 모두 자동
const pendingOf = (it) => it.engines.filter((e) => e.started && e.steps.some((s) => !s.done && s.actor === 'manual' && !s.optional)).length;
const total = d.items.reduce((a, it) => a + pendingOf(it), 0); $('srBadge').textContent = total || ''; $('srBadge').classList.toggle('hidden', !total);
$('srSummary').textContent = `— 등록 진행 ${d.items.length}개 사이트 · 수동 작업 대기 ${total}건`;
const box = $('srList'); box.innerHTML = '';
const items = $('srPendingOnly').checked ? d.items.filter((it) => pendingOf(it)) : d.items;
for (const it of items) {
const card = document.createElement('div'); card.className = 'card'; card.style.marginBottom = '10px';
const h = document.createElement('h3'); h.textContent = `${it.title} — ${it.ownerName || ''} (${it.ownerId})`; card.appendChild(h);
const info = document.createElement('p'); info.className = 'muted'; info.textContent = it.availability.ok ? `운영 주소 ${it.liveUrl}` : `진행 불가: ${it.availability.reason}`; card.appendChild(info);
for (const e of it.engines.filter((x) => x.started)) {
const eb = document.createElement('div'); eb.style.margin = '6px 0 10px';
const t = document.createElement('b'); t.textContent = `${T(e.label)} — ${T(e.state)}`; eb.appendChild(t);
if (e.auto) { const ap = document.createElement('p'); ap.className = e.auto.state === 'error' ? 'msg err' : 'muted'; ap.textContent = `${T('자동 진행')}: ${T(e.auto.detail)}`; eb.appendChild(ap); if (e.auto.state === 'error' && it.availability.ok) eb.appendChild(mkBtn(T('자동 진행 다시'), 'primary', async () => { try { await api(`/api/admin/search/${it.id}/auto`, { method: 'POST', body: JSON.stringify({ engine: e.engine }) }); loadSearchRegs(); } catch (er) { alert(er.message); } })); }
const ol = document.createElement('ol'); ol.style.margin = '4px 0';
for (const st of e.steps) {
if (st.actor === 'auto' && !$('srShowAuto').checked) continue; // v1.3.10 기본은 사람이 할 단계만 보인다
const li = document.createElement('li'); li.style.margin = '4px 0'; li.style.opacity = st.done ? '.6' : '1';
li.appendChild(document.createTextNode(`${st.done ? '✓ ' : ''}[${st.actor === 'auto' ? '자동' : '직접'}] ${st.title} — ${st.detail}`));
const row = document.createElement('div'); row.className = 'row';
const run = (action, body) => async () => { try { const r = await api(`/api/admin/search/${it.id}/${action}`, { method: 'POST', body: JSON.stringify({ engine: e.engine, ...body }) }); if (r.check) alert(`${r.check.ok ? '✓' : '✗'} ${r.check.detail}`); loadSearchRegs(); } catch (er) { alert(er.message); } };
if (st.id === 'code' && st.actor === 'manual') { const inp = document.createElement('input'); inp.placeholder = '확인 코드 또는 meta 태그'; inp.value = e.code || ''; inp.style.flex = '1'; inp.style.margin = '0'; row.appendChild(inp); row.appendChild(mkBtn('저장', 'ghost', async () => { try { await api(`/api/admin/search/${it.id}/code`, { method: 'POST', body: JSON.stringify({ engine: e.engine, code: inp.value }) }); loadSearchRegs(); } catch (er) { alert(er.message); } })); }
if (st.id === 'apply' && e.code) row.appendChild(mkBtn(st.done ? '다시 넣고 재배포' : '태그 넣고 재배포', 'primary', run('apply', {})));
if (st.id === 'dns' && e.code) row.appendChild(mkBtn('DNS 확인', 'ghost', run('check', {})));
if (st.id === 'verify' && e.method !== 'dns' && e.code) row.appendChild(mkBtn('운영 페이지 태그 확인', 'ghost', run('check', {})));
if (['verify', 'sitemap', 'dns'].includes(st.id) && st.actor === 'manual') row.appendChild(mkBtn(st.done ? '완료 취소' : '완료 표시', 'ghost', run('step', { step: st.id, done: !st.done })));
if (row.children.length && it.availability.ok) li.appendChild(row); ol.appendChild(li);
}
eb.appendChild(ol); const a = document.createElement('a'); a.className = 'btn ghost'; a.href = e.console; a.target = '_blank'; a.rel = 'noopener'; a.textContent = `${e.label} 열기`; eb.appendChild(a); card.appendChild(eb);
}
if (it.indexnow) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = `IndexNow: ${it.indexnow.last ? `${dt(it.indexnow.lastPingAt)} ${it.indexnow.last.detail}` : '키 준비됨'}`; card.appendChild(p); }
box.appendChild(card);
}
if (!items.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = $('srPendingOnly').checked ? '수동 작업이 남은 사이트가 없습니다' : '검색 등록을 시작한 사이트가 없습니다'; box.appendChild(p); }
}
$('srPendingOnly').onchange = loadSearchRegs; $('srShowAuto').onchange = loadSearchRegs;
// ---------- 메일 시스템 (v1.3.2: 사이트 단위) ----------
/**
* 메일 시스템 탭. 사이트(호스팅 도메인)마다 "메일 시스템 사용" 을 켜고 설정한다.
* GET /api/admin/mail 기본값·발송 백엔드·사이트 목록(상태)
* GET /api/admin/mail/<id> 한 사이트의 설정·DNS 레코드·확인 결과
* POST /api/admin/mail/<id> 켜기/설정 저장 {domain, mxHost, spfInclude, dmarc}
* POST /api/admin/mail/<id>/check DNS 다시 확인, POST /api/admin/mail/<id>/disable 끄기
* 사용자 화면에는 DNS 전파·보내기 인증이 끝나기 전까지 "메일시스템 설정중", 끝나면 "사용 가능" 으로 보인다.
*/
let msCur = null; // 지금 패널에서 설정 중인 사이트 ID
/**
* v1.6.38 (요구 2): 메일 서버(Arimail) 등록부 표 — 닉네임·IP·주소·도메인·연결 확인·기본. 기본 서버(설정 파일 mail.*)는 id 'primary'.
* 비밀번호는 서버가 hasPassword 만 내려주므로 화면에 나오지 않는다.
*/
let mvServers = []; let mvEditing = null;
function drawMailServers(list) {
mvServers = list || []; const tb = $('mvTable').querySelector('tbody'); tb.innerHTML = '';
for (const s of mvServers) {
const tr = document.createElement('tr'); const locked = s.lockedUntil && Date.parse(s.lockedUntil) > Date.now();
td(tr, `${s.nickname}${s.source === 'config' ? ' (설정 파일)' : ''}`, 'wrap'); td(tr, s.ip || '-'); td(tr, `${s.url || '-'}${s.apiUrl && s.apiUrl !== s.url ? ` / ${s.apiUrl}` : ''}`, 'wrap'); td(tr, s.domain || '-');
// v1.6.39 (요구 4): 관리자 ID 칸 — 저장된 비밀번호를 [보기/숨기기] 로 확인하고 복사한다 (초기 설정 뒤 Arimail /admin 에 직접 들어갈 때 필요). 서버에서 받아 60초 뒤 자동으로 숨긴다
const ac = document.createElement('div'); ac.style.whiteSpace = 'nowrap'; ac.appendChild(document.createTextNode(s.adminLogin + (s.hasPassword ? ' ' : ' (비밀번호 없음) ')));
if (s.hasPassword) {
const code = document.createElement('code'); code.className = 'mv-pw hidden'; code.style.userSelect = 'all'; let hideT = null;
const show = mkBtn('보기', 'ghost small', async () => {
if (!code.classList.contains('hidden')) { code.classList.add('hidden'); code.textContent = ''; show.textContent = '보기'; clearTimeout(hideT); return; }
try { const r = await api(`/api/admin/mail-servers/${s.id}/password`); code.textContent = r.password; code.classList.remove('hidden'); show.textContent = '숨기기'; hideT = setTimeout(() => { code.classList.add('hidden'); code.textContent = ''; show.textContent = '보기'; }, 60000); }
catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; }
});
const copy = mkBtn('복사', 'ghost small', async () => { try { const r = await api(`/api/admin/mail-servers/${s.id}/password`); await navigator.clipboard.writeText(r.password); $('mvMsg').textContent = `${s.nickname}: 관리자 비밀번호를 복사했습니다 — ${r.adminUrl || 'Arimail /admin'} 에 ${r.adminLogin} 으로 로그인`; $('mvMsg').className = 'muted ok'; } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } });
ac.appendChild(show); ac.appendChild(document.createTextNode(' ')); ac.appendChild(code); ac.appendChild(document.createTextNode(' ')); ac.appendChild(copy);
}
td(tr, ac, s.hasPassword ? 'wrap' : 'st-cancelled wrap');
// 연결 확인 칸 — v1.6.39 (요구 2): 잠금 중이면 해제 시각과 남은 시간을 보여 준다 (mvTick 이 매초 갱신)
const tc = document.createElement('div');
if (locked) { tc.className = 'st-error'; tc.dataset.lockUntil = s.lockedUntil; tc.textContent = mvLockText(s.lockedUntil); }
else tc.textContent = s.lastTest ? `${s.lastTest.ok ? '성공' : '실패'} · ${dt(s.lastTest.at)}${s.lastTest.ok ? '' : ' — ' + (s.lastTest.detail || '')}` : '-';
// v1.7.3 추가: 웹메이커 관리자 ↔ Arimail 같은 ID 계정 비밀번호 동기화 기록(관리자 로그인 때 맞춰진다)
for (const a of (s.adminSync || [])) { const d = document.createElement('div'); d.className = 'small ' + (a.error ? 'st-error' : (a.at ? 'muted' : 'muted')); d.textContent = a.error ? `관리자 ${a.login} 비밀번호 동기화 실패 ${dt(a.errorAt)} — ${a.error}` : (a.at ? `관리자 ${a.login} 비밀번호 동기화 ✓ ${dt(a.at)}` : (a.skipped === 'no-account' ? `관리자 ${a.login}: Arimail 에 같은 ID 계정 없음(동기화 생략)` : '')); if (d.textContent) tc.appendChild(d); }
td(tr, tc, locked ? 'wrap' : (s.lastTest ? (s.lastTest.ok ? 'st-done wrap' : 'st-error wrap') : ''));
// v1.7.3 (요구 2): 사용량 API 칸 — 키 유무(앞뒤 글자)와 [사용량](모든 도메인의 메일함 용량·계정·발송량 표) · [키 발급](저장된 관리자 계정으로 Arimail 에서 받아 보관) · [키 삭제]
const uc = document.createElement('div'); uc.style.whiteSpace = 'nowrap';
uc.appendChild(document.createTextNode(s.hasUsageKey ? `키 ${s.usageKeyHint || '있음'} ` : '키 없음 '));
if (s.hasUsageKey) uc.appendChild(mkBtn('사용량', 'ghost small', () => loadMvUsage(s, false)));
uc.appendChild(mkBtn(s.hasUsageKey ? '키 다시 발급' : '키 발급', 'ghost small', () => mvIssueUsageKey(s)));
if (s.hasUsageKey) uc.appendChild(mkBtn('키 삭제', 'ghost small', async () => { if (!confirm(`"${s.nickname}" 에 보관한 사용량 API 키를 지울까요?\nArimail 쪽 키는 그대로이며 메일 사용량 표시만 꺼집니다.`)) return; try { const r = await api(`/api/admin/mail-servers/${s.id}/usage-key`, { method: 'POST', body: JSON.stringify({ action: 'clear' }) }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname}: ${r.detail}`; $('mvMsg').className = 'muted'; $('mvUsage').classList.add('hidden'); } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } }));
td(tr, uc, s.hasUsageKey ? 'wrap' : 'st-cancelled wrap');
td(tr, s.isDefault ? '기본' : '', s.isDefault ? 'st-done' : '');
const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap';
const tb2 = mkBtn('연결 확인', 'ghost', async () => { $('mvMsg').textContent = `${s.nickname}: 확인 중…`; try { const r = await api(`/api/admin/mail-servers/${s.id}/test`, { method: 'POST', body: '{}' }); $('mvMsg').textContent = `${s.nickname}: ${r.detail}`; $('mvMsg').className = r.ok ? 'muted ok' : 'msg err'; drawMailServers(r.servers); } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } });
if (locked) { tb2.disabled = true; tb2.dataset.lockUntil = s.lockedUntil; tb2.title = `Arimail 로그인 잠금 — ${mvLockText(s.lockedUntil)}. 그 전에 시도하면 잠금이 길어집니다`; }
act.appendChild(tb2);
act.appendChild(mkBtn('수정', 'ghost', () => openMvForm(s)));
if (!s.isDefault) act.appendChild(mkBtn('기본으로', 'ghost', async () => { try { const r = await api(`/api/admin/mail-servers/${s.id}/default`, { method: 'POST', body: '{}' }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname} 을(를) 기본 메일 서버로 정했습니다`; $('mvMsg').className = 'muted ok'; loadMailSys(); } catch (e) { $('mvMsg').textContent = e.message; $('mvMsg').className = 'msg err'; } }));
// v1.6.39 (요구 2): 비밀번호를 바꾼 뒤 보관된 직전 값으로 되돌리기 (한 번 더 누르면 다시 바뀜 — 맞바꾸기)
if (s.hasPrevPassword) act.appendChild(mkBtn('이전 비밀번호로', 'ghost', async () => {
if (!confirm(`"${s.nickname}" 의 관리자 비밀번호를 ${s.prevPasswordAt ? dt(s.prevPasswordAt) + ' 에 바꾸기 전' : '이전'} 값으로 되돌릴까요?\n지금 값은 "이전 비밀번호" 로 보관되어 다시 되돌릴 수 있습니다.`)) return;
try { const r = await api(`/api/admin/mail-servers/${s.id}/revert-password`, { method: 'POST', body: '{}' }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname}: 이전 비밀번호로 되돌렸습니다${r.server.lockedUntil ? ' — 잠금이 풀린 뒤 "연결 확인" 을 누르세요' : '. "연결 확인" 으로 로그인되는지 확인하세요'}`; $('mvMsg').className = 'muted ok'; } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; }
}));
// v1.6.39 (요구 4): 연동 관리자 계정 비밀번호 초기화 (새 무작위 비밀번호 → Arimail 에 설정 → 보관)
act.appendChild(mkBtn('비밀번호 초기화', 'ghost', () => mvReset(s)));
if (s.source !== 'config') act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`메일 서버 "${s.nickname}" 등록을 지울까요?\nArimail 서버 자체와 그 안의 계정·메일은 그대로이며, 이 서버를 고른 사이트는 자동 선택(도메인이 같은 서버 → 기본 서버)으로 돌아갑니다.`)) return; try { const r = await api(`/api/admin/mail-servers/${s.id}`, { method: 'DELETE' }); drawMailServers(r.servers); $('mvMsg').textContent = '지웠습니다'; $('mvMsg').className = 'muted'; loadMailSys(); } catch (e) { $('mvMsg').textContent = e.message; $('mvMsg').className = 'msg err'; } }));
tr.appendChild(act); tb.appendChild(tr);
}
if (!mvServers.length) { const tr = document.createElement('tr'); td(tr, '등록된 메일 서버가 없습니다 — "메일 서버 추가" 로 다른 서버에 설치된 Arimail 을 등록하거나, 이 서버에서 deploy/install-arimail.sh 를 실행하세요').colSpan = 8; tb.appendChild(tr); }
}
/** v1.6.39: 잠금 안내 문구 — "잠금 · 14:52 까지 (남은 12:31)" */
function mvLockText(untilIso) {
const left = Math.max(0, Math.round((Date.parse(untilIso) - Date.now()) / 1000)); const t = new Date(untilIso);
return `Arimail 로그인 잠금 · ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')} 까지 (남은 ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')})`;
}
// 매초: 잠금 남은 시간을 갱신하고, 해제 시각이 지나면 표를 다시 그려 "연결 확인" 을 다시 켠다
setInterval(() => {
const els = document.querySelectorAll('[data-lock-until]'); if (!els.length) return; let expired = false;
for (const e of els) { if (Date.parse(e.dataset.lockUntil) <= Date.now()) expired = true; else if (e.tagName !== 'BUTTON') e.textContent = mvLockText(e.dataset.lockUntil); }
if (expired) drawMailServers(mvServers.map((s) => ({ ...s, lockedUntil: s.lockedUntil && Date.parse(s.lockedUntil) > Date.now() ? s.lockedUntil : null })));
}, 1000);
/**
* v1.6.39 (요구 4): 비밀번호 초기화. 먼저 저장된 비밀번호로 시도하고, 그 로그인이 안 되면(비밀번호를 잘못 저장한 경우) Arimail 의 다른 관리자 계정(초기 admin 등)으로
* 한 번 로그인해 바꾸는 폼(#mvReset)을 연다. 새 비밀번호는 응답을 한 번 보여 주고(표의 "보기" 로 다시 볼 수 있음) 이전 값은 "이전 비밀번호로" 되돌릴 수 있다.
*/
let mvResetTarget = null;
async function mvReset(s, via) {
if (!via && !confirm(`"${s.nickname}" 의 연동 관리자 계정 "${s.adminLogin}" 비밀번호를 새 무작위 값으로 바꿀까요?\n\nArimail 의 그 계정 비밀번호가 실제로 바뀌고 웹메이커 보관소에 저장됩니다. 이전 값은 "이전 비밀번호로" 되돌릴 수 있습니다.`)) return;
$('mvMsg').textContent = `${s.nickname}: 비밀번호 초기화 중…`; $('mvMsg').className = 'muted';
try {
const r = await api(`/api/admin/mail-servers/${s.id}/reset-password`, { method: 'POST', body: JSON.stringify(via ? { via } : {}) });
drawMailServers(r.servers); $('mvReset').classList.add('hidden'); mvResetTarget = null;
$('mvMsg').innerHTML = ''; const b = document.createElement('b'); b.textContent = `${s.nickname}: ${r.how} 비밀번호를 초기화했습니다${r.created ? ' (계정이 없어 새로 만들었습니다)' : ''}. `; $('mvMsg').appendChild(b);
const code = document.createElement('code'); code.style.userSelect = 'all'; code.textContent = r.password; $('mvMsg').appendChild(document.createTextNode(`새 비밀번호: `)); $('mvMsg').appendChild(code);
$('mvMsg').appendChild(document.createTextNode(` — ${r.server.adminUrl || 'Arimail /admin'} 에 ${s.adminLogin} 으로 로그인할 수 있습니다. 표의 "보기" 로 다시 볼 수 있습니다.${r.test ? (r.test.ok ? ' 연결 확인 성공.' : ` 연결 확인: ${r.test.detail}`) : ''}`));
$('mvMsg').className = 'muted ok';
} catch (e) {
$('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err';
if (!via && /로그인 실패|저장된 비밀번호가 없어/.test(e.message)) { mvResetTarget = s; $('mvReset').classList.remove('hidden'); $('mvRvTitle').textContent = `다른 관리자 계정으로 재설정 — ${s.nickname} (${s.adminLogin})`; $('mvRvLogin').value = ''; $('mvRvPw').value = ''; $('mvRvMsg').textContent = ''; $('mvRvLogin').focus(); }
}
}
$('mvRvGo').onclick = () => { if (!mvResetTarget) return; const login = $('mvRvLogin').value.trim(), password = $('mvRvPw').value; if (!login || !password) { $('mvRvMsg').textContent = 'Arimail 관리자 아이디와 비밀번호를 입력하세요'; return; } $('mvRvMsg').textContent = ''; mvReset(mvResetTarget, { login, password }); };
$('mvRvCancel').onclick = () => { $('mvReset').classList.add('hidden'); mvResetTarget = null; };
/** v1.7.3: 바이트 표시 (관리자 콘솔은 app.js 의 fmtBytes 를 쓰지 않으므로 여기서) */
const mvBytes = (n) => { n = Number(n) || 0; if (n < 1024) return `${n} B`; const u = ['KB', 'MB', 'GB', 'TB']; let i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); return `${n < 10 ? n.toFixed(2) : n < 100 ? n.toFixed(1) : Math.round(n)} ${u[i]}`; };
/**
* v1.7.3 (요구 2): 사용량 API 키 발급 — 서버가 저장된 관리자 계정으로 Arimail 에 로그인해 POST /api/setup/usage-key 로 키를 받아 보관한다(키는 화면에 오지 않는다).
* 이미 키가 있으면 Arimail 쪽 키가 바뀌어 이전 키는 즉시 무효 — 확인 창에 적는다.
*/
async function mvIssueUsageKey(s) {
if (!confirm(`"${s.nickname}" 의 Arimail 에 저장된 관리자 계정(${s.adminLogin})으로 로그인해 사용량 API 키를 ${s.hasUsageKey ? '다시 ' : ''}발급받아 보관합니다.${s.hasUsageKey ? '\n기존 키는 Arimail 에서 즉시 무효가 됩니다.' : ''}\n조건: Arimail 1.0.9 이상, 등록 계정이 기본 도메인의 관리자. 계속할까요?`)) return;
$('mvMsg').textContent = `${s.nickname}: 키 발급 중…`; $('mvMsg').className = 'muted';
try { const r = await api(`/api/admin/mail-servers/${s.id}/usage-key`, { method: 'POST', body: JSON.stringify({ action: 'issue' }) }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname}: ${r.detail}`; $('mvMsg').className = 'muted ok'; if (r.usage) renderMvUsage(r.server || s, r.usage); }
catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; }
}
/** 사용량 표 열기 — GET /api/admin/mail-servers/<id>/usage (refresh 면 캐시 무시) */
async function loadMvUsage(s, refresh) {
$('mvUsage').classList.remove('hidden'); $('mvUsageTitle').textContent = `메일 사용량 — ${s.nickname}`; $('mvUsageMsg').textContent = '불러오는 중…'; $('mvUsageMsg').className = 'muted';
try { const r = await api(`/api/admin/mail-servers/${s.id}/usage${refresh ? '?refresh=1' : ''}`); renderMvUsage(s, r); } catch (e) { $('mvUsageMsg').textContent = e.message; $('mvUsageMsg').className = 'msg err'; }
}
/** 사용량 표 그리기 — 합계 한 줄 + 도메인별(계정 / 메일함 용량 / 메일 수 / 오늘·이번 주·이달 발송/수신) */
function renderMvUsage(s, u) {
$('mvUsage').classList.remove('hidden'); $('mvUsageTitle').textContent = `메일 사용량 — ${s.nickname}`; $('mvUsageRefresh').onclick = () => loadMvUsage(s, true);
const tb = $('mvUsageTable').querySelector('tbody'); tb.innerHTML = '';
if (!u.ok) { $('mvUsageMsg').textContent = `받지 못함 — ${u.error ? u.error.message : '알 수 없는 오류'}`; $('mvUsageMsg').className = 'msg err'; return; }
const t = u.totals;
$('mvUsageMsg').textContent = `${dt(u.at)}${u.cached ? ' (캐시)' : ''} · Arimail ${u.version || ''} · 도메인 ${t.domains}개 · 계정 ${t.accounts}개 · 메일함 ${mvBytes(t.storageBytes)}${t.largeFileBytes ? ` (+대용량 첨부 ${mvBytes(t.largeFileBytes)})` : ''} · 메일 ${t.messages.toLocaleString()}통 · 이달 발송 ${t.sentMonth.toLocaleString()} / 수신 ${t.receivedMonth.toLocaleString()}`; $('mvUsageMsg').className = 'muted';
for (const d of u.domains) {
const tr = document.createElement('tr');
if (d.error) { td(tr, d.domain, 'wrap'); td(tr, `조회 실패 (${d.error})`, 'st-error wrap').colSpan = 7; tb.appendChild(tr); continue; }
td(tr, `${d.domain}${d.primary ? ' (기본)' : ''}${d.enabled ? '' : ' · 중지'}`, 'wrap');
td(tr, `${d.accounts.total}${d.accounts.limit ? ` / ${d.accounts.limit}` : ''}${d.accounts.external ? ` (웹메이커 ${d.accounts.external})` : ''}`, 'num');
td(tr, `${mvBytes(d.storage.bytes)}${d.storage.limit ? ` / ${mvBytes(d.storage.limit)} (${d.storage.percent != null ? d.storage.percent : '-'}%)` : ''}`, 'num');
td(tr, d.messages.total.toLocaleString(), 'num');
td(tr, `${d.traffic.day.sent} / ${d.traffic.day.received}`, 'num'); td(tr, `${d.traffic.week.sent} / ${d.traffic.week.received}`, 'num');
td(tr, `${d.traffic.month.sent} / ${d.traffic.month.received}${d.traffic.monthlyLimit ? ` (한도 ${d.traffic.monthlyLimit})` : ''}`, 'num'); td(tr, `${mvBytes(d.traffic.month.sentBytes)} / ${mvBytes(d.traffic.month.receivedBytes)}`, 'num');
tb.appendChild(tr);
}
if (!u.domains.length) { const tr = document.createElement('tr'); td(tr, '도메인이 없습니다').colSpan = 8; tb.appendChild(tr); }
}
$('mvUsageClose').onclick = () => $('mvUsage').classList.add('hidden');
/** 추가·수정 폼 열기 (s 가 있으면 수정) */
function openMvForm(s) {
mvEditing = s || null; $('mvForm').classList.remove('hidden'); $('mvFormTitle').textContent = s ? `메일 서버 수정 — ${s.nickname}` : '메일 서버 추가';
$('mvNick').value = s ? (s.source === 'config' && /\(설정 파일\)$/.test(s.nickname) ? '' : s.nickname) : ''; $('mvIp').value = s ? s.ip || '' : ''; $('mvUrl').value = s ? s.url || '' : ''; $('mvApi').value = s ? (s.apiUrl && s.apiUrl !== s.url ? s.apiUrl : '') : ''; $('mvDomain').value = s ? s.domain || '' : ''; $('mvLogin').value = s ? s.adminLogin || '' : 'admin'; $('mvPw').value = '';
$('mvPwNote').textContent = s ? (s.hasPassword ? '(비우면 기존 비밀번호 유지)' : '(저장된 비밀번호가 없습니다 — 입력하세요)') : '(새 서버는 필수)'; $('mvFormMsg').textContent = '';
$('mvUsageKey').value = ''; $('mvUkNote').textContent = s ? (s.hasUsageKey ? `(보관 중 ${s.usageKeyHint || ''} — 비우면 유지)` : '(없음 — 붙여 넣거나 표의 "키 발급")') : '(선택 — 저장 뒤 표의 "키 발급" 으로 받을 수도 있음)'; // v1.7.3
}
$('mvAdd').onclick = () => openMvForm(null);
$('mvCancel').onclick = () => { $('mvForm').classList.add('hidden'); mvEditing = null; };
$('mvSave').onclick = async () => {
const body = { nickname: $('mvNick').value.trim(), ip: $('mvIp').value.trim(), url: $('mvUrl').value.trim(), apiUrl: $('mvApi').value.trim(), domain: $('mvDomain').value.trim(), adminLogin: $('mvLogin').value.trim() }; if ($('mvPw').value) body.password = $('mvPw').value;
if ($('mvUsageKey').value.trim()) body.usageKey = $('mvUsageKey').value.trim(); // v1.7.3: 사용량 API 키(비우면 유지)
// v1.6.39 (요구 2): 저장된 비밀번호가 있는 서버의 비밀번호 칸에 값이 있으면 덮어쓰기 전에 확인 — 2026-10-21 운영 서버에서 동작하던 비밀번호를 실수로 덮어쓴 일의 재발 방지
if (mvEditing && mvEditing.hasPassword && body.password && !confirm(`"${mvEditing.nickname}" 의 저장된 Arimail 관리자 비밀번호를 지금 입력한 값으로 바꿉니다.\n\n틀린 값으로 바꾸면 메일 계정 관리·연결 확인이 실패합니다(여러 번 실패하면 Arimail 이 15분간 로그인을 막습니다). 비밀번호를 그대로 두려면 "취소" 를 누르고 칸을 비우세요.\n바꾼 뒤에는 표의 "이전 비밀번호로" 로 되돌릴 수 있습니다.\n\n계속할까요?`)) { $('mvFormMsg').textContent = '비밀번호를 바꾸지 않았습니다 (저장 취소)'; $('mvFormMsg').className = 'muted'; return; }
$('mvFormMsg').textContent = '저장 중…';
try { const r = mvEditing ? await api(`/api/admin/mail-servers/${mvEditing.id}`, { method: 'PUT', body: JSON.stringify(body) }) : await api('/api/admin/mail-servers', { method: 'POST', body: JSON.stringify(body) }); $('mvPw').value = ''; $('mvUsageKey').value = ''; drawMailServers(r.servers); $('mvFormMsg').textContent = `저장했습니다 — ${r.server.nickname}${r.server.ip ? ' (' + r.server.ip + ')' : ''}. "연결 확인" 으로 Arimail 로그인까지 확인하세요`; $('mvFormMsg').className = 'muted ok'; mvEditing = r.server; $('mvFormTitle').textContent = `메일 서버 수정 — ${r.server.nickname}`; $('mvPwNote').textContent = '(비우면 기존 비밀번호 유지)'; loadMailSys(); }
catch (e) { $('mvFormMsg').textContent = e.message; $('mvFormMsg').className = 'msg err'; }
};
async function loadMailSys() {
let v; try { v = await api('/api/admin/mail'); } catch (_) { return; }
const c = v.config; $('msDefMx').value = c.mxHost || ''; $('msDefSpf').value = c.spfInclude || ''; $('msDefDmarc').value = c.dmarc || '';
drawMailServers(v.servers || []); // v1.6.38
// v1.5.7: 받는 메일은 연동 모듈(Arimail) 상태로 — 연결되어 있으면 주소·도메인·버전, 아니면 연결 방법(시스템 설정 탭)
const am = v.backend.arimail;
$('msBackend').textContent = `보내기: ${v.backend.canSend ? '가능 — 메일 발송 키(Resend) 사용, 메일 도메인이 Resend 에서 확인되면 발송' : '불가 — 설정 탭 › 외부 연동 접속 정보에 메일 발송(Resend) 키를 넣으세요'} · 받는 메일·웹메일(기본 서버): ${am ? (am.ok ? `Arimail ${am.version || ''} 연결됨 (${am.nickname || am.host}${am.ip ? ' ' + am.ip : ''} · ${am.host}, 도메인 ${am.domain}) — 메일 도메인이 ${am.domain} 인 사이트에 적용` : `Arimail 설정은 있지만 응답이 없습니다 (${am.detail})`) : '메일 서버(Arimail) 미연결 — 아래 "메일 서버 추가" 로 등록하거나(다른 서버에 설치된 Arimail 도 가능) 이 서버에서 deploy/install-arimail.sh 를 실행'}`;
const tb = $('msSites').querySelector('tbody'); tb.innerHTML = '';
for (const x of v.sites) {
const tr = document.createElement('tr'); td(tr, x.title, 'wrap'); td(tr, `${x.ownerName || ''} (${x.ownerId || '-'})`, 'wrap'); td(tr, x.domains.join(', ') || '-', 'wrap'); td(tr, x.domain || '-');
const st = document.createElement('span'); st.className = 'ms-pill ' + x.level; st.textContent = x.label; st.title = x.pending && x.pending.length ? `미확인: ${x.pending.join(', ')}` : ''; td(tr, st);
td(tr, x.enabled ? `${x.accounts}개` : '-', 'num');
const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap';
act.appendChild(mkBtn(x.enabled ? '설정' : '메일 시스템 사용', x.enabled ? 'ghost' : 'primary', () => openMailPanel(x.id)));
tr.appendChild(act); tb.appendChild(tr);
}
if (!v.sites.length) { const tr = document.createElement('tr'); td(tr, '사이트가 없습니다').colSpan = 7; tb.appendChild(tr); }
if (msCur) openMailPanel(msCur, true);
}
/** 사이트 메일 설정 패널 열기 (keep: 새로고침 때 입력값을 덮어쓰지 않음) */
async function openMailPanel(id, keep) {
let d; try { d = await api(`/api/admin/mail/${id}`); } catch (e) { return alert(e.message); }
msCur = id; $('msPanel').classList.remove('hidden'); $('msPanelTitle').textContent = `${d.title} — ${d.enabled ? d.label : '메일 시스템 사용 설정'}`;
const dl = $('msDomainList'); dl.innerHTML = ''; for (const dm of d.domains) dl.appendChild(new Option(dm, dm));
// v1.6.38 (요구 2): 메일 서버 선택 — 자동(메일 도메인이 같은 서버 → 기본 서버) 또는 등록부의 서버(닉네임 — IP (도메인))
const ss = $('msServer'); const curSrv = keep ? ss.value : (d.mailServer || ''); ss.innerHTML = ''; ss.appendChild(new Option('자동 — 메일 도메인이 같은 서버, 없으면 기본 서버', ''));
for (const sv of mvServers) ss.appendChild(new Option(sv.label + (sv.isDefault ? ' · 기본' : '') + (sv.hasPassword ? '' : ' · 비밀번호 없음'), sv.id)); ss.value = mvServers.some((x) => x.id === curSrv) ? curSrv : '';
$('msServerNote').textContent = d.pickedServer ? `(지금 쓰일 서버: ${d.pickedServer.label})` : '(등록된 메일 서버가 없습니다)';
if (!keep) { $('msDomain').value = d.domain || d.domains[0] || ''; $('msMx').value = (d.settings && d.settings.mxHost) || ''; $('msSpf').value = (d.settings && d.settings.spfInclude) || ''; $('msDmarc').value = (d.settings && d.settings.dmarc) || ''; $('msMsg').textContent = ''; }
$('msMxDefault').textContent = d.effective && d.effective.mxHost ? `(기본값: ${d.effective.mxHost})` : '(기본값 없음 — 꼭 입력)';
$('msApply').textContent = d.enabled ? '설정 저장' : '메일 시스템 사용 · 설정 저장';
$('msCheck').disabled = !d.enabled; $('msOff').classList.toggle('hidden', !d.enabled);
const st = d.status; const box = $('msStatus'); box.innerHTML = '';
const line = (k, v) => { const p = document.createElement('div'); const b = document.createElement('b'); b.textContent = k + ' '; p.appendChild(b); p.appendChild(document.createTextNode(v)); box.appendChild(p); };
line('사용자 화면 표시:', st.label); line('상태:', st.message);
// v1.7.3 (요구 2): 메일 사용량 — 이 사이트 메일 도메인의 메일함 용량·계정·메일 수·이달 발송/수신 (Arimail 사용량 API). 미연동이면 이유
if (d.usage) { const u = d.usage; line('메일 사용량:', u.ok ? `메일함 ${mvBytes(u.storage.bytes)}${u.storage.limit ? ` / ${mvBytes(u.storage.limit)}` : ''} · 계정 ${u.accounts.total}개(이 사이트 ${u.accounts.mine}개, ${mvBytes(u.myBytes || 0)}) · 메일 ${u.messages.total.toLocaleString()}통 · 이달 발송 ${u.traffic.month.sent} / 수신 ${u.traffic.month.received} — ${u.server ? u.server.nickname : ''} ${dt(u.at)}${u.cached ? ' (캐시)' : ''}` : `(미연동) ${u.message}`); }
if (d.enabled) { line('메일 주소 형식:', `아이디@${d.domain}`); line('웹메일:', d.webmailUrl); if (st.dns) line('마지막 확인:', dt(st.dns.checkedAt)); }
if (!d.domains.length) line('참고:', '이 사이트에 연결된 도메인이 없습니다. 메일 도메인을 직접 입력할 수 있습니다(도메인 소유자가 DNS 를 등록해야 함).');
const rt = $('msRecords').querySelector('tbody'); rt.innerHTML = '';
const chk = {}; for (const r of ((st.dns && st.dns.records) || [])) chk[r.key] = r;
for (const r of d.records || []) { const tr = document.createElement('tr'); const c = chk[r.key]; td(tr, r.type); td(tr, r.host, 'wrap'); td(tr, r.value, 'wrap'); td(tr, r.priority || '', 'num'); td(tr, r.purpose === 'send' ? '보내기 인증' : r.required ? '받기 (필수)' : '권장'); td(tr, c ? `${c.ok ? '✓ 확인됨' : '✗ 미확인'} — 현재: ${c.found}` : '-', c && c.ok ? 'st-done wrap' : 'st-error wrap'); rt.appendChild(tr); }
if (!(d.records || []).length) { const tr = document.createElement('tr'); td(tr, d.recordsError || '메일 시스템을 켜면 이 도메인에 등록할 DNS 레코드가 표시됩니다').colSpan = 6; rt.appendChild(tr); }
}
$('msApply').onclick = async () => {
if (!msCur) return; $('msMsg').textContent = '저장하고 DNS 를 확인하는 중…';
try { await api(`/api/admin/mail/${msCur}`, { method: 'POST', body: JSON.stringify({ domain: $('msDomain').value.trim(), mxHost: $('msMx').value.trim(), spfInclude: $('msSpf').value.trim(), dmarc: $('msDmarc').value.trim(), mailServer: $('msServer').value /* v1.6.38 */ }) }); $('msMsg').textContent = '저장했습니다. DNS 가 전파·확인될 때까지 사용자에게 "메일시스템 설정중" 으로 표시됩니다.'; await loadMailSys(); }
catch (e) { $('msMsg').textContent = e.message; }
};
$('msCheck').onclick = async () => { if (!msCur) return; $('msMsg').textContent = '확인 중…'; try { await api(`/api/admin/mail/${msCur}/check`, { method: 'POST', body: '{}' }); $('msMsg').textContent = '다시 확인했습니다'; await loadMailSys(); } catch (e) { $('msMsg').textContent = e.message; } };
$('msOff').onclick = async () => { if (!msCur || !confirm('이 사이트의 메일 시스템을 끕니다. 사용자는 웹메일에 들어갈 수 없게 되며, 계정과 메일은 남아 다시 켜면 이어서 씁니다. 계속할까요?')) return; try { await api(`/api/admin/mail/${msCur}/disable`, { method: 'POST', body: '{}' }); $('msMsg').textContent = '껐습니다'; await loadMailSys(); } catch (e) { $('msMsg').textContent = e.message; } };
$('msClose').onclick = () => { msCur = null; $('msPanel').classList.add('hidden'); };
$('msSave').onclick = async () => { try { await api('/api/admin/mail', { method: 'PUT', body: JSON.stringify({ mxHost: $('msDefMx').value.trim(), spfInclude: $('msDefSpf').value.trim(), dmarc: $('msDefDmarc').value.trim() }) }); $('msDefMsg').textContent = '기본값을 저장했습니다'; loadMailSys(); } catch (e) { $('msDefMsg').textContent = e.message; } };
// ---------- Webmaker 화면 외형 (v1.2.0) ----------
/**
* 관리자 테마 편집기. GET /api/admin/settings/theme 로 현재 값·기본값·미리 만든 색 구성을 받고,
* PUT /api/admin/settings {theme} 로 저장한다(서버가 색은 #rrggbb, 외형은 허용 범위로 검증).
* 저장된 테마는 /theme.css 로 사용자 화면에 적용된다. 아래 미리보기 상자는 CSS 변수로 즉시 반영한다.
*/
const TH_COLOR_LABEL = { accent: '강조색 (버튼·선택)', accent2: '강조색 진하게 (마우스 올림)', bg: '바탕', panel: '패널·카드', text: '글자', muted: '보조 글자', line: '테두리', ok: '성공', warn: '경고', topBg: '위쪽 영역 바탕' };
let thInfo = null; let thCur = null;
async function loadTheme() {
try { thInfo = await api('/api/admin/settings/theme'); } catch (_) { return; }
thCur = JSON.parse(JSON.stringify(thInfo.theme));
const sel = $('thPreset'); sel.innerHTML = '';
for (const [id, p] of Object.entries(thInfo.presets)) sel.appendChild(new Option(p.label, id));
sel.appendChild(new Option('직접 조정', 'custom'));
$('thState').textContent = thInfo.saved ? '— 사용자 지정 적용 중' : '— 기본값';
renderTheme(); buildPreview();
}
$('thRevert').onclick = () => { if (!thInfo) return; thCur = JSON.parse(JSON.stringify(thInfo.theme)); renderTheme(); $('thMsg').textContent = '저장된 값으로 되돌렸습니다 (아직 저장 안 함)'; };
function renderTheme() {
const c = thCur.colors, l = thCur.layout; $('thPreset').value = thCur.preset || 'default';
const box = $('thColors'); box.innerHTML = '';
for (const k of Object.keys(thInfo.defaults.colors)) {
const lab = document.createElement('label'); lab.textContent = TH_COLOR_LABEL[k] || k;
const inp = document.createElement('input'); inp.type = 'color'; inp.value = c[k]; inp.style.height = '32px'; inp.style.padding = '2px';
inp.oninput = () => { c[k] = inp.value; thCur.preset = 'custom'; $('thPreset').value = 'custom'; previewTheme(); };
lab.appendChild(inp); box.appendChild(lab);
}
$('thLeftWidth').value = l.leftWidth; $('thFontSize').value = l.fontSize; $('thRadius').value = l.radius; $('thDensity').value = l.density; $('thPanelSide').value = l.panelSide; $('thHeaderStyle').value = l.headerStyle;
previewTheme();
}
// ---------- 화면 설정 미리보기 (v1.3.2) ----------
/**
* 실제 사용자 화면(빌더 index.html·login.html·site-admin.html)의 HTML 을 받아 스크립트를 빼고 예시 내용을 채운 뒤
* 같은 출처의 iframe(srcdoc)에 넣는다. 테마 CSS 는 서버가 만든 것(POST /api/admin/settings/theme-preview)을
* 각 iframe 의 <style id="wmPreviewTheme"> 에 직접 넣어, 저장하지 않은 값도 실제 화면 그대로 미리 볼 수 있다.
* 보기: builder(PC 1280px) · mobile(390px) · login · siteadmin · all(넷을 작게 한 화면에)
*/
const TH_VIEWS = { builder: { page: 'index', w: 1280, h: 800, label: '빌더 (PC)' }, mobile: { page: 'index', w: 390, h: 800, label: '빌더 (모바일)' }, login: { page: 'login', w: 1100, h: 700, label: '로그인' }, siteadmin: { page: 'site-admin', w: 1200, h: 760, label: '사이트 관리자' } };
let thPages = {}; let thView = 'builder'; let thCss = ''; let thTimer = null;
/** 예시 내용 채우기: 스크립트가 없으니 비어 있는 숫자·목록·숨김 영역을 사람이 보는 상태로 만든다 */
function fillSample(doc, page) {
const q = (sel) => doc.querySelector(sel); const set = (sel, v) => { const e = q(sel); if (e) e.textContent = v; };
if (page === 'index') {
set('#verBadge', 'v1.3.2'); set('#creditBadge', '12,000원'); set('#userName', '샘플 회사'); set('#userId', '(sample)'); set('#siteLiveLine', '운영 주소: https://shop.example.com');
const sel = q('#siteSelect'); if (sel) { sel.innerHTML = '<option>+ 새 사이트 만들기</option><optgroup label="완성된 사이트"><option selected>샘플 베이커리 · 발행됨</option></optgroup>'; }
set('#siteStatePill', '발행됨'); const pill = q('#siteStatePill'); if (pill) pill.className = 'pill small done';
set('#statusPill', '진행 중'); const sp = q('#statusPill'); if (sp) sp.className = 'pill running'; const pr = q('#progress'); if (pr) pr.className = 'progress running'; set('#progressText', '수정 반영 진행 중…'); set('#progressSub', '경과 12초');
for (const id of ['aiStartCard']) { const e = q('#' + id); if (e) e.classList.remove('hidden'); }
const tg = q('#tierGuideBox'); if (tg) tg.innerHTML = '<table><tr><th>등급</th><th>특징</th><th class="cost">생성 1회</th></tr><tr class="on"><td>기본</td><td>HTML5·CSS3·자바스크립트 정적 웹페이지</td><td class="cost">약 500원</td></tr><tr><td>중급</td><td>시각적 완성도(UI/UX)·심미성</td><td class="cost">약 900원</td></tr></table>';
const ta = q('#aiText'); if (ta) ta.textContent = '강남역 근처 필라테스 스튜디오. 1:1 수업 중심, 예약을 많이 받고 싶어요.';
set('#siteUrl', 'https://shop.example.com/'); const es = q('#emptyState'); if (es) es.innerHTML = '<div class="empty-inner"><h3>샘플 베이커리</h3><p>오늘 구운 빵을 매일 아침 8시에 만나 보세요.</p><p><button class="primary">예약하기</button> <button class="ghost">메뉴 보기</button></p></div>';
} else if (page === 'site-admin') {
const lb = q('#loginBox'); if (lb) lb.classList.add('hidden'); const app = q('#app'); if (app) app.classList.remove('hidden');
set('#hSite', '샘플 베이커리'); set('#hWho', '샘플 회사 (빌더 소유자)');
const tabs = q('#tabs'); if (tabs) tabs.innerHTML = '<button class="active">모듈 · 설정</button><button>게시판</button><button>문의</button><button>메일</button><button>관리자 계정</button>';
const main = q('#main'); if (main) main.innerHTML = '<div class="card"><h2>문의 접수</h2><p class="mail-banner warn">메일시스템 설정중 — DNS 전파 확인 중입니다.</p><div class="table-wrap"><table><tr><th>접수</th><th>이름</th><th>제목</th><th>메일</th></tr><tr><td>10. 05.</td><td>홍길동</td><td>단체 주문 문의</td><td><span class="pill ok">발송</span></td></tr><tr><td>10. 04.</td><td>김영희</td><td>영업 시간</td><td><span class="pill warn">대기</span></td></tr></table></div><div class="row"><button class="primary">설정 저장</button><button>취소</button></div></div>';
}
}
/** 페이지 HTML → 미리보기용 문서 (스크립트 제거, /theme.css 대신 미리보기 스타일) */
function previewDoc(html) { return html.replace(/<script\b[\s\S]*?<\/script>/gi, '').replace(/<link[^>]+href="\/theme\.css"[^>]*>/i, '').replace(/<\/head>/i, '<style id="wmPreviewTheme"></style><style>html,body{overflow:hidden}</style></head>'); }
async function buildPreview() {
for (const v of ['index', 'login', 'site-admin']) if (!thPages[v]) { try { thPages[v] = previewDoc((await api(`/api/admin/settings/preview-page/${v}`)).html); } catch (_) { thPages[v] = '<p>미리보기를 불러오지 못했습니다</p>'; } }
const box = $('thFrames'); box.innerHTML = ''; box.className = 'th-frames ' + (thView === 'all' ? 'all' : 'one');
const views = thView === 'all' ? Object.keys(TH_VIEWS) : [thView];
for (const v of views) {
const conf = TH_VIEWS[v]; const wrap = document.createElement('div'); wrap.className = 'th-frame-wrap'; wrap.dataset.view = v;
const cap = document.createElement('div'); cap.className = 'muted'; cap.textContent = `${conf.label} — ${conf.w}px`; wrap.appendChild(cap);
const vp = document.createElement('div'); vp.className = 'th-vp'; const f = document.createElement('iframe'); f.width = conf.w; f.height = conf.h; f.srcdoc = thPages[conf.page]; f.setAttribute('sandbox', 'allow-same-origin');
f.onload = () => { try { fillSample(f.contentDocument, conf.page); applyCss(f); } catch (_) {} };
vp.appendChild(f); wrap.appendChild(vp); box.appendChild(wrap);
}
fitFrames();
}
/** 각 iframe 을 칸 폭에 맞게 축소 (실제 화면 폭으로 그린 뒤 transform: scale) */
function fitFrames() { document.querySelectorAll('#thFrames .th-frame-wrap').forEach((w) => { const f = w.querySelector('iframe'); const vp = w.querySelector('.th-vp'); const avail = vp.clientWidth || w.clientWidth; const sc = Math.min(1, avail / f.width); f.style.transform = `scale(${sc})`; vp.style.height = Math.round(f.height * sc) + 'px'; }); }
window.addEventListener('resize', () => { if (!$('tab-theme').classList.contains('hidden')) fitFrames(); });
function applyCss(f) { try { const st = f.contentDocument.getElementById('wmPreviewTheme'); if (st) st.textContent = thCss; } catch (_) {} }
document.querySelectorAll('#thViews button').forEach((b) => (b.onclick = () => { thView = b.dataset.view; document.querySelectorAll('#thViews button').forEach((x) => x.classList.toggle('active', x === b)); buildPreview(); }));
/** 편집 값이 바뀌면(0.15초 모아서) 서버에서 CSS 를 받아 모든 미리보기 iframe 에 넣는다 */
function previewTheme() {
const l = thCur.layout; $('thLeftWidthV').textContent = l.leftWidth + 'px'; $('thFontSizeV').textContent = l.fontSize + 'px'; $('thRadiusV').textContent = l.radius + 'px';
clearTimeout(thTimer); thTimer = setTimeout(async () => { try { thCss = (await api('/api/admin/settings/theme-preview', { method: 'POST', body: JSON.stringify({ theme: thCur }) })).css; document.querySelectorAll('#thFrames iframe').forEach(applyCss); } catch (_) {} }, 150);
}
$('thPreset').onchange = () => {
const id = $('thPreset').value; if (id === 'custom') { thCur.preset = 'custom'; return; }
const p = thInfo.presets[id]; thCur = { preset: id, colors: { ...thInfo.defaults.colors, ...(p.colors || {}) }, layout: { ...thCur.layout, ...(p.layout || {}) } }; renderTheme();
};
for (const [id, key, num] of [['thLeftWidth', 'leftWidth', 1], ['thFontSize', 'fontSize', 1], ['thRadius', 'radius', 1], ['thDensity', 'density', 0], ['thPanelSide', 'panelSide', 0], ['thHeaderStyle', 'headerStyle', 0]]) $(id).oninput = $(id).onchange = () => { thCur.layout[key] = num ? Number($(id).value) : $(id).value; previewTheme(); };
$('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
$('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
async function loadSettings() {
settingsData = await api('/api/admin/settings'); const s = settingsData;
loadVault();
$('ver').textContent = s.version ? `v${s.version}` : '';
renderTiers(s);
$('sSignupCredit').value = s.signupCredit || 0; $('sMaxEdits').value = s.maxEdits == null ? 10 : s.maxEdits;
// v1.5.27 (요구 6) 회원가입 규칙: ON/OFF 는 설정 파일(service.allowSignup, /api/admin/config), 승인 방식은 settings.signupApproval
$('suApproval').value = s.signupApproval || 'immediate';
api('/api/admin/config').then((c) => { const k = ((c.config && c.config.groups) || []).flatMap((g) => g.keys || []).find((x) => x.key === 'service.allowSignup'); if (k) $('suEnabled').checked = k.value !== false && k.value !== 'false' && k.value !== '0'; markSaved(); }).catch(() => {});
fillDomains(s.domainsConfig); // v1.4.0 도메인 서비스 설정
fillWelcome(s); // v1.4.5 사용자 첫 화면 페이지
cashUnit = s.cashUnit || '원'; $('sCashUnit').value = cashUnit; document.querySelectorAll('.cash-unit').forEach((e) => { e.textContent = cashUnit; }); $('chargeAmount').placeholder = TF('금액({1})', cashUnit); // v1.4.1 캐시 표시 단위
$('sBacklogKey').value = ''; $('sBacklogKey').placeholder = s.backlogKeyHint ? `저장됨: ${s.backlogKeyHint} (바꾸려면 새 키 입력)` : '수집 키 (비워 두면 비활성)';
$('sServiceKey').value = ''; $('sServiceKey').placeholder = s.serviceApiKeyHint ? `저장됨: ${s.serviceApiKeyHint} (바꾸려면 새 키 입력)` : '서비스 키 (비워 두면 외부 연동 비활성)';
renderClaudeModels(s); // v1.5.16 AI 연결 탭: 사용할 Claude 모델 체크 목록
document.querySelector(`input[name=authMode][value=${s.auth.mode}]`).checked = true;
$('sOauth').value = ''; $('sOauth').placeholder = s.auth.oauthTokenHint ? `저장됨: ${s.auth.oauthTokenHint} (바꾸려면 새 토큰 입력)` : 'OAuth 토큰 붙여넣기';
$('sApiKey').value = ''; $('sApiKey').placeholder = s.auth.apiKeyHint ? `저장됨: ${s.auth.apiKeyHint} (바꾸려면 새 키 입력)` : 'sk-ant-api03-…';
$('serverLoginInfo').textContent = (s.serverLogin.email ? `— 서버에 로그인된 계정: ${s.serverLogin.email}${s.serverLogin.billing ? ` (${s.serverLogin.billing})` : ''}` : '— 서버에 로그인된 계정 없음') + (s.claude ? (s.claude.bin ? ` · claude ${s.claude.version || ''} @ ${s.claude.bin}` : ` · claude 실행 파일 없음 (탐색: ${s.claude.searched.join(', ')})`) : '');
document.querySelector(`input[name=fxMode][value=${s.fx.mode}]`).checked = true; $('sSpread').value = s.fx.spreadPct; $('sManualRate').value = s.fx.manualRate || ''; $('sMargin').value = s.marginPct; $('sLeEmail').value = s.letsEncryptEmail || '';
// v1.3.17 비밀번호 규칙 (없으면 기본: 6자 이상)
{ const pp = { minLength: 6, maxLength: 256, ...(s.passwordPolicy || {}) }; $('ppMin').value = pp.minLength; $('ppMax').value = pp.maxLength; $('ppLetter').checked = !!pp.requireLetter; $('ppMixed').checked = !!pp.requireMixedCase; $('ppDigit').checked = !!pp.requireDigit; $('ppSymbol').checked = !!pp.requireSymbol; $('ppNoId').checked = !!pp.forbidId; $('ppMsg').textContent = TF('지금 규칙: {1}', ppDescribe(pp)); }
markSaved(); // v1.3.16 지금 값을 "저장된 값" 으로 기억 (고치면 .dirty 표시)
refreshFxNow(false); loadProviders().catch(() => {}); loadMail().catch(() => {}); loadGit().catch(() => {}); loadSsl().catch(() => {}); // v1.4.14 와일드카드 인증서 상태
}
async function loadGit() {
$('gStatus').textContent = '확인 중…'; $('gStatus').style.color = '';
const g = await api('/api/admin/settings/git');
$('gStatus').textContent = (g.enabled ? `원격: ${g.remote} · ` : '') + g.detail + (g.ms ? ` (${g.ms}ms)` : '');
$('gStatus').style.color = g.ok ? '#6ee7b7' : (g.enabled ? '#fca5a5' : '');
if (g.publicKey) { $('gPubKey').value = `이 서버의 공개키 (${g.keyFile || ''}):\n${g.publicKey}`; $('gPubKey').classList.remove('hidden'); } else $('gPubKey').classList.add('hidden');
}
$('gTest').onclick = () => loadGit().catch((e) => { $('gStatus').textContent = '확인 실패: ' + e.message; });
/**
* 모델 등급 지정 (기본·중급·고급 → 실제 모델).
* v1.4.14 (요구 6): 사용 가능한 모델 전체를 제공자별 묶음(optgroup)으로 보이고, 켜 두었지만 목록에 못 들어간 제공자는 이유를 아래에 적는다.
* 1.4.5 까지는 AI 제공자에서 모델을 추가·저장해도 이 목록이 다시 그려지지 않아(설정 화면을 새로 열어야 보임) "추가한 모델이 안 보인다" 는 문제가 있었다
* → pSave 가 저장 뒤 loadSettings() 를 다시 부른다.
*/
function renderTiers(s) {
const box = $('tierRows'); box.innerHTML = '';
const models = s.availableModels || [];
const groups = []; for (const m of models) { const key = m.providerLabel || m.provider || ''; let g = groups.find((x) => x.key === key); if (!g) { g = { key, items: [] }; groups.push(g); } g.items.push(m); }
/** 사용 가능한 모델 전체를 제공자별 묶음으로 채운 select. cur 가 목록에 없으면(제공자를 껐거나 모델을 뺌) "현재 사용 불가" 항목으로 보인다 — v1.5.16: 내부 호출 모델도 같은 목록을 쓴다 */
const fillModelSelect = (sel, cur) => {
sel.innerHTML = '';
for (const g of groups) { const og = document.createElement('optgroup'); og.label = g.key; for (const m of g.items) og.appendChild(new Option(`${m.label} (${m.ref}) — 입력 ${m.input} / 출력 ${m.output} per 1M`, m.ref)); sel.appendChild(og); }
if (cur && ![...sel.options].some((o) => o.value === cur)) sel.appendChild(new Option(`${cur} (현재 사용 불가 — AI 연결 탭에서 제공자·모델 확인)`, cur));
sel.value = cur;
};
for (const t of s.tierCatalog || []) {
const row = document.createElement('div'); row.className = 'row'; row.style.marginBottom = '6px';
const lab = document.createElement('span'); lab.style.minWidth = '110px'; lab.innerHTML = `<b>${t.label}</b> <span class="muted">${t.desc}</span>`; row.appendChild(lab);
const sel = document.createElement('select'); sel.dataset.tier = t.id; sel.style.margin = '0'; sel.style.flex = '1';
fillModelSelect(sel, (s.tiers || {})[t.id] || ''); row.appendChild(sel); box.appendChild(row);
}
// v1.5.16 (요구 2): 내부 호출 모델 — 등급 지정과 같은 목록(AI 연결 탭에서 켠 모델)에서 고른다. 저장은 "모델 변경" 버튼(btnSaveInternal)
fillModelSelect($('sInternalRef'), s.internalRef || '');
const note = document.createElement('p'); note.className = 'muted'; note.style.margin = '2px 0 6px';
const miss = s.unavailableProviders || [];
note.textContent = TF('고를 수 있는 모델 {1}개 ({2})', models.length, groups.map((g) => `${g.key} ${g.items.length}`).join(', '));
box.appendChild(note);
// v1.6.38 (요구 1): 켜 두었지만 목록에 못 들어간 제공자는 빨간 줄로 따로 — 회색 한 줄에 묻혀 "추가한 모델이 안 보인다" 로 이어지던 문제
if (miss.length) { const warn = document.createElement('p'); warn.className = 'msg err'; warn.style.margin = '0 0 6px'; warn.textContent = TF('목록에 들어가지 못한 AI 제공자: {1} — AI 연결 탭에서 고치면 바로 이 목록에 나옵니다', miss.map((x) => `${x.label}(${x.reason})`).join(' / ')); box.appendChild(warn); }
const dsel = $('sDefaultTier'); dsel.innerHTML = ''; for (const t of s.tierCatalog || []) dsel.appendChild(new Option(t.label, t.id)); dsel.value = s.defaultTier || 'standard';
}
$('btnStatsCollect').onclick = async () => { $('statsCollectLine').textContent = '접속 로그 수집 중…'; try { const r = await api('/api/admin/stats/collect', { method: 'POST', body: '{}' }); const l = r.local || {}; const rem = (r.remote && r.remote.items) || []; $('statsCollectLine').textContent = `접속 로그 수집: 로컬 ${l.ok ? `정상 (${l.lines || 0}줄)` : '실패 — ' + (l.detail || '')}${rem.length ? ' · 원격 ' + rem.map((x) => `${x.target}/${x.site}: ${x.ok ? x.lines + '줄' : '실패(' + x.detail + ')'}`).join(', ') : ''}`; loadOverview(); } catch (e) { $('statsCollectLine').textContent = '수집 실패: ' + e.message; } };
$('btnSaveSignup').onclick = async () => {
// v1.5.27: 두 저장소를 차례로 — 설정 파일(가입 허용) → 관리자 설정(승인 방식)
$('suMsg').textContent = T('저장 중…');
try { await api('/api/admin/config', { method: 'PUT', body: JSON.stringify({ values: { 'service.allowSignup': $('suEnabled').checked } }) }); await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ signupApproval: $('suApproval').value }) }); await loadSettings(); $('suMsg').textContent = TF('저장했습니다 — 회원가입 {1}, {2}', $('suEnabled').checked ? T('허용') : T('닫힘'), $('suApproval').value === 'approval' ? T('관리자 승인 후 이용') : T('가입 즉시 이용')); $('suMsg').className = 'muted ok'; }
catch (e) { $('suMsg').textContent = T('실패') + ': ' + e.message; $('suMsg').className = 'msg err'; }
};
$('btnSaveMaxEdits').onclick = () => saveSetting({ maxEdits: $('sMaxEdits').value }, 'maxEditsMsg', (s) => (s.maxEdits ? TF('저장했습니다 — {1}회', s.maxEdits) : T('저장했습니다 — 무제한')));
/** v1.4.5 사용자 첫 화면 페이지 선택 상자 (관리자가 만든 사이트) */
function fillWelcome(s) {
const sel = $('sWelcome'); const list = s.welcomeCandidates || []; sel.innerHTML = '';
sel.appendChild(new Option(T('기본 안내 (페이지 없음)'), ''));
for (const c of list) { const o = new Option(`${c.title} — ${c.published ? T('배포됨') : (c.hasIndex ? T('미배포') : T('첫 페이지 없음'))}`, c.id); o.disabled = !c.published && !c.hasIndex; sel.appendChild(o); }
sel.value = list.some((c) => c.id === s.welcomeSiteId) ? s.welcomeSiteId : '';
const cur = list.find((c) => c.id === sel.value); $('welcomeOpen').href = cur ? cur.url : '#'; $('welcomeOpen').classList.toggle('hidden', !cur);
if (!list.length) $('welcomeMsg').textContent = T('관리자 계정이 만든 사이트가 없습니다. /builder 에서 먼저 만드세요');
}
$('btnSaveWelcome').onclick = () => saveSetting({ welcomeSiteId: $('sWelcome').value }, 'welcomeMsg', (s) => { const c = (s.welcomeCandidates || []).find((x) => x.id === s.welcomeSiteId); return c ? TF('저장했습니다 — {1}', c.title) : T('저장했습니다 — 기본 안내'); });
$('btnSaveCashUnit').onclick = () => saveSetting({ cashUnit: $('sCashUnit').value }, 'cashUnitMsg', () => TF('저장했습니다 — 예: {1}', krw(12000))); // v1.4.1 저장 후 loadSettings 가 cashUnit 을 바꾼 뒤 예시를 보인다
$('btnSaveSignupCredit').onclick = () => saveSetting({ signupCredit: $('sSignupCredit').value }, 'signupCreditMsg', (s) => TF('저장했습니다 — {1}', krw(s.signupCredit || 0)));
$('btnSaveTiers').onclick = async () => { const tiers = {}; document.querySelectorAll('#tierRows select').forEach((sel) => { tiers[sel.dataset.tier] = sel.value; }); try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ tiers, defaultTier: $('sDefaultTier').value }) }); $('tierMsg').textContent = '저장되었습니다 — 다음 작업부터 적용'; loadOverview(); } catch (e) { $('tierMsg').textContent = '실패: ' + e.message; } };
$('btnGenBacklogKey').onclick = () => { const a = new Uint8Array(24); crypto.getRandomValues(a); $('sBacklogKey').value = 'wmb_' + [...a].map((x) => x.toString(16).padStart(2, '0')).join(''); };
$('btnSaveBacklogKey').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ backlogKey: $('sBacklogKey').value.trim() }) }); $('backlogKeyMsg').textContent = $('sBacklogKey').value.trim() ? '저장되었습니다. 이 키를 작업 서버의 수집 설정에 등록하세요(다시 표시되지 않음).' : '수집을 껐습니다.'; await loadSettings(); } catch (e) { $('backlogKeyMsg').textContent = '실패: ' + e.message; } };
$('btnGenServiceKey').onclick = () => { const a = new Uint8Array(24); crypto.getRandomValues(a); $('sServiceKey').value = 'wmk_' + [...a].map((x) => x.toString(16).padStart(2, '0')).join(''); };
$('btnSaveServiceKey').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ serviceApiKey: $('sServiceKey').value.trim() }) }); $('serviceKeyMsg').textContent = $('sServiceKey').value.trim() ? '저장되었습니다. 이 키는 다시 표시되지 않으니 외부 서비스에 지금 등록하세요.' : '외부 연동을 비활성화했습니다.'; await loadSettings(); } catch (e) { $('serviceKeyMsg').textContent = '실패: ' + e.message; } };
async function loadMail() {
const m = await api('/api/admin/settings/mail'); $('mKeyFile').value = m.keyFile || ''; $('mApiKey').value = ''; $('mApiKey').placeholder = m.source === 'vault' ? '암호화 보관소의 키 사용 중 (바꾸려면 새 키 입력)' : m.source === 'settings' ? '저장된 키 사용 중 (바꾸려면 새 키 입력)' : '또는 Resend API 키 직접 입력 (re_…)'; $('mDomain').value = m.defaultDomain || ''; $('mFromName').value = m.fromName || ''; $('mFormBase').value = m.formBase || '';
$('mStatus').textContent = m.configured ? `키 확인됨 (${m.source === 'vault' ? '암호화 보관소' : m.source === 'file' ? '평문 키 파일 ' + (m.keyName || '') : '직접 입력'}) · Resend 검증 도메인: ${m.verifiedDomains.length ? m.verifiedDomains.join(', ') : '없음/조회 실패'}${m.error ? ' · ' + m.error : ''}` : `미설정: ${m.error || ''}`;
}
$('mSave').onclick = async () => { try { const body = { mail: { keyFile: $('mKeyFile').value, defaultDomain: $('mDomain').value, fromName: $('mFromName').value, formBase: $('mFormBase').value } }; if ($('mApiKey').value.trim()) body.mail.apiKey = $('mApiKey').value.trim(); await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) }); await loadMail(); alert('저장되었습니다'); } catch (e) { alert(e.message); } };
$('mTest').onclick = async () => { const to = $('mTestTo').value.trim(); if (!to) return alert('테스트 수신 주소를 입력하세요'); $('mTestMsg').textContent = '발송 중…'; try { const r = await api('/api/admin/settings/mail-test', { method: 'POST', body: JSON.stringify({ to, domain: $('mDomain').value }) }); $('mTestMsg').textContent = r.ok ? `발송 성공 (발신 ${r.from})` : '실패: ' + r.error; } catch (e) { $('mTestMsg').textContent = '실패: ' + e.message; } };
async function refreshFxNow(force) { try { const r = force ? await api('/api/admin/settings/refresh-rate', { method: 'POST', body: '{}' }) : (billingData ? billingData.fx.today : await api('/api/admin/settings/refresh-rate', { method: 'POST', body: '{}' })); $('fxNow').textContent = `현재 적용: 1 USD = ${r.rate.toLocaleString(uiLocale())}원 (${r.source}${r.spreadPct ? `, 스프레드 ${r.spreadPct}%` : ''})`; } catch (e) { $('fxNow').textContent = '환율 조회 실패: ' + e.message; } }
$('btnSaveAuth').onclick = async () => {
const mode = document.querySelector('input[name=authMode]:checked').value; const body = { auth: { mode } };
if ($('sOauth').value.trim()) body.auth.oauthToken = $('sOauth').value.trim(); if ($('sApiKey').value.trim()) body.auth.apiKey = $('sApiKey').value.trim();
if (mode === 'oauth-token' && !body.auth.oauthToken && !settingsData.auth.oauthToken) return alert('OAuth 토큰을 입력하세요'); if (mode === 'api-key' && !body.auth.apiKey && !settingsData.auth.apiKey) return alert('API 키를 입력하세요');
try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) }); $('connResult').textContent = '저장됨. 연결 테스트로 확인하세요. (인증이 되면 사용할 Claude 모델이 설정 탭의 등급 목록에 나옵니다)'; loadSettings(); } catch (e) { alert(e.message); }
};
$('btnTestConn').onclick = async () => { $('connResult').textContent = '테스트 중… (10~30초)'; $('btnTestConn').disabled = true; try { const r = await api('/api/admin/settings/test-connection', { method: 'POST', body: '{}' }); $('connResult').textContent = r.ok ? `연결 성공 — 모델 ${r.model}, 응답 "${r.reply}", 비용 ${usd(r.costUsd)}${r.claude && r.claude.bin ? ` (${r.claude.bin})` : ''}` : `연결 실패 — ${r.error || r.subtype}`; } catch (e) { $('connResult').textContent = '연결 실패 — ' + e.message; } $('btnTestConn').disabled = false; };
$('aPwSave').onclick = async () => { try { await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ current: $('aPwCur').value, next: $('aPwNew').value }) }); $('aPwMsg').textContent = '변경되었습니다'; $('aPwCur').value = $('aPwNew').value = ''; } catch (e) { $('aPwMsg').textContent = e.message; } };
/**
* v1.3.16 설정 값 하나(또는 한 묶음)를 저장하고, 서버에 다시 물어 실제로 저장된 값을 화면에 보인다.
* 저장 버튼이 값마다 따로 있어 "고쳤는데 저장 안 됨" 을 막고, 결과 메시지에 저장된 값을 그대로 적어 확인할 수 있게 한다.
* @param {object} patch PUT /api/admin/settings 본문
* @param {string} msgId 결과를 쓸 요소 id
* @param {(s:object)=>string} done 저장 후 서버 설정으로 만든 확인 문구
*/
async function saveSetting(patch, msgId, done) {
$(msgId).textContent = T('저장 중…');
try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(patch) }); await loadSettings(); $(msgId).textContent = done(settingsData); $(msgId).className = 'muted ok'; }
catch (e) { $(msgId).textContent = T('실패') + ': ' + e.message; $(msgId).className = 'msg err'; }
}
$('btnSaveFx').onclick = async () => { await saveSetting({ fx: { mode: document.querySelector('input[name=fxMode]:checked').value, spreadPct: $('sSpread').value, manualRate: $('sManualRate').value } }, 'fxMsg', (s) => TF('저장했습니다 — {1}', s.fx.mode === 'manual' ? TF('직접 입력 1 USD = {1}원', s.fx.manualRate) : TF('자동 조회, 스프레드 {1}%', s.fx.spreadPct))); billingData = null; refreshFxNow(true); };
/** v1.3.17 비밀번호 규칙 설명 (서버 lib/passpolicy describe 와 같은 문장) */
function ppDescribe(p) { const parts = [TF('{1}자 이상', p.minLength)]; if (p.maxLength < 256) parts.push(TF('{1}자 이하', p.maxLength)); if (p.requireLetter) parts.push(T('영문자 포함')); if (p.requireMixedCase) parts.push(T('대문자·소문자 모두 포함')); if (p.requireDigit) parts.push(T('숫자 포함')); if (p.requireSymbol) parts.push(T('특수문자 포함')); if (p.forbidId) parts.push(T('아이디를 포함하지 않음')); return parts.join(', '); }
/* v1.4.0 도메인 서비스 설정 — 값마다 자기 저장 버튼 (금액·비율은 바로 옆 버튼으로만 바뀐다) */
function fillDomains(d) {
if (!d || !$('dmMargin')) return;
$('dmMargin').value = d.marginPct; $('dmPlatform').value = d.platformDomain || ''; $('dmIp').value = d.targetIp || '';
// v1.5.36: DNS 공급자·등록 업체 선택 상자 (준비 여부 표시). 목록은 /api/admin/settings 의 domainProviders
{ const cat = (settingsData && settingsData.domainProviders) || { dns: [], registrar: [] }; const fill = (sel, items, cur) => { sel.innerHTML = ''; for (const it of items) sel.appendChild(new Option(`${it.label}${it.id === 'manual' ? '' : (it.ready ? ' — 준비됨' : ' — 접속 정보 없음')}`, it.id)); sel.value = cur; if (sel.value !== cur && items.length) sel.value = items[0].id; }; fill($('dmDns'), cat.dns, d.dnsProvider || d.platformDns || 'manual'); fill($('dmRegistrar'), cat.registrar, d.registrar || 'openprovider'); $('dmProvNote').textContent = '접속 정보는 왼쪽 "외부 연동 접속 정보" 에서 종류 Openprovider · Porkbun · Route 53 · Google Cloud DNS 로 넣고 "연결 확인" 으로 검사합니다. 구매한 도메인의 영역은 DNS 공급자에 만들어지고, 등록 업체와 공급자가 다르면 네임서버를 자동으로 지정합니다.'; const dz = $('dzProvider'); if (dz) { dz.innerHTML = ''; for (const it of cat.dns.filter((x) => x.id !== 'manual')) dz.appendChild(new Option(`${it.label}${it.ready ? '' : ' (접속 정보 없음)'}`, it.id)); dz.value = (d.dnsProvider && d.dnsProvider !== 'manual') ? d.dnsProvider : (cat.dns.find((x) => x.ready && x.id !== 'manual') || {}).id || ''; } }
$('dmTlds').value = (d.tlds || []).join(', '); $('dmReserved').value = (d.reserved || []).join(', '); $('dmDefType').value = d.defaultAccountType; $('dmUserMail').checked = d.userMail !== false;
if ($('dmAllowSub')) $('dmAllowSub').checked = d.allowSubdomain !== false; // v1.6.35 (요구 1-1) 서브 도메인 등록 허용
}
if ($('btnDmAllowSub')) $('btnDmAllowSub').onclick = () => saveDomains({ allowSubdomain: $('dmAllowSub').checked }, 'dmAllowSubMsg', (d) => (d.allowSubdomain !== false ? T('저장했습니다 — 서브 도메인 등록 허용') : T('저장했습니다 — 서브 도메인 등록 금지 (기존 등록은 유지)')));
const saveDomains = (patch, msgId, done) => saveSetting({ domains: patch }, msgId, (s) => { fillDomains(s.domainsConfig); return done(s.domainsConfig || {}); });
$('btnDmMargin').onclick = () => saveDomains({ marginPct: $('dmMargin').value }, 'dmMarginMsg', (d) => TF('저장했습니다 — 사용자 가격 = 공급사 가격 × {1}%', d.marginPct));
$('btnDmPlatform').onclick = () => saveDomains({ platformDomain: $('dmPlatform').value, dnsProvider: $('dmDns').value, registrar: $('dmRegistrar').value }, 'dmPlatformMsg', (d) => TF('저장했습니다 — {1} (DNS {2} · 등록 {3})', d.platformDomain || '-', d.dnsProvider || 'manual', d.registrar || 'openprovider')); // v1.5.36: DNS 공급자·등록 업체 함께 저장
$('btnDmIp').onclick = () => saveDomains({ targetIp: $('dmIp').value }, 'dmIpMsg', (d) => (d.targetIp ? TF('저장했습니다 — {1}', d.targetIp) : T('비웠습니다 — 서비스 주소의 IP 를 씁니다')));
$('btnDmTlds').onclick = () => saveDomains({ tlds: $('dmTlds').value }, 'dmTldsMsg', (d) => TF('저장했습니다 — {1}', (d.tlds || []).join(', ')));
$('btnDmReserved').onclick = () => saveDomains({ reserved: $('dmReserved').value }, 'dmReservedMsg', (d) => TF('저장했습니다 — {1}개', (d.reserved || []).length));
$('btnDmDefType').onclick = () => saveDomains({ defaultAccountType: $('dmDefType').value }, 'dmDefTypeMsg', (d) => TF('저장했습니다 — 새 가입자: {1}', d.defaultAccountType === 'domain' ? T('자체 도메인') : T('서브 도메인 전용')));
$('btnDmUserMail').onclick = () => saveDomains({ userMail: $('dmUserMail').checked }, 'dmUserMailMsg', (d) => (d.userMail ? T('저장했습니다 — 켜짐') : T('저장했습니다 — 꺼짐')));
$('btnSavePp').onclick = () => saveSetting({ passwordPolicy: { minLength: $('ppMin').value, maxLength: $('ppMax').value, requireLetter: $('ppLetter').checked, requireMixedCase: $('ppMixed').checked, requireDigit: $('ppDigit').checked, requireSymbol: $('ppSymbol').checked, forbidId: $('ppNoId').checked } }, 'ppMsg', (s) => TF('저장했습니다 — {1}', ppDescribe({ minLength: 6, maxLength: 256, ...(s.passwordPolicy || {}) })));
$('btnSaveMargin').onclick = () => saveSetting({ marginPct: $('sMargin').value }, 'marginMsg', (s) => TF('마진 {1}% 로 변경했습니다', s.marginPct)).then(() => { billingData = null; });
// ---------- v1.4.14 (요구 9) 플랫폼 도메인 와일드카드 인증서 ----------
/**
* 상태를 받아 그린다. 발급이 진행 중이면 10초마다 다시 받아 수동 DNS 모드의 "넣을 TXT" 와 결과를 보인다.
* cert.hasCert → 만료일·남은 일수, pending → TXT 이름·값, last → 마지막 발급 결과
*/
let sslTimer = null;
async function loadSsl() {
let st; try { st = await api('/api/admin/ssl'); } catch (e) { $('sslStatus').textContent = T('상태를 불러오지 못했습니다') + ': ' + e.message; return; }
if (st.platformDomain) $('sslPlatformEx').textContent = `이름.${st.platformDomain}`;
const parts = [];
parts.push(st.platformDomain ? TF('플랫폼 도메인 {1} · 인증서 이름 {2}', st.platformDomain, st.certName) : T('플랫폼 도메인 없음'));
parts.push(TF('DNS 인증 방식: {1}', st.dnsMode === 'openprovider' ? T('자동 (Openprovider)') : T('수동 (TXT 직접 입력)')));
if (st.target) parts.push(TF('배포 서버: {1}', st.target.name));
if (st.cert && st.cert.hasCert) parts.push(TF('인증서 있음 — {1}, 만료 {2} ({3}일 남음)', (st.cert.sans || []).join(', '), st.cert.expires || '?', st.cert.daysLeft == null ? '?' : st.cert.daysLeft) + (st.external ? ' · ' + TF('서버에 미리 발급된 인증서 {1} 를 씁니다 (서브 도메인에 자동 적용, 갱신은 그 인증서의 certbot 타이머)', st.certName) : '')); // v1.5.17
if (st.autoApply && st.autoApply.reconfigured) parts.push(TF('기동 때 자동 적용: {1} — 사이트 {2}개 HTTPS 로 갱신', dt(st.autoApply.at), st.autoApply.reconfigured));
else if (st.supported) parts.push(T('인증서 없음 — 서브 도메인 사이트는 지금 HTTP 로 열립니다'));
if (!st.supported && st.reason) parts.push(st.reason);
if (st.running) parts.push(TF('발급 진행 중 ({1} 시작)', dt(st.job.startedAt)));
if (st.last) parts.push(`${T('마지막 발급')}: ${dt(st.last.at)} ${st.last.ok ? T('성공') : T('실패')} — ${st.last.detail}${st.last.reconfigured ? ` · ${TF('사이트 {1}개 설정 갱신', st.last.reconfigured)}` : ''}`);
if (st.cert && st.cert.hasCert && st.cert.daysLeft != null && st.cert.daysLeft < 20) parts.push(T('⚠ 만료가 가까워 갱신이 필요합니다 — 수동 DNS 방식이면 "발급·갱신" 을 누르고 뜨는 TXT 를 넣어 주세요'));
$('sslStatus').textContent = parts.join(' · ');
$('sslStatus').style.color = st.cert && st.cert.hasCert && (st.cert.daysLeft == null || st.cert.daysLeft >= 20) ? '#6ee7b7' : (st.supported ? '' : '#fca5a5');
const pend = st.pending; $('sslPending').classList.toggle('hidden', !pend);
if (pend) { $('sslTxtName').value = pend.name; $('sslTxtValue').value = pend.value; $('sslPendingNote').textContent = pend.auto ? T('(자동 등록됨 — 전파 확인 중)') : TF('— DNS 관리 화면에 넣으면 자동으로 이어집니다 (최대 {1}분 대기, {2} 요청)', pend.waitMinutes || st.waitMinutes, dt(pend.at)); }
$('sslIssue').disabled = !st.supported || st.running; $('sslCancel').classList.toggle('hidden', !st.running);
clearTimeout(sslTimer); if (st.running) sslTimer = setTimeout(loadSsl, 10000);
}
$('sslRefresh').onclick = loadSsl;
$('sslIssue').onclick = async () => {
if (!confirm(T('와일드카드 인증서를 발급(또는 갱신)합니다. 수동 DNS 방식이면 잠시 뒤 TXT 레코드가 표시되며, DNS 에 넣기 전까지 진행이 멈춰 있습니다. 계속할까요?'))) return;
$('sslIssue').disabled = true; $('sslMsg').textContent = T('시작 중…');
try { const r = await api('/api/admin/ssl/wildcard', { method: 'POST', body: '{}' }); $('sslMsg').textContent = r.mode === 'manual' ? TF('시작했습니다 — 몇 초 뒤 위에 TXT 레코드가 표시됩니다 (최대 {1}분 대기)', r.waitMinutes) : T('시작했습니다 — DNS 에 TXT 를 자동으로 넣고 인증합니다 (수 분)'); setTimeout(loadSsl, 4000); }
catch (e) { $('sslMsg').textContent = T('실패') + ': ' + e.message; $('sslIssue').disabled = false; }
};
$('sslCancel').onclick = async () => { try { await api('/api/admin/ssl/wildcard', { method: 'DELETE' }); $('sslMsg').textContent = T('취소했습니다'); setTimeout(loadSsl, 2000); } catch (e) { $('sslMsg').textContent = e.message; } };
document.querySelectorAll('[data-copy]').forEach((b) => { b.onclick = async () => { const v = $(b.dataset.copy).value; try { await navigator.clipboard.writeText(v); b.textContent = T('복사됨'); setTimeout(() => (b.textContent = T('복사')), 1200); } catch (_) { prompt('', v); } }; });
$('btnSaveLeEmail').onclick = () => saveSetting({ letsEncryptEmail: $('sLeEmail').value }, 'leMsg', (s) => (s.letsEncryptEmail ? TF('저장했습니다 — {1}', s.letsEncryptEmail) : T('비웠습니다 (이메일 없이 발급)')));
$('btnSaveInternal').onclick = () => saveSetting({ internalRef: $('sInternalRef').value }, 'internalMsg', (s) => TF('저장했습니다 — {1}', s.internalRef)).then(() => loadOverview()); // v1.5.16 내부 호출 모델
/**
* v1.5.16 (요구 3) AI 연결 탭 › 사용할 Claude 모델: Claude 가격표(settings.MODELS)를 체크 목록으로 보이고, 체크한 id 배열을 settings.claudeModels 로 저장한다.
* 비어 있으면(= 저장한 적 없음) 전체 사용으로 본다. 저장 뒤 loadSettings 가 등급·내부 호출 목록을 다시 그린다.
*/
function renderClaudeModels(s) {
const box = $('claudeModelRows'); if (!box) return; box.innerHTML = '';
const on = new Set((s.claudeModels && s.claudeModels.length ? s.claudeModels : (s.models || []).map((m) => m.id)));
for (const m of s.models || []) {
const l = document.createElement('label'); l.className = 'radio'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = m.id; cb.checked = on.has(m.id); cb.dataset.claudeModel = '1';
l.appendChild(cb); l.appendChild(document.createTextNode(` ${m.label} (${m.id}) — 입력 ${m.input} / 출력 ${m.output} per 1M${m.note ? ' · ' + m.note : ''}`)); box.appendChild(l);
}
const used = new Set(Object.values(s.tiers || {}).concat([s.internalRef || '']).filter((r) => String(r).startsWith('anthropic:')).map((r) => r.slice(10)));
const note = document.createElement('p'); note.className = 'muted'; note.textContent = used.size ? TF('지금 등급·내부 호출에 지정된 Claude 모델: {1} — 이 모델을 끄면 설정 탭에서 "현재 사용 불가" 로 보입니다', [...used].join(', ')) : ''; box.appendChild(note);
}
$('btnSaveClaudeModels').onclick = () => {
const ids = [...document.querySelectorAll('#claudeModelRows input[data-claude-model]')].filter((c) => c.checked).map((c) => c.value);
if (!ids.length) { $('claudeModelsMsg').textContent = T('적어도 한 모델은 사용으로 두세요'); $('claudeModelsMsg').className = 'msg err'; return; }
saveSetting({ claudeModels: ids }, 'claudeModelsMsg', (s) => TF('저장했습니다 — 사용 {1}개 (설정 탭의 등급·내부 호출 목록에 반영)', (s.claudeModels || []).length || (s.models || []).length));
};
/**
* v1.3.16 저장하지 않은 변경 표시: data-dirty-watch 입력칸은 불러온 값(dataset.saved)과 다르면 노란 테두리(.dirty)로 표시하고,
* 그런 칸이 있는 채로 페이지를 떠나거나 새로고침하면 브라우저가 확인을 묻는다.
*/
function markSaved() { document.querySelectorAll('[data-dirty-watch]').forEach((i) => { i.dataset.saved = i.type === 'radio' || i.type === 'checkbox' ? String(i.checked) : i.value; i.classList.remove('dirty'); }); }
document.addEventListener('input', (e) => { const i = e.target; if (!i.matches || !i.matches('[data-dirty-watch]')) return; if (i.type === 'radio') { document.querySelectorAll(`input[name="${i.name}"]`).forEach((r) => r.classList.toggle('dirty', String(r.checked) !== r.dataset.saved)); return; } i.classList.toggle('dirty', (i.type === 'checkbox' ? String(i.checked) : i.value) !== i.dataset.saved); });
document.addEventListener('change', (e) => { if (e.target.matches && e.target.matches('select[data-dirty-watch], input[type=radio][data-dirty-watch]')) e.target.dispatchEvent(new Event('input', { bubbles: true })); });
window.addEventListener('beforeunload', (e) => { if (document.querySelector('.dirty')) { e.preventDefault(); e.returnValue = ''; } });
$('btnRefreshFx').onclick = () => refreshFxNow(true);
/**
* 서비스 버전 감시 (v1.3.5). 관리자 화면을 열어 둔 채 서비스가 새 버전으로 배포되면, 화면의 데이터(언어 상태·원본 버전 등)가
* 예전 값으로 남아 "표시 중", "v1.3.1" 처럼 현재와 다르게 보였다. 1분마다 버전을 확인해 바뀌면 지금 탭과 개요를 다시 불러오고,
* 새 화면 코드를 받으려면 페이지를 새로 열라고 띠로 알린다.
*/
let loadedVersion = null;
setInterval(async () => {
let v; try { v = (await api('/api/config')).version; } catch (_) { return; }
if (!loadedVersion) { loadedVersion = v; return; }
if (v && v !== loadedVersion) {
const bar = $('verBar'); bar.classList.remove('hidden'); bar.textContent = ''; bar.appendChild(document.createTextNode(`서비스가 v${loadedVersion} → v${v} 로 업데이트되어 화면 데이터를 다시 불러왔습니다. 새 기능 화면까지 받으려면 `)); const a = document.createElement('button'); a.type = 'button'; a.className = 'ghost'; a.textContent = '페이지 새로 열기'; a.onclick = () => location.reload(); bar.appendChild(a);
loadedVersion = v; $('btnReload').click();
}
}, 60000);
(async () => { const c = await api('/api/config'); me = c.user; loadedVersion = c.version; $('who').textContent = `${me.name} (${me.id})`; settingsData = await api('/api/admin/settings').catch(() => null); await loadOverview(); loadSearchRegs(); { const h = location.hash.slice(1); if (h && h !== 'detail' && document.getElementById('tab-' + h)) openTab(h); } })();
$('fbTypeFilter').onchange = () => { if (fbData) loadFeedback(); };
// ---------- v1.5.7 (요구 2·7) 시스템 설정 탭: 설정 파일·연동 서비스·AI 엔진 — 초기 설정 페이지와 같은 화면(public/setup.js)을 관리자 API 로 붙인다 ----------
// 탭을 처음 열 때 한 번 붙이고, 이후에는 화면 안의 "새로고침" 으로 다시 읽는다 (설정 저장 뒤 서비스를 재시작하면 서버 상태가 바뀌므로).
let sysMounted = null;
document.querySelectorAll('.tabs button[data-tab="system"]').forEach((b) => b.addEventListener('click', () => { if (!sysMounted && window.WMSetup) sysMounted = WMSetup.mount($('sysConfig'), { apiBase: '/api/admin/config' }); }));
/* ============================================================================
* v1.5.36 (요구 5) DNS 영역 관리 (관리자) — /api/admin/dns/*
* 목록(영역·네임서버·회원 소유 여부) · 만들기 · 이전된 영역 받기 · 레코드 보기/추가/삭제 · 지우기(회원 도메인은 force 확인) · 감사 기록
* ========================================================================== */
let dzZones = []; let dzDomains = []; let dzCur = null;
async function dzLoad() {
const prov = $('dzProvider').value; $('dzMsg').textContent = T('불러오는 중…');
try { const info = await api('/api/admin/dns'); dzDomains = info.domains || []; const at = $('dzAudit').querySelector('tbody'); at.innerHTML = ''; for (const a of info.audit || []) { const tr = document.createElement('tr'); td(tr, dt(a.at)); td(tr, a.by || ''); td(tr, a.action); td(tr, a.provider || ''); td(tr, a.zone || ''); td(tr, Array.isArray(a.records) ? a.records.join(' · ') : (a.imported != null ? TF('{1}개 가져옴', a.imported) : ''), 'wrap'); at.appendChild(tr); } } catch (_) {}
const tb = $('dzTable').querySelector('tbody'); tb.innerHTML = '';
if (!prov) { $('dzMsg').textContent = T('준비된 DNS 공급자가 없습니다 — 외부 연동 접속 정보에 넣으세요'); return; }
try { const r = await api(`/api/admin/dns/zones?provider=${encodeURIComponent(prov)}`); dzZones = r.zones || []; $('dzMsg').textContent = TF('{1}: 영역 {2}개', prov, dzZones.length); }
catch (e) { $('dzMsg').textContent = T('실패') + ': ' + e.message; return; }
for (const z of dzZones) {
const tr = document.createElement('tr'); td(tr, z.name); td(tr, (z.nameServers || []).join(', ') || '-', 'wrap'); const owner = dzDomains.find((d) => d.name === z.name); td(tr, owner ? `${owner.userId}${owner.dnsProvider ? ' (' + owner.dnsProvider + ')' : ''}` : '-'); td(tr, z.records != null ? String(z.records) : '-', 'num');
const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn('레코드', 'ghost', () => dzRecords(z.name))); act.appendChild(mkBtn('지우기', 'ghost danger', async () => { if (!confirm(`${z.name} 영역을 ${prov} 에서 지울까요? 레코드가 모두 사라집니다.`)) return; let q = ''; if (owner) { if (!confirm(`이 영역은 회원 ${owner.userId} 의 도메인입니다. 그래도 지울까요?`)) return; q = '&force=1'; } try { await api(`/api/admin/dns/zones/${encodeURIComponent(z.name)}?provider=${encodeURIComponent(prov)}${q}`, { method: 'DELETE' }); dzLoad(); } catch (e) { alert(e.message); } })); tr.appendChild(act); tb.appendChild(tr);
}
if (!dzZones.length) { const tr = document.createElement('tr'); td(tr, T('영역이 없습니다')).colSpan = 5; tb.appendChild(tr); }
}
async function dzRecords(zone) {
dzCur = zone; $('dzRecBox').classList.remove('hidden'); $('dzRecTitle').textContent = `${zone} — ${$('dzProvider').value}`; const tb = $('dzRecTable').querySelector('tbody'); tb.innerHTML = '';
try { const r = await api(`/api/admin/dns/records/${encodeURIComponent(zone)}?provider=${encodeURIComponent($('dzProvider').value)}`); for (const rec of r.records || []) { const tr = document.createElement('tr'); td(tr, rec.name || '@'); td(tr, rec.type); td(tr, rec.value, 'wrap'); td(tr, rec.ttl, 'num'); td(tr, rec.prio != null ? rec.prio : '', 'num'); const act = document.createElement('td'); act.className = 'act'; if (!(rec.type === 'NS' && !rec.name) && rec.type !== 'SOA') act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`${rec.type} ${rec.name || '@'} ${rec.value} 레코드를 지울까요?`)) return; try { await api(`/api/admin/dns/records/${encodeURIComponent(zone)}?provider=${encodeURIComponent($('dzProvider').value)}`, { method: 'DELETE', body: JSON.stringify({ records: [rec] }) }); dzRecords(zone); } catch (e) { alert(e.message); } })); tr.appendChild(act); tb.appendChild(tr); } if (!(r.records || []).length) { const tr = document.createElement('tr'); td(tr, T('레코드가 없습니다')).colSpan = 6; tb.appendChild(tr); } }
catch (e) { const tr = document.createElement('tr'); td(tr, T('실패') + ': ' + e.message).colSpan = 6; tb.appendChild(tr); }
}
if ($('dzRefresh')) {
$('dzRefresh').onclick = dzLoad; $('dzProvider').onchange = dzLoad;
// v1.6.35 (요구 1-1): "영역 만들기"·"이전된 영역 받기" 는 사용자 도메인 탭(이미 가진 도메인 등록 › 네임서버 이전)으로 옮겨 관리자 화면에서 뺐다. API(/api/admin/dns/zones POST)는 호환을 위해 남아 있다
$('dzRecAdd').onclick = async () => { if (!dzCur) return; const rec = { name: $('dzRecName').value.trim(), type: $('dzRecType').value, value: $('dzRecValue').value.trim(), prio: $('dzRecPrio').value || undefined }; if (!rec.value) return alert('값을 입력하세요'); try { await api(`/api/admin/dns/records/${encodeURIComponent(dzCur)}?provider=${encodeURIComponent($('dzProvider').value)}`, { method: 'POST', body: JSON.stringify({ records: [rec] }) }); $('dzRecValue').value = ''; dzRecords(dzCur); } catch (e) { alert(e.message); } };
$('dzRecClose').onclick = () => { $('dzRecBox').classList.add('hidden'); dzCur = null; };
}
/* ============================================================================
* v1.6.31 (요구 20·21) 운영 복구 · 작업 기록
* ========================================================================== */
async function loadRecovery() {
const tb = $('rcTable').querySelector('tbody'); tb.innerHTML = ''; $('rcMsg').textContent = T('불러오는 중…');
let r; try { r = await api('/api/admin/recovery'); } catch (e) { $('rcMsg').textContent = T('실패') + ': ' + e.message; return; }
const items = r.items || []; const b = $('rcBadge'); if (b) { b.textContent = items.length ? String(items.length) : ''; b.classList.toggle('hidden', !items.length); }
$('rcMsg').textContent = items.length ? TF('{1}건 — 관리자 확인 필요 {2}건', items.length, items.filter((x) => x.needsAdmin).length) : T('정상 완료되지 않은 작업이 없습니다');
const KIND = { deploy: '배포', purchase: '도메인 구매', domain: '도메인 등록', mail: '메일 계정', cert: '인증서', data: '데이터 파일', job: 'AI 작업' };
for (const it of items) {
const tr = document.createElement('tr'); td(tr, KIND[it.kind] || it.kind); td(tr, `${it.siteTitle ? it.siteTitle + ' ' : ''}${it.siteId ? '(' + it.siteId.slice(0, 8) + '…) ' : ''}${it.userId || ''}`, 'wrap'); td(tr, (it.needsAdmin ? '⚠ ' : '') + (it.stateLabel || it.state), it.needsAdmin ? 'st-cancelled' : 'st-running'); td(tr, it.at ? dt(it.at) : '-'); td(tr, it.detail || '', 'wrap'); td(tr, it.userTodo || '', 'wrap');
const act = document.createElement('td'); act.className = 'act';
for (const a of it.actions || []) act.appendChild(mkBtn(a.label, 'ghost', async () => { if (!confirm(`${a.label} — 실행할까요?`)) return; try { const res = await api('/api/admin/recovery/action', { method: 'POST', body: JSON.stringify({ kind: it.kind, id: it.id, action: a.id }) }); alert(res.detail || res.message || T('실행했습니다')); loadRecovery(); } catch (e) { alert(e.message); } }));
if (it.siteId && it.kind === 'deploy') act.appendChild(mkBtn('사이트', 'ghost', () => openSite(it.siteId)));
tr.appendChild(act); tb.appendChild(tr);
}
}
async function loadAudit() {
const q = new URLSearchParams(); for (const [k, id] of [['userId', 'auUser'], ['siteId', 'auSite'], ['action', 'auAction'], ['q', 'auQ']]) { const v = $(id).value.trim(); if (v) q.set(k, v); }
const tb = $('auTable').querySelector('tbody'); tb.innerHTML = '';
try { const r = await api('/api/admin/audit?' + q.toString()); for (const e of r.items || []) { const tr = document.createElement('tr'); td(tr, dt(e.at)); td(tr, e.by || ''); td(tr, e.action); td(tr, `${e.targetType || ''} ${e.target || ''}${e.userId && e.userId !== e.target ? ' / ' + e.userId : ''}`, 'wrap'); td(tr, e.siteId ? e.siteId.slice(0, 8) + '…' : ''); td(tr, e.ok === false ? '실패' : '완료', e.ok === false ? 'st-error' : 'st-done'); td(tr, e.detail ? (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)).slice(0, 300) : '', 'wrap'); tb.appendChild(tr); } if (!(r.items || []).length) { const tr = document.createElement('tr'); td(tr, T('기록이 없습니다')).colSpan = 7; tb.appendChild(tr); } }
catch (e) { const tr = document.createElement('tr'); td(tr, T('실패') + ': ' + e.message).colSpan = 7; tb.appendChild(tr); }
}
if ($('rcRefresh')) { $('rcRefresh').onclick = loadRecovery; $('auSearch').onclick = loadAudit; }