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

1246 lines
178 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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; }