사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·HTTPS, Source 옵션과 Webmaker 도입 수정(SSO, 프로젝트↔사이트 구분, 삭제 영향 확인, 동시 편집 충돌, 발행 경로) (§7~§12·§0.2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a1cde81332
commit
b1c06a0304
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* portal/sites/api.mjs — 사이트 영역 라우트(화면 + JSON API + 내부/운영자 API).
|
||||
*
|
||||
* 화면: /sites, /sites/new, /sites/:id(?tab=), /sites/:id/deploy, /sites/:id/deploy/mode, /sites/:id/external
|
||||
* API : /api/sites (GET 목록, POST 생성) · /api/sites/:id (GET, PATCH 설정) · /api/sites/:id/overview · /versions · /precheck · /deploy(POST) · /deployments · /rollback · /cleanup
|
||||
* /api/sites/:id/mode (GET 안내, POST 선택) · /consent(POST) · /external (GET 상태, POST 토큰 발급, DELETE 해제) · /external/remove(POST) · /jobs/:jobId/(retry|cancel|resume|revert)
|
||||
* /api/sites/:id/usage · /activity
|
||||
* 내부: /internal/bundles/:token (Agent 가 저장본을 받아 감) · /internal/webmaker/project-impact (Webmaker 가 폐기 전 영향 조회, 키 id 'webmaker')
|
||||
* 운영자: /internal/ops/sites, /internal/ops/sites/:id (키 id 'hal')
|
||||
* 권한: 조회 view · 설정 edit/admin · 배포 deploy · 외부 Agent deploy · 해지 billing(다른 영역)
|
||||
*/
|
||||
import { requireGroup, requireSitePerm, requireRole } from '../auth/guard.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
||||
import { listAudit } from '../../lib/audit.mjs';
|
||||
import { describe as describeJob } from '../../lib/jobs.mjs';
|
||||
import * as quota from '../../lib/quota.mjs';
|
||||
import { createSitesService } from './service.mjs';
|
||||
import * as ui from './ui.mjs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export async function register(app, deps) {
|
||||
const sites = createSitesService(deps); deps.sites = sites;
|
||||
app.static('/ui/sites', join(HERE, 'public'));
|
||||
const secretOf = (keyId) => deps.vault.get('service:' + keyId) || (keyId === 'webmaker' && deps.webmaker ? deps.webmaker.secret() : null);
|
||||
async function requireService(ctx, keyIds) { const v = await verifyCtx(ctx, (id) => (keyIds.includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${v.reason})`); return v.keyId; }
|
||||
/** 현재 그룹의 사이트를 권한과 함께 가져온다(다른 그룹 사이트는 404) */
|
||||
/** 조회(view)는 그룹 구성원 누구나(viewer 포함 §4.3 '조회'), 그 밖의 권한은 guard.requireSitePerm */
|
||||
const siteOf = (ctx, perm = 'view') => { requireGroup(ctx); const s = sites.getForGroup(ctx.group.id, ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (perm !== 'view') requireSitePerm(ctx, s, perm); return s; };
|
||||
const pageCtx = (ctx) => { requireGroup(ctx); return ctx; };
|
||||
|
||||
// ---------- 화면 ----------
|
||||
app.get('/sites', (ctx) => ctx.html(ui.listPage(pageCtx(ctx), deps, { filter: ctx.query.filter || '' })));
|
||||
app.get('/sites/new', (ctx) => { pageCtx(ctx); requireRole(ctx, 'member'); return ctx.html(ui.newPage(ctx, deps)); });
|
||||
app.get('/sites/:id', (ctx) => ctx.html(ui.detailPage(ctx, deps, siteOf(ctx, 'view'), { tab: ctx.query.tab || 'overview' })));
|
||||
app.get('/sites/:id/deploy', (ctx) => ctx.html(ui.deployPage(ctx, deps, siteOf(ctx, 'view'))));
|
||||
app.get('/sites/:id/deploy/mode', (ctx) => ctx.html(ui.modePage(ctx, deps, siteOf(ctx, 'view'))));
|
||||
app.get('/sites/:id/external', (ctx) => ctx.html(ui.externalPage(ctx, deps, siteOf(ctx, 'view'))));
|
||||
/** Webmaker SSO 진입(별도 로그인 없음). site 를 주면 그 프로젝트로, path 로 Webmaker 안의 특정 화면 */
|
||||
app.get('/sso/webmaker', (ctx) => { requireGroup(ctx); let site = null; if (ctx.query.site) { site = sites.getForGroup(ctx.group.id, ctx.query.site); if (!site) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); requireSitePerm(ctx, site, 'edit'); } if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연동이 설정되지 않았습니다'); return ctx.redirect(deps.webmaker.ssoUrl({ user: ctx.user, group: ctx.group, site, projectId: ctx.query.project || null, path: ctx.query.path || null })); });
|
||||
|
||||
// ---------- JSON API ----------
|
||||
app.get('/api/sites', (ctx) => { requireGroup(ctx); return { ok: true, sites: sites.list(ctx.group.id, { lifecycle: ctx.query.filter || null }), siteCount: quota.figures(deps.store, ctx.group.id, 'sites') }; });
|
||||
app.post('/api/sites', async (ctx) => { requireRole(ctx, 'member'); if (!ctx.user.email_verified_at) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 사이트를 만들 수 있습니다'); const r = await sites.create({ user: ctx.user, group: ctx.group, ip: ctx.ip }, await ctx.body()); return { ok: true, ...r }; });
|
||||
app.get('/api/sites/:id', (ctx) => { const s = siteOf(ctx); return { ok: true, site: pub(s), summary: sites.summarize(s) }; });
|
||||
app.patch('/api/sites/:id', async (ctx) => { const s = siteOf(ctx, 'admin'); const r = sites.update(s, await ctx.body(), ctx.user); return { ok: true, site: pub(r.site), impact: r.impact, jobId: r.jobId || null }; });
|
||||
app.get('/api/sites/:id/overview', (ctx) => { const s = siteOf(ctx); let refreshed = null; try { refreshed = sites.refresh(s.group_id); } catch (_) {} return { ok: true, overview: sites.overview(s), refreshJob: refreshed ? refreshed.id : null }; });
|
||||
/** 제작 탭: Webmaker 프로젝트 요약·저장본·AI Wallet(호스팅 사용량과 분리) */
|
||||
app.get('/api/sites/:id/versions', async (ctx) => { const s = siteOf(ctx); try { const v = await sites.versionsFor(s); let wallet = null; try { wallet = deps.webmaker ? await deps.webmaker.credits(sites.userLogin(ctx.user, ctx.group)) : null; } catch (_) { wallet = { stale: true }; } return { ok: true, ...v, wallet, webmakerUrl: `/sso/webmaker?site=${s.id}` }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)', stale: true }, 503); throw e; } });
|
||||
app.get('/api/sites/:id/precheck', async (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, precheck: await sites.precheck(s, { versionRef: ctx.query.version || 'current' }) }; });
|
||||
app.post('/api/sites/:id/deploy', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.startDeploy({ user: ctx.user, group: ctx.group, site: s, versionRef: b.versionRef || 'current', versionLabel: b.versionLabel || null, ip: ctx.ip }); return { ok: true, ...r }; });
|
||||
app.get('/api/sites/:id/deployments', (ctx) => { const s = siteOf(ctx); return { ok: true, deployments: sites.deployments(s.id), activeJobs: sites.activeJobsFor(s.id).map(describeJob), site: pub(sites.get(s.id)) }; });
|
||||
app.post('/api/sites/:id/rollback', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.rollback(s, ctx.user) }; });
|
||||
app.post('/api/sites/:id/cleanup', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.cleanupAndReturn(s, ctx.user) }; });
|
||||
app.get('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx); return { ok: true, ...(await modeInfo(s, ctx)) }; });
|
||||
app.post('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.setDeployMode(s, b.mode, ctx.user); return { ok: true, site: pub(r.site), changed: r.changed, job: r.job || null }; });
|
||||
app.post('/api/sites/:id/consent', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); if (b.version !== sites.EXTERNAL_CONSENT.version || !b.agree) throw new HttpError(400, 'consent_required', '확인 사항에 동의해야 합니다'); return { ok: true, site: pub(sites.recordConsent(s, ctx.user, { ip: ctx.ip })) }; });
|
||||
app.get('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx); const a = await sites.refreshExternalAgent(s); return { ok: true, agent: a, consent: { version: sites.EXTERNAL_CONSENT.version, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, lastDeploy: sites.deployments(s.id, 1)[0] || null }; });
|
||||
app.post('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); if (s.consent_version !== sites.EXTERNAL_CONSENT.version) throw new HttpError(409, 'consent_required', '외부 배포 확인 사항에 먼저 동의하세요', { href: `/sites/${s.id}/deploy/mode` }); const b = await ctx.body(); return { ok: true, ...(await sites.createExternalAgent(s, ctx.user, String(b.label || '').slice(0, 80))) }; });
|
||||
app.delete('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); await sites.revokeExternalAgent(s, ctx.user); return { ok: true }; });
|
||||
app.post('/api/sites/:id/external/remove', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.removeExternalDeploy(s, ctx.user) }; });
|
||||
app.post('/api/sites/:id/jobs/:jobId/:action', async (ctx) => { const s = siteOf(ctx, 'deploy'); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); const a = ctx.params.action; let r; if (a === 'retry') r = deps.jobs.retry(j.id, ctx.user.id); else if (a === 'cancel') r = deps.jobs.cancel(j.id, ctx.user.id); else if (a === 'resume') r = deps.jobs.resume(j.id, ctx.user.id); else if (a === 'revert') r = await deps.jobs.revert(j.id, ctx.user.id); else throw new HttpError(400, 'bad_action', '알 수 없는 작업 동작'); return { ok: true, job: describeJob(r) }; });
|
||||
app.get('/api/sites/:id/jobs/:jobId', (ctx) => { const s = siteOf(ctx); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); return { ok: true, job: describeJob(j) }; });
|
||||
app.get('/api/sites/:id/usage', (ctx) => { const s = siteOf(ctx); const o = sites.overview(s); const alloc = ['web_disk', 'web_out'].map((r) => ({ resource: r, ...quota.RESOURCES[r], allocation: quota.allocationOf(deps.store, s.group_id, r, 'site', s.id), usage: quota.usageOf(deps.store, s.group_id, r, 'site', s.id) })); return { ok: true, overview: o, allocations: alloc, samples: deps.store.all('SELECT metric, value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric<>\'series_json\' ORDER BY measured_at DESC LIMIT 200', s.id) }; });
|
||||
app.get('/api/sites/:id/activity', (ctx) => { const s = siteOf(ctx); return { ok: true, items: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 50 }).map(describeJob) }; });
|
||||
|
||||
// ---------- 내부 ----------
|
||||
/** 저장본 다운로드(Agent). 토큰은 1시간 유효. 비밀값 없는 tar.gz */
|
||||
app.get('/internal/bundles/:token', (ctx) => { const t = sites.bundleByToken(ctx.params.token); if (!t || !existsSync(t.file)) throw new HttpError(404, 'not_found', '번들을 찾을 수 없거나 만료되었습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': statSync(t.file).size, 'Cache-Control': 'no-store' }); createReadStream(t.file).pipe(ctx.res); return undefined; });
|
||||
app.post('/internal/webmaker/project-impact', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, impact: sites.impactForProject(String(b.projectId || ''), b.siteId || null) }; });
|
||||
app.post('/internal/webmaker/project-destroyed', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, unlinked: sites.onProjectDestroyed(String(b.projectId || '')) }; });
|
||||
// ---------- 운영자(HAL 콘솔이 부른다) ----------
|
||||
app.get('/internal/ops/sites', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const { groupId, lifecycle, q } = ctx.query; const w = ['lifecycle<>\'deleted\'']; const p = []; if (groupId) { w.push('group_id=?'); p.push(groupId); } if (lifecycle) { w.push('lifecycle=?'); p.push(lifecycle); } if (q) { w.push('(name LIKE ? OR primary_domain LIKE ? OR id=?)'); p.push(`%${q}%`, `%${q}%`, q); } return { ok: true, sites: deps.store.all(`SELECT * FROM sites WHERE ${w.join(' AND ')} ORDER BY updated_at DESC LIMIT 500`, ...p).map((r) => sites.summarize(sites.get(r.id))).map((s) => ({ ...s, groupId: sites.get(s.id).group_id, serverId: sites.get(s.id).server_id })) }; });
|
||||
app.get('/internal/ops/sites/:id', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, site: s, overview: sites.overview(s), deployments: sites.deployments(s.id, 50), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 100 }).map(describeJob), externalAgent: sites.externalAgentFor(s.id), domains: deps.store.all('SELECT * FROM domains WHERE site_id=?', s.id), audit: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }) }; });
|
||||
app.post('/internal/ops/sites/:id/refresh', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, job: sites.refresh(s.group_id, { force: true }) }; });
|
||||
app.post('/internal/ops/sites/sweep', async (ctx) => { await requireService(ctx, ['hal', 'portal']); return { ok: true, ...sites.sweep() }; });
|
||||
|
||||
/** 배포 방식 안내 데이터(§10.1·§10.2): 권리·보장량·백업 가능 여부·비용(활성 상품정책에서) */
|
||||
async function modeInfo(s, ctx) {
|
||||
const ent = ['sites', 'web_disk', 'web_out', 'backup'].map((r) => quota.figures(deps.store, s.group_id, r));
|
||||
const policy = deps.productPolicy ? await deps.productPolicy.activeFor(s.group_id).catch(() => null) : null; // 상품 영역(fork C)이 제공하면 비용 표시
|
||||
let servers = null; let halStale = false; try { servers = (await deps.hal.listServers({ role: 'web' })).filter((x) => x.acceptsNew).length; } catch (_) { halStale = true; }
|
||||
const agent = sites.externalAgentFor(s.id);
|
||||
return { current: s.deploy_mode, runtime: s.runtime, consent: { ...sites.EXTERNAL_CONSENT, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, agent: agent ? { status: agent.status, label: agent.label } : null,
|
||||
managed: { description: 'Ariweb 가 서버 배치와 운영(모니터링·자동 재시작·용량 조정 이전)을 관리합니다', monitoring: ['외부 접속 확인(대표 도메인, 10분 주기)', '디스크·데이터베이스·네트워크 사용량 수집', '오류율·응답 시간'], limits: ent.filter((e) => ['web_disk', 'web_out'].includes(e.resource)), backupAvailable: ent.find((e) => e.resource === 'backup').purchased > 0 || true, backupNote: '백업은 관리형 사이트 단위 유료 옵션입니다(백업 화면에서 구매)', relocation: '정적 사이트는 조건이 맞으면 자동 이전, 동적 사이트는 운영자 승인 후 이전', estimatedMinutes: s.runtime === 'node' ? 10 : 3, cost: policy ? policy.managedDeployCost || null : null, costNote: policy ? null : '승인된 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', supportedRuntimes: ['static', 'node'], runtimeNotes: { static: 'HTML·CSS·JS·이미지. 서버 코드 실행 없음', node: 'Ariweb 가 승인한 Node 실행 형태. 데이터베이스: SQLite 파일만. 외부 DB·cron·메일 발송·소켓 서버는 이번 버전에서 지원하지 않음' }, serversAvailable: servers, halStale },
|
||||
external: { targets: ['External Pull Agent 를 설치할 수 있는 지원 Linux 서버(Ubuntu 22.04/24.04, Debian 12, systemd, sudo, 외부 HTTPS 접속)'], prepare: ['root 또는 sudo 가능한 계정', '서버에서 HAL(hal.ariweb.net) 로 나가는 HTTPS 연결', '웹서버(nginx) 또는 Agent 가 설치할 nginx 허용', '도메인 DNS 를 고객이 직접 관리'], perDeployCost: policy ? policy.externalDeployCost || null : null, costNote: policy ? null : '건별 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', notProvided: ['관리형 모니터링', '백업·복원', '서버 이전', 'SLA', '메일(외부 메일 배포 미지원)'], isolation: '외부 환경 장애는 Ariweb 정상 상태와 분리되어 상태 페이지·알림에 Ariweb 장애로 표시되지 않습니다', siteCountNote: '외부 배포도 공개 사이트라면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다' } };
|
||||
}
|
||||
function pub(s) { if (!s) return null; const { settings_json, pending_cleanup_json, last_external_check_json, next_job_json, ...rest } = s; return rest; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* portal/sites/fake-hal.mjs — 테스트용 가짜 HAL 클라이언트(docs/HAL-JOBS.md 와 같은 인터페이스).
|
||||
* 작업은 즉시 '완료' 로 두되, 시나리오(script)로 특정 kind 를 실패·단계 중간 상태로 만들 수 있다. 실제 HAL 클라이언트는 fork A 의 portal/hal/client.mjs.
|
||||
*
|
||||
* site.deploy 자식 단계 id(실제 Agent 도 같은 id 를 쓴다): apply · data_check · start · internal_check · external_check
|
||||
*/
|
||||
import { newId } from '../../lib/crypto.mjs';
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
|
||||
export function createFakeHal(opts = {}) {
|
||||
const jobs = new Map(); const calls = []; const agents = new Map();
|
||||
const servers = opts.servers || [{ id: 'srv_web1', name: 'web-1', role: 'web', location: 'KR', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: nowIso(), publicIp: '203.0.113.10', capacity: { diskFreeBytes: 50e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.2, sitesHosted: 3, reservedBytes: 0 }, version: { agent: '0.1.1', modules: {} } }, { id: 'srv_web2', name: 'web-2', role: 'web', location: 'KR', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: nowIso(), publicIp: '203.0.113.20', capacity: { diskFreeBytes: 80e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.1, sitesHosted: 1, reservedBytes: 0 }, version: { agent: '0.1.1', modules: {} } }];
|
||||
const script = { failKinds: new Set(), pendingKinds: new Set(), results: {}, dns: { a: [], txt: {} }, unreachable: false };
|
||||
const guard = () => { if (script.unreachable) { const e = new Error('HAL 에 연결할 수 없습니다'); e.code = 'service_unreachable'; throw e; } };
|
||||
const defaultResult = (kind, payload, target) => ({
|
||||
'site.deploy': { version: payload.versionRef, deployedAt: nowIso(), internalCheck: { ok: true, status: 200, ms: 12 }, externalCheck: { ok: true, url: `https://${payload.primaryDomain || 'preview.example.test'}/`, status: 200 }, siteUrl: `https://${target}.example.test/${payload.siteId}/`, leftovers: [] },
|
||||
'site.rollback': { version: payload.toVersion }, 'site.remove': { removed: true, leftovers: [] },
|
||||
'site.check': { checks: (payload.urls || []).map((u) => ({ url: u, status: 200, ok: true, ms: 30, tls: { valid: true, expiresAt: new Date(Date.now() + 60 * 86400000).toISOString() } })) },
|
||||
'site.usage': { sites: Object.fromEntries((payload.siteIds || []).map((s) => [s, { diskBytes: 120e6, dbBytes: 5e6, uploadsBytes: 20e6, outBytesDay: 3e8, outBytesMonth: 5e9, outBytesTotal: 2e10, inBytesDay: 1e7, inBytesMonth: 2e8, inBytesTotal: 1e9, req24h: 1200, pv24h: 800, series7d: [1, 2, 3, 4, 5, 6, 7].map((d) => ({ date: `2026-10-0${d}`, pv: 100 * d, req: 150 * d })), series30d: [], errorRate: 0.4, p95ms: 210, measuredAt: nowIso() }])) },
|
||||
'domain.vhost': { applied: payload.domains || [], certs: Object.fromEntries((payload.domains || []).map((d) => [d, { state: 'valid', expiresAt: new Date(Date.now() + 80 * 86400000).toISOString() }])) },
|
||||
'domain.cert': { issued: payload.domains || [], failed: [], expiresAt: new Date(Date.now() + 80 * 86400000).toISOString() },
|
||||
'domain.probe': { a: script.dns.a, txtOk: !!(payload.expectTxt && script.dns.txt[payload.expectTxt.name] === payload.expectTxt.value), txt: payload.expectTxt ? script.dns.txt[payload.expectTxt.name] || null : null, resolvedAt: nowIso() },
|
||||
'site.relocate': { newServerId: payload.toServerId, switchedAt: nowIso(), oldRetainedUntil: new Date(Date.now() + 7 * 86400000).toISOString() },
|
||||
}[kind] || { ok: true });
|
||||
const hal = {
|
||||
calls, jobs, script, servers, agents,
|
||||
async createJob({ kind, target, payload, idempotencyKey }) {
|
||||
guard(); calls.push({ kind, target, payload, idempotencyKey });
|
||||
if (idempotencyKey) { const ex = [...jobs.values()].find((j) => j.idempotencyKey === idempotencyKey); if (ex) return { halJobId: ex.id, status: ex.status }; }
|
||||
const id = newId('hj_'); const fail = script.failKinds.has(kind); const pending = script.pendingKinds.has(kind);
|
||||
const steps = kind === 'site.deploy' ? ['apply', 'data_check', 'start', 'internal_check', 'external_check'].map((s, i) => ({ id: s, title: s, state: fail && i === 3 ? 'failed' : (fail && i > 3) || pending ? 'pending' : 'done' })) : [];
|
||||
jobs.set(id, { id, kind, target, payload, idempotencyKey, status: pending ? 'running' : fail ? 'failed' : 'done', steps, progress: pending ? 40 : 100, result: fail || pending ? null : (script.results[kind] || defaultResult(kind, payload, target)), error: fail ? '내부 확인 실패: HTTP 502' : null, errorCode: fail ? 'internal_check_failed' : null, updatedAt: nowIso() });
|
||||
return { halJobId: id, status: jobs.get(id).status };
|
||||
},
|
||||
async getJob(id) { guard(); const j = jobs.get(id); if (!j) throw Object.assign(new Error('작업 없음'), { code: 'hal_404' }); return { ...j }; },
|
||||
async cancelJob(id) { guard(); const j = jobs.get(id); if (j) j.status = 'cancelled'; return { ok: true }; },
|
||||
async retryJob(id) { guard(); const j = jobs.get(id); if (j) { j.status = 'done'; j.result = defaultResult(j.kind, j.payload, j.target); j.steps.forEach((s) => { s.state = 'done'; }); } return { ok: true }; },
|
||||
async revertJob(id) { guard(); return { ok: true }; }, async resumeJob(id) { guard(); return { ok: true }; },
|
||||
async listServers({ role } = {}) { guard(); return servers.filter((s) => !role || s.role === role); },
|
||||
async pickServer({ role = 'web', needBytes = 0 } = {}) { guard(); const s = servers.find((x) => x.role === role && x.acceptsNew && x.status === 'ready' && x.capacity.diskFreeBytes > needBytes); return s ? { serverId: s.id } : null; },
|
||||
async issueExternalAgentToken({ groupId, siteId, label }) { guard(); const agentId = newId('xa_'); agents.set(agentId, { id: agentId, status: 'pending', lastSeenAt: null, version: null, lastDeploy: null }); return { agentId, token: 'xat_' + newId(''), expiresAt: new Date(Date.now() + 600000).toISOString(), installCommand: `curl -fsSL https://hal.example.test/agent/install.sh | sudo bash -s -- --hal https://hal.example.test --token <토큰> --mode external` }; },
|
||||
async getExternalAgent(id) { guard(); return agents.get(id) || { id, status: 'revoked' }; },
|
||||
async revokeExternalAgent(id) { guard(); const a = agents.get(id); if (a) a.status = 'revoked'; return { ok: true }; },
|
||||
async health() { guard(); return { ok: true, version: '0.1.1', uptime: 1 }; },
|
||||
/** 테스트 도우미: 진행 중 작업을 끝낸다 */
|
||||
_finish(id, { fail = false } = {}) { const j = jobs.get(id); j.status = fail ? 'failed' : 'done'; j.progress = 100; j.steps.forEach((s) => { s.state = 'done'; }); j.result = fail ? null : defaultResult(j.kind, j.payload, j.target); j.error = fail ? '외부 접속 확인 실패' : null; j.updatedAt = nowIso(); },
|
||||
_connectAgent(id) { const a = agents.get(id); if (a) { a.status = 'connected'; a.lastSeenAt = nowIso(); a.version = '0.1.1'; } },
|
||||
};
|
||||
return hal;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* portal/sites/model.mjs — 사이트 영역의 세부 표(중심 표 sites·deployments 는 portal/core/model.mjs).
|
||||
* sso_tickets 포털 → Webmaker SSO 티켓 발급 기록(nonce 만, 티켓 원문 없음)
|
||||
* external_agents 외부 배포(External Pull Agent) 연결: HAL 이 발급한 agent id·상태·마지막 배포/확인 결과
|
||||
* site_consents 외부 배포 동의(버전 기록 §10.2) 등 사용자가 확인한 약관 버전
|
||||
* bundle_tokens 저장본 tar.gz 를 Agent 가 받아 갈 1회용 다운로드 토큰(해시 저장)
|
||||
* deploy_mode_changes 배포 방식 변경 작업(§10.3) 기록
|
||||
* sites 표에는 runtime(static|node)·consent_version·pending_cleanup(정리하지 않은 중간 결과)·return_due_at(예약 반환 예정) 열을 더한다.
|
||||
*/
|
||||
export const MIGRATIONS = [{ id: 'sites_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS sso_tickets (id TEXT PRIMARY KEY, nonce TEXT NOT NULL, user_id TEXT NOT NULL, group_id TEXT NOT NULL, site_id TEXT, project_id TEXT, issued_at TEXT NOT NULL, expires_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS external_agents (
|
||||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, hal_agent_id TEXT NOT NULL, label TEXT, status TEXT NOT NULL DEFAULT 'pending', token_expires_at TEXT, install_command TEXT,
|
||||
last_seen_at TEXT, agent_version TEXT, last_deploy_json TEXT, last_check_json TEXT, revoked_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS external_agents_site ON external_agents(site_id);
|
||||
CREATE TABLE IF NOT EXISTS site_consents (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, user_id TEXT NOT NULL, kind TEXT NOT NULL, version TEXT NOT NULL, text_hash TEXT, agreed_at TEXT NOT NULL, ip TEXT);
|
||||
CREATE TABLE IF NOT EXISTS bundle_tokens (id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, site_id TEXT NOT NULL, deployment_id TEXT, file TEXT NOT NULL, bytes INTEGER, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS deploy_mode_changes (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, from_mode TEXT, to_mode TEXT NOT NULL, from_target TEXT, to_target TEXT, status TEXT NOT NULL, job_id TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ended_at TEXT);
|
||||
ALTER TABLE sites ADD COLUMN runtime TEXT NOT NULL DEFAULT 'static';
|
||||
ALTER TABLE sites ADD COLUMN consent_version TEXT;
|
||||
ALTER TABLE sites ADD COLUMN pending_cleanup_json TEXT;
|
||||
ALTER TABLE sites ADD COLUMN return_due_at TEXT;
|
||||
ALTER TABLE sites ADD COLUMN last_deploy_at TEXT;
|
||||
ALTER TABLE sites ADD COLUMN last_external_check_json TEXT;
|
||||
ALTER TABLE sites ADD COLUMN next_job_json TEXT;
|
||||
` }];
|
||||
@@ -0,0 +1,103 @@
|
||||
/* portal/sites/public/sites.js — 사이트 화면 스크립트. data-page 로 화면을 고르고 /api/sites/* 를 채운다. 모든 화면: 불러오는 중 → 데이터 | 비어 있음 | 지연(stale) | 부분/전체 실패 | 권한 없음. */
|
||||
(() => {
|
||||
const $ = (s, r = document) => r.querySelector(s); const esc = aw.esc; const fmt = aw.fmt;
|
||||
const B = (n) => { if (n == null) return '제공되지 않음'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < 4) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; };
|
||||
const L = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' };
|
||||
const H = { ok: ['ok', '정상'], degraded: ['warning', '성능 저하'], partial_outage: ['warning', '일부 장애'], down: ['failed', '중단'], unknown: ['neutral', '확인 전'], stale: ['warning', '정보 확인 지연'], not_provided: ['neutral', '제공되지 않음'] };
|
||||
const V = { verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' };
|
||||
const SC = { none: '-', reserved: '예약됨', in_use: '사용 중', returning: '반환 중' };
|
||||
const badge = (kind, text) => `<span class="badge ${kind}">${({ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' })[kind] || '○'} ${esc(text)}</span>`;
|
||||
const onErr = (el, e, retry) => aw.state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' || e.status === 503 ? 'stale' : 'failed', e.message, { retry });
|
||||
const siteId = () => $('[data-site]')?.dataset.site;
|
||||
const jobAction = (sid) => async (a, j) => { if (a === 'cancel' && !(await aw.confirmDanger({ title: '작업 취소', target: j.label, impacts: ['진행 중인 단계는 안전한 지점에서 멈추고, 남은 중간 결과는 배포 화면에 표시됩니다'] }))) return; if (a === 'revert' && !(await aw.confirmDanger({ title: '되돌리기', target: j.label, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '실제 외부 접속을 확인한 뒤 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/jobs/${j.id}/${a === 'go' ? 'resume' : a}`); aw.toast('요청했습니다'); location.reload(); } catch (e) { aw.toast(e.message); } };
|
||||
|
||||
// ---------- 목록 ----------
|
||||
async function list() {
|
||||
const el = $('#list'); try { const r = await aw.api('GET', `/api/sites${el.dataset.filter ? '?filter=' + el.dataset.filter : ''}`); if (!r.sites.length) return aw.state(el, 'empty', '아직 사이트가 없습니다. "새 사이트" 로 시작하세요', { href: '/sites/new', hrefLabel: '새 사이트' });
|
||||
const sc = r.siteCount; el.innerHTML = `<p class="sub">운영 사이트 수: 사용 중 ${sc.allocated} · 예약 ${sc.reserved} · 구매 ${sc.purchased} (사용 가능 ${sc.unallocated}) — 초안·제작 중 사이트는 포함되지 않습니다</p><table class="r"><thead><tr><th>이름 · 대표 도메인</th><th>제작·배포 상태</th><th>배포 방식</th><th>메일 · 백업</th><th>최근 배포</th><th>건강 · 조치</th></tr></thead><tbody>${r.sites.map((s) => { const h = H[s.health] || H.unknown; return `<tr><td data-l="이름"><a href="/sites/${s.id}"><b>${esc(s.name)}</b></a><br><span class="mono sub">${esc(s.primaryDomain || '대표 도메인 없음')}</span></td><td data-l="상태">${badge(s.lifecycle === 'live' ? 'ok' : ['paused', 'needs_domain'].includes(s.lifecycle) ? 'warning' : 'neutral', L[s.lifecycle] || s.lifecycle)}${s.deployState ? `<br><span class="sub">${esc(V[s.deployState.verify] || s.deployState.status)}</span>` : ''}<br><span class="sub">사이트 수 ${esc(SC[s.siteCountState] || s.siteCountState)}</span></td><td data-l="배포 방식">${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부' : '<span class="muted">미선택</span>'}</td><td data-l="메일·백업">${s.mail ? '✉ 메일' : '<span class="muted">메일 없음</span>'}<br>${s.backup ? '⟲ 백업 ' + esc(s.backup.status) : '<span class="muted">백업 없음</span>'}</td><td data-l="최근 배포">${s.lastDeployAt ? esc(fmt(s.lastDeployAt)) : '<span class="muted">없음</span>'}</td><td data-l="건강·조치">${badge(h[0], h[1])}${s.actionNeeded.map((a) => `<br><a href="${esc(a.href)}">! ${esc(a.text)}</a>`).join('')}</td></tr>`; }).join('')}</tbody></table>`; } catch (e) { onErr(el, e, list); }
|
||||
}
|
||||
// ---------- 생성 ----------
|
||||
async function create() {
|
||||
const f = $('#newSite'); const pick = $('#projectPick'); const bm = $('#buildModePick');
|
||||
f.querySelectorAll('[name=projectMode]').forEach((r) => { r.onchange = async () => { pick.hidden = r.value !== 'existing' || !r.checked; bm.hidden = r.value !== 'new'; if (r.value === 'existing' && r.checked) { const sel = $('#projectId'); try { const r2 = await aw.api('GET', '/api/webmaker/projects'); sel.innerHTML = r2.projects.length ? r2.projects.map((p) => `<option value="${esc(p.id)}" ${p.link ? 'disabled' : ''}>${esc(p.title)} (${p.versions}개 저장본${p.link ? ', 이미 연결됨' : ''})</option>`).join('') : '<option value="">연결할 수 있는 프로젝트가 없습니다</option>'; } catch (e) { sel.innerHTML = `<option value="">${esc(e.message)}</option>`; } } }; });
|
||||
f.onsubmit = async (e) => { e.preventDefault(); f.querySelectorAll('.err').forEach((x) => { x.textContent = ''; }); const fd = new FormData(f); const body = Object.fromEntries(fd.entries()); const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
|
||||
try { const r = await aw.api('POST', '/api/sites', body); aw.markClean(); f.hidden = true; const c = $('#created'); c.hidden = false; c.innerHTML = `<h2>사이트 "${esc(r.site.name)}" 을(를) 만들었습니다</h2>${r.projectNote ? `<p class="err">${esc(r.projectNote)}</p>` : ''}<p>다음 단계</p><ol>${r.nextSteps.map((s) => `<li>${s.unsupported ? `<span class="muted">${esc(s.label)} — ${esc(s.unsupported)}</span>` : `<a href="${esc(s.href)}">${esc(s.label)}</a>`}${s.done ? ' ✔' : ''}</li>`).join('')}</ol><div class="actions"><a class="btn primary" href="/sites/${r.site.id}">사이트 상세로</a><a class="btn" href="/sso/webmaker?site=${r.site.id}" target="_blank" rel="noopener">제작 화면 열기</a></div>`; }
|
||||
catch (err) { btn.disabled = false; const t = f.querySelector(`[data-err="${err.detail && err.detail.field || 'form'}"]`) || f.querySelector('[data-err=form]'); t.textContent = err.message; } };
|
||||
}
|
||||
// ---------- 개요 ----------
|
||||
async function overview() {
|
||||
const el = $('#overview'); const sid = siteId();
|
||||
const render = (o) => {
|
||||
const head = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>서비스 상태</dt><dd>${badge(o.lifecycle === 'live' ? 'ok' : 'neutral', L[o.lifecycle] || o.lifecycle)}</dd><dt>배포 작업</dt><dd>${o.lastDeploy ? badge(o.lastDeploy.status === 'done' ? 'done' : o.lastDeploy.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[o.lastDeploy.status] || o.lastDeploy.status) + (o.lastDeploy.verify ? ' ' + esc(V[o.lastDeploy.verify]) : '') : '<span class="muted">없음</span>'}</dd><dt>외부 건강</dt><dd>${o.kind === 'external' ? badge('neutral', '제공되지 않음') : o.health ? badge((H[o.health] || H.unknown)[0], (H[o.health] || H.unknown)[1]) + (o.healthCheckedAt ? ` <span class="sub">${esc(fmt(o.healthCheckedAt))}</span>` : '') : '-'}</dd><dt>사이트 수</dt><dd>${esc(SC[o.siteCountState] || '-')}${o.returnDueAt ? ` <span class="sub">(반환 예정 ${esc(fmt(o.returnDueAt))})</span>` : ''}</dd>${o.nextJob ? `<dt>다음 예정 작업</dt><dd>${esc(o.nextJob.label)} ${o.nextJob.at ? esc(fmt(o.nextJob.at)) : ''}</dd>` : ''}</dl></div>`;
|
||||
const actions = o.actionNeeded.length ? `<div class="card"><h2>조치 필요</h2><ul>${o.actionNeeded.map((a) => `<li><a href="${esc(a.href)}">${esc(a.text)}</a></li>`).join('')}</ul></div>` : '';
|
||||
if (o.kind === 'not_deployed') return `${head}${actions}<div class="card"><h2>준비 단계</h2><p>${esc(o.message)}</p><ol>${o.readiness.map((s) => `<li>${s.unsupported ? `<span class="muted">${esc(s.label)} — ${esc(s.unsupported)}</span>` : `<a href="${esc(s.href)}">${esc(s.label)}</a>`} ${s.done ? '✔' : ''}</li>`).join('')}</ol></div></div>`;
|
||||
if (o.kind === 'external') return `${head}${actions}<div class="card"><h2>외부 배포 사이트</h2><dl class="kv"><dt>Agent 연결</dt><dd>${o.agent ? badge(o.agent.status === 'connected' ? 'ok' : 'warning', { connected: '연결됨', pending: '연결 대기', offline: '응답 없음', revoked: '해제됨' }[o.agent.status] || o.agent.status) + (o.agent.lastSeenAt ? ` <span class="sub">마지막 응답 ${esc(fmt(o.agent.lastSeenAt))}</span>` : '') : '<span class="muted">없음</span>'}</dd><dt>마지막 배포</dt><dd>${o.lastDeployResult ? `${o.lastDeployResult.ok ? '성공' : '실패'} · ${esc(fmt(o.lastDeployResult.at))} · 버전 ${esc(o.lastDeployResult.version || '-')}` : '없음'}</dd><dt>마지막 외부 확인</dt><dd>${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} ${esc(o.lastExternalCheck.url || '')} (${o.lastExternalCheck.status || '-'})` : '제공되지 않음'}</dd><dt>고객 책임 범위</dt><dd>${o.customerResponsibility.map(esc).join('<br>')}</dd></dl><h2>제공되지 않음</h2><ul>${o.notProvided.map((x) => `<li>${esc(x)} — 제공되지 않음</li>`).join('')}</ul></div></div>`;
|
||||
const w = (p) => p == null ? '' : p >= 100 ? 'w100' : p >= 95 ? 'w95' : p >= 90 ? 'w90' : p >= 80 ? 'w80' : p >= 70 ? 'w70' : '';
|
||||
return `${head}${actions}<div class="card"><h2>접속</h2>${o.stale ? `<p class="aw-state stale"><b>정보 확인 지연</b> ${esc(o.staleReason)}</p>` : ''}<dl class="kv"><dt>대표 도메인</dt><dd>${o.primaryDomain ? `<a href="https://${esc(o.primaryDomain)}/" target="_blank" rel="noopener">${esc(o.primaryDomain)}</a>` : '없음'}</dd><dt>현재 배포 버전</dt><dd>${esc(o.currentVersion || '-')} <span class="sub">${esc(fmt(o.lastDeployAt))}</span></dd><dt>최근 24시간 접속</dt><dd>${o.visits24h != null ? `${o.visits24h.toLocaleString()} 방문 · ${(o.requests24h || 0).toLocaleString()} 요청` : '수집 전'}</dd><dt>7일 추이</dt><dd>${o.series.series7d.length ? o.series.series7d.map((d) => `${esc(d.date.slice(5))}: ${d.pv}`).join(' · ') : '수집 전'}</dd><dt>30일 추이</dt><dd>${o.series.series30d.length ? o.series.series30d.length + '일 수집' : '수집 전'}</dd><dt>마지막 수집</dt><dd>${o.measuredAt ? esc(fmt(o.measuredAt)) : '-'}</dd></dl></div>
|
||||
<div class="card"><h2>디스크와 데이터베이스</h2><div class="bar ${w(o.disk.percent)}"><i style="width:${Math.min(100, o.disk.percent || 0)}%"></i></div><dl class="kv"><dt>사용</dt><dd>${B(o.disk.used)} / 한도 ${B(o.disk.limit)} ${o.disk.percent != null ? `(${o.disk.percent}%)` : ''}</dd><dt>데이터베이스</dt><dd>${B(o.disk.db)}</dd><dt>업로드</dt><dd>${B(o.disk.uploads)}</dd></dl></div>
|
||||
<div class="card"><h2>네트워크</h2><dl class="kv"><dt>아웃바운드 일/월/누적</dt><dd>${B(o.network.out.day)} / ${B(o.network.out.month)} / ${B(o.network.out.total)} ${o.network.out.limit ? `(월 한도 ${B(o.network.out.limit)})` : ''}</dd><dt>인바운드 일/월/누적</dt><dd>${B(o.network.in.day)} / ${B(o.network.in.month)} / ${B(o.network.in.total)}</dd></dl></div>
|
||||
<div class="card"><h2>오류와 성능</h2><dl class="kv"><dt>오류율</dt><dd>${o.errors.errorRate != null ? o.errors.errorRate + '%' : '수집 전'}</dd><dt>응답 시간(p95)</dt><dd>${o.errors.p95ms != null ? o.errors.p95ms + ' ms' : '수집 전'}</dd><dt>마지막 외부 확인</dt><dd>${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} (${o.lastExternalCheck.status || '-'}, ${o.lastExternalCheck.ms || '-'}ms)` : '-'}</dd></dl></div></div>`;
|
||||
};
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/overview`); el.innerHTML = render(r.overview); return !!r.refreshJob; } catch (e) { onErr(el, e, load); return false; } };
|
||||
if (await load()) aw.poll(async () => { await load(); return false; }, 15000);
|
||||
}
|
||||
// ---------- 제작 탭 ----------
|
||||
async function build() {
|
||||
const el = $('#buildInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); if (!r.project) return aw.state(el, 'empty', 'Webmaker 프로젝트가 연결되어 있지 않습니다'); el.innerHTML = `<dl class="kv"><dt>프로젝트</dt><dd>${esc(r.project.title)} <span class="sub">(${esc(r.project.id.slice(0, 8))}…, rev ${r.project.rev})</span></dd><dt>제작 방식</dt><dd>${esc({ ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '제목·내용 입력형' }[r.project.mode] || r.project.mode)}</dd><dt>내용</dt><dd>${r.project.hasContent ? '홈 페이지 있음' : '아직 페이지가 없습니다'}</dd><dt>저장본</dt><dd>${r.versions.length}개 ${r.versions.length ? `(최근: #${r.versions[0].no} ${esc(r.versions[0].message || r.versions[0].kind)} · ${esc(fmt(r.versions[0].at))})` : ''}</dd></dl>`; const wl = $('#wallet'); if (wl) wl.innerHTML = r.wallet && !r.wallet.stale && r.wallet.credit ? `<h2>AI Wallet</h2><p class="hint">AI 생성·수정 비용은 AI Wallet(캐시)에서 차감되며 호스팅 디스크·트래픽 사용량과 분리됩니다. 충전·사용 이력은 상품 및 결제 화면에서 봅니다.</p><dl class="kv"><dt>잔액</dt><dd>${r.wallet.credit.balance.toLocaleString()} ${esc(r.wallet.unit || '원')}</dd><dt>사용</dt><dd>${r.wallet.credit.used.toLocaleString()} ${esc(r.wallet.unit || '원')}</dd></dl>` : `<h2>AI Wallet</h2><p class="aw-state stale"><b>정보 확인 지연</b> 잔액을 지금 확인할 수 없습니다</p>`; } catch (e) { onErr(el, e, build); }
|
||||
}
|
||||
// ---------- 배포 탭 ----------
|
||||
async function deploy() {
|
||||
const sid = siteId(); const cur = $('#deployCurrent'); const start = $('#deployStart'); const hist = $('#deployHistory');
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/deployments`); const s = r.site; const active = r.activeJobs.find((j) => ['site.deploy', 'site.rollback', 'site.cleanup', 'site.mode_change'].includes(j.kind)); const last = r.deployments[0];
|
||||
cur.innerHTML = `<h2>현재 배포</h2><dl class="kv"><dt>현재 버전</dt><dd>${esc(s.current_version || '없음')} ${s.deploy_verify ? badge(s.deploy_verify === 'public' ? 'ok' : 'warning', V[s.deploy_verify]) : ''}</dd><dt>배포 방식</dt><dd>${s.deploy_mode === 'managed' ? '관리형' : s.deploy_mode === 'external' ? '외부' : '미선택'} <a href="/sites/${sid}/deploy/mode">변경</a></dd><dt>사이트 수</dt><dd>${esc(SC[s.site_count_state] || '-')}${s.return_due_at ? ` · 반환 예정 ${esc(fmt(s.return_due_at))} <button class="btn" id="btnCleanup">지금 정리하고 반환</button>` : ''}</dd></dl>${s.pending_cleanup_json ? '' : ''}<div id="activeJob"></div>${active ? '' : `<div class="actions">${last && last.previous_version ? '<button class="btn danger" id="btnRollback">이전 버전으로 되돌리기</button>' : ''}</div>`}`;
|
||||
if (active) { aw.job($('#activeJob'), active, { onAction: jobAction(sid) }); } else if (last && last.job && ['failed', 'needs_user'].includes(last.job.status)) { aw.job($('#activeJob'), last.job, { onAction: jobAction(sid) }); }
|
||||
const rb = $('#btnRollback'); if (rb) rb.onclick = async () => { if (!(await aw.confirmDanger({ title: '이전 버전으로 되돌리기', target: `${s.current_version} → ${last.previous_version}`, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/rollback`); aw.toast('되돌리기를 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
const cl = $('#btnCleanup'); if (cl) cl.onclick = async () => { if (!(await aw.confirmDanger({ title: '중간 결과 정리와 사이트 수 반환', target: s.name, impacts: ['배포 위치에 남은 중간 파일을 제거합니다', '사이트 수 예약을 반환합니다(다른 사이트가 쓸 수 있게 됨)', '현재 운영 중인 버전이 있으면 유지됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/cleanup`); aw.toast('정리 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
hist.innerHTML = `<h2>배포 기록</h2>${r.deployments.length ? `<table class="r"><thead><tr><th>시각</th><th>버전</th><th>결과</th><th>완료 상태</th><th>작업</th></tr></thead><tbody>${r.deployments.map((d) => `<tr><td data-l="시각">${esc(fmt(d.created_at))}</td><td data-l="버전">${esc(d.version_label || d.version_ref)}${d.previous_version ? `<br><span class="sub">이전 ${esc(d.previous_version)}</span>` : ''}</td><td data-l="결과">${badge(d.status === 'done' ? 'done' : d.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[d.status] || d.status)}${d.result && d.result.error ? `<br><span class="err">${esc(d.result.error)}</span><br><span class="sub">문의 번호 ${esc(d.job_id || '')}</span>` : ''}</td><td data-l="완료 상태">${d.verify_state ? esc(V[d.verify_state]) : '-'}</td><td data-l="작업">${d.job ? `${esc(aw.JOB_LABEL[d.job.status] || d.job.status)} ${d.job.attempt > 1 ? `(${d.job.attempt}회)` : ''}` : '-'}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">배포 기록이 없습니다</p>'}`;
|
||||
if (!active) await startForm(s); else start.innerHTML = '';
|
||||
return !!active; } catch (e) { onErr(cur, e, load); return false; } };
|
||||
const startForm = async (s) => { try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); const opts = [`<option value="current">현재 저장 상태 (rev ${r.project ? r.project.rev : '-'})</option>`, ...r.versions.map((v) => `<option value="${v.no}">#${v.no} ${esc(v.message || v.kind)} · ${esc(fmt(v.at))}</option>`)].join(''); start.innerHTML = `<h2>배포 시작</h2><div class="f"><label for="ver">배포할 저장본</label><select id="ver">${opts}</select><span class="hint">이전 저장본을 골라 배포할 수 있습니다. 배포 전 Webmaker 미리보기로 내용을 확인하세요(운영 버전은 배포 완료 전까지 바뀌지 않습니다)</span></div><div class="actions"><button class="btn" id="btnPrecheck">배포 전 확인</button><button class="btn primary" id="btnDeploy" disabled>배포 시작</button></div><div id="precheck"></div>`; $('#btnPrecheck').onclick = precheck; $('#ver').onchange = () => { $('#btnDeploy').disabled = true; $('#precheck').innerHTML = ''; }; $('#btnDeploy').onclick = async () => { $('#btnDeploy').disabled = true; try { const v = $('#ver').value; const r2 = await aw.api('POST', `/api/sites/${sid}/deploy`, { versionRef: v, versionLabel: $('#ver').selectedOptions[0].textContent.trim() }); aw.toast(r2.created ? '배포를 시작했습니다. 화면을 닫아도 계속 진행됩니다' : '이미 같은 배포가 진행 중입니다'); load(); } catch (e) { $('#precheck').innerHTML = `<p class="err">${esc(e.message)}</p>` + (e.detail && e.detail.items ? renderPrecheck(e.detail.items) : ''); } }; } catch (e) { start.innerHTML = `<h2>배포 시작</h2>`; aw.state(start.appendChild(document.createElement('div')), e.code === 'service_unreachable' ? 'stale' : 'failed', e.message); } };
|
||||
const renderPrecheck = (items) => `<ul class="steps">${items.map((i) => `<li class="${i.state === 'block' ? 'failed' : i.state === 'pass' ? 'done' : 'pending'}"><span class="s">${i.state === 'block' ? '✘' : i.state === 'pass' ? '✔' : i.state === 'warn' ? '!' : 'ⓘ'}</span><span><b>${esc(i.title)}</b> — ${esc(i.text)} ${i.href ? `<a href="${esc(i.href)}">해결하기</a>` : ''}</span></li>`).join('')}</ul>`;
|
||||
const precheck = async () => { const pc = $('#precheck'); aw.state(pc, 'loading'); try { const r = await aw.api('GET', `/api/sites/${sid}/precheck?version=${encodeURIComponent($('#ver').value)}`); pc.innerHTML = `<p>${r.precheck.ok ? '✔ 모든 항목을 통과했습니다' : '✘ 차단된 항목이 있습니다. 해결 후 다시 확인하세요'}</p>${renderPrecheck(r.precheck.items)}`; $('#btnDeploy').disabled = !r.precheck.ok; } catch (e) { onErr(pc, e, precheck); } };
|
||||
if (await load()) aw.poll(load, 4000);
|
||||
}
|
||||
// ---------- 배포 방식 ----------
|
||||
async function mode() {
|
||||
const el = $('#modeInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/mode`); const m = r.managed; const x = r.external;
|
||||
el.innerHTML = `<div class="cards"><div class="card"><h2>관리형 배포 ${r.current === 'managed' ? badge('ok', '현재') : ''}</h2><p>${esc(m.description)}</p><dl class="kv"><dt>모니터링 범위</dt><dd>${m.monitoring.map(esc).join('<br>')}</dd><dt>용량·트래픽 한도</dt><dd>${m.limits.map((l) => `${esc(l.label)}: 구매 ${B(l.purchased)} · 미배정 ${B(l.unallocated)}`).join('<br>') || '구매한 권리 없음'}</dd><dt>백업 옵션</dt><dd>${esc(m.backupNote)}</dd><dt>서버 이전</dt><dd>${esc(m.relocation)}</dd><dt>예상 배포 시간</dt><dd>약 ${m.estimatedMinutes}분</dd><dt>승인된 비용</dt><dd>${m.cost != null ? esc(String(m.cost)) : esc(m.costNote || '-')}</dd><dt>지원 실행 형태</dt><dd>정적: ${esc(m.runtimeNotes.static)}<br>동적: ${esc(m.runtimeNotes.node)}</dd><dt>배치 가능 서버</dt><dd>${m.halStale ? '<span class="aw-state stale" style="padding:2px 6px">정보 확인 지연</span>' : m.serversAvailable + '대'}</dd></dl><button class="btn primary" data-mode="managed" ${r.current === 'managed' ? 'disabled' : ''}>관리형 선택</button></div>
|
||||
<div class="card"><h2>외부 배포 ${r.current === 'external' ? badge('ok', '현재') : ''}</h2><dl class="kv"><dt>지원 대상</dt><dd>${x.targets.map(esc).join('<br>')}</dd><dt>준비할 것</dt><dd>${x.prepare.map(esc).join('<br>')}</dd><dt>건별 배포 비용</dt><dd>${x.perDeployCost != null ? esc(String(x.perDeployCost)) : esc(x.costNote || '-')}</dd><dt>제공하지 않음</dt><dd>${x.notProvided.map(esc).join(', ')}</dd><dt>장애 분리</dt><dd>${esc(x.isolation)}</dd><dt>사이트 수</dt><dd>${esc(x.siteCountNote)}</dd><dt>Agent</dt><dd>${r.agent ? esc(r.agent.status) : '없음'} — <a href="/sites/${sid}/external">외부 서버 연결 화면</a></dd></dl><h3>${esc(r.consent.title)} (버전 ${esc(r.consent.version)})</h3><ul>${r.consent.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="agree" ${r.consent.agreed ? 'checked disabled' : ''}> 위 내용을 확인했고 동의합니다${r.consent.agreed ? ' (동의 기록됨)' : ''}</label><div class="actions"><button class="btn primary" data-mode="external" ${r.current === 'external' ? 'disabled' : ''}>외부 배포 선택</button></div></div></div><div id="modeJob"></div>`;
|
||||
el.querySelectorAll('[data-mode]').forEach((b) => { b.onclick = async () => { const md = b.dataset.mode; try { if (md === 'external' && !r.consent.agreed) { if (!$('#agree').checked) return aw.toast('확인 사항에 동의해야 합니다'); await aw.api('POST', `/api/sites/${sid}/consent`, { version: r.consent.version, agree: true }); } if (r.current && r.current !== md && !(await aw.confirmDanger({ title: '배포 방식 변경', target: `${r.current} → ${md}`, impacts: ['새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리 순서로 진행합니다', '완료 전까지 현재 위치가 유지되고, 실패하면 되돌립니다'] }))) return; const r2 = await aw.api('POST', `/api/sites/${sid}/mode`, { mode: md }); if (r2.job) { aw.toast('배포 방식 변경 작업을 시작했습니다'); aw.job($('#modeJob'), r2.job, { onAction: jobAction(sid) }); } else { aw.toast('배포 방식을 저장했습니다'); location.href = `/sites/${sid}?tab=deploy`; } } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, mode); }
|
||||
}
|
||||
// ---------- 외부 서버 ----------
|
||||
async function external() {
|
||||
const el = $('#external'); const sid = siteId();
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/external`); const a = r.agent; const st = a ? ({ connected: ['ok', '연결됨'], pending: ['warning', '연결 대기'], offline: ['failed', '응답 없음'], revoked: ['neutral', '해제됨'] }[a.status] || ['neutral', a.status]) : null;
|
||||
el.innerHTML = `<div class="card"><h2>External Pull Agent</h2>${!r.consent.agreed ? `<p class="aw-state partial"><b>동의 필요</b> 외부 배포 확인 사항에 먼저 동의하세요. <a href="/sites/${sid}/deploy/mode">배포 방식 화면</a></p>` : ''}<dl class="kv"><dt>연결 상태</dt><dd>${a ? badge(st[0], st[1]) + (a.stale ? ` <span class="aw-state stale" style="padding:2px 6px">${esc(a.staleReason)}</span>` : '') : '없음'}</dd>${a ? `<dt>이름</dt><dd>${esc(a.label || '-')}</dd><dt>마지막 응답</dt><dd>${a.last_seen_at ? esc(fmt(a.last_seen_at)) : '-'}</dd><dt>Agent 버전</dt><dd>${esc(a.agent_version || '-')}</dd><dt>마지막 배포</dt><dd>${a.lastDeploy ? esc(JSON.stringify(a.lastDeploy)).slice(0, 200) : '-'}</dd><dt>마지막 외부 확인</dt><dd>${a.lastCheck ? esc(JSON.stringify(a.lastCheck)).slice(0, 200) : '제공되지 않음'}</dd>` : ''}</dl>
|
||||
<div class="actions">${!a || a.status !== 'connected' ? `<button class="btn primary" id="btnIssue" ${r.consent.agreed ? '' : 'disabled'}>연결 토큰 발급</button>` : ''}${a && a.status === 'pending' ? '<button class="btn" id="btnCheck">연결 확인</button>' : ''}${a && a.status === 'connected' ? `<a class="btn primary" href="/sites/${sid}?tab=deploy">배포 / 재배포</a><button class="btn danger" id="btnRemove">배포 제거</button>` : ''}${a && a.status !== 'revoked' ? '<button class="btn danger" id="btnRevoke">Agent 해제</button>' : ''}</div><div id="issued"></div></div>
|
||||
<div class="card"><h2>설치 안내</h2><ol><li>고객 서버(지원 Linux)에 root 또는 sudo 계정으로 접속합니다.</li><li>"연결 토큰 발급" 을 누르면 설치 명령이 한 번만 표시됩니다(토큰은 10분 유효, 1회용). 복사해 서버에서 실행하세요.</li><li>Agent 가 HAL 에 연결되면 상태가 "연결됨" 으로 바뀝니다. 그 뒤 배포 탭에서 배포합니다.</li><li>실패한 배포의 남은 파일·실행 상태는 "배포 제거" 로 정리할 수 있습니다.</li></ol><p class="hint">외부 서버의 운영·보안·장애 대응은 고객 책임입니다. Ariweb 는 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.</p></div>`;
|
||||
const bi = $('#btnIssue'); if (bi) bi.onclick = async () => { try { const r2 = await aw.api('POST', `/api/sites/${sid}/external`, { label: prompt('서버 이름(표시용)', '') || '' }); $('#issued').innerHTML = `<p class="aw-state partial"><b>한 번만 표시됩니다</b> 아래 명령을 복사해 서버에서 실행하세요 (만료 ${esc(fmt(r2.expiresAt))})</p><pre class="mono" style="white-space:pre-wrap">${esc(r2.installCommand)}</pre><button class="btn copy" data-action="copy" data-copy="${esc(r2.installCommand)}">복사</button>`; } catch (e) { aw.toast(e.message); } };
|
||||
const bc = $('#btnCheck'); if (bc) bc.onclick = load;
|
||||
const br = $('#btnRevoke'); if (br) br.onclick = async () => { if (!(await aw.confirmDanger({ title: 'Agent 해제', target: a.label || a.hal_agent_id, impacts: ['이 서버로의 배포·재배포·제거가 더 이상 되지 않습니다', '이미 배포된 파일은 서버에 남습니다(고객이 직접 정리)'] }))) return; try { await aw.api('DELETE', `/api/sites/${sid}/external`); load(); } catch (e) { aw.toast(e.message); } };
|
||||
const bm = $('#btnRemove'); if (bm) bm.onclick = async () => { if (!(await aw.confirmDanger({ title: '외부 서버 배포 제거', target: a.label || a.hal_agent_id, impacts: ['외부 서버의 사이트 파일과 실행 상태를 제거합니다', '남은 파일 목록이 결과에 표시됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/external/remove`); aw.toast('제거 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
} catch (e) { onErr(el, e, load); } };
|
||||
load();
|
||||
}
|
||||
// ---------- 사용량·활동·설정·도메인 ----------
|
||||
async function usage() { const el = $('#usage'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/usage`); if (r.overview.kind !== 'managed') return aw.state(el, 'empty', r.overview.kind === 'external' ? '외부 배포 사이트에는 관리형 사용량이 제공되지 않습니다' : '정상 배포되지 않은 사이트에는 운영 사용량을 표시하지 않습니다'); el.innerHTML = `<div class="card"><h2>배정과 사용</h2><table class="r"><thead><tr><th>자원</th><th>배정</th><th>사용</th><th>마지막 계측</th></tr></thead><tbody>${r.allocations.map((a) => `<tr><td data-l="자원">${esc(a.label)}</td><td data-l="배정">${a.allocation ? B(a.allocation.amount) : '미배정'}</td><td data-l="사용">${a.usage ? B(a.usage.used) : '-'}</td><td data-l="계측">${a.usage ? esc(fmt(a.usage.measured_at)) : '-'}</td></tr>`).join('')}</tbody></table><p class="hint">배정량 변경은 <a href="/usage">사용량 화면</a>에서 합니다.</p></div><div class="card"><h2>최근 계측값</h2><table class="r"><thead><tr><th>항목</th><th>값</th><th>기간</th><th>시각</th></tr></thead><tbody>${r.samples.slice(0, 60).map((s) => `<tr><td data-l="항목">${esc(s.metric)}</td><td data-l="값">${/bytes/.test(s.metric) ? B(s.value) : s.value}</td><td data-l="기간">${esc(s.period || '')}</td><td data-l="시각">${esc(fmt(s.measured_at))}</td></tr>`).join('') || '<tr><td colspan="4">수집 전</td></tr>'}</tbody></table></div>`; } catch (e) { onErr(el, e, usage); } }
|
||||
async function activity() { const el = $('#activity'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/activity`); if (!r.items.length && !r.jobs.length) return aw.state(el, 'empty', '기록이 없습니다'); el.innerHTML = `<div class="card"><h2>작업</h2>${r.jobs.map((j) => `<p><b>${esc(j.label)}</b> ${esc(aw.JOB_LABEL[j.status] || j.status)} <span class="sub">${esc(fmt(j.updatedAt))}</span></p>`).join('') || '<p class="muted">없음</p>'}</div><div class="card"><h2>활동 기록</h2><table class="r"><thead><tr><th>시각</th><th>누가</th><th>무엇</th><th>결과</th></tr></thead><tbody>${r.items.map((a) => `<tr><td data-l="시각">${esc(fmt(a.at))}</td><td data-l="누가">${a.automatic ? '자동' : esc(a.actor_label || a.actor_id || '-')}</td><td data-l="무엇">${esc(a.action)}</td><td data-l="결과">${esc(a.outcome)}</td></tr>`).join('')}</tbody></table></div>`; } catch (e) { onErr(el, e, activity); } }
|
||||
async function settings() { const f = $('#settings'); const sid = siteId(); f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); const body = { name: fd.get('name'), description: fd.get('description'), lang: fd.get('lang'), timezone: fd.get('timezone'), runtime: fd.get('runtime'), maintenance: fd.get('maintenance') === 'on', searchVisible: fd.get('searchVisible') === 'on' }; if (body.maintenance !== (f.dataset.maint === '1') && $('[data-lifecycle]')?.dataset.lifecycle === 'live') { if (!(await aw.confirmDanger({ title: '유지보수 모드 변경', target: fd.get('name'), impacts: [body.maintenance ? '방문자에게 안내 페이지가 보이고 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '즉시 다시 공개됩니다'] }))) return; } try { const r = await aw.api('PATCH', `/api/sites/${sid}`, body); aw.markClean(); aw.toast(r.impact.length ? r.impact.join(' ') : '저장했습니다'); } catch (err) { f.querySelector('[data-err=form]').textContent = err.message; } }; f.dataset.maint = f.querySelector('[name=maintenance]').checked ? '1' : '0';
|
||||
const pe = $('#perms'); try { const r = await aw.api('GET', `/api/sites/${sid}/permissions`); pe.innerHTML = r.members && r.members.length ? `<table class="r"><thead><tr><th>구성원</th><th>권한</th></tr></thead><tbody>${r.members.map((m) => `<tr><td data-l="구성원">${esc(m.alias || m.email)}</td><td data-l="권한">${(m.perms || []).join(', ') || '(그룹 역할 기준)'}</td></tr>`).join('')}</tbody></table><p class="hint">권한 변경은 사용자 그룹 설정 › 사이트별 권한에서 합니다.</p>` : '<p class="muted">사이트별 권한이 없습니다(그룹 역할 기준)</p>'; } catch (e) { aw.state(pe, e.status === 404 ? 'empty' : 'failed', e.status === 404 ? '사이트별 권한 기능은 사용자 그룹 설정에서 제공됩니다' : e.message); } }
|
||||
async function siteDomains() { const el = $('#siteDomains'); const sid = siteId(); try { const r = await aw.api('GET', `/api/domains?site=${sid}`); if (!r.domains.length) return aw.state(el, 'empty', '연결된 도메인이 없습니다. 여러 도메인을 연결해도 운영 사이트 수는 하나만 씁니다'); el.innerHTML = `<table class="r"><thead><tr><th>도메인</th><th>대표</th><th>소유 확인</th><th>DNS</th><th>HTTPS</th><th>마지막 확인</th></tr></thead><tbody>${r.domains.map((d) => `<tr><td data-l="도메인"><a href="/domains/${d.id}">${esc(d.name)}</a></td><td data-l="대표">${d.is_primary ? '★ 대표' : `<button class="btn" data-primary="${d.id}">대표로 지정</button>`}</td><td data-l="소유">${esc(d.ownership_state)}</td><td data-l="DNS">${esc(d.dns_state)}</td><td data-l="HTTPS">${esc(d.cert_state)}</td><td data-l="확인">${d.last_checked_at ? esc(fmt(d.last_checked_at)) : '-'}</td></tr>`).join('')}</tbody></table>`; el.querySelectorAll('[data-primary]').forEach((b) => { b.onclick = async () => { const redirect = confirm('기존 대표 주소를 새 대표 도메인으로 이동(301) 처리할까요?'); try { await aw.api('POST', `/api/domains/${b.dataset.primary}/primary`, { redirectOld: redirect }); aw.toast('대표 도메인을 지정했습니다'); siteDomains(); } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, siteDomains); } }
|
||||
// ---------- Source ----------
|
||||
async function source() { const el = $('#source'); const sid = siteId(); const load = async () => { try { const r = await aw.api('GET', `/api/source/${sid}`); const o = r.option; const s = r.source;
|
||||
if (!o) return (el.innerHTML = `<div class="card"><h2>Source 옵션</h2><p>${r.entitlement.unallocated > 0 ? 'Source 옵션 권리가 있습니다. 이 사이트에 활성화하면 Gitea 저장소·변경 이력·clone·export 를 쓸 수 있습니다.' : 'Source 옵션을 구매한 사이트에서 제공합니다. <a href="/billing">상품 및 결제</a>에서 옵션을 추가하세요.'}</p>${r.entitlement.unallocated > 0 ? '<button class="btn primary" id="btnAct">이 사이트에 Source 옵션 활성화</button>' : ''}</div>`, $('#btnAct') && ($('#btnAct').onclick = async () => { try { await aw.api('POST', `/api/source/${sid}/activate`); load(); } catch (e) { aw.toast(e.message); } }));
|
||||
const stBadge = o.status === 'active' ? badge('ok', '활성') : o.status === 'readonly' ? badge('warning', '읽기 전용 보존') : badge('failed', '삭제 완료');
|
||||
el.innerHTML = `<div class="card"><h2>Source 옵션 ${stBadge}</h2><dl class="kv">${o.status === 'active' ? `<dt>만료 예정</dt><dd>${o.expires_at ? esc(fmt(o.expires_at)) : '상품 갱신과 연동'}</dd>` : ''}${o.status === 'readonly' ? `<dt>읽기 전용 기간</dt><dd>${esc(fmt(o.readonly_until))} 까지 조회·clone·export 가능 (push·새 변경 불가)</dd><dt>삭제 예정일</dt><dd>${esc(fmt(o.delete_after))}</dd><dt>재활성화</dt><dd><a href="/billing">Source 옵션을 다시 구매</a>하면 삭제 전까지 복구됩니다</dd>` : ''}${o.status === 'deleted' ? `<dt>삭제 완료</dt><dd>${esc(fmt(o.deleted_at))} — 저장소 데이터는 복구할 수 없습니다</dd>` : ''}</dl></div>
|
||||
${s ? `<div class="card"><h2>저장소</h2>${s.stale ? '<p class="aw-state stale"><b>정보 확인 지연</b> Webmaker/Gitea 상태를 지금 확인할 수 없습니다</p>' : ''}<dl class="kv"><dt>저장소</dt><dd>${esc(s.remote || '-')} (${esc(s.provider)})</dd><dt>기본 브랜치</dt><dd class="mono">${esc(s.mainBranch || '-')} ${s.mainUrl ? `<a href="${esc(s.mainUrl)}" target="_blank" rel="noopener">열기</a>` : ''}</dd><dt>선택한 revision</dt><dd class="mono">${esc(r.selectedRevision || '현재 작업 트리')}</dd><dt>마지막 배포 revision</dt><dd class="mono">${esc(r.lastDeployedRevision || '-')} ${r.lastDeployedVersion ? `(배포 버전 ${esc(r.lastDeployedVersion)})` : ''}</dd><dt>저장소 사용량</dt><dd>${B(s.repoBytes)} / 한도 ${r.limits ? B(r.limits.repoBytes) : '상품정책'}</dd><dt>LFS</dt><dd>${s.lfsSupported ? B(s.lfsBytes) : '제공되지 않음(이번 버전)'}</dd><dt>첨부 파일</dt><dd>${B(s.uploadBytes)}</dd><dt>clone</dt><dd class="mono">${esc(r.cloneInfo || '-')}</dd></dl><div class="actions">${o.status !== 'deleted' ? `<a class="btn" href="/api/source/${sid}/export" target="_blank">export (tar.gz)</a>` : ''}${o.status === 'active' ? `<button class="btn" id="btnUnlink">저장소 연결 해제</button>` : ''}</div></div>
|
||||
<div class="card"><h2>변경 이력</h2>${s.changes && s.changes.length ? `<table class="r"><thead><tr><th>#</th><th>종류</th><th>메시지</th><th>시각</th><th>브랜치</th><th>배포</th></tr></thead><tbody>${s.changes.slice().reverse().map((c) => `<tr><td data-l="#">${c.no}</td><td data-l="종류">${esc(c.kind)}</td><td data-l="메시지">${esc(c.message || '')}</td><td data-l="시각">${esc(fmt(c.at))}</td><td data-l="브랜치" class="mono">${esc(c.branch || '')} ${c.pushed ? '' : '(push 대기)'}</td><td data-l="배포">${r.deployedCommits && r.deployedCommits[c.commit] ? '✔ ' + esc(r.deployedCommits[c.commit]) : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">변경 이력이 없습니다</p>'}</div>` : ''}`;
|
||||
const bu = $('#btnUnlink'); if (bu) bu.onclick = async () => { if (!(await aw.confirmDanger({ title: '저장소 연결 해제', target: s.remote, impacts: ['새 변경이 Gitea 에 push 되지 않습니다', '30일 동안 read-only 조회·clone·export 가 가능하고 그 뒤 삭제됩니다'] }))) return; try { await aw.api('POST', `/api/source/${sid}/unlink`); load(); } catch (e) { aw.toast(e.message); } };
|
||||
} catch (e) { onErr(el, e, load); } }; load(); }
|
||||
|
||||
const page = document.querySelector('[data-page]')?.dataset.page;
|
||||
({ 'sites-list': list, 'sites-new': create, 'site-overview': overview, 'site-build': build, 'site-deploy': deploy, 'site-mode': mode, 'site-external': external, 'site-usage': usage, 'site-activity': activity, 'site-settings': settings, 'site-domains': siteDomains, 'site-source': source }[page] || (() => {}))();
|
||||
})();
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* portal/sites/service.mjs — 사이트·배포 업무 규칙(개발 지시 §7·§8·§10·§11·§13.1·§35·§36).
|
||||
*
|
||||
* 이 모듈이 지키는 규칙
|
||||
* - 초안(draft) 사이트는 운영 사이트 수를 쓰지 않는다. 최초 공개 배포 직전에 lib/quota 로 1 을 **예약**(site_count_state='reserved')하고,
|
||||
* 배포와 대표 도메인 확인이 끝나면 **확정**(in_use)한다. 취소·실패 정리는 예약을 **반환**한다(§5.1·§11.2).
|
||||
* - 배포는 lib/jobs 의 내구성 작업(kind 'site.deploy')이다. 화면을 닫아도 계속되고, 같은 사이트·저장본의 요청은 멱등 키로 한 번만 만들어진다(§36).
|
||||
* 실제 서버 작업은 HAL(docs/HAL-JOBS.md)에 맡기고 결과(실제 실행 버전·내부/외부 확인)를 받아야만 완료로 표시한다(§32.2 "명령 성공만으로 완료하지 않는다").
|
||||
* - 배포 완료는 세 단계로 구분한다: verified(배포 검증 완료) → needs_domain(도메인 설정 필요) → public(공개 운영 중)(§11.2).
|
||||
* - 외부 배포 사이트에는 관리형 지표(uptime·디스크·트래픽·백업 보호)를 보이지 않고 '제공되지 않음' 으로 둔다(§8.1).
|
||||
* - Webmaker 프로젝트(webmaker_project_id)와 운영 사이트는 다른 것이다. 프로젝트 삭제는 포털 사이트를 지우지 않는다(impactForProject).
|
||||
*
|
||||
* HAL site.deploy 작업의 자식 단계 id(Agent 가 보고): apply · data_check · start · internal_check · external_check — 포털의 10단계(§11.2) 4~8 에 대응.
|
||||
*/
|
||||
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
|
||||
import * as quota from '../../lib/quota.mjs';
|
||||
import { JobError, describe as describeJob } from '../../lib/jobs.mjs';
|
||||
import { notify } from '../../lib/notify.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import { SERVICE_STATE, DEPLOY_VERIFY, HEALTH, isStale } from '../../lib/status.mjs';
|
||||
import { monthStart } from '../../lib/kst.mjs';
|
||||
import { loginFor } from '../webmaker/adapter.mjs';
|
||||
import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** 사용자용 10단계(§11.2). id 는 작업 steps 와 HAL 단계 대응에 쓴다. */
|
||||
export const DEPLOY_STEPS = [
|
||||
{ id: 'accept', title: '요청 접수' }, { id: 'prepare_version', title: '저장본 준비' }, { id: 'prepare_target', title: '배포 위치 준비' }, { id: 'apply', title: '파일과 설정 적용' },
|
||||
{ id: 'data_check', title: '데이터베이스와 업로드 데이터 확인' }, { id: 'start', title: '서비스 시작' }, { id: 'internal_check', title: '내부 확인' }, { id: 'external_check', title: '외부 접속 확인' },
|
||||
{ id: 'domain_https', title: '도메인과 HTTPS 확인' }, { id: 'done', title: '완료' },
|
||||
];
|
||||
export const EXTERNAL_CONSENT_VERSION = 'external-deploy-2026-10-0.1.1';
|
||||
export const EXTERNAL_CONSENT = {
|
||||
version: EXTERNAL_CONSENT_VERSION,
|
||||
title: '외부 서버 배포 확인 사항',
|
||||
items: ['외부 서버의 준비·보안·운영·장애 대응은 고객 책임입니다. Ariweb 는 External Pull Agent 를 통한 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.',
|
||||
'관리형 모니터링·백업·서버 이전·SLA 는 제공되지 않습니다. 외부 환경 장애는 Ariweb 서비스 정상 상태와 분리됩니다.',
|
||||
'배포 건마다 건별 비용이 적용됩니다(상품 조건의 "외부 배포 건별 비용"). 승인된 금액은 배포 화면에 표시됩니다.',
|
||||
'외부 배포 사이트도 공개 사이트이면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다.',
|
||||
'버전 0.1.1 에서는 외부 메일 배포를 지원하지 않습니다.'],
|
||||
};
|
||||
const RETURN_GRACE_MS = 24 * 3600 * 1000; // 실패한 중간 결과를 정리하지 않을 때 예약이 반환되는 유예(§11.2 "반환 예정 시각")
|
||||
const USAGE_BUCKET_MS = 10 * 60 * 1000; // 사용량·건강 확인 작업을 10분 단위로 하나만 만든다(멱등)
|
||||
const LIFECYCLE_MODIFIABLE = new Set(['draft', 'building', 'deploying', 'live', 'needs_domain', 'paused']);
|
||||
|
||||
export function createSitesService(deps) {
|
||||
const { store, jobs, config, log = () => {} } = deps;
|
||||
const hal = () => { if (!deps.hal) throw Object.assign(new Error('HAL 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.hal; };
|
||||
const wm = () => { if (!deps.webmaker) throw Object.assign(new Error('Webmaker 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.webmaker; };
|
||||
const bundleDir = join(config.dataDir, 'bundles');
|
||||
const row = (r) => (r ? { ...r, settings: parseJson(r.settings_json, {}), pendingCleanup: parseJson(r.pending_cleanup_json, null), lastExternalCheck: parseJson(r.last_external_check_json, null), nextJob: parseJson(r.next_job_json, null) } : null);
|
||||
const dep = (r) => (r ? { ...r, result: parseJson(r.result_json, null) } : null);
|
||||
const log2 = (...a) => log('[sites]', ...a);
|
||||
|
||||
function get(id) { return row(store.get('SELECT * FROM sites WHERE id=?', id)); }
|
||||
function getForGroup(groupId, id) { const s = get(id); return s && s.group_id === groupId ? s : null; }
|
||||
function save(id, patch) { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE sites SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return get(id); }
|
||||
function userLogin(user, group) { return loginFor(user, group); }
|
||||
|
||||
// ------------------------------------------------------------------ 목록·생성·설정
|
||||
/** 사이트 목록(§7.1). lifecycle 필터: draft|building|deploying|live|paused|cancel_pending */
|
||||
function list(groupId, { lifecycle = null } = {}) {
|
||||
const map = { draft: ['draft'], building: ['building'], deploying: ['deploying'], live: ['live', 'needs_domain'], paused: ['paused'], cancel_pending: ['cancel_scheduled', 'readonly_retention', 'pending_delete', 'delete_hold'] };
|
||||
const states = lifecycle ? map[lifecycle] || [lifecycle] : null;
|
||||
const rows = store.all(`SELECT * FROM sites WHERE group_id=? AND lifecycle<>'deleted' ${states ? `AND lifecycle IN (${states.map(() => '?').join(',')})` : ''} ORDER BY created_at DESC`, ...(states ? [groupId, ...states] : [groupId])).map(row);
|
||||
return rows.map((s) => summarize(s));
|
||||
}
|
||||
/** 목록 한 줄 요약: 메일·백업 옵션 여부, 마지막 배포, 건강, 사용자 조치 필요 */
|
||||
function summarize(s) {
|
||||
const mail = store.get('SELECT id, status FROM mail_services WHERE site_id=? AND deleted_at IS NULL', s.id) || null;
|
||||
const backup = store.get('SELECT id, status FROM backup_options WHERE site_id=?', s.id) || null;
|
||||
const lastDeploy = dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1', s.id));
|
||||
const actions = actionNeeded(s, lastDeploy);
|
||||
return { id: s.id, name: s.name, primaryDomain: s.primary_domain, lifecycle: s.lifecycle, deployMode: s.deploy_mode, runtime: s.runtime, buildState: buildState(s), deployState: lastDeploy ? { status: lastDeploy.status, verify: lastDeploy.verify_state, at: lastDeploy.created_at, version: lastDeploy.version_label || lastDeploy.version_ref } : null,
|
||||
mail: mail ? { id: mail.id, status: mail.status } : null, backup: backup ? { id: backup.id, status: backup.status } : null, lastDeployAt: s.last_deploy_at, health: s.deploy_mode === 'external' ? HEALTH.NOT_PROVIDED : (isStale(s.health_checked_at, 15 * 60 * 1000) && ['live', 'needs_domain'].includes(s.lifecycle) ? HEALTH.STALE : s.health),
|
||||
siteCountState: s.site_count_state, actionNeeded: actions, webmakerProjectId: s.webmaker_project_id, updatedAt: s.updated_at, createdAt: s.created_at };
|
||||
}
|
||||
function buildState(s) { if (!s.webmaker_project_id) return 'no_project'; return s.lifecycle === 'draft' ? 'drafting' : 'ready'; }
|
||||
/** 사용자가 해야 할 일(§7.1 "조치 필요"). 링크는 조치 화면 */
|
||||
function actionNeeded(s, lastDeploy) {
|
||||
const out = [];
|
||||
if (s.lifecycle === 'needs_domain') out.push({ code: 'needs_domain', text: '대표 도메인 연결과 HTTPS 확인이 필요합니다', href: `/sites/${s.id}?tab=domains` });
|
||||
if (lastDeploy && lastDeploy.status === 'failed') out.push({ code: 'deploy_failed', text: '마지막 배포가 실패했습니다. 재시도 또는 되돌리기', href: `/sites/${s.id}?tab=deploy` });
|
||||
if (s.pendingCleanup) out.push({ code: 'cleanup', text: `정리하지 않은 중간 결과가 있습니다 (예약 반환 예정 ${s.return_due_at || '-'})`, href: `/sites/${s.id}?tab=deploy` });
|
||||
if (s.deploy_mode === 'external') { const a = store.get('SELECT status FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', s.id); if (!a || a.status !== 'connected') out.push({ code: 'agent', text: '외부 서버 Agent 연결이 필요합니다', href: `/sites/${s.id}/external` }); }
|
||||
if (!s.deploy_mode && s.lifecycle === 'draft') out.push({ code: 'mode', text: '배포 방식을 선택하세요', href: `/sites/${s.id}/deploy/mode` });
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* 사이트 생성(§7.2). 초안이며 사이트 수를 쓰지 않는다. projectMode: new(Webmaker 프로젝트를 새로 만들어 연결) | existing(내 프로젝트 연결) | none(나중에).
|
||||
*/
|
||||
async function create({ user, group, ip = '' }, input) {
|
||||
const name = String(input.name || '').trim().slice(0, 120); if (!name) throw new HttpError(400, 'name_required', '사이트 이름을 입력하세요', { field: 'name' });
|
||||
const purpose = String(input.purpose || '').trim().slice(0, 300);
|
||||
const lang = /^[a-z]{2}(-[A-Z]{2})?$/.test(input.lang || '') ? input.lang : 'ko'; const timezone = String(input.timezone || 'Asia/Seoul').slice(0, 60);
|
||||
const deployMode = ['managed', 'external'].includes(input.deployMode) ? input.deployMode : null;
|
||||
const projectMode = ['new', 'existing', 'none'].includes(input.projectMode) ? input.projectMode : 'new';
|
||||
const id = newId('s_'); const now = nowIso(); const login = userLogin(user, group);
|
||||
let projectId = null; let projectNote = null;
|
||||
if (projectMode === 'existing') {
|
||||
const pid = String(input.projectId || ''); if (!/^[a-f0-9]{32}$/.test(pid)) throw new HttpError(400, 'project_required', '연결할 Webmaker 프로젝트를 고르세요', { field: 'projectId' });
|
||||
const p = await wm().getProject(pid).catch((e) => { throw new HttpError(e.status === 404 ? 404 : 502, e.code || 'webmaker_error', e.status === 404 ? '프로젝트를 찾을 수 없습니다' : 'Webmaker 에 연결할 수 없어 프로젝트를 확인하지 못했습니다'); });
|
||||
if (p.ownerId !== login) throw new HttpError(403, 'project_not_owned', '내 Webmaker 계정의 프로젝트만 연결할 수 있습니다');
|
||||
if (p.link && p.link.siteId && get(p.link.siteId)) throw new HttpError(409, 'project_linked', `이 프로젝트는 이미 다른 사이트(${p.link.siteName || p.link.siteId})에 연결되어 있습니다`);
|
||||
const other = store.get('SELECT id FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'', pid); if (other) throw new HttpError(409, 'project_linked', '이 프로젝트는 이미 다른 사이트에 연결되어 있습니다');
|
||||
projectId = pid;
|
||||
}
|
||||
store.run('INSERT INTO sites(id, group_id, name, purpose, webmaker_project_id, template, lang, timezone, lifecycle, deploy_mode, site_count_state, health, runtime, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,\'draft\',?,\'none\',\'unknown\',?,?,?,?)',
|
||||
id, group.id, name, purpose, projectId, String(input.template || '').slice(0, 80) || null, lang, timezone, deployMode, ['static', 'node'].includes(input.runtime) ? input.runtime : 'static', user.id, now, now);
|
||||
const link = { siteId: id, groupId: group.id, siteName: name, groupName: group.name, portalUrl: config.publicUrl };
|
||||
try {
|
||||
if (projectMode === 'new') { const p = await wm().createProject({ login, ownerName: user.display_name || user.email, title: name, mode: ['ai', 'manual', 'quick'].includes(input.buildMode) ? input.buildMode : 'manual', purpose, link }); projectId = p.id; save(id, { webmaker_project_id: projectId }); }
|
||||
else if (projectMode === 'existing') await wm().linkProject(projectId, link);
|
||||
} catch (e) { projectNote = e.code === 'service_unreachable' ? 'Webmaker 에 연결할 수 없어 프로젝트를 만들지 못했습니다. 제작 탭에서 다시 시도하세요' : `프로젝트 연결 실패: ${e.message}`; log2(projectNote); }
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'site', subjectId: id, action: 'site.create', detail: { name, projectMode, projectId, deployMode }, ip });
|
||||
return { site: get(id), nextSteps: nextSteps(get(id)), projectNote };
|
||||
}
|
||||
/** 생성 후 안내할 다음 단계(§7.2) */
|
||||
function nextSteps(s) {
|
||||
const steps = [{ id: 'build', label: 'Webmaker 에서 사이트 제작', href: `/sites/${s.id}?tab=build`, done: !!s.webmaker_project_id && s.lifecycle !== 'draft' }, { id: 'import', label: '콘텐츠 가져오기', href: `/sites/${s.id}?tab=build`, done: false, unsupported: '이번 버전에서 지원하지 않음' }, { id: 'domain', label: '도메인 연결', href: `/sites/${s.id}?tab=domains`, done: !!s.primary_domain }, { id: 'mode', label: '배포 방식 선택', href: `/sites/${s.id}/deploy/mode`, done: !!s.deploy_mode }, { id: 'perms', label: '구성원 권한 설정', href: `/sites/${s.id}?tab=settings`, done: false }];
|
||||
return steps;
|
||||
}
|
||||
/** 설정 변경(§8.2). 서비스 중단이 예상되는 항목(유지보수 모드·검색 공개)은 impact 를 함께 돌려준다 */
|
||||
function update(site, patch, actor) {
|
||||
if (!LIFECYCLE_MODIFIABLE.has(site.lifecycle)) throw new HttpError(409, 'site_not_modifiable', `지금 상태(${site.lifecycle})에서는 설정을 바꿀 수 없습니다`);
|
||||
const p = {}; const impact = [];
|
||||
if (patch.name !== undefined) { const n = String(patch.name).trim().slice(0, 120); if (!n) throw new HttpError(400, 'name_required', '사이트 이름을 입력하세요', { field: 'name' }); p.name = n; }
|
||||
if (patch.description !== undefined) p.description = String(patch.description).slice(0, 2000);
|
||||
if (patch.lang !== undefined && /^[a-z]{2}(-[A-Z]{2})?$/.test(patch.lang)) p.lang = patch.lang;
|
||||
if (patch.timezone !== undefined) p.timezone = String(patch.timezone).slice(0, 60);
|
||||
if (patch.maintenance !== undefined) { p.maintenance = patch.maintenance ? 1 : 0; if (site.lifecycle === 'live' && p.maintenance !== site.maintenance) impact.push(p.maintenance ? '유지보수 모드를 켜면 공개 주소에 안내 페이지가 보이고 방문자는 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '유지보수 모드를 끄면 즉시 공개됩니다'); }
|
||||
if (patch.searchVisible !== undefined) p.search_visible = patch.searchVisible ? 1 : 0;
|
||||
if (patch.runtime !== undefined && ['static', 'node'].includes(patch.runtime)) p.runtime = patch.runtime;
|
||||
if (Object.keys(p).length) { save(site.id, p); audit(store, { actorType: 'user', actorId: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.settings', detail: p }); }
|
||||
if (p.maintenance !== undefined && ['live', 'needs_domain'].includes(site.lifecycle) && site.deploy_mode === 'managed' && site.server_id) {
|
||||
// 유지보수 모드는 vhost 안내 페이지 전환 작업으로 적용한다(HAL domain.vhost maintenance)
|
||||
const { job } = jobs.enqueue({ kind: 'site.vhost', subject: { siteId: site.id, maintenance: !!p.maintenance }, idempotencyKey: `site.vhost:${site.id}:maint:${p.maintenance}:${Date.now() / 60000 | 0}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: p.maintenance ? '유지보수 모드 켜기' : '유지보수 모드 끄기' });
|
||||
return { site: get(site.id), impact, jobId: job.id };
|
||||
}
|
||||
return { site: get(site.id), impact };
|
||||
}
|
||||
/** 생명주기 전환(다른 영역 — 중지·해지·이전 — 이 부른다). 상태별 부수 효과: 공개 중지는 vhost 안내 페이지, 삭제는 프로젝트 연결 해제 */
|
||||
function setLifecycle(siteId, lifecycle, { actor = null, extra = {} } = {}) {
|
||||
if (!Object.values(SERVICE_STATE).includes(lifecycle)) throw new HttpError(400, 'bad_lifecycle', `알 수 없는 상태: ${lifecycle}`);
|
||||
const before = get(siteId); if (!before) throw new HttpError(404, 'not_found', '사이트가 없습니다');
|
||||
const p = { lifecycle, ...extra }; if (lifecycle === 'paused') p.paused_at = nowIso(); if (lifecycle === 'deleted') p.deleted_at = nowIso();
|
||||
const after = save(siteId, p);
|
||||
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: before.group_id, subjectType: 'site', subjectId: siteId, action: `site.lifecycle.${lifecycle}`, detail: { from: before.lifecycle, to: lifecycle }, automatic: !actor, immutable: true });
|
||||
return after;
|
||||
}
|
||||
/** 운영 사이트 수 반환 완료(§14 조건은 호출자가 검사) → 배정 삭제·상태 none */
|
||||
function returnSiteCount(siteId, actor = null) {
|
||||
const s = get(siteId); if (!s) return null;
|
||||
const a = quota.allocationOf(store, s.group_id, 'sites', 'site', s.id); if (a) quota.finishReturn(store, a.id);
|
||||
if (s.site_count_reservation_id) quota.release(store, s.site_count_reservation_id);
|
||||
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: s.group_id, subjectType: 'site', subjectId: s.id, action: 'site_count.returned', detail: { allocation: a ? a.id : null } });
|
||||
return save(siteId, { site_count_state: 'none', site_count_reservation_id: null, return_due_at: null });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Webmaker 연동
|
||||
function buildUrl({ user, group, site, path = null }) { return wm().ssoUrl({ user, group, site, projectId: site.webmaker_project_id, path }); }
|
||||
async function versionsFor(site) { if (!site.webmaker_project_id) return { project: null, versions: [] }; const p = await wm().getProject(site.webmaker_project_id); return { project: p, versions: (p.versionList || []).slice().reverse() }; }
|
||||
/** Webmaker 프로젝트 삭제 영향(Webmaker → 포털 /internal/webmaker/project-impact). blocked 면 Webmaker 가 폐기를 거부한다 */
|
||||
function impactForProject(projectId, siteId = null) {
|
||||
const s = siteId ? get(siteId) : row(store.get('SELECT * FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'', projectId));
|
||||
if (!s) return { blocked: false, liveSite: false, lifecycle: null, deployments: 0, domains: [], message: '연결된 운영 사이트가 없습니다' };
|
||||
const n = store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?', s.id).n; const domains = store.all('SELECT name FROM domains WHERE site_id=?', s.id).map((d) => d.name);
|
||||
const live = ['live', 'needs_domain', 'deploying', 'paused'].includes(s.lifecycle);
|
||||
return { blocked: live, liveSite: live, lifecycle: s.lifecycle, siteName: s.name, deployments: n, domains, message: live ? `사이트 "${s.name}" 이(가) ${s.lifecycle === 'paused' ? '일시 중지' : '운영'} 중이라 프로젝트를 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요. 운영 사이트·배포본·도메인은 프로젝트 폐기로 지워지지 않습니다` : `사이트 "${s.name}"(${s.lifecycle}) 의 제작 프로젝트 연결이 끊깁니다. 포털의 사이트·배포 기록(${n}건)·도메인(${domains.length}개)은 유지됩니다` };
|
||||
}
|
||||
/** Webmaker 가 프로젝트를 폐기한 뒤 연결 해제 */
|
||||
function onProjectDestroyed(projectId) { const s = row(store.get('SELECT * FROM sites WHERE webmaker_project_id=?', projectId)); if (s) save(s.id, { webmaker_project_id: null }); return !!s; }
|
||||
|
||||
// ------------------------------------------------------------------ 배포 전 확인(§11.1)
|
||||
function activeJobsFor(siteId) { return jobs.list({ subjectType: 'site', subjectId: siteId, status: ['queued', 'running', 'awaiting_external', 'verifying', 'reverting', 'needs_user'], limit: 20 }); }
|
||||
function externalAgentFor(siteId) { const a = store.get('SELECT * FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId); return a ? { ...a, lastDeploy: parseJson(a.last_deploy_json, null), lastCheck: parseJson(a.last_check_json, null) } : null; }
|
||||
/**
|
||||
* 배포 전 확인. items[] 마다 { id, title, state: 'pass'|'block'|'warn'|'info', text, href }. ok 는 block 이 없을 때.
|
||||
*/
|
||||
async function precheck(site, { versionRef = null } = {}) {
|
||||
const items = []; const add = (id, title, state, text, href = null) => items.push({ id, title, state, text, href });
|
||||
// 1 저장본
|
||||
let project = null; let bundleBytes = 0;
|
||||
if (!site.webmaker_project_id) add('version', '배포할 저장본', 'block', 'Webmaker 프로젝트가 연결되어 있지 않습니다', `/sites/${site.id}?tab=build`);
|
||||
else { try { project = await wm().getProject(site.webmaker_project_id); const vlist = project.versionList || []; const v = versionRef && versionRef !== 'current' ? vlist.find((x) => String(x.no) === String(versionRef)) : null; if (versionRef && versionRef !== 'current' && !v) add('version', '배포할 저장본', 'block', `저장본 #${versionRef} 이(가) 없습니다`, `/sites/${site.id}?tab=deploy`); else if (!project.hasContent && !v) add('version', '배포할 저장본', 'block', '아직 제작된 페이지가 없습니다. Webmaker 에서 홈 페이지를 만들고 저장하세요', `/sites/${site.id}?tab=build`); else add('version', '배포할 저장본', 'pass', v ? `저장본 #${v.no} (${v.message || v.kind}, ${v.at})` : `현재 저장 상태(rev ${project.rev})`); } catch (e) { add('version', '배포할 저장본', 'block', e.code === 'service_unreachable' ? 'Webmaker 상태를 확인할 수 없습니다(정보 확인 지연). 잠시 후 다시 시도하세요' : `저장본 확인 실패: ${e.message}`); } }
|
||||
// 2 운영 사이트 수
|
||||
const f = quota.figures(store, site.group_id, 'sites');
|
||||
if (site.site_count_state === 'in_use') add('sites', '운영 사이트 수', 'pass', '이 사이트는 이미 운영 사이트 수를 사용 중입니다');
|
||||
else if (site.site_count_state === 'reserved') add('sites', '운영 사이트 수', 'pass', '예약됨(배포와 대표 도메인 확인 뒤 사용 중으로 전환)');
|
||||
else if (f.unallocated > 0) add('sites', '운영 사이트 수', 'pass', `사용 가능 ${f.unallocated}개 (구매 ${f.purchased} · 사용 중 ${f.allocated} · 예약 ${f.reserved}) — 배포 시작 때 1개를 예약합니다`);
|
||||
else add('sites', '운영 사이트 수', 'block', f.purchased === 0 ? '운영 사이트 수를 포함한 상품을 먼저 구매·승인받아야 합니다' : `운영 사이트 수가 모두 사용 중입니다(구매 ${f.purchased}). 상품을 추가하거나 다른 사이트의 사이트 수를 반환하세요`, '/billing');
|
||||
// 3 배포 위치
|
||||
if (!site.deploy_mode) add('target', '배포 위치', 'block', '배포 방식을 먼저 선택하세요', `/sites/${site.id}/deploy/mode`);
|
||||
else if (site.deploy_mode === 'managed') { try { const picked = site.server_id ? { serverId: site.server_id } : await hal().pickServer({ role: 'web', needBytes: bundleBytes }); if (picked) add('target', '배포 위치', 'pass', site.server_id ? '현재 운영 서버에 배포합니다' : '배치 가능한 관리형 서버가 있습니다'); else add('target', '배포 위치', 'block', '지금 신규 배치를 받을 수 있는 서버가 없습니다. 잠시 후 다시 시도하거나 문의하세요'); } catch (e) { add('target', '배포 위치', 'block', 'HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정보 확인 지연). 기존 서비스는 유지되며 신규 배포는 복구 후 가능합니다'); } }
|
||||
else { const a = externalAgentFor(site.id); if (a && a.status === 'connected') add('target', '배포 위치', 'pass', `외부 서버 Agent 연결됨 (${a.label || a.hal_agent_id})`); else add('target', '배포 위치', 'block', a ? `외부 서버 Agent 가 아직 연결되지 않았습니다(${a.status})` : '외부 서버 Agent 를 먼저 연결하세요', `/sites/${site.id}/external`);
|
||||
if (!site.consent_version || site.consent_version !== EXTERNAL_CONSENT_VERSION) add('consent', '외부 배포 확인 사항 동의', 'block', '외부 배포 확인 사항(고객 책임·미제공 기능·건별 비용)에 동의해야 합니다', `/sites/${site.id}/deploy/mode`); else add('consent', '외부 배포 확인 사항 동의', 'pass', `동의함 (${site.consent_version})`); }
|
||||
// 4 디스크·DB 용량(관리형만)
|
||||
if (site.deploy_mode === 'managed') { const alloc = quota.allocationOf(store, site.group_id, 'web_disk', 'site', site.id); const wf = quota.figures(store, site.group_id, 'web_disk'); if (alloc && alloc.amount > 0) add('capacity', '디스크와 데이터베이스 용량', 'pass', `배정 ${fmtBytes(alloc.amount)} (사용 ${fmtBytes(usageOf(site, 'web_disk'))})`); else if (wf.unallocated > 0) add('capacity', '디스크와 데이터베이스 용량', 'warn', `이 사이트에 배정된 웹 디스크가 없습니다. 배포 시작 때 미배정량(${fmtBytes(wf.unallocated)})에서 기본량을 배정합니다`, '/usage'); else add('capacity', '디스크와 데이터베이스 용량', 'block', '배정할 수 있는 웹 디스크가 없습니다. 상품을 추가하거나 다른 사이트의 배정을 줄이세요', '/usage'); if (site.runtime === 'node') add('runtime', '동적 사이트 실행 형태', 'info', 'Ariweb 가 승인한 Node 실행 형태입니다. 필요한 저장 공간: 사이트 파일 + 업로드. 데이터베이스는 SQLite 파일만 지원(외부 DB·cron·메일 발송은 이번 버전에서 지원하지 않음)'); else add('runtime', '실행 형태', 'info', '정적 사이트(HTML·CSS·JS·이미지). 서버 측 코드는 실행되지 않습니다'); }
|
||||
// 5 도메인 상태
|
||||
const pd = site.primary_domain ? store.get('SELECT * FROM domains WHERE name=? AND site_id=?', site.primary_domain, site.id) : null;
|
||||
if (pd && pd.dns_state === 'ok' && pd.cert_state === 'valid') add('domain', '도메인 상태', 'pass', `대표 도메인 ${pd.name} 연결·HTTPS 정상`); else if (pd) add('domain', '도메인 상태', 'warn', `대표 도메인 ${pd.name} 의 DNS/HTTPS 가 아직 완료되지 않았습니다(DNS ${pd.dns_state}, 인증서 ${pd.cert_state}). 배포는 진행되며 완료 상태는 "도메인 설정 필요" 가 됩니다`, `/domains/${pd.id}`); else add('domain', '도메인 상태', 'warn', '대표 도메인이 없습니다. 배포 후 "도메인 설정 필요" 상태가 되며 플랫폼 확인 주소에서만 볼 수 있습니다', `/sites/${site.id}?tab=domains`);
|
||||
// 6 필수 설정
|
||||
if (['cancel_scheduled', 'readonly_retention', 'pending_delete', 'deleted', 'delete_hold'].includes(site.lifecycle)) add('lifecycle', '사이트 상태', 'block', `해지·보존 중인 사이트(${site.lifecycle})에는 배포할 수 없습니다`, `/sites/${site.id}/lifecycle`);
|
||||
// 7 다른 작업과 충돌
|
||||
const act = activeJobsFor(site.id).filter((j) => ['site.deploy', 'site.rollback', 'site.mode_change', 'backup.restore', 'site.relocate', 'site.cleanup'].includes(j.kind));
|
||||
if (act.length) add('conflict', '다른 작업과의 충돌', 'block', `진행 중인 작업이 있습니다: ${act.map((j) => `${j.label}(${j.status})`).join(', ')}`, `/sites/${site.id}?tab=deploy`); else add('conflict', '다른 작업과의 충돌', 'pass', '진행 중인 배포·복원·이전 작업이 없습니다');
|
||||
return { ok: !items.some((i) => i.state === 'block'), items, project: project ? { id: project.id, title: project.title, rev: project.rev, hasContent: project.hasContent } : null };
|
||||
}
|
||||
function usageOf(site, resource) { const u = quota.usageOf(store, site.group_id, resource, 'site', site.id); return u ? u.used : 0; }
|
||||
|
||||
// ------------------------------------------------------------------ 배포 시작·작업
|
||||
/**
|
||||
* 배포 시작(§11). 사전 확인 통과 → (최초 공개 배포면) 사이트 수 예약 → 웹 디스크 기본 배정 → deployments 행 → 작업 enqueue(멱등).
|
||||
*/
|
||||
async function startDeploy({ user, group, site, versionRef = 'current', versionLabel = null, ip = '' }) {
|
||||
// 멱등: 같은 사이트·저장본·배포 순번의 요청(버튼 연타·새로 고침)은 기존 작업을 돌려준다 — 사전 확인보다 먼저 본다(진행 중 작업 충돌로 409 가 나지 않게)
|
||||
const seq = store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?', site.id).n;
|
||||
const idem = `site.deploy:${site.id}:${versionRef}:${seq}`;
|
||||
const existing = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idem) || activeJobsFor(site.id).find((j) => j.kind === 'site.deploy' && String(j.subject.versionRef) === String(versionRef));
|
||||
if (existing) { const d = dep(store.get('SELECT * FROM deployments WHERE job_id=?', existing.id)); return { deployment: d, job: describeJob(jobs.get(existing.id)), created: false }; }
|
||||
const pc = await precheck(site, { versionRef });
|
||||
if (!pc.ok) throw new HttpError(409, 'precheck_failed', '배포 전 확인에서 차단된 항목이 있습니다', { items: pc.items });
|
||||
const now = nowIso(); const did = newId('d_');
|
||||
let reserved; try { reserved = store.tx(() => {
|
||||
let resId = site.site_count_reservation_id; let state = site.site_count_state;
|
||||
if (state === 'none') { const r = quota.reserve(store, { groupId: group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: site.id, reason: '최초 공개 배포', ttlMs: 0 }); resId = r.id; state = 'reserved'; }
|
||||
if (site.deploy_mode === 'managed' && !quota.allocationOf(store, group.id, 'web_disk', 'site', site.id)) { const wf = quota.figures(store, group.id, 'web_disk'); const def = Math.min(wf.unallocated, defaultDiskBytes(group.id)); if (def > 0) quota.setAllocation(store, { groupId: group.id, resource: 'web_disk', subjectType: 'site', subjectId: site.id, amount: def }); }
|
||||
store.run('INSERT INTO deployments(id, site_id, group_id, version_ref, version_label, mode, target_id, status, previous_version, started_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,\'queued\',?,?,?,?)', did, site.id, group.id, String(versionRef), versionLabel, site.deploy_mode, site.deploy_mode === 'managed' ? site.server_id : (externalAgentFor(site.id) || {}).hal_agent_id, site.current_version, user.id, now, now);
|
||||
save(site.id, { site_count_state: state, site_count_reservation_id: resId, lifecycle: site.lifecycle === 'draft' || site.lifecycle === 'building' ? 'deploying' : site.lifecycle });
|
||||
return { resId, state };
|
||||
}); } catch (e) {
|
||||
// 동시에 다른 사이트가 마지막 단위를 가져갔다(§13.3): 사전 확인과 같은 모양으로 부족 사유를 돌려준다
|
||||
if (e instanceof quota.QuotaError) throw new HttpError(409, 'precheck_failed', '배포 전 확인에서 차단된 항목이 있습니다', { items: pc.items.map((i) => (i.id === 'sites' || (i.id === 'capacity' && e.detail && e.detail.resource === 'web_disk') ? { ...i, state: 'block', text: e.message + (e.detail && e.detail.shortage ? ` (부족 ${e.detail.shortage})` : ''), href: i.id === 'sites' ? '/billing' : '/usage' } : i)) });
|
||||
throw e;
|
||||
}
|
||||
const { job } = jobs.enqueue({ kind: 'site.deploy', subject: { siteId: site.id, deploymentId: did, versionRef: String(versionRef), versionLabel, mode: site.deploy_mode }, idempotencyKey: idem, createdBy: user.id, actorType: 'user', groupId: group.id, subjectType: 'site', subjectId: site.id, label: `사이트 배포 (${versionLabel || (versionRef === 'current' ? '현재 저장 상태' : '저장본 #' + versionRef)})`, maxAttempts: 3 });
|
||||
store.run('UPDATE deployments SET job_id=? WHERE id=?', job.id, did);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'site', subjectId: site.id, action: 'deploy.start', detail: { deploymentId: did, versionRef, mode: site.deploy_mode, siteCount: reserved.state }, ip });
|
||||
return { deployment: dep(store.get('SELECT * FROM deployments WHERE id=?', did)), job: describeJob(job), created: true };
|
||||
}
|
||||
function defaultDiskBytes(groupId) { const s = store.get('SELECT value_json FROM settings WHERE key=\'sites.defaultDiskBytes\''); return s ? Number(parseJson(s.value_json, 0)) || 1024 ** 3 : 1024 ** 3; }
|
||||
/** 다운로드 토큰 만들기(Agent 가 저장본을 받아 갈 서명 URL). 1시간 유효 */
|
||||
function issueBundleToken(siteId, deploymentId, file, bytes) { const token = randomToken(24); store.run('INSERT INTO bundle_tokens(id, token_hash, site_id, deployment_id, file, bytes, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('bt_'), sha256(token), siteId, deploymentId, file, bytes, nowIso(), new Date(Date.now() + 3600000).toISOString()); return `${config.publicUrl.replace(/\/$/, '')}/internal/bundles/${token}`; }
|
||||
function bundleByToken(token) { const t = store.get('SELECT * FROM bundle_tokens WHERE token_hash=?', sha256(String(token || ''))); if (!t || Date.parse(t.expires_at) < Date.now()) return null; store.run('UPDATE bundle_tokens SET used_at=? WHERE id=?', nowIso(), t.id); return t; }
|
||||
/**
|
||||
* 단계에 묶인 HAL 작업 보장. 단계 결과(step.result.halJobId)에 HAL 작업 id 를 기억해 재진입(폴링 재개) 때 같은 작업을 보고,
|
||||
* 그 HAL 작업이 실패·취소로 끝났는데 다시 실행되는 경우(사용자 재시도)에만 새 멱등 키로 새 HAL 작업을 만든다.
|
||||
* @param {Function} factory (idemKey) => hal.createJob(...)
|
||||
*/
|
||||
async function halJobFor(ctx, job, stepId, factory, { fresh = false } = {}) {
|
||||
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
|
||||
// fresh: 사용자 재시도(이전 실행이 실패로 끝남) — 끝난 HAL 작업의 결과를 다시 쓰지 않고 새 작업을 만든다. 아니면 진행 중·완료된 이전 작업을 그대로 본다
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
|
||||
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`);
|
||||
if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); }
|
||||
return r.halJobId;
|
||||
}
|
||||
/** HAL 작업을 포털 작업 단계에 비추고, 끝날 때까지 5초 간격으로 다시 깨어난다(재진입). */
|
||||
async function awaitHal(ctx, halJobId, { mirror = [] } = {}) {
|
||||
let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; }
|
||||
if (mirror.length) { const st = ctx.steps(); for (const hs of hj.steps || []) { const ps = st.find((x) => x.id === hs.id); if (ps && ps.state !== 'done') { ps.state = hs.state === 'done' ? 'done' : hs.state === 'failed' ? 'failed' : hs.state === 'running' ? 'running' : ps.state; if (hs.detail) ps.detail = hs.detail; if (hs.state === 'done') ps.endedAt = hs.endedAt || nowIso(); } } ctx.flush(); }
|
||||
if (typeof hj.progress === 'number') ctx.progress(Math.min(95, Math.max(10, hj.progress)));
|
||||
if (['done'].includes(hj.status)) return hj;
|
||||
if (['failed', 'cancelled', 'reverted'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || `서버 작업이 ${hj.status} 상태로 끝났습니다`);
|
||||
if (hj.status === 'needs_user') ctx.needsUser(hj.error || '서버 작업에 운영자 확인이 필요합니다');
|
||||
ctx.retryLater(5000, '배포 서버 작업 진행 중');
|
||||
}
|
||||
/** 배포 작업 종류 등록 */
|
||||
jobs.register('site.deploy', { title: '사이트 배포', steps: DEPLOY_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const { siteId, deploymentId, versionRef } = job.subject; const site = get(siteId); if (!site) throw new JobError('site_missing', '사이트가 없습니다');
|
||||
store.run('UPDATE deployments SET status=?, updated_at=? WHERE id=?', 'running', nowIso(), deploymentId);
|
||||
await ctx.step('accept', () => ({ at: nowIso(), by: job.created_by }));
|
||||
const bundle = await ctx.step('prepare_version', async () => { mkdirSync(bundleDir, { recursive: true, mode: 0o700 }); const file = join(bundleDir, `${siteId}-${deploymentId}.tar.gz`); const r = await wm().exportVersion(site.webmaker_project_id, versionRef === 'current' ? null : versionRef, file); const url = issueBundleToken(siteId, deploymentId, file, r.bytes); return { file, bytes: r.bytes, version: r.version, commit: r.commit, url }; });
|
||||
const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name);
|
||||
// 배포 위치 준비 = 서버(또는 외부 Agent) 결정 + HAL 작업 등록. HAL 작업 멱등 키는 포털 작업 id 라 재진입해도 하나만 만들어진다
|
||||
const target = await ctx.step('prepare_target', async () => {
|
||||
if (site.deploy_mode === 'managed') { let serverId = site.server_id; if (!serverId) { const p = await hal().pickServer({ role: 'web', needBytes: bundle.bytes * 3 }); if (!p) throw new JobError('no_server', '지금 신규 배치를 받을 수 있는 서버가 없습니다', { retryable: true }); serverId = p.serverId; save(siteId, { server_id: serverId }); } return { kind: 'server', id: serverId }; }
|
||||
const a = externalAgentFor(siteId); if (!a || a.status !== 'connected') throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않습니다'); return { kind: 'external', id: a.hal_agent_id };
|
||||
});
|
||||
const retrying = (job.steps || []).some((x) => x.state === 'failed'); // 이전 실행이 실패한 뒤의 재시도면 새 HAL 작업
|
||||
const halJobId = await halJobFor(ctx, job, 'prepare_target', async (key) => { const r = await hal().createJob({ kind: 'site.deploy', target: target.id, idempotencyKey: key, groupId: site.group_id, subjectType: 'site', subjectId: siteId, payload: { siteId, versionRef: bundle.version === 'current' ? `rev-${bundle.commit || nowIso()}` : `v${bundle.version}`, bundleUrl: bundle.url, bundleSha256: null, runtime: site.runtime, env: {}, domains, primaryDomain: site.primary_domain, previousVersion: site.current_version, external: site.deploy_mode === 'external', maintenance: !!site.maintenance } }); store.run('UPDATE deployments SET hal_job_id=?, target_id=?, updated_at=? WHERE id=?', r.halJobId, target.id, nowIso(), deploymentId); return r; }, { fresh: retrying });
|
||||
const hj = await awaitHal(ctx, halJobId, { mirror: ['apply', 'data_check', 'start', 'internal_check', 'external_check'] });
|
||||
for (const id of ['apply', 'data_check', 'start', 'internal_check', 'external_check']) { const s = ctx.steps().find((x) => x.id === id); if (s && s.state !== 'done') { s.state = 'done'; s.endedAt = nowIso(); } } ctx.flush();
|
||||
const res = hj.result || {};
|
||||
const verify = await ctx.step('domain_https', async () => { const pd = site.primary_domain ? store.get('SELECT * FROM domains WHERE name=? AND site_id=?', site.primary_domain, siteId) : null; const ext = res.externalCheck && res.externalCheck.ok; const publicOk = !!(pd && pd.dns_state === 'ok' && pd.cert_state === 'valid' && ext); return { state: publicOk ? DEPLOY_VERIFY.PUBLIC : DEPLOY_VERIFY.NEEDS_DOMAIN, primaryDomain: pd ? pd.name : null, dns: pd ? pd.dns_state : null, cert: pd ? pd.cert_state : null, externalOk: !!ext, detail: publicOk ? `${pd.name} 에서 외부 확인 완료` : pd ? `대표 도메인 ${pd.name} 의 DNS(${pd.dns_state})/HTTPS(${pd.cert_state}) 가 아직 완료되지 않았습니다` : '대표 도메인이 없어 플랫폼 확인 주소에서만 볼 수 있습니다' }; });
|
||||
await ctx.step('done', () => { finishDeployment(site, deploymentId, { ...res, bundleCommit: bundle.commit || null, bundleVersion: bundle.version }, verify, job); return { version: res.version, verify: verify.state }; });
|
||||
try { if (bundle.file && existsSync(bundle.file)) unlinkSync(bundle.file); } catch (_) {}
|
||||
return { version: res.version, verify: verify.state, siteUrl: res.siteUrl, externalCheck: res.externalCheck };
|
||||
},
|
||||
async revert(job, ctx) { const { siteId, deploymentId } = job.subject; const site = get(siteId); const d = dep(store.get('SELECT * FROM deployments WHERE id=?', deploymentId)); if (!d || !d.previous_version) throw new JobError('no_previous', '되돌릴 이전 버전이 없습니다'); await ctx.step('rollback', async () => { const id = await halJobFor(ctx, job, 'rollback', (key) => hal().createJob({ kind: 'site.rollback', target: d.target_id, idempotencyKey: key, payload: { siteId, toVersion: d.previous_version } }), { fresh: (job.steps || []).some((x) => x.id === 'rollback' && x.state === 'failed') }); const hj = await awaitHal(ctx, id); save(siteId, { current_version: d.previous_version }); store.run('UPDATE deployments SET status=\'reverted\', updated_at=? WHERE id=?', nowIso(), deploymentId); return hj.result; }); } });
|
||||
/** 배포 완료 처리: deployments·sites 갱신, 사이트 수 확정(public 일 때), 알림·감사 */
|
||||
function finishDeployment(site, deploymentId, res, verify, job) {
|
||||
const now = nowIso(); const version = res.version || null;
|
||||
store.run('UPDATE deployments SET status=\'done\', verify_state=?, result_json=?, updated_at=?, ended_at=? WHERE id=?', verify.state, toJson({ ...res, verify }), now, now, deploymentId);
|
||||
const patch = { current_version: version, current_deployment_id: deploymentId, deploy_verify: verify.state, last_deploy_at: now, health: res.externalCheck && res.externalCheck.ok ? HEALTH.OK : (res.internalCheck && res.internalCheck.ok ? HEALTH.OK : HEALTH.UNKNOWN), health_checked_at: now, last_external_check_json: res.externalCheck || null, pending_cleanup_json: null, return_due_at: null, lifecycle: verify.state === DEPLOY_VERIFY.PUBLIC ? 'live' : 'needs_domain' };
|
||||
if (verify.state === DEPLOY_VERIFY.PUBLIC && site.site_count_state !== 'in_use') { const fresh = get(site.id); if (fresh.site_count_reservation_id) quota.commit(store, fresh.site_count_reservation_id); patch.site_count_state = 'in_use'; }
|
||||
save(site.id, patch);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'info', incidentKey: `deploy:${site.id}:${deploymentId}`, title: `사이트 "${site.name}" 배포 ${verify.state === 'public' ? '완료 — 공개 운영 중' : '검증 완료 — 도메인 설정 필요'}`, body: verify.detail, link: `/sites/${site.id}?tab=deploy`, subjectType: 'site', subjectId: site.id, actionRequired: verify.state === 'public' ? null : '대표 도메인 연결과 HTTPS 확인' });
|
||||
audit(store, { actorType: 'system', groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'deploy.done', detail: { deploymentId, version, verify: verify.state, jobId: job.id }, automatic: true });
|
||||
}
|
||||
/** 작업 실패 시 배포 기록 갱신(jobs.onChange 가 호출) */
|
||||
jobs.onChange((j) => {
|
||||
if (j.kind !== 'site.deploy' || !['failed', 'cancelled'].includes(j.status)) return;
|
||||
const { siteId, deploymentId } = j.subject; const site = get(siteId); if (!site) return;
|
||||
const d = store.get('SELECT status FROM deployments WHERE id=?', deploymentId); if (!d || ['failed', 'done', 'cancelled'].includes(d.status)) return;
|
||||
const now = nowIso(); store.run('UPDATE deployments SET status=?, result_json=?, updated_at=?, ended_at=? WHERE id=?', j.status, toJson({ error: j.error, errorCode: j.error_code, failedStep: (j.steps || []).find((s) => s.state === 'failed')?.id || null }), now, now, deploymentId);
|
||||
const patch = { lifecycle: site.current_version ? site.lifecycle : 'building' };
|
||||
// 최초 배포가 실패하면 예약은 남긴 채 '반환 예정 시각' 을 두고, 정리하지 않은 중간 결과를 표시한다(§11.2)
|
||||
if (site.site_count_state === 'reserved') { patch.return_due_at = new Date(Date.now() + RETURN_GRACE_MS).toISOString(); patch.pending_cleanup_json = { deploymentId, failedAt: now, leftovers: ['배포 위치의 중간 파일(있을 수 있음)'], message: '실패한 배포의 중간 결과가 남아 있을 수 있습니다. "지금 정리하고 반환" 을 누르면 정리 작업을 실행하고 사이트 수 예약을 반환합니다' }; }
|
||||
save(siteId, patch);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'warning', incidentKey: `deploy:${siteId}:${deploymentId}`, title: `사이트 "${site.name}" 배포 ${j.status === 'failed' ? '실패' : '취소'}`, body: `${j.error || ''} (문의 번호 ${j.id}). 기존 버전${site.current_version ? '은 유지됩니다' : '이 없어 공개되지 않았습니다'}`, link: `/sites/${siteId}?tab=deploy`, subjectType: 'site', subjectId: siteId, actionRequired: '재시도 또는 되돌리기' });
|
||||
});
|
||||
/** 중간 결과 정리 + 예약 반환(§11.2 "지금 정리하고 반환") */
|
||||
function cleanupAndReturn(site, actor) {
|
||||
if (site.site_count_state !== 'reserved') throw new HttpError(409, 'nothing_to_return', '반환할 예약이 없습니다');
|
||||
if (activeJobsFor(site.id).some((j) => j.kind === 'site.deploy')) throw new HttpError(409, 'deploy_running', '배포가 진행 중입니다. 먼저 취소하세요');
|
||||
const { job } = jobs.enqueue({ kind: 'site.cleanup', subject: { siteId: site.id, reservationId: site.site_count_reservation_id, targetId: site.server_id || (externalAgentFor(site.id) || {}).hal_agent_id || null }, idempotencyKey: `site.cleanup:${site.id}:${site.site_count_reservation_id}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: '중간 결과 정리와 사이트 수 반환' });
|
||||
return describeJob(job);
|
||||
}
|
||||
jobs.register('site.cleanup', { title: '중간 결과 정리', steps: [{ id: 'remove', title: '배포 위치의 중간 결과 제거' }, { id: 'return', title: '사이트 수 예약 반환' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) { const { siteId, reservationId, targetId } = job.subject; const site = get(siteId);
|
||||
const leftovers = await ctx.step('remove', async () => { if (!targetId) return { skipped: '배포 위치 없음' }; const id = await halJobFor(ctx, job, 'remove', (key) => hal().createJob({ kind: 'site.remove', target: targetId, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'remove' && x.state === 'failed') }); const hj = await awaitHal(ctx, id); return hj.result; });
|
||||
await ctx.step('return', () => { if (reservationId) quota.release(store, reservationId); save(siteId, { site_count_state: 'none', site_count_reservation_id: null, pending_cleanup_json: null, return_due_at: null, lifecycle: site.current_version ? site.lifecycle : 'building', server_id: site.current_version ? site.server_id : null }); audit(store, { actorType: 'user', actorId: job.created_by, groupId: site.group_id, subjectType: 'site', subjectId: siteId, action: 'site_count.reservation_returned', detail: { reservationId, leftovers } }); return { returned: true }; });
|
||||
return { leftovers };
|
||||
} });
|
||||
/** 유지보수 모드 전환(vhost 안내 페이지) */
|
||||
jobs.register('site.vhost', { title: 'vhost 적용', steps: [{ id: 'vhost', title: '웹서버 설정 적용' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) { const { siteId, maintenance } = job.subject; const site = get(siteId); const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name); return ctx.step('vhost', async () => { const id = await halJobFor(ctx, job, 'vhost', (key) => hal().createJob({ kind: 'domain.vhost', target: site.server_id, idempotencyKey: key, payload: { siteId, domains, primaryDomain: site.primary_domain, redirectOld: false, maintenance: !!maintenance } }), { fresh: (job.steps || []).some((x) => x.id === 'vhost' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); } });
|
||||
/** 되돌리기(§11.3): 이전 배포 버전으로. 데이터 영향 안내는 화면이 보여 주고 확인을 받는다 */
|
||||
function rollback(site, actor) {
|
||||
const last = dep(store.get('SELECT * FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 1', site.id));
|
||||
if (!last || !last.previous_version) throw new HttpError(409, 'no_previous', '되돌릴 이전 버전이 없습니다');
|
||||
if (activeJobsFor(site.id).some((j) => ['site.deploy', 'site.rollback'].includes(j.kind))) throw new HttpError(409, 'job_running', '배포 작업이 진행 중입니다');
|
||||
const { job } = jobs.enqueue({ kind: 'site.rollback', subject: { siteId: site.id, toVersion: last.previous_version, fromVersion: site.current_version, targetId: last.target_id }, idempotencyKey: `site.rollback:${site.id}:${site.current_version}:${last.previous_version}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `이전 버전(${last.previous_version})으로 되돌리기`, maxAttempts: 2 });
|
||||
audit(store, { actorType: 'user', actorId: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'deploy.rollback', detail: { to: last.previous_version, from: site.current_version } });
|
||||
return describeJob(job);
|
||||
}
|
||||
jobs.register('site.rollback', { title: '이전 버전으로 되돌리기', steps: [{ id: 'rollback', title: '이전 버전 전환' }, { id: 'external_check', title: '외부 접속 확인' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) { const { siteId, toVersion, targetId } = job.subject; const site = get(siteId);
|
||||
const r1 = await ctx.step('rollback', async () => { const id = await halJobFor(ctx, job, 'rollback', (key) => hal().createJob({ kind: 'site.rollback', target: targetId, idempotencyKey: key, payload: { siteId, toVersion } }), { fresh: (job.steps || []).some((x) => x.id === 'rollback' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
const chk = await ctx.step('external_check', async () => { const urls = site.primary_domain ? [`https://${site.primary_domain}/`] : []; if (!urls.length) return { skipped: '대표 도메인 없음' }; const id = await halJobFor(ctx, job, 'external_check', (key) => hal().createJob({ kind: 'site.check', target: targetId, idempotencyKey: key, payload: { siteId, urls } }), { fresh: (job.steps || []).some((x) => x.id === 'external_check' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
const now = nowIso(); save(siteId, { current_version: r1.version || toVersion, last_deploy_at: now, health_checked_at: now, last_external_check_json: chk && chk.checks ? chk.checks[0] : null });
|
||||
store.run('INSERT INTO deployments(id, site_id, group_id, version_ref, version_label, mode, target_id, job_id, status, verify_state, previous_version, result_json, started_by, created_at, updated_at, ended_at) VALUES(?,?,?,?,?,?,?,?,\'done\',?,?,?,?,?,?,?)', newId('d_'), siteId, site.group_id, String(toVersion), `되돌림 → ${toVersion}`, site.deploy_mode, targetId, job.id, site.deploy_verify, job.subject.fromVersion, toJson({ rollback: true, check: chk }), job.created_by, now, now, now);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'info', incidentKey: `rollback:${siteId}:${job.id}`, title: `사이트 "${site.name}" 를 이전 버전(${toVersion})으로 되돌렸습니다`, link: `/sites/${siteId}?tab=deploy`, subjectType: 'site', subjectId: siteId });
|
||||
return { version: r1.version || toVersion, check: chk };
|
||||
} });
|
||||
/** 대표 도메인 확인 완료 훅(domains 서비스가 부른다): 검증 완료 배포가 있으면 공개 운영 중으로 전환하고 사이트 수를 확정 */
|
||||
function onPrimaryDomainVerified(siteId, domainName) {
|
||||
const s = get(siteId); if (!s) return null;
|
||||
const patch = { primary_domain: domainName };
|
||||
if (s.current_version && s.deploy_verify !== DEPLOY_VERIFY.PUBLIC) { patch.deploy_verify = DEPLOY_VERIFY.PUBLIC; if (['needs_domain', 'deploying'].includes(s.lifecycle)) patch.lifecycle = 'live'; if (s.site_count_state === 'reserved' && s.site_count_reservation_id) { quota.commit(store, s.site_count_reservation_id); patch.site_count_state = 'in_use'; } if (s.current_deployment_id) store.run('UPDATE deployments SET verify_state=?, updated_at=? WHERE id=?', DEPLOY_VERIFY.PUBLIC, nowIso(), s.current_deployment_id); notify(store, { groupId: s.group_id, category: 'site', severity: 'info', incidentKey: `public:${siteId}`, title: `사이트 "${s.name}" 공개 운영 중 — ${domainName}`, link: `/sites/${siteId}`, subjectType: 'site', subjectId: siteId }); }
|
||||
return save(siteId, patch);
|
||||
}
|
||||
/** 대표 도메인이 해제·실패했을 때 */
|
||||
function onPrimaryDomainLost(siteId) { const s = get(siteId); if (!s) return null; const patch = { primary_domain: null }; if (s.lifecycle === 'live') { patch.lifecycle = 'needs_domain'; patch.deploy_verify = s.current_version ? DEPLOY_VERIFY.NEEDS_DOMAIN : s.deploy_verify; } return save(siteId, patch); }
|
||||
|
||||
// ------------------------------------------------------------------ 외부 배포(§10.2)
|
||||
function recordConsent(site, user, { kind = 'external', version = EXTERNAL_CONSENT_VERSION, ip = '' } = {}) { store.run('INSERT INTO site_consents(id, site_id, group_id, user_id, kind, version, text_hash, agreed_at, ip) VALUES(?,?,?,?,?,?,?,?,?)', newId('c_'), site.id, site.group_id, user.id, kind, version, sha256(JSON.stringify(EXTERNAL_CONSENT.items)), nowIso(), ip); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'consent.external', detail: { version }, ip, immutable: true }); return save(site.id, { consent_version: version }); }
|
||||
async function createExternalAgent(site, user, label = '') {
|
||||
const prev = externalAgentFor(site.id); if (prev && prev.status === 'connected') throw new HttpError(409, 'agent_exists', '이미 연결된 외부 서버 Agent 가 있습니다. 먼저 해제하세요');
|
||||
const r = await hal().issueExternalAgentToken({ groupId: site.group_id, siteId: site.id, label });
|
||||
if (prev) store.run('UPDATE external_agents SET revoked_at=?, status=\'revoked\' WHERE id=?', nowIso(), prev.id);
|
||||
const id = newId('xa_'); const now = nowIso();
|
||||
store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)', id, site.id, site.group_id, r.agentId, label || null, r.expiresAt, r.installCommand, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.issue', detail: { agentId: r.agentId, label } });
|
||||
// 토큰 원문은 이 응답에만 — 저장하지 않는다(§28.2 등록 정보 재표시 금지와 같은 원칙)
|
||||
return { agent: externalAgentFor(site.id), token: r.token, expiresAt: r.expiresAt, installCommand: r.installCommand.replace('<토큰>', r.token) };
|
||||
}
|
||||
async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { const s = await hal().getExternalAgent(a.hal_agent_id); store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?', s.status, s.lastSeenAt || a.last_seen_at, s.version || a.agent_version, s.lastDeploy ? toJson(s.lastDeploy) : null, nowIso(), a.id); return { ...externalAgentFor(site.id), stale: false }; } catch (e) { return { ...a, stale: true, staleReason: 'HAL 에 연결할 수 없습니다(정보 확인 지연)' }; } }
|
||||
async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), a.id); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.revoke', detail: { agentId: a.hal_agent_id } }); return true; }
|
||||
/** 외부 서버의 배포 제거(실패 잔여 파일 정리 포함) */
|
||||
function removeExternalDeploy(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(409, 'agent_missing', '외부 서버 Agent 가 없습니다'); const { job } = jobs.enqueue({ kind: 'site.remove_external', subject: { siteId: site.id, agentId: a.hal_agent_id }, idempotencyKey: `site.remove_external:${site.id}:${Date.now() / 60000 | 0}`, createdBy: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: '외부 서버 배포 제거' }); return describeJob(job); }
|
||||
jobs.register('site.remove_external', { title: '외부 서버 배포 제거', steps: [{ id: 'remove', title: '외부 서버에서 파일·실행 상태 제거' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { const { siteId, agentId } = job.subject; const r = await ctx.step('remove', async () => { const id = await halJobFor(ctx, job, 'remove', (key) => hal().createJob({ kind: 'site.remove', target: agentId, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'remove' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); const a = externalAgentFor(siteId); if (a) store.run('UPDATE external_agents SET last_deploy_json=?, updated_at=? WHERE id=?', toJson({ removedAt: nowIso(), leftovers: (r && r.leftovers) || [] }), nowIso(), a.id); save(siteId, { current_version: null, deploy_verify: null, lifecycle: 'building', pending_cleanup_json: null }); return r; } });
|
||||
/** 배포 방식 설정(처음 고르거나 바꿀 때). 운영 중 사이트의 변경은 작업(§10.3)으로 */
|
||||
async function setDeployMode(site, mode, user) {
|
||||
if (!['managed', 'external'].includes(mode)) throw new HttpError(400, 'bad_mode', '배포 방식은 managed 또는 external 입니다');
|
||||
if (site.deploy_mode === mode) return { site, changed: false };
|
||||
if (!site.current_version) { const s = save(site.id, { deploy_mode: mode, server_id: mode === 'external' ? null : site.server_id }); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.deploy_mode', detail: { from: site.deploy_mode, to: mode } }); return { site: s, changed: true }; }
|
||||
// 운영 중: 새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리
|
||||
if (activeJobsFor(site.id).length) throw new HttpError(409, 'job_running', '진행 중인 작업이 끝난 뒤 배포 방식을 바꿀 수 있습니다');
|
||||
if (mode === 'external') { const a = await refreshExternalAgent(site); if (!a || a.status !== 'connected') throw new HttpError(409, 'agent_not_connected', '외부 서버 Agent 를 먼저 연결하세요', { href: `/sites/${site.id}/external` }); }
|
||||
const id = newId('mc_'); const now = nowIso();
|
||||
const { job } = jobs.enqueue({ kind: 'site.mode_change', subject: { siteId: site.id, changeId: id, fromMode: site.deploy_mode, toMode: mode, fromTarget: site.deploy_mode === 'managed' ? site.server_id : (externalAgentFor(site.id) || {}).hal_agent_id, versionRef: 'current' }, idempotencyKey: `site.mode_change:${site.id}:${mode}:${store.get('SELECT COUNT(*) AS n FROM deploy_mode_changes WHERE site_id=?', site.id).n}`, createdBy: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `배포 방식 변경 (${site.deploy_mode} → ${mode})`, maxAttempts: 2 });
|
||||
store.run('INSERT INTO deploy_mode_changes(id, site_id, group_id, from_mode, to_mode, from_target, status, job_id, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,\'queued\',?,?,?,?)', id, site.id, site.group_id, site.deploy_mode, mode, site.deploy_mode === 'managed' ? site.server_id : null, job.id, user.id, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.deploy_mode.change', detail: { from: site.deploy_mode, to: mode, jobId: job.id } });
|
||||
return { site, changed: false, job: describeJob(job) };
|
||||
}
|
||||
jobs.register('site.mode_change', { title: '배포 방식 변경', steps: [{ id: 'prepare_new', title: '새 위치 준비' }, { id: 'deploy_new', title: '새 위치에 별도 배포' }, { id: 'verify_new', title: '새 위치 검증' }, { id: 'switch_domain', title: '도메인 전환' }, { id: 'cleanup_old', title: '이전 위치 정리' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const { siteId, changeId, toMode, fromTarget } = job.subject; const site = get(siteId);
|
||||
const newTarget = await ctx.step('prepare_new', async () => { if (toMode === 'managed') { const p = await hal().pickServer({ role: 'web', needBytes: 0 }); if (!p) throw new JobError('no_server', '신규 배치를 받을 수 있는 서버가 없습니다', { retryable: true }); return p.serverId; } const a = externalAgentFor(siteId); if (!a || a.status !== 'connected') throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않습니다'); return a.hal_agent_id; });
|
||||
const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name);
|
||||
const bundle = await ctx.step('deploy_new', async () => { mkdirSync(bundleDir, { recursive: true, mode: 0o700 }); const file = join(bundleDir, `${siteId}-${changeId}.tar.gz`); const ex = await wm().exportVersion(site.webmaker_project_id, null, file); const url = issueBundleToken(siteId, null, file, ex.bytes); const id = await halJobFor(ctx, job, 'deploy_new', (key) => hal().createJob({ kind: 'site.deploy', target: newTarget, idempotencyKey: key, payload: { siteId, versionRef: site.current_version || `rev-${nowIso()}`, bundleUrl: url, runtime: site.runtime, env: {}, domains: [], primaryDomain: null, previousVersion: null, external: toMode === 'external' } }), { fresh: (job.steps || []).some((x) => x.id === 'deploy_new' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
await ctx.step('verify_new', async () => { if (!(bundle.internalCheck && bundle.internalCheck.ok)) throw new JobError('verify_failed', '새 위치 내부 확인에 실패했습니다 — 현재 위치를 유지합니다'); return bundle.internalCheck; });
|
||||
await ctx.step('switch_domain', async () => { if (!domains.length) return { skipped: '연결된 도메인 없음' }; const id = await halJobFor(ctx, job, 'switch_domain', (key) => hal().createJob({ kind: 'domain.vhost', target: newTarget, idempotencyKey: key, payload: { siteId, domains, primaryDomain: site.primary_domain, redirectOld: false, maintenance: false } }), { fresh: (job.steps || []).some((x) => x.id === 'switch_domain' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
save(siteId, { deploy_mode: toMode, server_id: toMode === 'managed' ? newTarget : null, external_agent_id: toMode === 'external' ? newTarget : null });
|
||||
await ctx.step('cleanup_old', async () => { if (!fromTarget) return { skipped: true }; const id = await halJobFor(ctx, job, 'cleanup_old', (key) => hal().createJob({ kind: 'site.remove', target: fromTarget, idempotencyKey: key, payload: { siteId, keepData: true } }), { fresh: (job.steps || []).some((x) => x.id === 'cleanup_old' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
store.run('UPDATE deploy_mode_changes SET status=\'done\', to_target=?, updated_at=?, ended_at=? WHERE id=?', newTarget, nowIso(), nowIso(), changeId);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'info', incidentKey: `modechange:${siteId}:${changeId}`, title: `사이트 "${site.name}" 배포 방식을 ${toMode === 'managed' ? '관리형' : '외부'} 으로 바꿨습니다`, link: `/sites/${siteId}?tab=deploy`, subjectType: 'site', subjectId: siteId });
|
||||
return { newTarget };
|
||||
},
|
||||
async revert(job, ctx) { const { siteId, changeId } = job.subject; const st = job.steps || []; const target = st.find((s) => s.id === 'prepare_new')?.result; await ctx.step('revert_new', async () => { if (!target) return { skipped: true }; const id = await halJobFor(ctx, job, 'revert_new', (key) => hal().createJob({ kind: 'site.remove', target, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'revert_new' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); store.run('UPDATE deploy_mode_changes SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), changeId); } });
|
||||
jobs.onChange((j) => { if (j.kind === 'site.mode_change' && ['failed', 'cancelled'].includes(j.status)) { store.run('UPDATE deploy_mode_changes SET status=?, updated_at=? WHERE id=?', j.status, nowIso(), j.subject.changeId); const s = get(j.subject.siteId); if (s) notify(store, { groupId: s.group_id, category: 'site', severity: 'warning', incidentKey: `modechange:${s.id}:${j.subject.changeId}`, title: `사이트 "${s.name}" 배포 방식 변경 실패 — 현재 위치를 유지합니다`, body: j.error, link: `/sites/${s.id}?tab=deploy`, subjectType: 'site', subjectId: s.id, actionRequired: '되돌리기 또는 재시도' }); } });
|
||||
|
||||
// ------------------------------------------------------------------ 사용량·건강(§8.1·§13.1)
|
||||
/** 관리형 운영 사이트의 사용량·건강을 10분 단위 멱등 작업으로 모은다. 화면이 열릴 때와 주기 작업이 부른다 */
|
||||
function refresh(groupId, { force = false } = {}) {
|
||||
const sites = store.all('SELECT id, server_id, primary_domain FROM sites WHERE group_id=? AND deploy_mode=\'managed\' AND current_version IS NOT NULL AND lifecycle IN (\'live\',\'needs_domain\',\'paused\')', groupId);
|
||||
if (!sites.length) return null; const bucket = force ? Date.now() : Math.floor(Date.now() / USAGE_BUCKET_MS);
|
||||
const { job } = jobs.enqueue({ kind: 'site.usage', subject: { groupId, siteIds: sites.map((s) => s.id) }, idempotencyKey: `site.usage:${groupId}:${bucket}`, actorType: 'system', groupId, subjectType: 'group', subjectId: groupId, label: '사이트 사용량·건강 수집' });
|
||||
return describeJob(job);
|
||||
}
|
||||
jobs.register('site.usage', { title: '사용량 수집', steps: [{ id: 'usage', title: '서버 사용량 수집' }, { id: 'health', title: '외부 접속 확인' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const { groupId, siteIds } = job.subject; const bySrv = {}; for (const id of siteIds) { const s = get(id); if (s && s.server_id) (bySrv[s.server_id] = bySrv[s.server_id] || []).push(s); }
|
||||
await ctx.step('usage', async () => { const out = {}; for (const [srv, list] of Object.entries(bySrv)) { const r = await hal().createJob({ kind: 'site.usage', target: srv, idempotencyKey: `portal:${job.id}:${srv}`, payload: { siteIds: list.map((s) => s.id) } }); const hj = await awaitHal(ctx, r.halJobId); for (const [sid, u] of Object.entries((hj.result && hj.result.sites) || {})) { recordUsage(groupId, sid, u); out[sid] = true; } } return { collected: Object.keys(out).length }; });
|
||||
await ctx.step('health', async () => { let n = 0; for (const [srv, list] of Object.entries(bySrv)) { const urls = list.filter((s) => s.primary_domain).map((s) => `https://${s.primary_domain}/`); if (!urls.length) continue; const r = await hal().createJob({ kind: 'site.check', target: srv, idempotencyKey: `portal:${job.id}:check:${srv}`, payload: { urls } }); const hj = await awaitHal(ctx, r.halJobId); for (const c of (hj.result && hj.result.checks) || []) { const s = list.find((x) => c.url.includes(x.primary_domain)); if (!s) continue; const h = !c.ok ? HEALTH.DOWN : c.ms > 3000 ? HEALTH.DEGRADED : HEALTH.OK; save(s.id, { health: h, health_checked_at: nowIso(), last_external_check_json: c }); if (h !== HEALTH.OK) notify(store, { groupId, category: 'incident', severity: h === HEALTH.DOWN ? 'critical' : 'warning', incidentKey: `health:${s.id}`, title: `사이트 "${s.name}" 외부 접속 ${h === HEALTH.DOWN ? '중단' : '성능 저하'}`, body: `${c.url} 응답 ${c.status || '없음'} (${c.ms}ms)`, link: `/sites/${s.id}`, subjectType: 'site', subjectId: s.id, mandatory: h === HEALTH.DOWN }); else { const { resolveIncident } = await import('../../lib/notify.mjs'); resolveIncident(store, `health:${s.id}`, '외부 확인 정상 복구'); } n++; } } return { checked: n }; });
|
||||
return { sites: siteIds.length };
|
||||
} });
|
||||
/** HAL 사용량 결과 → usage_samples + quota 사용량 */
|
||||
function recordUsage(groupId, siteId, u) {
|
||||
const at = u.measuredAt || nowIso(); const ps = monthStart(at);
|
||||
const ins = (metric, value, period = null) => store.run('INSERT INTO usage_samples(id, group_id, subject_type, subject_id, metric, value, period, measured_at, source) VALUES(?,?,?,?,?,?,?,?,?)', newId('us_'), groupId, 'site', siteId, metric, Math.round(value || 0), period, at, 'hal');
|
||||
ins('disk_bytes', u.diskBytes); ins('db_bytes', u.dbBytes); ins('uploads_bytes', u.uploadsBytes); ins('out_bytes_day', u.outBytesDay, at.slice(0, 10)); ins('out_bytes_month', u.outBytesMonth, ps); ins('out_bytes_total', u.outBytesTotal); ins('in_bytes_day', u.inBytesDay, at.slice(0, 10)); ins('in_bytes_month', u.inBytesMonth, ps); ins('in_bytes_total', u.inBytesTotal); ins('req_24h', u.req24h); ins('pv_24h', u.pv24h); ins('error_rate_x1000', (u.errorRate || 0) * 1000); ins('p95_ms', u.p95ms);
|
||||
store.run('INSERT INTO usage_samples(id, group_id, subject_type, subject_id, metric, value, period, measured_at, source) VALUES(?,?,?,?,?,?,?,?,?)', newId('us_'), groupId, 'site', siteId, 'series_json', 0, toJson({ series7d: u.series7d || [], series30d: u.series30d || [] }), at, 'hal');
|
||||
quota.recordUsage(store, { groupId, resource: 'web_disk', subjectType: 'site', subjectId: siteId, used: (u.diskBytes || 0) + (u.dbBytes || 0) + (u.uploadsBytes || 0), source: 'hal', measuredAt: at });
|
||||
quota.recordUsage(store, { groupId, resource: 'web_out', subjectType: 'site', subjectId: siteId, used: u.outBytesMonth || 0, periodStart: ps, source: 'hal', measuredAt: at });
|
||||
}
|
||||
function latestMetric(siteId, metric) { return store.get('SELECT value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric=? ORDER BY measured_at DESC LIMIT 1', siteId, metric) || null; }
|
||||
/** 사이트 개요 데이터(§8.1). 관리형/외부/미배포를 구분해 보일 값만 돌려준다 */
|
||||
function overview(site) {
|
||||
const lastDeploy = dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1', site.id));
|
||||
const base = { id: site.id, name: site.name, lifecycle: site.lifecycle, deployMode: site.deploy_mode, runtime: site.runtime, primaryDomain: site.primary_domain, currentVersion: site.current_version, lastDeployAt: site.last_deploy_at, deployVerify: site.deploy_verify, siteCountState: site.site_count_state, returnDueAt: site.return_due_at, pendingCleanup: site.pendingCleanup, lastDeploy: lastDeploy ? { id: lastDeploy.id, status: lastDeploy.status, verify: lastDeploy.verify_state, at: lastDeploy.created_at, version: lastDeploy.version_label || lastDeploy.version_ref, jobId: lastDeploy.job_id } : null, nextJob: nextJobFor(site), actionNeeded: actionNeeded(site, lastDeploy) };
|
||||
if (!site.current_version) return { ...base, kind: 'not_deployed', readiness: nextSteps(site), message: '아직 정상 배포되지 않은 사이트입니다. 통계는 배포 뒤에 보입니다' };
|
||||
if (site.deploy_mode === 'external') { const a = externalAgentFor(site.id); return { ...base, kind: 'external', agent: a ? { status: a.status, label: a.label, lastSeenAt: a.last_seen_at, version: a.agent_version } : null, lastDeployResult: lastDeploy && lastDeploy.result ? { ok: lastDeploy.status === 'done', at: lastDeploy.ended_at, version: lastDeploy.result.version, externalCheck: lastDeploy.result.externalCheck || null } : null, lastExternalCheck: site.lastExternalCheck, customerResponsibility: EXTERNAL_CONSENT.items.slice(0, 2), notProvided: ['정상 여부(uptime)', '관리형 디스크·데이터베이스 사용량', '네트워크 트래픽', '접속 통계', '백업 보호 상태', '서버 이전'] }; }
|
||||
const disk = latestMetric(site.id, 'disk_bytes'); const db = latestMetric(site.id, 'db_bytes'); const up = latestMetric(site.id, 'uploads_bytes'); const alloc = quota.allocationOf(store, site.group_id, 'web_disk', 'site', site.id); const outAlloc = quota.allocationOf(store, site.group_id, 'web_out', 'site', site.id);
|
||||
const series = latestMetric(site.id, 'series_json'); const measuredAt = disk ? disk.measured_at : null; const stale = isStale(measuredAt, 30 * 60 * 1000);
|
||||
const m = (k) => { const r = latestMetric(site.id, k); return r ? r.value : null; };
|
||||
return { ...base, kind: 'managed', health: isStale(site.health_checked_at, 15 * 60 * 1000) ? HEALTH.STALE : site.health, healthCheckedAt: site.health_checked_at, measuredAt, stale, staleReason: stale ? '마지막 수집으로부터 30분이 지났습니다(정보 확인 지연)' : null,
|
||||
visits24h: m('pv_24h'), requests24h: m('req_24h'), series: series ? parseJson(series.period, { series7d: [], series30d: [] }) : { series7d: [], series30d: [] },
|
||||
disk: { used: disk ? disk.value : null, db: db ? db.value : null, uploads: up ? up.value : null, limit: alloc ? alloc.amount : null, percent: alloc && disk ? quota.percent((disk.value || 0) + (db ? db.value : 0) + (up ? up.value : 0), alloc.amount) : null },
|
||||
network: { out: { day: m('out_bytes_day'), month: m('out_bytes_month'), total: m('out_bytes_total'), limit: outAlloc ? outAlloc.amount : null }, in: { day: m('in_bytes_day'), month: m('in_bytes_month'), total: m('in_bytes_total') } },
|
||||
errors: { errorRate: m('error_rate_x1000') != null ? m('error_rate_x1000') / 1000 : null, p95ms: m('p95_ms') }, lastExternalCheck: site.lastExternalCheck };
|
||||
}
|
||||
function nextJobFor(site) { const j = jobs.list({ subjectType: 'site', subjectId: site.id, status: ['queued', 'awaiting_external'], limit: 1 })[0]; if (j) return { id: j.id, label: j.label, at: j.next_run_at, status: j.status }; const b = store.get('SELECT next_run_at FROM backup_options WHERE site_id=? AND status=\'active\'', site.id); return b && b.next_run_at ? { label: '정기 백업', at: b.next_run_at } : null; }
|
||||
function deployments(siteId, limit = 20) { return store.all('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT ?', siteId, limit).map((d) => ({ ...dep(d), job: d.job_id ? describeJob(jobs.get(d.job_id)) : null })); }
|
||||
/** 주기 작업: 반환 예정 시각이 지난 예약 반환, 오래된 SSO 기록·번들 토큰 정리 */
|
||||
function sweep(now = Date.now()) {
|
||||
const due = store.all('SELECT * FROM sites WHERE site_count_state=\'reserved\' AND return_due_at IS NOT NULL AND return_due_at<=?', new Date(now).toISOString()).map(row); let returned = 0;
|
||||
for (const s of due) { if (activeJobsFor(s.id).length) continue; if (s.site_count_reservation_id) quota.release(store, s.site_count_reservation_id); save(s.id, { site_count_state: 'none', site_count_reservation_id: null, return_due_at: null, pending_cleanup_json: s.pendingCleanup ? { ...s.pendingCleanup, autoReturnedAt: nowIso() } : null }); notify(store, { groupId: s.group_id, category: 'site', severity: 'info', incidentKey: `return:${s.id}`, title: `사이트 "${s.name}" 의 사이트 수 예약이 반환되었습니다`, body: '정리하지 않은 중간 결과가 있으면 배포 화면에서 정리하세요', link: `/sites/${s.id}?tab=deploy`, subjectType: 'site', subjectId: s.id }); returned++; }
|
||||
const cut = new Date(now - 7 * 86400000).toISOString(); store.run('DELETE FROM sso_tickets WHERE expires_at<?', cut); for (const t of store.all('SELECT * FROM bundle_tokens WHERE expires_at<?', new Date(now - 86400000).toISOString())) { try { if (existsSync(t.file)) unlinkSync(t.file); } catch (_) {} store.run('DELETE FROM bundle_tokens WHERE id=?', t.id); }
|
||||
return { returned };
|
||||
}
|
||||
function fmtBytes(n) { if (n == null) return '-'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; }
|
||||
return { DEPLOY_STEPS, EXTERNAL_CONSENT, get, getForGroup, list, summarize, create, update, setLifecycle, returnSiteCount, nextSteps, buildUrl, versionsFor, impactForProject, onProjectDestroyed, precheck, startDeploy, cleanupAndReturn, rollback, onPrimaryDomainVerified, onPrimaryDomainLost, recordConsent, createExternalAgent, refreshExternalAgent, revokeExternalAgent, removeExternalDeploy, externalAgentFor, setDeployMode, refresh, recordUsage, overview, deployments, activeJobsFor, bundleByToken, sweep, userLogin, fmtBytes, save };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* portal/sites/ui.mjs — 사이트 화면 HTML 뼈대(목록·생성·상세 탭·배포·배포 방식·외부 배포). 데이터는 /ui/sites/sites.js 가 /api/sites/* 로 채운다.
|
||||
* 각 화면은 data-page 속성으로 스크립트가 어떤 화면인지 알고, 모든 화면에 공통 상태 패널(불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음)이 있다.
|
||||
*/
|
||||
import { esc } from '../../lib/http.mjs';
|
||||
import { t } from '../../lib/i18n.mjs';
|
||||
|
||||
const TABS = ['overview', 'build', 'source', 'deploy', 'domains', 'mail', 'backup', 'usage', 'activity', 'settings'];
|
||||
export function listPage(ctx, deps, { filter = '' } = {}) {
|
||||
const filters = ['all', 'draft', 'building', 'deploying', 'live', 'paused', 'cancel_pending'].map((f) => `<a class="btn${(filter || 'all') === f ? ' primary' : ''}" href="/sites${f === 'all' ? '' : '?filter=' + f}">${esc(t('sites.filters.' + f))}</a>`).join('');
|
||||
return shell(ctx, deps, { title: t('sites.title'), nav: 'sites', crumbs: [{ label: t('nav.dashboard'), href: '/' }, { label: t('sites.title') }], actions: [{ label: t('sites.new'), href: '/sites/new', kind: 'primary' }], body: `<div class="actions" style="margin-bottom:12px">${filters}</div><div id="list" data-page="sites-list" data-filter="${esc(filter)}">${deps.statePanel('loading')}</div>` });
|
||||
}
|
||||
export function newPage(ctx, deps) {
|
||||
return shell(ctx, deps, { title: t('sites.new'), nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: t('sites.new') }], body: `<form id="newSite" class="card" data-page="sites-new" data-dirty-guard>
|
||||
<p class="hint">${esc(t('sites.create.noSiteCount'))}</p>
|
||||
<div class="f"><label for="name">${esc(t('sites.create.name'))} <span class="muted">(${esc(t('common.required'))})</span></label><input id="name" name="name" required maxlength="120" placeholder="예: 우리 회사 홈페이지"><span class="hint">사이트 목록과 알림에 보이는 이름입니다</span><span class="err" data-err="name"></span></div>
|
||||
<div class="f"><label for="purpose">${esc(t('sites.create.purpose'))}</label><input id="purpose" name="purpose" maxlength="300" placeholder="예: 회사 소개와 문의 접수"><span class="hint">Webmaker 의 기획 단계에 참고 정보로 전달됩니다</span></div>
|
||||
<fieldset class="f"><legend>${esc(t('sites.create.project'))}</legend><label><input type="radio" name="projectMode" value="new" checked> ${esc(t('sites.create.projectNew'))}</label><label><input type="radio" name="projectMode" value="existing"> ${esc(t('sites.create.projectExisting'))}</label><label><input type="radio" name="projectMode" value="none"> ${esc(t('sites.create.projectNone'))}</label>
|
||||
<div id="projectPick" hidden><select name="projectId" id="projectId"><option value="">불러오는 중…</option></select><span class="hint">내 Webmaker 계정의 프로젝트만 보입니다. 이미 다른 사이트에 연결된 프로젝트는 고를 수 없습니다</span><span class="err" data-err="projectId"></span></div>
|
||||
<div id="buildModePick"><label for="buildMode">제작 방식</label><select id="buildMode" name="buildMode"><option value="manual">내가 고르기 (단계별 선택)</option><option value="ai">AI에게 맡기기</option><option value="quick">제목·내용 입력형</option></select><span class="hint">Webmaker 의 기존 제작 방식 그대로입니다</span></div></fieldset>
|
||||
<div class="f"><label for="template">${esc(t('sites.create.template'))}</label><select id="template" name="template"><option value="">선택 안 함 (Webmaker 에서 고르기)</option></select><span class="hint">템플릿 목록은 Webmaker 제작 화면에서 그대로 제공됩니다</span></div>
|
||||
<div class="f"><label for="lang">${esc(t('sites.create.lang'))}</label><select id="lang" name="lang"><option value="ko">한국어</option><option value="en">English</option><option value="ja">日本語</option><option value="zh">中文</option></select></div>
|
||||
<div class="f"><label for="timezone">${esc(t('sites.create.timezone'))}</label><select id="timezone" name="timezone"><option value="Asia/Seoul">Asia/Seoul (KST)</option><option value="UTC">UTC</option><option value="Asia/Tokyo">Asia/Tokyo</option><option value="America/New_York">America/New_York</option></select></div>
|
||||
<fieldset class="f"><legend>${esc(t('sites.create.deployMode'))}</legend><label><input type="radio" name="deployMode" value="" checked> ${esc(t('sites.create.modeLater'))}</label><label><input type="radio" name="deployMode" value="managed"> ${esc(t('sites.mode.managed'))} — ${esc(t('sites.mode.managedDesc'))}</label><label><input type="radio" name="deployMode" value="external"> ${esc(t('sites.mode.external'))} — ${esc(t('sites.mode.externalDesc'))}</label></fieldset>
|
||||
<div class="actions"><button class="btn primary" type="submit">${esc(t('common.create'))}</button><a class="btn" href="/sites">${esc(t('common.cancel'))}</a></div><div class="err" data-err="form"></div></form><div id="created" class="card" hidden></div>` });
|
||||
}
|
||||
export function detailPage(ctx, deps, site, { tab = 'overview' } = {}) {
|
||||
if (!TABS.includes(tab)) tab = 'overview';
|
||||
const tabs = TABS.map((k) => `<a class="${tab === k ? 'on' : ''}" href="/sites/${esc(site.id)}?tab=${k}">${esc(t('sites.tabs.' + k))}</a>`).join('');
|
||||
const bodies = {
|
||||
overview: `<div id="overview" data-page="site-overview">${deps.statePanel('loading')}</div>`,
|
||||
build: `<div class="card" data-page="site-build"><h2>Webmaker 제작</h2><p>제작·AI 생성·페이지 편집·미리보기·저장·이전 저장본 조회는 Webmaker 화면에서 그대로 합니다(별도 로그인 없음). 현재 그룹과 사이트 이름이 Webmaker 상단에 표시됩니다.</p><div class="actions"><a class="btn primary" href="/sso/webmaker?site=${esc(site.id)}" target="_blank" rel="noopener">제작 화면 열기</a><a class="btn" href="/sso/webmaker?site=${esc(site.id)}&path=/domains" target="_blank" rel="noopener">도메인·DNS 관리(Webmaker)</a></div><div id="buildInfo">${deps.statePanel('loading')}</div><p class="hint">이 화면의 "프로젝트" 는 운영 사이트와 다릅니다. 프로젝트를 지워도 운영 사이트·배포본·도메인은 지워지지 않습니다. 콘텐츠 가져오기(기존 사이트 복사·가져오기)는 이번 버전에서 지원하지 않습니다.</p><div id="wallet"></div></div>`,
|
||||
source: `<div id="source" data-page="site-source">${deps.statePanel('loading')}</div>`,
|
||||
deploy: `<div data-page="site-deploy"><div id="deployCurrent" class="card">${deps.statePanel('loading')}</div><div id="deployStart" class="card"></div><div id="deployHistory" class="card"></div></div>`,
|
||||
domains: `<div data-page="site-domains" class="card"><div class="actions" style="margin-bottom:10px"><a class="btn primary" href="/domains/connect?site=${esc(site.id)}">도메인 연결</a><a class="btn" href="/domains">도메인 목록</a></div><div id="siteDomains">${deps.statePanel('loading')}</div></div>`,
|
||||
mail: `<div class="card"><h2>메일</h2><p>사이트 대표 메일과 메일함은 메일 화면에서 관리합니다.</p><a class="btn primary" href="/mail?site=${esc(site.id)}">메일 화면으로</a></div>`,
|
||||
backup: `<div class="card"><h2>백업</h2><p>관리형 사이트 백업 옵션·보관 목록·복원 요청은 백업 화면에서 관리합니다.</p><a class="btn primary" href="/backup?site=${esc(site.id)}">백업 화면으로</a></div>`,
|
||||
usage: `<div id="usage" data-page="site-usage">${deps.statePanel('loading')}</div>`,
|
||||
activity: `<div id="activity" data-page="site-activity">${deps.statePanel('loading')}</div>`,
|
||||
settings: `<div data-page="site-settings"><form id="settings" class="card" data-dirty-guard><h2>사이트 설정</h2>
|
||||
<div class="f"><label for="s_name">사이트 이름</label><input id="s_name" name="name" value="${esc(site.name)}" maxlength="120"></div>
|
||||
<div class="f"><label for="s_desc">설명</label><textarea id="s_desc" name="description" rows="3">${esc(site.description || '')}</textarea></div>
|
||||
<div class="f"><label>대표 웹 도메인</label><div id="primaryDomain" class="mono">${esc(site.primary_domain || '없음')}</div><span class="hint">대표 도메인은 <a href="/sites/${esc(site.id)}?tab=domains">도메인 탭</a>에서 지정합니다</span></div>
|
||||
<div class="f"><label for="s_lang">기본 언어</label><select id="s_lang" name="lang">${['ko', 'en', 'ja', 'zh'].map((l) => `<option value="${l}"${site.lang === l ? ' selected' : ''}>${l}</option>`).join('')}</select></div>
|
||||
<div class="f"><label for="s_tz">시간대</label><input id="s_tz" name="timezone" value="${esc(site.timezone || 'Asia/Seoul')}"></div>
|
||||
<div class="f"><label for="s_runtime">실행 형태</label><select id="s_runtime" name="runtime"><option value="static"${site.runtime === 'static' ? ' selected' : ''}>정적 사이트</option><option value="node"${site.runtime === 'node' ? ' selected' : ''}>동적 사이트(승인된 Node 실행 형태)</option></select><span class="hint">배포 전 확인 화면에서 필요한 저장 공간과 지원하지 않는 기능을 안내합니다</span></div>
|
||||
<div class="f"><label><input type="checkbox" name="maintenance" ${site.maintenance ? 'checked' : ''}> 유지보수 모드</label><span class="hint">켜면 방문자에게 안내 페이지가 보입니다. 적용 전 영향과 예상 시간을 다시 보여 줍니다</span></div>
|
||||
<div class="f"><label><input type="checkbox" name="searchVisible" ${site.search_visible ? 'checked' : ''}> 검색 엔진 공개</label></div>
|
||||
<div class="actions"><button class="btn primary" type="submit">저장</button></div><div class="err" data-err="form"></div></form>
|
||||
<div class="card"><h2>사이트별 접근 권한</h2><div id="perms">${deps.statePanel('loading')}</div></div>
|
||||
<div class="card"><h2>중지 · 소유권 이전 · 해지</h2><p class="hint">서비스 중단이 예상되는 작업은 영향과 예상 시간을 적용 전에 보여 줍니다.</p><div class="actions"><a class="btn" href="/sites/${esc(site.id)}/lifecycle">생명주기 화면(중지·해지·보존)</a><a class="btn" href="/transfer/new?site=${esc(site.id)}">소유권 이전</a></div></div></div>`,
|
||||
};
|
||||
return shell(ctx, deps, { title: site.name, nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name }], actions: tab === 'overview' && site.primary_domain && site.lifecycle === 'live' ? [{ label: '사이트 열기', href: `https://${site.primary_domain}/`, kind: '' }, { label: '배포', href: `/sites/${site.id}?tab=deploy`, kind: 'primary' }] : [{ label: '배포', href: `/sites/${site.id}?tab=deploy`, kind: 'primary' }], body: `<div class="tabs">${tabs}</div><div data-site="${esc(site.id)}" data-lifecycle="${esc(site.lifecycle)}" data-mode="${esc(site.deploy_mode || '')}">${bodies[tab]}</div>` });
|
||||
}
|
||||
export function modePage(ctx, deps, site) {
|
||||
return shell(ctx, deps, { title: '배포 방식 선택', nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: '배포 방식' }], body: `<div data-page="site-mode" data-site="${esc(site.id)}" data-current="${esc(site.deploy_mode || '')}"><div id="modeInfo">${deps.statePanel('loading')}</div></div>` });
|
||||
}
|
||||
export function externalPage(ctx, deps, site) {
|
||||
return shell(ctx, deps, { title: t('sites.external.title'), nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('sites.external.title') }], body: `<div data-page="site-external" data-site="${esc(site.id)}"><div id="external">${deps.statePanel('loading')}</div></div>` });
|
||||
}
|
||||
export function deployPage(ctx, deps, site) { return detailPage(ctx, deps, site, { tab: 'deploy' }); }
|
||||
function shell(ctx, deps, o) { return deps.page({ ...o, user: ctx.user, group: ctx.group, groups: ctx.groups || [], unread: ctx.unread || 0, csrf: ctx.session ? ctx.session.csrf : '', scripts: ['/ui/sites/sites.js'] }); }
|
||||
Reference in New Issue
Block a user