Files
anvil-hosting/app.js
kappa ea35848a97 feat: 텔레그램 사용자용 대시보드 추가
- 텔레그램 환경 감지 시 대시보드 모드 자동 활성화
- 내 서버 목록/관리 (시작/중지/삭제)
- 사용량/비용 통계
- 알림 센터
- Mock 데이터로 UI 테스트 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 13:45:25 +09:00

678 lines
20 KiB
JavaScript

/**
* Anvil Hosting - Main Application JavaScript
* 대화형 서버 런처 및 가격 데이터 관리
*/
// 텔레그램 봇 URL 상수
const TELEGRAM_BOT_URL = 'https://t.me/AnvilForgeBot';
// 단일 가격 데이터 소스 (VAT 포함, 월간 기준)
const PRICING_DATA = {
global: [
{ plan: 'Micro', vcpu: '1 Core', ram: '1 GB', ssd: '25 GB', transfer: '1 TB', price: 8500 },
{ plan: 'Starter', vcpu: '1 Core', ram: '2 GB', ssd: '50 GB', transfer: '2 TB', price: 20400 },
{ plan: 'Pro', vcpu: '2 Cores', ram: '4 GB', ssd: '80 GB', transfer: '4 TB', price: 40700, featured: true },
{ plan: 'Business', vcpu: '4 Cores', ram: '8 GB', ssd: '160 GB', transfer: '5 TB', price: 67800 }
],
seoul: [
{ plan: 'Nano', vcpu: '1 Core', ram: '512 MB', ssd: '20 GB', transfer: '1 TB', price: 6000 },
{ plan: 'Micro', vcpu: '1 Core', ram: '1 GB', ssd: '40 GB', transfer: '2 TB', price: 8500 },
{ plan: 'Starter', vcpu: '1 Core', ram: '2 GB', ssd: '60 GB', transfer: '3 TB', price: 17000 },
{ plan: 'Pro', vcpu: '2 Cores', ram: '4 GB', ssd: '80 GB', transfer: '4 TB', price: 33900, featured: true },
{ plan: 'Business', vcpu: '2 Cores', ram: '8 GB', ssd: '160 GB', transfer: '5 TB', price: 67800 }
]
};
// 런처 모달용 가격 - PRICING_DATA에서 파생
const LAUNCHER_PRICES = Object.fromEntries(
PRICING_DATA.global.map(p => [p.plan, {
base: p.price,
seoul: PRICING_DATA.seoul.find(s => s.plan === p.plan)?.price || p.price
}])
);
// 플랜별 스펙 정보 - PRICING_DATA에서 파생
const PLAN_SPECS = Object.fromEntries(
PRICING_DATA.global.map(p => [p.plan, `${p.vcpu} / ${p.ram} RAM`])
);
// 리전 정보
const REGIONS = [
{ id: 'Seoul', flag: '🇰🇷', name: '서울', ping: '2ms' },
{ id: 'Tokyo', flag: '🇯🇵', name: '도쿄', ping: '35ms' },
{ id: 'Singapore', flag: '🇸🇬', name: '싱가포르', ping: '65ms' },
{ id: 'HongKong', flag: '🇭🇰', name: '홍콩', ping: '45ms' }
];
// OS 정보
const OS_LIST = [
{ id: 'Debian 12', icon: '🐧', name: 'Debian 12' },
{ id: 'Ubuntu 24.04', icon: '🟠', name: 'Ubuntu 24.04' },
{ id: 'CentOS 9', icon: '🔵', name: 'CentOS 9' },
{ id: 'Alpine', icon: '🏔️', name: 'Alpine' }
];
// 결제 방식
const PAYMENT_METHODS = [
{ id: 'monthly', name: '월간 결제', desc: '매월 자동 결제', discount: 0 },
{ id: 'yearly', name: '연간 결제', desc: '2개월 무료 (17% 할인)', discount: 17 }
];
// 배포 시뮬레이션 타이밍 상수
const DEPLOY_TIMING = {
IMAGE_READY: 1500,
CONTAINER_CREATED: 3000,
NETWORK_CONFIGURED: 4500,
COMPLETE: 6000
};
// Mock 데이터 (API 없이 UI 테스트용)
const MOCK_SERVERS = [
{
id: 'srv-001',
name: 'production-web',
region: '🇯🇵 Tokyo',
ip: '45.12.89.101',
os: 'Debian 12',
plan: 'Pro',
status: 'running',
created_at: '2026-01-15',
vcpu: '2 Cores',
ram: '4 GB',
cost: 40700
},
{
id: 'srv-002',
name: 'dev-api',
region: '🇰🇷 Seoul',
ip: '45.12.89.102',
os: 'Ubuntu 24.04',
plan: 'Starter',
status: 'stopped',
created_at: '2026-01-20',
vcpu: '1 Core',
ram: '2 GB',
cost: 17000
}
];
const MOCK_STATS = {
totalCost: 57700,
totalServers: 2,
runningServers: 1,
costBreakdown: [
{ plan: 'Pro', count: 1, cost: 40700 },
{ plan: 'Starter', count: 1, cost: 17000 }
]
};
const MOCK_NOTIFICATIONS = [
{ id: 'n-001', type: 'info', title: '서버 생성 완료', message: 'production-web 서버가 Tokyo 리전에 생성되었습니다.', time: '10분 전', read: false },
{ id: 'n-002', type: 'warning', title: '결제 예정', message: '이번 달 결제가 3일 후 예정되어 있습니다. (₩57,700)', time: '1시간 전', read: false },
{ id: 'n-003', type: 'success', title: '시스템 업데이트 완료', message: 'dev-api 서버의 Ubuntu 패키지가 업데이트되었습니다.', time: '2일 전', read: true }
];
/**
* 가격 포맷팅 (한국 원화)
*/
function formatPrice(price) {
return '₩' + price.toLocaleString('ko-KR');
}
/**
* Alpine.js 메인 앱 데이터 - 대화형 위자드
*/
function anvilApp() {
return {
// 모달 상태
launcherOpen: false,
launching: false,
wizardStep: 0, // 0: region, 1: plan, 2: os, 3: payment, 4: confirm, 5+: deploying
deployStep: 0,
// 대시보드 상태
dashboardMode: false, // true면 대시보드, false면 랜딩
currentView: 'servers', // 'servers' | 'stats' | 'notifications'
// 텔레그램 연동
telegram: {
isAvailable: false, // 텔레그램 환경인지
user: null, // 사용자 정보
initData: null // 검증용 데이터
},
// 서버 구성
config: {
region: null,
plan: null,
os: null,
payment: null,
telegram_id: null
},
// 서버 목록
servers: [],
loadingServers: false,
// 통계
stats: {
totalCost: 0,
totalServers: 0,
runningServers: 0,
costBreakdown: []
},
// 알림
notifications: [],
unreadCount: 0,
// API 상태
apiLoading: false,
apiError: null,
// 대화 메시지
messages: [],
// 배포 로그
logs: [],
// 데이터 접근자
regions: REGIONS,
osList: OS_LIST,
paymentMethods: PAYMENT_METHODS,
// 플랜 목록 (리전에 따라 다름)
get plans() {
return ['Micro', 'Starter', 'Pro', 'Business'];
},
// 초기화 (텔레그램 연동 + 대시보드)
init() {
if (window.Telegram?.WebApp) {
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
this.telegram.isAvailable = true;
this.telegram.user = tg.initDataUnsafe.user || null;
this.telegram.initData = tg.initData;
// 텔레그램 테마 색상 적용 (선택)
// document.body.style.backgroundColor = tg.backgroundColor;
console.log('[Telegram] Mini App initialized', {
user: this.telegram.user,
platform: tg.platform
});
// 텔레그램 환경이면 대시보드 모드 활성화
// (user가 없어도 텔레그램에서 열린 경우 대시보드 표시)
this.dashboardMode = true;
this.loadDashboard();
console.log('[Telegram] Dashboard mode activated', {
isAvailable: this.telegram.isAvailable,
hasUser: !!this.telegram.user
});
} else {
console.log('[Telegram] Running in web browser mode');
}
},
// 대시보드 초기 로드
async loadDashboard() {
console.log('[Dashboard] Loading dashboard data...');
await Promise.all([
this.fetchServers(),
this.fetchStats(),
this.fetchNotifications()
]);
},
// 서버 목록 조회
async fetchServers() {
this.loadingServers = true;
this.apiError = null;
try {
// TODO: 실제 API 호출로 교체
// const response = await fetch('/api/servers', {
// headers: { 'X-Telegram-Init-Data': this.telegram.initData }
// });
// this.servers = await response.json();
// Mock 데이터 사용
await new Promise(resolve => setTimeout(resolve, 500));
this.servers = [...MOCK_SERVERS];
console.log('[Dashboard] Servers loaded:', this.servers.length);
} catch (error) {
console.error('[Dashboard] Failed to fetch servers:', error);
this.apiError = '서버 목록을 불러오는데 실패했습니다.';
} finally {
this.loadingServers = false;
}
},
// 통계 조회
async fetchStats() {
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 300));
this.stats = { ...MOCK_STATS };
console.log('[Dashboard] Stats loaded:', this.stats);
} catch (error) {
console.error('[Dashboard] Failed to fetch stats:', error);
}
},
// 알림 조회
async fetchNotifications() {
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 400));
this.notifications = [...MOCK_NOTIFICATIONS];
this.unreadCount = this.notifications.filter(n => !n.read).length;
console.log('[Dashboard] Notifications loaded:', this.notifications.length);
} catch (error) {
console.error('[Dashboard] Failed to fetch notifications:', error);
}
},
// 서버 시작
async startServer(serverId) {
const server = this.servers.find(s => s.id === serverId);
if (!server) return;
console.log('[Dashboard] Starting server:', serverId);
this.apiLoading = true;
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 1000));
server.status = 'running';
this.stats.runningServers++;
console.log('[Dashboard] Server started:', serverId);
} catch (error) {
console.error('[Dashboard] Failed to start server:', error);
alert('서버를 시작하는데 실패했습니다.');
} finally {
this.apiLoading = false;
}
},
// 서버 중지
async stopServer(serverId) {
const server = this.servers.find(s => s.id === serverId);
if (!server) return;
console.log('[Dashboard] Stopping server:', serverId);
this.apiLoading = true;
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 1000));
server.status = 'stopped';
this.stats.runningServers--;
console.log('[Dashboard] Server stopped:', serverId);
} catch (error) {
console.error('[Dashboard] Failed to stop server:', error);
alert('서버를 중지하는데 실패했습니다.');
} finally {
this.apiLoading = false;
}
},
// 서버 삭제
async deleteServer(serverId) {
if (!confirm('정말로 이 서버를 삭제하시겠습니까?')) return;
console.log('[Dashboard] Deleting server:', serverId);
this.apiLoading = true;
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 800));
const serverIndex = this.servers.findIndex(s => s.id === serverId);
if (serverIndex !== -1) {
const deletedServer = this.servers[serverIndex];
this.servers.splice(serverIndex, 1);
// 통계 업데이트
this.stats.totalServers--;
this.stats.totalCost -= deletedServer.cost;
if (deletedServer.status === 'running') {
this.stats.runningServers--;
}
console.log('[Dashboard] Server deleted:', serverId);
}
} catch (error) {
console.error('[Dashboard] Failed to delete server:', error);
alert('서버를 삭제하는데 실패했습니다.');
} finally {
this.apiLoading = false;
}
},
// 모든 알림 읽음 처리
markAllRead() {
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
console.log('[Dashboard] All notifications marked as read');
},
// 뷰 전환
switchView(view) {
this.currentView = view;
console.log('[Dashboard] View switched to:', view);
},
// 가격 조회
getPrice(plan) {
const prices = LAUNCHER_PRICES[plan];
if (!prices) return '0';
const price = this.config.region === 'Seoul' ? prices.seoul : prices.base;
return price.toLocaleString('ko-KR');
},
// 월간 가격 (할인 적용)
getFinalPrice() {
const prices = LAUNCHER_PRICES[this.config.plan];
if (!prices) return 0;
let price = this.config.region === 'Seoul' ? prices.seoul : prices.base;
if (this.config.payment === 'yearly') {
price = Math.round(price * 0.83); // 17% 할인
}
return price;
},
// 플랜 스펙 조회
getPlanSpec(plan) {
return PLAN_SPECS[plan] || '';
},
// 리전 선택
selectRegion(region) {
this.config.region = region.id;
this.addMessage('user', `${region.flag} ${region.name}`);
this.wizardStep = 1;
setTimeout(() => this.addMessage('bot', '어떤 사양이 필요하신가요?'), 300);
},
// 플랜 선택
selectPlan(plan) {
this.config.plan = plan;
this.addMessage('user', `${plan} (${this.getPlanSpec(plan)})`);
this.wizardStep = 2;
setTimeout(() => this.addMessage('bot', '어떤 운영체제를 설치할까요?'), 300);
},
// OS 선택
selectOS(os) {
this.config.os = os.id;
this.addMessage('user', `${os.icon} ${os.name}`);
this.wizardStep = 3;
setTimeout(() => this.addMessage('bot', '결제 방식을 선택해주세요.'), 300);
},
// 결제 방식 선택
selectPayment(payment) {
this.config.payment = payment.id;
this.addMessage('user', payment.name);
this.wizardStep = 4;
setTimeout(() => this.addMessage('bot', '구성을 확인하고 서버를 생성해주세요! 🚀'), 300);
},
// 메시지 추가
addMessage(type, text) {
this.messages.push({ type, text, time: new Date() });
// 스크롤 하단으로
this.$nextTick(() => {
const container = document.getElementById('chat-container');
if (container) container.scrollTop = container.scrollHeight;
});
},
// 이전 단계로
goBack() {
if (this.wizardStep > 0 && !this.launching) {
// 마지막 2개 메시지 제거 (사용자 선택 + 봇 질문)
this.messages.pop();
this.messages.pop();
this.wizardStep--;
// 선택 초기화
if (this.wizardStep === 0) this.config.region = null;
else if (this.wizardStep === 1) this.config.plan = null;
else if (this.wizardStep === 2) this.config.os = null;
else if (this.wizardStep === 3) this.config.payment = null;
}
},
// 서버 배포 시작
startLaunch() {
// 텔레그램 사용자 ID 추가
if (this.telegram.user) {
this.config.telegram_id = this.telegram.user.id;
}
this.launching = true;
this.wizardStep = 5;
this.deployStep = 1;
this.logs = [
'🔄 배포를 시작합니다...',
`📍 ${this.config.region} 리전 노드 선택 중...`,
`📦 ${this.config.os} 이미지 준비 중...`
];
// 디버그: config 출력
console.log('[Server Launch] Config:', this.config);
setTimeout(() => {
this.logs.push('✅ 이미지 준비 완료');
this.logs.push('🔧 컨테이너 인스턴스 생성 중...');
this.deployStep = 2;
}, DEPLOY_TIMING.IMAGE_READY);
setTimeout(() => {
this.logs.push('✅ 컨테이너 생성 완료');
this.logs.push('🌐 네트워크 및 방화벽 구성 중...');
this.deployStep = 3;
}, DEPLOY_TIMING.CONTAINER_CREATED);
setTimeout(() => {
const randomIP = Math.floor(Math.random() * 254 + 1);
this.logs.push(`✅ IP 할당 완료: 45.12.89.${randomIP}`);
this.logs.push('⚙️ 시스템 서비스 시작 중...');
this.deployStep = 4;
}, DEPLOY_TIMING.NETWORK_CONFIGURED);
setTimeout(() => {
this.launching = false;
this.deployStep = 5;
this.logs.push('🎉 서버가 활성화되었습니다!');
// 대시보드 모드에서는 서버 목록 새로고침
if (this.dashboardMode) {
setTimeout(() => {
this.fetchServers();
this.fetchStats();
}, 1000);
}
}, DEPLOY_TIMING.COMPLETE);
},
// 런처 열기 (대시보드용 추가 기능)
openLauncher() {
this.launcherOpen = true;
this.messages = [{ type: 'bot', text: '어느 리전에 서버를 생성할까요?', time: new Date() }];
// 대시보드 모드에서는 서버 생성 후 목록 새로고침
if (this.dashboardMode) {
console.log('[Dashboard] Launcher opened from dashboard');
}
},
// 런처 초기화
resetLauncher() {
this.launcherOpen = false;
this.launching = false;
this.wizardStep = 0;
this.deployStep = 0;
this.config = { region: null, plan: null, os: null, payment: null, telegram_id: null };
this.messages = [];
this.logs = [];
},
// ESC 키로 모달 닫기
handleKeydown(event) {
if (event.key === 'Escape' && this.launcherOpen && !this.launching) {
this.resetLauncher();
}
}
};
}
/**
* 가격표 컴포넌트
*/
function pricingTable() {
return {
region: 'global',
get plans() {
return PRICING_DATA[this.region] || [];
},
formatPrice(price) {
return formatPrice(price);
},
isSeoul() {
return this.region === 'seoul';
}
};
}
/**
* 탭 전환 (n8n/Terraform)
*/
function switchTab(tab) {
const btnN8n = document.getElementById('btn-n8n');
const btnTf = document.getElementById('btn-tf');
const panelN8n = document.getElementById('panel-n8n');
const panelTf = document.getElementById('panel-tf');
// Null checks for DOM elements
if (!btnN8n || !btnTf || !panelN8n || !panelTf) return;
if (tab === 'n8n') {
// Update ARIA states for n8n tab
btnN8n.setAttribute('aria-selected', 'true');
btnN8n.setAttribute('tabindex', '0');
btnTf.setAttribute('aria-selected', 'false');
btnTf.setAttribute('tabindex', '-1');
// Update classes
btnN8n.className = 'px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-bold transition shadow-lg shadow-purple-500/20';
btnTf.className = 'px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-sm font-bold border border-slate-700 hover:text-white transition';
panelN8n.classList.remove('hidden');
panelTf.classList.add('hidden');
} else {
// Update ARIA states for Terraform tab
btnTf.setAttribute('aria-selected', 'true');
btnTf.setAttribute('tabindex', '0');
btnN8n.setAttribute('aria-selected', 'false');
btnN8n.setAttribute('tabindex', '-1');
// Update classes
btnN8n.className = 'px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-sm font-bold border border-slate-700 hover:text-white transition';
btnTf.className = 'px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-bold transition shadow-lg shadow-blue-500/20';
panelN8n.classList.add('hidden');
panelTf.classList.remove('hidden');
}
}
/**
* 실시간 Ping 시뮬레이션
*/
function updatePing() {
const regions = [
{ id: 'ping-kr', base: 2, variance: 2 },
{ id: 'ping-jp', base: 35, variance: 5 },
{ id: 'ping-hk', base: 45, variance: 8 },
{ id: 'ping-sg', base: 65, variance: 10 }
];
regions.forEach(region => {
const el = document.getElementById(region.id);
if (el) {
const jitter = Math.floor(Math.random() * region.variance) - (region.variance / 2);
let val = Math.max(1, Math.floor(region.base + jitter));
el.innerText = val;
}
});
}
// Ping 업데이트 시작 (visibility-aware)
let pingInterval;
function startPingUpdates() {
if (!pingInterval) {
pingInterval = setInterval(updatePing, 2000);
}
}
function stopPingUpdates() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
// Visibility change handler
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPingUpdates();
} else {
startPingUpdates();
}
});
// Initial start
startPingUpdates();
// Tab switching with event listeners and keyboard navigation
document.addEventListener('DOMContentLoaded', () => {
const btnN8n = document.getElementById('btn-n8n');
const btnTf = document.getElementById('btn-tf');
if (btnN8n && btnTf) {
// Click event listeners
btnN8n.addEventListener('click', () => switchTab('n8n'));
btnTf.addEventListener('click', () => switchTab('tf'));
// Keyboard navigation (Arrow keys)
[btnN8n, btnTf].forEach(btn => {
btn.addEventListener('keydown', (e) => {
const currentTab = btn.getAttribute('data-tab');
if (e.key === 'ArrowRight') {
e.preventDefault();
if (currentTab === 'n8n') {
btnTf.focus();
switchTab('tf');
}
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
if (currentTab === 'tf') {
btnN8n.focus();
switchTab('n8n');
}
}
});
});
}
});